Compare commits
No commits in common. "9bed6b54a7cecf21def66b614ea2096235c74db9" and "94039598d90bc70c205a74fb2733abe3328cbba7" have entirely different histories.
9bed6b54a7
...
94039598d9
@ -1,12 +1,7 @@
|
|||||||
import { Controller, Get, Post, Param, Query, Body } from '@nestjs/common';
|
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery, ApiBody } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } 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,
|
|
||||||
VerifyDocumentAccessDto,
|
|
||||||
DocumentAccessRequirementsDto,
|
|
||||||
} from '../dto/carrier-documents.dto';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Actions Controller (Public Routes)
|
* CSV Booking Actions Controller (Public Routes)
|
||||||
@ -93,84 +88,4 @@ export class CsvBookingActionsController {
|
|||||||
reason: reason || null,
|
reason: reason || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Check document access requirements (PUBLIC - token-based)
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-booking-actions/documents/:token/requirements
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('documents/:token/requirements')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Check document access requirements (public)',
|
|
||||||
description:
|
|
||||||
'Check if a password is required to access booking documents. Use this before showing the password form.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Access requirements retrieved successfully.',
|
|
||||||
type: DocumentAccessRequirementsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
|
||||||
async getDocumentAccessRequirements(
|
|
||||||
@Param('token') token: string
|
|
||||||
): Promise<DocumentAccessRequirementsDto> {
|
|
||||||
return this.csvBookingService.checkDocumentAccessRequirements(token);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get booking documents for carrier with password verification (PUBLIC - token-based)
|
|
||||||
*
|
|
||||||
* POST /api/v1/csv-booking-actions/documents/:token
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Post('documents/:token')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get booking documents with password (public)',
|
|
||||||
description:
|
|
||||||
'Public endpoint for carriers to access booking documents after acceptance. Requires password verification. Returns booking summary and documents with signed download URLs.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
|
||||||
@ApiBody({ type: VerifyDocumentAccessDto })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Booking documents retrieved successfully.',
|
|
||||||
type: CarrierDocumentsResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Invalid password' })
|
|
||||||
async getBookingDocumentsWithPassword(
|
|
||||||
@Param('token') token: string,
|
|
||||||
@Body() dto: VerifyDocumentAccessDto
|
|
||||||
): Promise<CarrierDocumentsResponseDto> {
|
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token, dto.password);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get booking documents for carrier (PUBLIC - token-based) - Legacy without password
|
|
||||||
* Kept for backward compatibility with bookings created before password protection
|
|
||||||
*
|
|
||||||
* GET /api/v1/csv-booking-actions/documents/:token
|
|
||||||
*/
|
|
||||||
@Public()
|
|
||||||
@Get('documents/:token')
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get booking documents (public) - Legacy',
|
|
||||||
description:
|
|
||||||
'Public endpoint for carriers to access booking documents. For new bookings, use POST with password instead.',
|
|
||||||
})
|
|
||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
|
||||||
@ApiResponse({
|
|
||||||
status: 200,
|
|
||||||
description: 'Booking documents retrieved successfully.',
|
|
||||||
type: CarrierDocumentsResponseDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
|
||||||
@ApiResponse({ status: 400, description: 'Booking has not been accepted yet' })
|
|
||||||
@ApiResponse({ status: 401, description: 'Password required for this booking' })
|
|
||||||
async getBookingDocuments(@Param('token') token: string): Promise<CarrierDocumentsResponseDto> {
|
|
||||||
return this.csvBookingService.getDocumentsForCarrier(token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,20 +14,13 @@ import {
|
|||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Res,
|
Res,
|
||||||
Req,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiResponse } from '@nestjs/swagger';
|
||||||
import { Response, Request } from 'express';
|
import { Response } from 'express';
|
||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../decorators/current-user.decorator';
|
import { CurrentUser } from '../decorators/current-user.decorator';
|
||||||
import { UserPayload } from '../decorators/current-user.decorator';
|
import { UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { GDPRService } from '../services/gdpr.service';
|
import { GDPRService, ConsentData } from '../services/gdpr.service';
|
||||||
import {
|
|
||||||
UpdateConsentDto,
|
|
||||||
ConsentResponseDto,
|
|
||||||
WithdrawConsentDto,
|
|
||||||
ConsentSuccessDto,
|
|
||||||
} from '../dto/consent.dto';
|
|
||||||
|
|
||||||
@ApiTags('GDPR')
|
@ApiTags('GDPR')
|
||||||
@Controller('gdpr')
|
@Controller('gdpr')
|
||||||
@ -84,13 +77,6 @@ export class GDPRController {
|
|||||||
csv += `User Data,${key},"${value}"\n`;
|
csv += `User Data,${key},"${value}"\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cookie consent data
|
|
||||||
if (exportData.cookieConsent) {
|
|
||||||
Object.entries(exportData.cookieConsent).forEach(([key, value]) => {
|
|
||||||
csv += `Cookie Consent,${key},"${value}"\n`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
res.setHeader('Content-Type', 'text/csv');
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
@ -133,26 +119,22 @@ export class GDPRController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Record user consent',
|
summary: 'Record user consent',
|
||||||
description: 'Record consent for cookies (GDPR Article 7)',
|
description: 'Record consent for marketing, analytics, etc. (GDPR Article 7)',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Consent recorded',
|
description: 'Consent recorded',
|
||||||
type: ConsentResponseDto,
|
|
||||||
})
|
})
|
||||||
async recordConsent(
|
async recordConsent(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: UpdateConsentDto,
|
@Body() body: Omit<ConsentData, 'userId'>
|
||||||
@Req() req: Request
|
): Promise<{ success: boolean }> {
|
||||||
): Promise<ConsentResponseDto> {
|
await this.gdprService.recordConsent({
|
||||||
// Add IP and user agent from request if not provided
|
|
||||||
const consentData: UpdateConsentDto = {
|
|
||||||
...body,
|
...body,
|
||||||
ipAddress: body.ipAddress || req.ip || req.socket.remoteAddress,
|
userId: user.id,
|
||||||
userAgent: body.userAgent || req.headers['user-agent'],
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return this.gdprService.recordConsent(user.id, consentData);
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -162,18 +144,19 @@ export class GDPRController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Withdraw consent',
|
summary: 'Withdraw consent',
|
||||||
description: 'Withdraw consent for functional, analytics, or marketing (GDPR Article 7.3)',
|
description: 'Withdraw consent for marketing or analytics (GDPR Article 7.3)',
|
||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Consent withdrawn',
|
description: 'Consent withdrawn',
|
||||||
type: ConsentResponseDto,
|
|
||||||
})
|
})
|
||||||
async withdrawConsent(
|
async withdrawConsent(
|
||||||
@CurrentUser() user: UserPayload,
|
@CurrentUser() user: UserPayload,
|
||||||
@Body() body: WithdrawConsentDto
|
@Body() body: { consentType: 'marketing' | 'analytics' }
|
||||||
): Promise<ConsentResponseDto> {
|
): Promise<{ success: boolean }> {
|
||||||
return this.gdprService.withdrawConsent(user.id, body.consentType);
|
await this.gdprService.withdrawConsent(user.id, body.consentType);
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -187,9 +170,8 @@ export class GDPRController {
|
|||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
description: 'Consent status retrieved',
|
description: 'Consent status retrieved',
|
||||||
type: ConsentResponseDto,
|
|
||||||
})
|
})
|
||||||
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<ConsentResponseDto | null> {
|
async getConsentStatus(@CurrentUser() user: UserPayload): Promise<any> {
|
||||||
return this.gdprService.getConsentStatus(user.id);
|
return this.gdprService.getConsentStatus(user.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,12 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Get,
|
Get,
|
||||||
Body,
|
Body,
|
||||||
Query,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
UsePipes,
|
UsePipes,
|
||||||
ValidationPipe,
|
ValidationPipe,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Inject,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -19,7 +17,6 @@ import {
|
|||||||
ApiBadRequestResponse,
|
ApiBadRequestResponse,
|
||||||
ApiInternalServerErrorResponse,
|
ApiInternalServerErrorResponse,
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
ApiQuery,
|
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
import { RateSearchRequestDto, RateSearchResponseDto } from '../dto';
|
||||||
import { RateQuoteMapper } from '../mappers';
|
import { RateQuoteMapper } from '../mappers';
|
||||||
@ -28,15 +25,8 @@ import { CsvRateSearchService } from '@domain/services/csv-rate-search.service';
|
|||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
import { CsvRateSearchDto, CsvRateSearchResponseDto } from '../dto/csv-rate-search.dto';
|
||||||
import {
|
import { AvailableCompaniesDto, FilterOptionsDto } from '../dto/csv-rate-upload.dto';
|
||||||
AvailableCompaniesDto,
|
|
||||||
FilterOptionsDto,
|
|
||||||
AvailableOriginsDto,
|
|
||||||
AvailableDestinationsDto,
|
|
||||||
RoutePortInfoDto,
|
|
||||||
} from '../dto/csv-rate-upload.dto';
|
|
||||||
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
import { CsvRateMapper } from '../mappers/csv-rate.mapper';
|
||||||
import { PortRepository, PORT_REPOSITORY } from '@domain/ports/out/port.repository';
|
|
||||||
|
|
||||||
@ApiTags('Rates')
|
@ApiTags('Rates')
|
||||||
@Controller('rates')
|
@Controller('rates')
|
||||||
@ -47,8 +37,7 @@ export class RatesController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly rateSearchService: RateSearchService,
|
private readonly rateSearchService: RateSearchService,
|
||||||
private readonly csvRateSearchService: CsvRateSearchService,
|
private readonly csvRateSearchService: CsvRateSearchService,
|
||||||
private readonly csvRateMapper: CsvRateMapper,
|
private readonly csvRateMapper: CsvRateMapper
|
||||||
@Inject(PORT_REPOSITORY) private readonly portRepository: PortRepository
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('search')
|
@Post('search')
|
||||||
@ -282,168 +271,6 @@ export class RatesController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available origin ports from CSV rates
|
|
||||||
* Returns only ports that have routes defined in CSV files
|
|
||||||
*/
|
|
||||||
@Get('available-routes/origins')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get available origin ports from CSV rates',
|
|
||||||
description:
|
|
||||||
'Returns list of origin ports that have shipping routes defined in CSV rate files. Use this to populate origin port selection dropdown.',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'List of available origin ports with details',
|
|
||||||
type: AvailableOriginsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - missing or invalid token',
|
|
||||||
})
|
|
||||||
async getAvailableOrigins(): Promise<AvailableOriginsDto> {
|
|
||||||
this.logger.log('Fetching available origin ports from CSV rates');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get unique origin port codes from CSV rates
|
|
||||||
const originCodes = await this.csvRateSearchService.getAvailableOrigins();
|
|
||||||
|
|
||||||
// Fetch port details from database
|
|
||||||
const ports = await this.portRepository.findByCodes(originCodes);
|
|
||||||
|
|
||||||
// Map to response DTO with port details
|
|
||||||
const origins: RoutePortInfoDto[] = originCodes.map(code => {
|
|
||||||
const port = ports.find(p => p.code === code);
|
|
||||||
if (port) {
|
|
||||||
return {
|
|
||||||
code: port.code,
|
|
||||||
name: port.name,
|
|
||||||
city: port.city,
|
|
||||||
country: port.country,
|
|
||||||
countryName: port.countryName,
|
|
||||||
displayName: port.getDisplayName(),
|
|
||||||
latitude: port.coordinates.latitude,
|
|
||||||
longitude: port.coordinates.longitude,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Fallback if port not found in database
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
name: code,
|
|
||||||
city: '',
|
|
||||||
country: code.substring(0, 2),
|
|
||||||
countryName: '',
|
|
||||||
displayName: code,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by display name
|
|
||||||
origins.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
||||||
|
|
||||||
return {
|
|
||||||
origins,
|
|
||||||
total: origins.length,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to fetch available origins: ${error?.message || 'Unknown error'}`,
|
|
||||||
error?.stack
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available destination ports for a given origin
|
|
||||||
* Returns only destinations that have routes from the specified origin in CSV files
|
|
||||||
*/
|
|
||||||
@Get('available-routes/destinations')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({
|
|
||||||
summary: 'Get available destination ports for a given origin',
|
|
||||||
description:
|
|
||||||
'Returns list of destination ports that have shipping routes from the specified origin port in CSV rate files. Use this to populate destination port selection dropdown after origin is selected.',
|
|
||||||
})
|
|
||||||
@ApiQuery({
|
|
||||||
name: 'origin',
|
|
||||||
required: true,
|
|
||||||
description: 'Origin port code (UN/LOCODE format, e.g., NLRTM)',
|
|
||||||
example: 'NLRTM',
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: HttpStatus.OK,
|
|
||||||
description: 'List of available destination ports with details',
|
|
||||||
type: AvailableDestinationsDto,
|
|
||||||
})
|
|
||||||
@ApiResponse({
|
|
||||||
status: 401,
|
|
||||||
description: 'Unauthorized - missing or invalid token',
|
|
||||||
})
|
|
||||||
@ApiBadRequestResponse({
|
|
||||||
description: 'Origin port code is required',
|
|
||||||
})
|
|
||||||
async getAvailableDestinations(
|
|
||||||
@Query('origin') origin: string
|
|
||||||
): Promise<AvailableDestinationsDto> {
|
|
||||||
this.logger.log(`Fetching available destinations for origin: ${origin}`);
|
|
||||||
|
|
||||||
if (!origin) {
|
|
||||||
throw new Error('Origin port code is required');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get destination port codes for this origin from CSV rates
|
|
||||||
const destinationCodes = await this.csvRateSearchService.getAvailableDestinations(origin);
|
|
||||||
|
|
||||||
// Fetch port details from database
|
|
||||||
const ports = await this.portRepository.findByCodes(destinationCodes);
|
|
||||||
|
|
||||||
// Map to response DTO with port details
|
|
||||||
const destinations: RoutePortInfoDto[] = destinationCodes.map(code => {
|
|
||||||
const port = ports.find(p => p.code === code);
|
|
||||||
if (port) {
|
|
||||||
return {
|
|
||||||
code: port.code,
|
|
||||||
name: port.name,
|
|
||||||
city: port.city,
|
|
||||||
country: port.country,
|
|
||||||
countryName: port.countryName,
|
|
||||||
displayName: port.getDisplayName(),
|
|
||||||
latitude: port.coordinates.latitude,
|
|
||||||
longitude: port.coordinates.longitude,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Fallback if port not found in database
|
|
||||||
return {
|
|
||||||
code,
|
|
||||||
name: code,
|
|
||||||
city: '',
|
|
||||||
country: code.substring(0, 2),
|
|
||||||
countryName: '',
|
|
||||||
displayName: code,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by display name
|
|
||||||
destinations.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
||||||
|
|
||||||
return {
|
|
||||||
origin: origin.toUpperCase(),
|
|
||||||
destinations,
|
|
||||||
total: destinations.length,
|
|
||||||
};
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(
|
|
||||||
`Failed to fetch available destinations: ${error?.message || 'Unknown error'}`,
|
|
||||||
error?.stack
|
|
||||||
);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get available companies
|
* Get available companies
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { IsNotEmpty, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DTO for verifying document access password
|
|
||||||
*/
|
|
||||||
export class VerifyDocumentAccessDto {
|
|
||||||
@ApiProperty({ description: 'Password for document access (booking number code)' })
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response DTO for checking document access requirements
|
|
||||||
*/
|
|
||||||
export class DocumentAccessRequirementsDto {
|
|
||||||
@ApiProperty({ description: 'Whether password is required to access documents' })
|
|
||||||
requiresPassword: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Booking number (if available)' })
|
|
||||||
bookingNumber?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Current booking status' })
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Booking Summary DTO for Carrier Documents Page
|
|
||||||
*/
|
|
||||||
export class BookingSummaryDto {
|
|
||||||
@ApiProperty({ description: 'Booking unique ID' })
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Human-readable booking number' })
|
|
||||||
bookingNumber?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Carrier/Company name' })
|
|
||||||
carrierName: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Origin port code' })
|
|
||||||
origin: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Destination port code' })
|
|
||||||
destination: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Route description (origin -> destination)' })
|
|
||||||
routeDescription: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Volume in CBM' })
|
|
||||||
volumeCBM: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Weight in KG' })
|
|
||||||
weightKG: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Number of pallets' })
|
|
||||||
palletCount: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Price in the primary currency' })
|
|
||||||
price: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Currency (USD or EUR)' })
|
|
||||||
currency: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Transit time in days' })
|
|
||||||
transitDays: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Container type' })
|
|
||||||
containerType: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'When the booking was accepted' })
|
|
||||||
acceptedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Document with signed download URL for carrier access
|
|
||||||
*/
|
|
||||||
export class DocumentWithUrlDto {
|
|
||||||
@ApiProperty({ description: 'Document unique ID' })
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Document type',
|
|
||||||
enum: ['BILL_OF_LADING', 'PACKING_LIST', 'COMMERCIAL_INVOICE', 'CERTIFICATE_OF_ORIGIN', 'OTHER'],
|
|
||||||
})
|
|
||||||
type: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Original file name' })
|
|
||||||
fileName: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'File MIME type' })
|
|
||||||
mimeType: string;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'File size in bytes' })
|
|
||||||
size: number;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'Temporary signed download URL (valid for 1 hour)' })
|
|
||||||
downloadUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Carrier Documents Response DTO
|
|
||||||
*
|
|
||||||
* Response for carrier document access page
|
|
||||||
*/
|
|
||||||
export class CarrierDocumentsResponseDto {
|
|
||||||
@ApiProperty({ description: 'Booking summary information', type: BookingSummaryDto })
|
|
||||||
booking: BookingSummaryDto;
|
|
||||||
|
|
||||||
@ApiProperty({ description: 'List of documents with download URLs', type: [DocumentWithUrlDto] })
|
|
||||||
documents: DocumentWithUrlDto[];
|
|
||||||
}
|
|
||||||
@ -1,139 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cookie Consent DTOs
|
|
||||||
* GDPR compliant consent management
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { IsBoolean, IsOptional, IsString, IsEnum, IsDateString, IsIP } from 'class-validator';
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request DTO for recording/updating cookie consent
|
|
||||||
*/
|
|
||||||
export class UpdateConsentDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
|
||||||
description: 'Essential cookies consent (always true, required for functionality)',
|
|
||||||
default: true,
|
|
||||||
})
|
|
||||||
@IsBoolean()
|
|
||||||
essential: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Functional cookies consent (preferences, language, etc.)',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
@IsBoolean()
|
|
||||||
functional: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Analytics cookies consent (Google Analytics, Sentry, etc.)',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
@IsBoolean()
|
|
||||||
analytics: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Marketing cookies consent (ads, tracking, remarketing)',
|
|
||||||
default: false,
|
|
||||||
})
|
|
||||||
@IsBoolean()
|
|
||||||
marketing: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: '192.168.1.1',
|
|
||||||
description: 'IP address at time of consent (for GDPR audit trail)',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
ipAddress?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
example: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
||||||
description: 'User agent at time of consent',
|
|
||||||
})
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
userAgent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response DTO for consent status
|
|
||||||
*/
|
|
||||||
export class ConsentResponseDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: '550e8400-e29b-41d4-a716-446655440000',
|
|
||||||
description: 'User ID',
|
|
||||||
})
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
|
||||||
description: 'Essential cookies consent (always true)',
|
|
||||||
})
|
|
||||||
essential: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Functional cookies consent',
|
|
||||||
})
|
|
||||||
functional: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Analytics cookies consent',
|
|
||||||
})
|
|
||||||
analytics: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: false,
|
|
||||||
description: 'Marketing cookies consent',
|
|
||||||
})
|
|
||||||
marketing: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-27T10:30:00.000Z',
|
|
||||||
description: 'Date when consent was recorded',
|
|
||||||
})
|
|
||||||
consentDate: Date;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: '2025-01-27T10:30:00.000Z',
|
|
||||||
description: 'Last update timestamp',
|
|
||||||
})
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request DTO for withdrawing specific consent
|
|
||||||
*/
|
|
||||||
export class WithdrawConsentDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'marketing',
|
|
||||||
description: 'Type of consent to withdraw',
|
|
||||||
enum: ['functional', 'analytics', 'marketing'],
|
|
||||||
})
|
|
||||||
@IsEnum(['functional', 'analytics', 'marketing'], {
|
|
||||||
message: 'Consent type must be functional, analytics, or marketing',
|
|
||||||
})
|
|
||||||
consentType: 'functional' | 'analytics' | 'marketing';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Success response DTO
|
|
||||||
*/
|
|
||||||
export class ConsentSuccessDto {
|
|
||||||
@ApiProperty({
|
|
||||||
example: true,
|
|
||||||
description: 'Operation success status',
|
|
||||||
})
|
|
||||||
success: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
example: 'Consent preferences saved successfully',
|
|
||||||
description: 'Response message',
|
|
||||||
})
|
|
||||||
message: string;
|
|
||||||
}
|
|
||||||
@ -201,12 +201,6 @@ export class CsvBookingResponseDto {
|
|||||||
})
|
})
|
||||||
id: string;
|
id: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Booking number (e.g. XPD-2026-W75VPT)',
|
|
||||||
example: 'XPD-2026-W75VPT',
|
|
||||||
})
|
|
||||||
bookingNumber?: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
description: 'User ID who created the booking',
|
description: 'User ID who created the booking',
|
||||||
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',
|
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',
|
||||||
|
|||||||
@ -209,101 +209,3 @@ export class FilterOptionsDto {
|
|||||||
})
|
})
|
||||||
currencies: string[];
|
currencies: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Port Info for Route Response DTO
|
|
||||||
* Contains port details with coordinates for map display
|
|
||||||
*/
|
|
||||||
export class RoutePortInfoDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'UN/LOCODE port code',
|
|
||||||
example: 'NLRTM',
|
|
||||||
})
|
|
||||||
code: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Port name',
|
|
||||||
example: 'Rotterdam',
|
|
||||||
})
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'City name',
|
|
||||||
example: 'Rotterdam',
|
|
||||||
})
|
|
||||||
city: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Country code (ISO 3166-1 alpha-2)',
|
|
||||||
example: 'NL',
|
|
||||||
})
|
|
||||||
country: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Country full name',
|
|
||||||
example: 'Netherlands',
|
|
||||||
})
|
|
||||||
countryName: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Display name for UI',
|
|
||||||
example: 'Rotterdam, Netherlands (NLRTM)',
|
|
||||||
})
|
|
||||||
displayName: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Latitude coordinate',
|
|
||||||
example: 51.9244,
|
|
||||||
required: false,
|
|
||||||
})
|
|
||||||
latitude?: number;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Longitude coordinate',
|
|
||||||
example: 4.4777,
|
|
||||||
required: false,
|
|
||||||
})
|
|
||||||
longitude?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available Origins Response DTO
|
|
||||||
* Returns list of origin ports that have routes in CSV rates
|
|
||||||
*/
|
|
||||||
export class AvailableOriginsDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'List of origin ports with available routes in CSV rates',
|
|
||||||
type: [RoutePortInfoDto],
|
|
||||||
})
|
|
||||||
origins: RoutePortInfoDto[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Total number of available origin ports',
|
|
||||||
example: 15,
|
|
||||||
})
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available Destinations Response DTO
|
|
||||||
* Returns list of destination ports available for a given origin
|
|
||||||
*/
|
|
||||||
export class AvailableDestinationsDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Origin port code that was used to filter destinations',
|
|
||||||
example: 'NLRTM',
|
|
||||||
})
|
|
||||||
origin: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'List of destination ports available from the given origin',
|
|
||||||
type: [RoutePortInfoDto],
|
|
||||||
})
|
|
||||||
destinations: RoutePortInfoDto[];
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Total number of available destinations for this origin',
|
|
||||||
example: 8,
|
|
||||||
})
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities
|
|||||||
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
import { BookingOrmEntity } from '../../infrastructure/persistence/typeorm/entities/booking.orm-entity';
|
||||||
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
import { AuditLogOrmEntity } from '../../infrastructure/persistence/typeorm/entities/audit-log.orm-entity';
|
||||||
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
import { NotificationOrmEntity } from '../../infrastructure/persistence/typeorm/entities/notification.orm-entity';
|
||||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -21,7 +20,6 @@ import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm
|
|||||||
BookingOrmEntity,
|
BookingOrmEntity,
|
||||||
AuditLogOrmEntity,
|
AuditLogOrmEntity,
|
||||||
NotificationOrmEntity,
|
NotificationOrmEntity,
|
||||||
CookieConsentOrmEntity,
|
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
controllers: [GDPRController],
|
controllers: [GDPRController],
|
||||||
|
|||||||
@ -1,13 +1,5 @@
|
|||||||
import {
|
import { Injectable, Logger, NotFoundException, BadRequestException, Inject } from '@nestjs/common';
|
||||||
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';
|
||||||
@ -29,7 +21,6 @@ import {
|
|||||||
CsvBookingListResponseDto,
|
CsvBookingListResponseDto,
|
||||||
CsvBookingStatsDto,
|
CsvBookingStatsDto,
|
||||||
} from '../dto/csv-booking.dto';
|
} from '../dto/csv-booking.dto';
|
||||||
import { CarrierDocumentsResponseDto } from '../dto/carrier-documents.dto';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Document (simple class for domain)
|
* CSV Booking Document (simple class for domain)
|
||||||
@ -65,27 +56,6 @@ 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
|
||||||
*/
|
*/
|
||||||
@ -102,14 +72,9 @@ 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 and booking number
|
// Generate unique confirmation token
|
||||||
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);
|
||||||
@ -141,26 +106,13 @@ 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,
|
||||||
@ -176,7 +128,6 @@ export class CsvBookingService {
|
|||||||
fileName: doc.fileName,
|
fileName: doc.fileName,
|
||||||
})),
|
})),
|
||||||
confirmationToken,
|
confirmationToken,
|
||||||
notes: dto.notes,
|
|
||||||
});
|
});
|
||||||
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
|
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@ -250,130 +201,6 @@ export class CsvBookingService {
|
|||||||
return this.toResponseDto(booking);
|
return this.toResponseDto(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify password and get booking documents for carrier (public endpoint)
|
|
||||||
* Only accessible for ACCEPTED bookings with correct password
|
|
||||||
*/
|
|
||||||
async getDocumentsForCarrier(
|
|
||||||
token: string,
|
|
||||||
password?: string
|
|
||||||
): Promise<CarrierDocumentsResponseDto> {
|
|
||||||
this.logger.log(`Getting documents for carrier with token: ${token}`);
|
|
||||||
|
|
||||||
// Get ORM entity to access passwordHash
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { confirmationToken: token },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ormBooking) {
|
|
||||||
throw new NotFoundException('Réservation introuvable');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only allow access for ACCEPTED bookings
|
|
||||||
if (ormBooking.status !== 'ACCEPTED') {
|
|
||||||
throw new BadRequestException("Cette réservation n'a pas encore été acceptée");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if password protection is enabled for this booking
|
|
||||||
if (ormBooking.passwordHash) {
|
|
||||||
if (!password) {
|
|
||||||
throw new UnauthorizedException('Mot de passe requis pour accéder aux documents');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPasswordValid = await argon2.verify(ormBooking.passwordHash, password);
|
|
||||||
if (!isPasswordValid) {
|
|
||||||
throw new UnauthorizedException('Mot de passe incorrect');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get domain booking for business logic
|
|
||||||
const booking = await this.csvBookingRepository.findByToken(token);
|
|
||||||
if (!booking) {
|
|
||||||
throw new NotFoundException('Réservation introuvable');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate signed URLs for all documents
|
|
||||||
const documentsWithUrls = await Promise.all(
|
|
||||||
booking.documents.map(async doc => {
|
|
||||||
const signedUrl = await this.generateSignedUrlForDocument(doc.filePath);
|
|
||||||
return {
|
|
||||||
id: doc.id,
|
|
||||||
type: doc.type,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
size: doc.size,
|
|
||||||
downloadUrl: signedUrl,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const primaryCurrency = booking.primaryCurrency as 'USD' | 'EUR';
|
|
||||||
|
|
||||||
return {
|
|
||||||
booking: {
|
|
||||||
id: booking.id,
|
|
||||||
bookingNumber: ormBooking.bookingNumber || undefined,
|
|
||||||
carrierName: booking.carrierName,
|
|
||||||
origin: booking.origin.getValue(),
|
|
||||||
destination: booking.destination.getValue(),
|
|
||||||
routeDescription: booking.getRouteDescription(),
|
|
||||||
volumeCBM: booking.volumeCBM,
|
|
||||||
weightKG: booking.weightKG,
|
|
||||||
palletCount: booking.palletCount,
|
|
||||||
price: booking.getPriceInCurrency(primaryCurrency),
|
|
||||||
currency: primaryCurrency,
|
|
||||||
transitDays: booking.transitDays,
|
|
||||||
containerType: booking.containerType,
|
|
||||||
acceptedAt: booking.respondedAt!,
|
|
||||||
},
|
|
||||||
documents: documentsWithUrls,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a booking requires password for document access
|
|
||||||
*/
|
|
||||||
async checkDocumentAccessRequirements(
|
|
||||||
token: string
|
|
||||||
): Promise<{ requiresPassword: boolean; bookingNumber?: string; status: string }> {
|
|
||||||
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
|
||||||
where: { confirmationToken: token },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ormBooking) {
|
|
||||||
throw new NotFoundException('Réservation introuvable');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
requiresPassword: !!ormBooking.passwordHash,
|
|
||||||
bookingNumber: ormBooking.bookingNumber || undefined,
|
|
||||||
status: ormBooking.status,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate signed URL for a document file path
|
|
||||||
*/
|
|
||||||
private async generateSignedUrlForDocument(filePath: string): Promise<string> {
|
|
||||||
const bucket = 'xpeditis-documents';
|
|
||||||
|
|
||||||
// Extract key from the file path
|
|
||||||
let key = filePath;
|
|
||||||
if (filePath.includes('xpeditis-documents/')) {
|
|
||||||
key = filePath.split('xpeditis-documents/')[1];
|
|
||||||
} else if (filePath.startsWith('http')) {
|
|
||||||
const url = new URL(filePath);
|
|
||||||
key = url.pathname.replace(/^\//, '');
|
|
||||||
if (key.startsWith('xpeditis-documents/')) {
|
|
||||||
key = key.replace('xpeditis-documents/', '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate signed URL with 1 hour expiration
|
|
||||||
const signedUrl = await this.storageAdapter.getSignedUrl({ bucket, key }, 3600);
|
|
||||||
return signedUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a booking request
|
* Accept a booking request
|
||||||
*/
|
*/
|
||||||
@ -386,11 +213,6 @@ 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();
|
||||||
|
|
||||||
@ -398,31 +220,6 @@ 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
|
|
||||||
try {
|
|
||||||
await this.emailAdapter.sendDocumentAccessEmail(booking.carrierEmail, {
|
|
||||||
carrierName: booking.carrierName,
|
|
||||||
bookingId: booking.id,
|
|
||||||
bookingNumber: bookingNumber || undefined,
|
|
||||||
documentPassword: documentPassword,
|
|
||||||
origin: booking.origin.getValue(),
|
|
||||||
destination: booking.destination.getValue(),
|
|
||||||
volumeCBM: booking.volumeCBM,
|
|
||||||
weightKG: booking.weightKG,
|
|
||||||
documentCount: booking.documents.length,
|
|
||||||
confirmationToken: booking.confirmationToken,
|
|
||||||
});
|
|
||||||
this.logger.log(`Document access email sent to carrier: ${booking.carrierEmail}`);
|
|
||||||
} catch (error: any) {
|
|
||||||
this.logger.error(`Failed to send document access email: ${error?.message}`, error?.stack);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create notification for user
|
// Create notification for user
|
||||||
try {
|
try {
|
||||||
const notification = Notification.create({
|
const notification = Notification.create({
|
||||||
@ -678,9 +475,9 @@ export class CsvBookingService {
|
|||||||
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow adding documents to PENDING or ACCEPTED bookings
|
// Verify booking is still pending
|
||||||
if (booking.status !== CsvBookingStatus.PENDING && booking.status !== CsvBookingStatus.ACCEPTED) {
|
if (booking.status !== CsvBookingStatus.PENDING) {
|
||||||
throw new BadRequestException('Cannot add documents to a booking that is rejected or cancelled');
|
throw new BadRequestException('Cannot add documents to a booking that is not pending');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload new documents
|
// Upload new documents
|
||||||
@ -709,24 +506,6 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
this.logger.log(`Added ${newDocuments.length} documents to booking ${bookingId}`);
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Documents added successfully',
|
message: 'Documents added successfully',
|
||||||
@ -922,7 +701,6 @@ export class CsvBookingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
bookingNumber: booking.bookingNumber,
|
|
||||||
userId: booking.userId,
|
userId: booking.userId,
|
||||||
organizationId: booking.organizationId,
|
organizationId: booking.organizationId,
|
||||||
carrierName: booking.carrierName,
|
carrierName: booking.carrierName,
|
||||||
|
|||||||
@ -1,35 +1,37 @@
|
|||||||
/**
|
/**
|
||||||
* GDPR Compliance Service
|
* GDPR Compliance Service - Simplified Version
|
||||||
*
|
*
|
||||||
* Handles data export, deletion, and consent management
|
* Handles data export, deletion, and consent management
|
||||||
* with full database persistence
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
import { UserOrmEntity } from '../../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
import { CookieConsentOrmEntity } from '../../infrastructure/persistence/typeorm/entities/cookie-consent.orm-entity';
|
|
||||||
import { UpdateConsentDto, ConsentResponseDto } from '../dto/consent.dto';
|
|
||||||
|
|
||||||
export interface GDPRDataExport {
|
export interface GDPRDataExport {
|
||||||
exportDate: string;
|
exportDate: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
userData: any;
|
userData: any;
|
||||||
cookieConsent: any;
|
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ConsentData {
|
||||||
|
userId: string;
|
||||||
|
marketing: boolean;
|
||||||
|
analytics: boolean;
|
||||||
|
functional: boolean;
|
||||||
|
consentDate: Date;
|
||||||
|
ipAddress?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GDPRService {
|
export class GDPRService {
|
||||||
private readonly logger = new Logger(GDPRService.name);
|
private readonly logger = new Logger(GDPRService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(UserOrmEntity)
|
@InjectRepository(UserOrmEntity)
|
||||||
private readonly userRepository: Repository<UserOrmEntity>,
|
private readonly userRepository: Repository<UserOrmEntity>
|
||||||
@InjectRepository(CookieConsentOrmEntity)
|
|
||||||
private readonly consentRepository: Repository<CookieConsentOrmEntity>
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -44,9 +46,6 @@ export class GDPRService {
|
|||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch consent data
|
|
||||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
|
||||||
|
|
||||||
// Sanitize user data (remove password hash)
|
// Sanitize user data (remove password hash)
|
||||||
const sanitizedUser = {
|
const sanitizedUser = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
@ -64,15 +63,6 @@ export class GDPRService {
|
|||||||
exportDate: new Date().toISOString(),
|
exportDate: new Date().toISOString(),
|
||||||
userId,
|
userId,
|
||||||
userData: sanitizedUser,
|
userData: sanitizedUser,
|
||||||
cookieConsent: consent
|
|
||||||
? {
|
|
||||||
essential: consent.essential,
|
|
||||||
functional: consent.functional,
|
|
||||||
analytics: consent.analytics,
|
|
||||||
marketing: consent.marketing,
|
|
||||||
consentDate: consent.consentDate,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
message:
|
message:
|
||||||
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
'User data exported successfully. Additional data (bookings, notifications) can be exported from respective endpoints.',
|
||||||
};
|
};
|
||||||
@ -98,9 +88,6 @@ export class GDPRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Delete consent data first (will cascade with user deletion)
|
|
||||||
await this.consentRepository.delete({ userId });
|
|
||||||
|
|
||||||
// IMPORTANT: In production, implement full data anonymization
|
// IMPORTANT: In production, implement full data anonymization
|
||||||
// For now, we just mark the account for deletion
|
// For now, we just mark the account for deletion
|
||||||
// Real implementation should:
|
// Real implementation should:
|
||||||
@ -118,139 +105,55 @@ export class GDPRService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Record or update consent (GDPR Article 7 - Conditions for consent)
|
* Record consent (GDPR Article 7 - Conditions for consent)
|
||||||
*/
|
*/
|
||||||
async recordConsent(
|
async recordConsent(consentData: ConsentData): Promise<void> {
|
||||||
userId: string,
|
this.logger.log(`Recording consent for user ${consentData.userId}`);
|
||||||
consentData: UpdateConsentDto
|
|
||||||
): Promise<ConsentResponseDto> {
|
const user = await this.userRepository.findOne({
|
||||||
this.logger.log(`Recording consent for user ${userId}`);
|
where: { id: consentData.userId },
|
||||||
|
});
|
||||||
|
|
||||||
// Verify user exists
|
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if consent already exists
|
// In production, store in separate consent table
|
||||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
// For now, just log the consent
|
||||||
|
this.logger.log(
|
||||||
if (consent) {
|
`Consent recorded: marketing=${consentData.marketing}, analytics=${consentData.analytics}`
|
||||||
// Update existing consent
|
);
|
||||||
consent.essential = true; // Always true
|
|
||||||
consent.functional = consentData.functional;
|
|
||||||
consent.analytics = consentData.analytics;
|
|
||||||
consent.marketing = consentData.marketing;
|
|
||||||
consent.ipAddress = consentData.ipAddress || consent.ipAddress;
|
|
||||||
consent.userAgent = consentData.userAgent || consent.userAgent;
|
|
||||||
consent.consentDate = new Date();
|
|
||||||
|
|
||||||
await this.consentRepository.save(consent);
|
|
||||||
this.logger.log(`Consent updated for user ${userId}`);
|
|
||||||
} else {
|
|
||||||
// Create new consent record
|
|
||||||
consent = this.consentRepository.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
userId,
|
|
||||||
essential: true, // Always true
|
|
||||||
functional: consentData.functional,
|
|
||||||
analytics: consentData.analytics,
|
|
||||||
marketing: consentData.marketing,
|
|
||||||
ipAddress: consentData.ipAddress,
|
|
||||||
userAgent: consentData.userAgent,
|
|
||||||
consentDate: new Date(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.consentRepository.save(consent);
|
|
||||||
this.logger.log(`New consent created for user ${userId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
essential: consent.essential,
|
|
||||||
functional: consent.functional,
|
|
||||||
analytics: consent.analytics,
|
|
||||||
marketing: consent.marketing,
|
|
||||||
consentDate: consent.consentDate,
|
|
||||||
updatedAt: consent.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Withdraw specific consent (GDPR Article 7.3 - Withdrawal of consent)
|
* Withdraw consent (GDPR Article 7.3 - Withdrawal of consent)
|
||||||
*/
|
*/
|
||||||
async withdrawConsent(
|
async withdrawConsent(userId: string, consentType: 'marketing' | 'analytics'): Promise<void> {
|
||||||
userId: string,
|
|
||||||
consentType: 'functional' | 'analytics' | 'marketing'
|
|
||||||
): Promise<ConsentResponseDto> {
|
|
||||||
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
this.logger.log(`Withdrawing ${consentType} consent for user ${userId}`);
|
||||||
|
|
||||||
// Verify user exists
|
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find consent record
|
|
||||||
let consent = await this.consentRepository.findOne({ where: { userId } });
|
|
||||||
|
|
||||||
if (!consent) {
|
|
||||||
// Create default consent with withdrawn type
|
|
||||||
consent = this.consentRepository.create({
|
|
||||||
id: uuidv4(),
|
|
||||||
userId,
|
|
||||||
essential: true,
|
|
||||||
functional: consentType === 'functional' ? false : false,
|
|
||||||
analytics: consentType === 'analytics' ? false : false,
|
|
||||||
marketing: consentType === 'marketing' ? false : false,
|
|
||||||
consentDate: new Date(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Update specific consent type
|
|
||||||
consent[consentType] = false;
|
|
||||||
consent.consentDate = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.consentRepository.save(consent);
|
|
||||||
this.logger.log(`${consentType} consent withdrawn for user ${userId}`);
|
this.logger.log(`${consentType} consent withdrawn for user ${userId}`);
|
||||||
|
|
||||||
return {
|
|
||||||
userId,
|
|
||||||
essential: consent.essential,
|
|
||||||
functional: consent.functional,
|
|
||||||
analytics: consent.analytics,
|
|
||||||
marketing: consent.marketing,
|
|
||||||
consentDate: consent.consentDate,
|
|
||||||
updatedAt: consent.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get current consent status
|
* Get current consent status
|
||||||
*/
|
*/
|
||||||
async getConsentStatus(userId: string): Promise<ConsentResponseDto | null> {
|
async getConsentStatus(userId: string): Promise<any> {
|
||||||
// Verify user exists
|
|
||||||
const user = await this.userRepository.findOne({ where: { id: userId } });
|
const user = await this.userRepository.findOne({ where: { id: userId } });
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find consent record
|
// Default consent status
|
||||||
const consent = await this.consentRepository.findOne({ where: { userId } });
|
|
||||||
|
|
||||||
if (!consent) {
|
|
||||||
// No consent recorded yet - return null to indicate user should provide consent
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
userId,
|
marketing: false,
|
||||||
essential: consent.essential,
|
analytics: false,
|
||||||
functional: consent.functional,
|
functional: true,
|
||||||
analytics: consent.analytics,
|
message: 'Consent management fully implemented in production version',
|
||||||
marketing: consent.marketing,
|
|
||||||
consentDate: consent.consentDate,
|
|
||||||
updatedAt: consent.updatedAt,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,8 +79,7 @@ export class CsvBooking {
|
|||||||
public readonly requestedAt: Date,
|
public readonly requestedAt: Date,
|
||||||
public respondedAt?: Date,
|
public respondedAt?: Date,
|
||||||
public notes?: string,
|
public notes?: string,
|
||||||
public rejectionReason?: string,
|
public rejectionReason?: string
|
||||||
public readonly bookingNumber?: string
|
|
||||||
) {
|
) {
|
||||||
this.validate();
|
this.validate();
|
||||||
}
|
}
|
||||||
@ -362,8 +361,7 @@ export class CsvBooking {
|
|||||||
requestedAt: Date,
|
requestedAt: Date,
|
||||||
respondedAt?: Date,
|
respondedAt?: Date,
|
||||||
notes?: string,
|
notes?: string,
|
||||||
rejectionReason?: string,
|
rejectionReason?: string
|
||||||
bookingNumber?: string
|
|
||||||
): CsvBooking {
|
): CsvBooking {
|
||||||
// Create instance without calling constructor validation
|
// Create instance without calling constructor validation
|
||||||
const booking = Object.create(CsvBooking.prototype);
|
const booking = Object.create(CsvBooking.prototype);
|
||||||
@ -391,7 +389,6 @@ export class CsvBooking {
|
|||||||
booking.respondedAt = respondedAt;
|
booking.respondedAt = respondedAt;
|
||||||
booking.notes = notes;
|
booking.notes = notes;
|
||||||
booking.rejectionReason = rejectionReason;
|
booking.rejectionReason = rejectionReason;
|
||||||
booking.bookingNumber = bookingNumber;
|
|
||||||
|
|
||||||
return booking;
|
return booking;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,8 +85,6 @@ 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;
|
||||||
@ -102,7 +100,6 @@ export interface EmailPort {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
}>;
|
}>;
|
||||||
confirmationToken: string;
|
confirmationToken: string;
|
||||||
notes?: string;
|
|
||||||
}
|
}
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
@ -123,39 +120,4 @@ export interface EmailPort {
|
|||||||
carrierName: string,
|
carrierName: string,
|
||||||
temporaryPassword: string
|
temporaryPassword: string
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Send document access email to carrier after booking acceptance
|
|
||||||
*/
|
|
||||||
sendDocumentAccessEmail(
|
|
||||||
carrierEmail: string,
|
|
||||||
data: {
|
|
||||||
carrierName: string;
|
|
||||||
bookingId: string;
|
|
||||||
bookingNumber?: string;
|
|
||||||
documentPassword?: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
volumeCBM: number;
|
|
||||||
weightKG: number;
|
|
||||||
documentCount: number;
|
|
||||||
confirmationToken: string;
|
|
||||||
}
|
|
||||||
): Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to carrier when new documents are added
|
|
||||||
*/
|
|
||||||
sendNewDocumentsNotification(
|
|
||||||
carrierEmail: string,
|
|
||||||
data: {
|
|
||||||
carrierName: string;
|
|
||||||
bookingId: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
newDocumentsCount: number;
|
|
||||||
totalDocumentsCount: number;
|
|
||||||
confirmationToken: string;
|
|
||||||
}
|
|
||||||
): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -239,60 +239,6 @@ export class CsvRateSearchService implements SearchCsvRatesPort {
|
|||||||
return Array.from(types).sort();
|
return Array.from(types).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all unique origin port codes from CSV rates
|
|
||||||
* Used to limit port selection to only those with available routes
|
|
||||||
*/
|
|
||||||
async getAvailableOrigins(): Promise<string[]> {
|
|
||||||
const allRates = await this.loadAllRates();
|
|
||||||
const origins = new Set(allRates.map(rate => rate.origin.getValue()));
|
|
||||||
return Array.from(origins).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all destination port codes available for a given origin
|
|
||||||
* Used to limit destination selection based on selected origin
|
|
||||||
*/
|
|
||||||
async getAvailableDestinations(origin: string): Promise<string[]> {
|
|
||||||
const allRates = await this.loadAllRates();
|
|
||||||
const originCode = PortCode.create(origin);
|
|
||||||
|
|
||||||
const destinations = new Set(
|
|
||||||
allRates
|
|
||||||
.filter(rate => rate.origin.equals(originCode))
|
|
||||||
.map(rate => rate.destination.getValue())
|
|
||||||
);
|
|
||||||
|
|
||||||
return Array.from(destinations).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available routes (origin-destination pairs) from CSV rates
|
|
||||||
* Returns a map of origin codes to their available destination codes
|
|
||||||
*/
|
|
||||||
async getAvailableRoutes(): Promise<Map<string, string[]>> {
|
|
||||||
const allRates = await this.loadAllRates();
|
|
||||||
const routeMap = new Map<string, Set<string>>();
|
|
||||||
|
|
||||||
allRates.forEach(rate => {
|
|
||||||
const origin = rate.origin.getValue();
|
|
||||||
const destination = rate.destination.getValue();
|
|
||||||
|
|
||||||
if (!routeMap.has(origin)) {
|
|
||||||
routeMap.set(origin, new Set());
|
|
||||||
}
|
|
||||||
routeMap.get(origin)!.add(destination);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert Sets to sorted arrays
|
|
||||||
const result = new Map<string, string[]>();
|
|
||||||
routeMap.forEach((destinations, origin) => {
|
|
||||||
result.set(origin, Array.from(destinations).sort());
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load all rates from all CSV files
|
* Load all rates from all CSV files
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -239,8 +239,6 @@ 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;
|
||||||
@ -256,7 +254,6 @@ export class EmailAdapter implements EmailPort {
|
|||||||
fileName: string;
|
fileName: string;
|
||||||
}>;
|
}>;
|
||||||
confirmationToken: string;
|
confirmationToken: string;
|
||||||
notes?: string;
|
|
||||||
}
|
}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Use APP_URL (frontend) for accept/reject links
|
// Use APP_URL (frontend) for accept/reject links
|
||||||
@ -273,7 +270,7 @@ export class EmailAdapter implements EmailPort {
|
|||||||
|
|
||||||
await this.send({
|
await this.send({
|
||||||
to: carrierEmail,
|
to: carrierEmail,
|
||||||
subject: `Nouvelle demande de réservation ${bookingData.bookingNumber || ''} - ${bookingData.origin} → ${bookingData.destination}`,
|
subject: `Nouvelle demande de réservation - ${bookingData.origin} → ${bookingData.destination}`,
|
||||||
html,
|
html,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -430,194 +427,4 @@ export class EmailAdapter implements EmailPort {
|
|||||||
|
|
||||||
this.logger.log(`Carrier password reset email sent to ${email}`);
|
this.logger.log(`Carrier password reset email sent to ${email}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Send document access email to carrier after booking acceptance
|
|
||||||
*/
|
|
||||||
async sendDocumentAccessEmail(
|
|
||||||
carrierEmail: string,
|
|
||||||
data: {
|
|
||||||
carrierName: string;
|
|
||||||
bookingId: string;
|
|
||||||
bookingNumber?: string;
|
|
||||||
documentPassword?: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
volumeCBM: number;
|
|
||||||
weightKG: number;
|
|
||||||
documentCount: number;
|
|
||||||
confirmationToken: string;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
|
||||||
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
|
|
||||||
|
|
||||||
// Password section HTML - only show if password is set
|
|
||||||
const passwordSection = data.documentPassword
|
|
||||||
? `
|
|
||||||
<div style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 20px; margin: 20px 0;">
|
|
||||||
<h3 style="margin: 0 0 10px 0; color: #92400e; font-size: 16px;">🔐 Mot de passe d'accès aux documents</h3>
|
|
||||||
<p style="margin: 0; color: #78350f;">Pour accéder aux documents, vous aurez besoin du mot de passe suivant :</p>
|
|
||||||
<div style="background: white; border-radius: 6px; padding: 15px; margin-top: 15px; text-align: center;">
|
|
||||||
<code style="font-size: 24px; font-weight: bold; color: #1e293b; letter-spacing: 2px;">${data.documentPassword}</code>
|
|
||||||
</div>
|
|
||||||
<p style="margin: 15px 0 0 0; color: #78350f; font-size: 13px;">⚠️ Conservez ce mot de passe, il vous sera demandé à chaque accès.</p>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
|
||||||
.header { background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white; padding: 30px 20px; text-align: center; }
|
|
||||||
.header h1 { margin: 0; font-size: 24px; }
|
|
||||||
.content { padding: 30px; }
|
|
||||||
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
|
|
||||||
.route-arrow { color: #0284c7; margin: 0 10px; }
|
|
||||||
.summary { background: #f8fafc; border-radius: 8px; padding: 20px; margin: 20px 0; }
|
|
||||||
.summary-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #e2e8f0; }
|
|
||||||
.summary-row:last-child { border-bottom: none; }
|
|
||||||
.documents-badge { display: inline-block; background: #dbeafe; color: #1d4ed8; padding: 8px 16px; border-radius: 20px; font-size: 14px; font-weight: 500; margin: 20px 0; }
|
|
||||||
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #0284c7 0%, #0ea5e9 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
|
|
||||||
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Documents disponibles</h1>
|
|
||||||
<p style="margin: 10px 0 0 0; opacity: 0.9;">Votre reservation a ete acceptee</p>
|
|
||||||
${data.bookingNumber ? `<p style="margin: 5px 0 0 0; opacity: 0.9; font-size: 14px;">N° ${data.bookingNumber}</p>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
|
|
||||||
<p>Merci d'avoir accepte la demande de reservation. Les documents associes sont maintenant disponibles au telechargement.</p>
|
|
||||||
|
|
||||||
<div class="route">
|
|
||||||
${data.origin} <span class="route-arrow">→</span> ${data.destination}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="summary">
|
|
||||||
<div class="summary-row">
|
|
||||||
<span style="color: #64748b;">Volume</span>
|
|
||||||
<span style="font-weight: 500;">${data.volumeCBM} CBM</span>
|
|
||||||
</div>
|
|
||||||
<div class="summary-row">
|
|
||||||
<span style="color: #64748b;">Poids</span>
|
|
||||||
<span style="font-weight: 500;">${data.weightKG} kg</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style="text-align: center;">
|
|
||||||
<span class="documents-badge">${data.documentCount} document${data.documentCount > 1 ? 's' : ''} disponible${data.documentCount > 1 ? 's' : ''}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${passwordSection}
|
|
||||||
|
|
||||||
<a href="${documentsUrl}" class="cta-button">Acceder aux documents</a>
|
|
||||||
|
|
||||||
<p style="color: #64748b; font-size: 14px; text-align: center;">Ce lien est permanent. Vous pouvez y acceder a tout moment.</p>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>Reference: ${data.bookingNumber || data.bookingId.substring(0, 8).toUpperCase()}</p>
|
|
||||||
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await this.send({
|
|
||||||
to: carrierEmail,
|
|
||||||
subject: `Documents disponibles - Reservation ${data.bookingNumber || ''} ${data.origin} → ${data.destination}`,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Document access email sent to ${carrierEmail} for booking ${data.bookingId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send notification to carrier when new documents are added
|
|
||||||
*/
|
|
||||||
async sendNewDocumentsNotification(
|
|
||||||
carrierEmail: string,
|
|
||||||
data: {
|
|
||||||
carrierName: string;
|
|
||||||
bookingId: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
newDocumentsCount: number;
|
|
||||||
totalDocumentsCount: number;
|
|
||||||
confirmationToken: string;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const frontendUrl = this.configService.get('APP_URL', 'http://localhost:3000');
|
|
||||||
const documentsUrl = `${frontendUrl}/carrier/documents/${data.confirmationToken}`;
|
|
||||||
|
|
||||||
const html = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; background: #f5f5f5; }
|
|
||||||
.container { max-width: 600px; margin: 0 auto; background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
|
||||||
.header { background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white; padding: 30px 20px; text-align: center; }
|
|
||||||
.header h1 { margin: 0; font-size: 24px; }
|
|
||||||
.content { padding: 30px; }
|
|
||||||
.route { font-size: 20px; font-weight: 600; color: #1e293b; text-align: center; margin: 20px 0; }
|
|
||||||
.route-arrow { color: #f59e0b; margin: 0 10px; }
|
|
||||||
.highlight { background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 15px; margin: 20px 0; text-align: center; }
|
|
||||||
.cta-button { display: block; text-align: center; background: linear-gradient(135deg, #f59e0b 0%, #fbbf24 100%); color: white !important; text-decoration: none; padding: 16px 32px; border-radius: 8px; font-size: 16px; font-weight: 600; margin: 25px 0; }
|
|
||||||
.footer { background: #f8fafc; text-align: center; padding: 20px; color: #64748b; font-size: 12px; border-top: 1px solid #e2e8f0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<h1>Nouveaux documents ajoutes</h1>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<p>Bonjour <strong>${data.carrierName}</strong>,</p>
|
|
||||||
<p>De nouveaux documents ont ete ajoutes a votre reservation.</p>
|
|
||||||
|
|
||||||
<div class="route">
|
|
||||||
${data.origin} <span class="route-arrow">→</span> ${data.destination}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="highlight">
|
|
||||||
<p style="margin: 0; font-size: 18px; font-weight: 600; color: #92400e;">
|
|
||||||
+${data.newDocumentsCount} nouveau${data.newDocumentsCount > 1 ? 'x' : ''} document${data.newDocumentsCount > 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
<p style="margin: 5px 0 0 0; color: #a16207;">
|
|
||||||
Total: ${data.totalDocumentsCount} document${data.totalDocumentsCount > 1 ? 's' : ''}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="${documentsUrl}" class="cta-button">Voir les documents</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>Reference: ${data.bookingId.substring(0, 8).toUpperCase()}</p>
|
|
||||||
<p>© ${new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
await this.send({
|
|
||||||
to: carrierEmail,
|
|
||||||
subject: `Nouveaux documents - Reservation ${data.origin} → ${data.destination}`,
|
|
||||||
html,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`New documents notification sent to ${carrierEmail} for booking ${data.bookingId}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -261,8 +261,6 @@ export class EmailTemplates {
|
|||||||
*/
|
*/
|
||||||
async renderCsvBookingRequest(data: {
|
async renderCsvBookingRequest(data: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
bookingNumber?: string;
|
|
||||||
documentPassword?: string;
|
|
||||||
origin: string;
|
origin: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
volumeCBM: number;
|
volumeCBM: number;
|
||||||
@ -277,7 +275,6 @@ export class EmailTemplates {
|
|||||||
type: string;
|
type: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
}>;
|
}>;
|
||||||
notes?: string;
|
|
||||||
acceptUrl: string;
|
acceptUrl: string;
|
||||||
rejectUrl: string;
|
rejectUrl: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
@ -484,21 +481,6 @@ export class EmailTemplates {
|
|||||||
Vous avez reçu une nouvelle demande de réservation via Xpeditis. Veuillez examiner les détails ci-dessous et confirmer ou refuser cette demande.
|
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">
|
||||||
@ -558,14 +540,6 @@ export class EmailTemplates {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{#if notes}}
|
|
||||||
<!-- Notes -->
|
|
||||||
<div style="background-color: #f0f9ff; border-left: 4px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;">
|
|
||||||
<p style="margin: 0 0 5px 0; font-weight: 700; color: #045a8d; font-size: 14px;">📝 Notes du client</p>
|
|
||||||
<p style="margin: 0; font-size: 14px; color: #333; line-height: 1.6;">{{notes}}</p>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<p>Veuillez confirmer votre décision :</p>
|
<p>Veuillez confirmer votre décision :</p>
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cookie Consent ORM Entity (Infrastructure Layer)
|
|
||||||
*
|
|
||||||
* TypeORM entity for cookie consent persistence
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
Entity,
|
|
||||||
Column,
|
|
||||||
PrimaryColumn,
|
|
||||||
CreateDateColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
Index,
|
|
||||||
ManyToOne,
|
|
||||||
JoinColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { UserOrmEntity } from './user.orm-entity';
|
|
||||||
|
|
||||||
@Entity('cookie_consents')
|
|
||||||
@Index('idx_cookie_consents_user', ['userId'])
|
|
||||||
export class CookieConsentOrmEntity {
|
|
||||||
@PrimaryColumn('uuid')
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@Column({ name: 'user_id', type: 'uuid', unique: true })
|
|
||||||
userId: string;
|
|
||||||
|
|
||||||
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
|
|
||||||
@JoinColumn({ name: 'user_id' })
|
|
||||||
user: UserOrmEntity;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
|
||||||
essential: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
functional: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
analytics: boolean;
|
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
|
||||||
marketing: boolean;
|
|
||||||
|
|
||||||
@Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true })
|
|
||||||
ipAddress: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'user_agent', type: 'text', nullable: true })
|
|
||||||
userAgent: string | null;
|
|
||||||
|
|
||||||
@Column({ name: 'consent_date', type: 'timestamp', default: () => 'NOW()' })
|
|
||||||
consentDate: Date;
|
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at' })
|
|
||||||
createdAt: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({ name: 'updated_at' })
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
@ -96,13 +96,6 @@ export class CsvBookingOrmEntity {
|
|||||||
@Index()
|
@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;
|
||||||
|
|||||||
@ -41,8 +41,7 @@ export class CsvBookingMapper {
|
|||||||
ormEntity.requestedAt,
|
ormEntity.requestedAt,
|
||||||
ormEntity.respondedAt,
|
ormEntity.respondedAt,
|
||||||
ormEntity.notes,
|
ormEntity.notes,
|
||||||
ormEntity.rejectionReason,
|
ormEntity.rejectionReason
|
||||||
ormEntity.bookingNumber ?? undefined
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration: Create Cookie Consents Table
|
|
||||||
* GDPR compliant cookie preference storage
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class CreateCookieConsent1738100000000 implements MigrationInterface {
|
|
||||||
name = 'CreateCookieConsent1738100000000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Create cookie_consents table
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE TABLE "cookie_consents" (
|
|
||||||
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
|
|
||||||
"user_id" UUID NOT NULL,
|
|
||||||
"essential" BOOLEAN NOT NULL DEFAULT TRUE,
|
|
||||||
"functional" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
"analytics" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
"marketing" BOOLEAN NOT NULL DEFAULT FALSE,
|
|
||||||
"ip_address" VARCHAR(45) NULL,
|
|
||||||
"user_agent" TEXT NULL,
|
|
||||||
"consent_date" TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
"created_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
"updated_at" TIMESTAMP NOT NULL DEFAULT NOW(),
|
|
||||||
CONSTRAINT "pk_cookie_consents" PRIMARY KEY ("id"),
|
|
||||||
CONSTRAINT "uq_cookie_consents_user" UNIQUE ("user_id"),
|
|
||||||
CONSTRAINT "fk_cookie_consents_user" FOREIGN KEY ("user_id")
|
|
||||||
REFERENCES "users"("id") ON DELETE CASCADE
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create index for fast user lookups
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE INDEX "idx_cookie_consents_user" ON "cookie_consents" ("user_id")
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add comments
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON TABLE "cookie_consents" IS 'GDPR compliant cookie consent preferences per user'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "cookie_consents"."essential" IS 'Essential cookies - always true, required for functionality'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "cookie_consents"."functional" IS 'Functional cookies - preferences, language, etc.'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "cookie_consents"."analytics" IS 'Analytics cookies - Google Analytics, Sentry, etc.'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "cookie_consents"."marketing" IS 'Marketing cookies - ads, tracking, remarketing'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "cookie_consents"."ip_address" IS 'IP address at time of consent for GDPR audit trail'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
await queryRunner.query(`DROP TABLE "cookie_consents"`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
/**
|
|
||||||
* Migration: Add Password Protection to CSV Bookings
|
|
||||||
*
|
|
||||||
* Adds password protection for carrier document access
|
|
||||||
* Including: booking_number (readable ID) and password_hash
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
|
||||||
|
|
||||||
export class AddPasswordToCsvBookings1738200000000 implements MigrationInterface {
|
|
||||||
name = 'AddPasswordToCsvBookings1738200000000';
|
|
||||||
|
|
||||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Add password-related columns to csv_bookings
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
ADD COLUMN "booking_number" VARCHAR(20) NULL,
|
|
||||||
ADD COLUMN "password_hash" TEXT NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create unique index for booking_number
|
|
||||||
await queryRunner.query(`
|
|
||||||
CREATE UNIQUE INDEX "idx_csv_bookings_booking_number"
|
|
||||||
ON "csv_bookings" ("booking_number")
|
|
||||||
WHERE "booking_number" IS NOT NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Add comments
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "csv_bookings"."booking_number" IS 'Human-readable booking number (format: XPD-YYYY-XXXXXX)'
|
|
||||||
`);
|
|
||||||
await queryRunner.query(`
|
|
||||||
COMMENT ON COLUMN "csv_bookings"."password_hash" IS 'Argon2 hashed password for carrier document access'
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
|
||||||
// Remove index first
|
|
||||||
await queryRunner.query(`DROP INDEX IF EXISTS "idx_csv_bookings_booking_number"`);
|
|
||||||
|
|
||||||
// Remove columns
|
|
||||||
await queryRunner.query(`
|
|
||||||
ALTER TABLE "csv_bookings"
|
|
||||||
DROP COLUMN IF EXISTS "booking_number",
|
|
||||||
DROP COLUMN IF EXISTS "password_hash"
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,568 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import {
|
|
||||||
FileText,
|
|
||||||
Download,
|
|
||||||
Loader2,
|
|
||||||
XCircle,
|
|
||||||
Package,
|
|
||||||
Ship,
|
|
||||||
Clock,
|
|
||||||
AlertCircle,
|
|
||||||
ArrowRight,
|
|
||||||
Lock,
|
|
||||||
Eye,
|
|
||||||
EyeOff,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface Document {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
fileName: string;
|
|
||||||
mimeType: string;
|
|
||||||
size: number;
|
|
||||||
downloadUrl: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BookingSummary {
|
|
||||||
id: string;
|
|
||||||
bookingNumber?: string;
|
|
||||||
carrierName: string;
|
|
||||||
origin: string;
|
|
||||||
destination: string;
|
|
||||||
routeDescription: string;
|
|
||||||
volumeCBM: number;
|
|
||||||
weightKG: number;
|
|
||||||
palletCount: number;
|
|
||||||
price: number;
|
|
||||||
currency: string;
|
|
||||||
transitDays: number;
|
|
||||||
containerType: string;
|
|
||||||
acceptedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CarrierDocumentsData {
|
|
||||||
booking: BookingSummary;
|
|
||||||
documents: Document[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AccessRequirements {
|
|
||||||
requiresPassword: boolean;
|
|
||||||
bookingNumber?: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const documentTypeLabels: Record<string, string> = {
|
|
||||||
BILL_OF_LADING: 'Connaissement',
|
|
||||||
PACKING_LIST: 'Liste de colisage',
|
|
||||||
COMMERCIAL_INVOICE: 'Facture commerciale',
|
|
||||||
CERTIFICATE_OF_ORIGIN: "Certificat d'origine",
|
|
||||||
OTHER: 'Autre document',
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatFileSize = (bytes: number): string => {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getFileIcon = (mimeType: string) => {
|
|
||||||
if (mimeType.includes('pdf')) return '📄';
|
|
||||||
if (mimeType.includes('image')) return '🖼️';
|
|
||||||
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return '📊';
|
|
||||||
if (mimeType.includes('word') || mimeType.includes('document')) return '📝';
|
|
||||||
return '📎';
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function CarrierDocumentsPage() {
|
|
||||||
const params = useParams();
|
|
||||||
const token = params.token as string;
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [data, setData] = useState<CarrierDocumentsData | null>(null);
|
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Password protection state
|
|
||||||
const [requirements, setRequirements] = useState<AccessRequirements | null>(null);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
|
||||||
const [verifying, setVerifying] = useState(false);
|
|
||||||
|
|
||||||
const hasCalledApi = useRef(false);
|
|
||||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000';
|
|
||||||
|
|
||||||
// Check access requirements first
|
|
||||||
const checkRequirements = async () => {
|
|
||||||
if (!token) {
|
|
||||||
setError('Lien invalide');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
`${apiUrl}/api/v1/csv-booking-actions/documents/${token}/requirements`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await response.json();
|
|
||||||
} catch {
|
|
||||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = errorData.message || 'Erreur lors du chargement';
|
|
||||||
|
|
||||||
if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
|
||||||
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const reqData: AccessRequirements = await response.json();
|
|
||||||
setRequirements(reqData);
|
|
||||||
|
|
||||||
// If booking is not accepted yet
|
|
||||||
if (reqData.status !== 'ACCEPTED') {
|
|
||||||
setError(
|
|
||||||
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
|
||||||
);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no password required, fetch documents directly
|
|
||||||
if (!reqData.requiresPassword) {
|
|
||||||
await fetchDocumentsWithoutPassword();
|
|
||||||
} else {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error checking requirements:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch documents without password (legacy bookings)
|
|
||||||
const fetchDocumentsWithoutPassword = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await response.json();
|
|
||||||
} catch {
|
|
||||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = errorData.message || 'Erreur lors du chargement des documents';
|
|
||||||
|
|
||||||
if (
|
|
||||||
errorMessage.includes('pas encore été acceptée') ||
|
|
||||||
errorMessage.includes('not accepted')
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
"Cette réservation n'a pas encore été acceptée. Les documents seront disponibles après l'acceptation."
|
|
||||||
);
|
|
||||||
} else if (errorMessage.includes('introuvable') || errorMessage.includes('not found')) {
|
|
||||||
throw new Error('Réservation introuvable. Vérifiez que le lien est correct.');
|
|
||||||
} else if (errorMessage.includes('Mot de passe requis') || errorMessage.includes('required')) {
|
|
||||||
// Password is now required, show the form
|
|
||||||
setRequirements({ requiresPassword: true, status: 'ACCEPTED' });
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
setData(responseData);
|
|
||||||
setLoading(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error fetching documents:', err);
|
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch documents with password
|
|
||||||
const fetchDocumentsWithPassword = async (pwd: string) => {
|
|
||||||
setVerifying(true);
|
|
||||||
setPasswordError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${apiUrl}/api/v1/csv-booking-actions/documents/${token}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ password: pwd }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
let errorData;
|
|
||||||
try {
|
|
||||||
errorData = await response.json();
|
|
||||||
} catch {
|
|
||||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const errorMessage = errorData.message || 'Erreur lors de la vérification';
|
|
||||||
|
|
||||||
if (
|
|
||||||
response.status === 401 ||
|
|
||||||
errorMessage.includes('incorrect') ||
|
|
||||||
errorMessage.includes('invalid')
|
|
||||||
) {
|
|
||||||
setPasswordError('Mot de passe incorrect. Vérifiez votre email pour retrouver le mot de passe.');
|
|
||||||
setVerifying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseData = await response.json();
|
|
||||||
setData(responseData);
|
|
||||||
setVerifying(false);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error verifying password:', err);
|
|
||||||
setPasswordError(err instanceof Error ? err.message : 'Erreur lors de la vérification');
|
|
||||||
setVerifying(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasCalledApi.current) return;
|
|
||||||
hasCalledApi.current = true;
|
|
||||||
checkRequirements();
|
|
||||||
}, [token]);
|
|
||||||
|
|
||||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!password.trim()) {
|
|
||||||
setPasswordError('Veuillez entrer le mot de passe');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetchDocumentsWithPassword(password.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (doc: Document) => {
|
|
||||||
setDownloading(doc.id);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The downloadUrl is already a signed URL, open it directly
|
|
||||||
window.open(doc.downloadUrl, '_blank');
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Error downloading document:', err);
|
|
||||||
alert('Erreur lors du téléchargement. Veuillez réessayer.');
|
|
||||||
} finally {
|
|
||||||
// Small delay to show loading state
|
|
||||||
setTimeout(() => setDownloading(null), 500);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setData(null);
|
|
||||||
setRequirements(null);
|
|
||||||
setPassword('');
|
|
||||||
setPasswordError(null);
|
|
||||||
hasCalledApi.current = false;
|
|
||||||
checkRequirements();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
|
|
||||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
|
||||||
<Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
|
|
||||||
<p className="text-gray-600">Veuillez patienter</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error state
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-brand-gray">
|
|
||||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
|
|
||||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
|
||||||
<p className="text-gray-600 mb-6">{error}</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Réessayer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password form state
|
|
||||||
if (requirements?.requiresPassword && !data) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-brand-gray p-4">
|
|
||||||
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
|
|
||||||
<Lock className="w-8 h-8 text-brand-turquoise" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Cette page est protégée. Entrez le mot de passe reçu par email pour accéder aux
|
|
||||||
documents.
|
|
||||||
</p>
|
|
||||||
{requirements.bookingNumber && (
|
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
|
||||||
Réservation: <span className="font-mono font-bold">{requirements.bookingNumber}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Mot de passe
|
|
||||||
</label>
|
|
||||||
<div className="relative">
|
|
||||||
<input
|
|
||||||
type={showPassword ? 'text' : 'password'}
|
|
||||||
id="password"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value.toUpperCase())}
|
|
||||||
placeholder="Ex: A3B7K9"
|
|
||||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{passwordError && (
|
|
||||||
<p className="mt-2 text-sm text-red-600 flex items-center gap-1">
|
|
||||||
<AlertCircle className="w-4 h-4" />
|
|
||||||
{passwordError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={verifying}
|
|
||||||
className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
{verifying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-5 h-5 animate-spin" />
|
|
||||||
Vérification...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Lock className="w-5 h-5" />
|
|
||||||
Accéder aux documents
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className="mt-6 p-4 bg-amber-50 border border-amber-200 rounded-lg">
|
|
||||||
<p className="text-sm text-amber-800">
|
|
||||||
<strong>Où trouver le mot de passe ?</strong>
|
|
||||||
<br />
|
|
||||||
Le mot de passe vous a été envoyé dans l'email de confirmation de la réservation. Il
|
|
||||||
correspond aux 6 derniers caractères du numéro de devis.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data) return null;
|
|
||||||
|
|
||||||
const { booking, documents } = data;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-brand-gray">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="bg-white shadow-sm border-b">
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Image
|
|
||||||
src="/assets/logos/logo-black.svg"
|
|
||||||
alt="Xpeditis"
|
|
||||||
width={40}
|
|
||||||
height={48}
|
|
||||||
className="h-auto"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
|
|
||||||
>
|
|
||||||
Actualiser
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
{/* Booking Summary Card */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
|
|
||||||
<div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
|
|
||||||
<div className="flex items-center justify-center gap-4 text-white">
|
|
||||||
<span className="text-2xl font-bold">{booking.origin}</span>
|
|
||||||
<ArrowRight className="w-6 h-6" />
|
|
||||||
<span className="text-2xl font-bold">{booking.destination}</span>
|
|
||||||
</div>
|
|
||||||
{booking.bookingNumber && (
|
|
||||||
<p className="text-center text-white/70 text-sm mt-1">
|
|
||||||
N° {booking.bookingNumber}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xs text-gray-500">Volume</p>
|
|
||||||
<p className="font-semibold text-gray-900">{booking.volumeCBM} CBM</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<Package className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xs text-gray-500">Poids</p>
|
|
||||||
<p className="font-semibold text-gray-900">{booking.weightKG} kg</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<Clock className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xs text-gray-500">Transit</p>
|
|
||||||
<p className="font-semibold text-gray-900">{booking.transitDays} jours</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-3 bg-gray-50 rounded-lg">
|
|
||||||
<Ship className="w-5 h-5 text-gray-500 mx-auto mb-1" />
|
|
||||||
<p className="text-xs text-gray-500">Type</p>
|
|
||||||
<p className="font-semibold text-gray-900">{booking.containerType}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 pt-4 border-t border-gray-100 flex items-center justify-between text-sm">
|
|
||||||
<span className="text-gray-500">
|
|
||||||
Transporteur:{' '}
|
|
||||||
<span className="text-gray-900 font-medium">{booking.carrierName}</span>
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-500">
|
|
||||||
Ref:{' '}
|
|
||||||
<span className="font-mono text-gray-900">
|
|
||||||
{booking.bookingNumber || booking.id.substring(0, 8).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Documents Section */}
|
|
||||||
<div className="bg-white rounded-xl shadow-md overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-gray-100">
|
|
||||||
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
|
||||||
<FileText className="w-5 h-5 text-brand-turquoise" />
|
|
||||||
Documents ({documents.length})
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{documents.length === 0 ? (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
<AlertCircle className="w-12 h-12 text-gray-400 mx-auto mb-3" />
|
|
||||||
<p className="text-gray-600">Aucun document disponible pour le moment.</p>
|
|
||||||
<p className="text-gray-500 text-sm mt-1">
|
|
||||||
Les documents apparaîtront ici une fois ajoutés.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y divide-gray-100">
|
|
||||||
{documents.map(doc => (
|
|
||||||
<div
|
|
||||||
key={doc.id}
|
|
||||||
className="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<span className="text-2xl">{getFileIcon(doc.mimeType)}</span>
|
|
||||||
<div>
|
|
||||||
<p className="font-medium text-gray-900">{doc.fileName}</p>
|
|
||||||
<div className="flex items-center gap-2 mt-1">
|
|
||||||
<span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
|
|
||||||
{documentTypeLabels[doc.type] || doc.type}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => handleDownload(doc)}
|
|
||||||
disabled={downloading === doc.id}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
|
||||||
>
|
|
||||||
{downloading === doc.id ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
<span>Télécharger</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<p className="mt-6 text-center text-sm text-gray-500">
|
|
||||||
Cette page affiche toujours les documents les plus récents de la réservation.
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
|
|
||||||
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
|
import { getAllBookings, getAllUsers } from '@/lib/api/admin';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@ -228,31 +226,30 @@ export default function AdminDocumentsPage() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [searchTerm, filterUserId, filterQuoteNumber]);
|
}, [searchTerm, filterUserId, filterQuoteNumber]);
|
||||||
|
|
||||||
const getDocumentIcon = (type: string): ReactNode => {
|
const getDocumentIcon = (type: string) => {
|
||||||
const typeLower = type.toLowerCase();
|
const typeLower = type.toLowerCase();
|
||||||
const cls = "h-6 w-6";
|
const icons: Record<string, string> = {
|
||||||
const iconMap: Record<string, ReactNode> = {
|
'application/pdf': '📄',
|
||||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
'image/jpeg': '🖼️',
|
||||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
'image/png': '🖼️',
|
||||||
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
|
'image/jpg': '🖼️',
|
||||||
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
|
pdf: '📄',
|
||||||
pdf: <FileText className={`${cls} text-red-500`} />,
|
jpeg: '🖼️',
|
||||||
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
|
jpg: '🖼️',
|
||||||
jpg: <ImageIcon className={`${cls} text-green-500`} />,
|
png: '🖼️',
|
||||||
png: <ImageIcon className={`${cls} text-green-500`} />,
|
gif: '🖼️',
|
||||||
gif: <ImageIcon className={`${cls} text-green-500`} />,
|
image: '🖼️',
|
||||||
image: <ImageIcon className={`${cls} text-green-500`} />,
|
word: '📝',
|
||||||
word: <FileEdit className={`${cls} text-blue-500`} />,
|
doc: '📝',
|
||||||
doc: <FileEdit className={`${cls} text-blue-500`} />,
|
docx: '📝',
|
||||||
docx: <FileEdit className={`${cls} text-blue-500`} />,
|
excel: '📊',
|
||||||
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
xls: '📊',
|
||||||
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
xlsx: '📊',
|
||||||
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
csv: '📊',
|
||||||
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
text: '📄',
|
||||||
text: <FileText className={`${cls} text-gray-500`} />,
|
txt: '📄',
|
||||||
txt: <FileText className={`${cls} text-gray-500`} />,
|
|
||||||
};
|
};
|
||||||
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
|
return icons[typeLower] || '📎';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@ -448,7 +445,7 @@ export default function AdminDocumentsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
|
<span className="text-2xl mr-2">{getDocumentIcon(doc.fileType || doc.type)}</span>
|
||||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { ClipboardList, Mail, AlertTriangle } from 'lucide-react';
|
|
||||||
import type { CsvRateSearchResult } from '@/types/rates';
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
import { createCsvBooking } from '@/lib/api/bookings';
|
import { createCsvBooking } from '@/lib/api/bookings';
|
||||||
|
|
||||||
@ -50,7 +49,6 @@ function NewBookingPageContent() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [termsAccepted, setTermsAccepted] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<BookingForm>({
|
const [formData, setFormData] = useState<BookingForm>({
|
||||||
@ -189,7 +187,7 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
|
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
|
||||||
const canProceedToStep3 = formData.documents.length >= 1;
|
const canProceedToStep3 = formData.documents.length >= 1;
|
||||||
const canSubmit = canProceedToStep3 && termsAccepted;
|
const canSubmit = canProceedToStep3;
|
||||||
|
|
||||||
const formatPrice = (price: number, currency: string) => {
|
const formatPrice = (price: number, currency: string) => {
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
return new Intl.NumberFormat('fr-FR', {
|
||||||
@ -219,10 +217,10 @@ function NewBookingPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Progress Steps */}
|
{/* Progress Steps */}
|
||||||
<div className="mb-8 bg-white rounded-lg shadow-md p-6">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{[1, 2, 3].map(step => (
|
{[1, 2, 3].map(step => (
|
||||||
<div key={step} className={`flex items-center ${step < 3 ? 'flex-1' : ''}`}>
|
<div key={step} className="flex items-center flex-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div
|
<div
|
||||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
|
||||||
@ -261,7 +259,7 @@ function NewBookingPageContent() {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
|
<div className="mb-6 bg-red-50 border-2 border-red-200 rounded-lg p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<AlertTriangle className="h-6 w-6 mr-3 text-red-500 flex-shrink-0" />
|
<span className="text-2xl mr-3">⚠️</span>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
|
<h4 className="font-semibold text-red-900 mb-1">Erreur</h4>
|
||||||
<p className="text-red-700 whitespace-pre-line">{error}</p>
|
<p className="text-red-700 whitespace-pre-line">{error}</p>
|
||||||
@ -386,7 +384,7 @@ function NewBookingPageContent() {
|
|||||||
|
|
||||||
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
|
<div className="mb-6 bg-yellow-50 border-2 border-yellow-200 rounded-lg p-4">
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<ClipboardList className="h-6 w-6 mr-3 text-yellow-700 flex-shrink-0" />
|
<span className="text-2xl mr-3">📋</span>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
|
<h4 className="font-semibold text-yellow-900 mb-1">Information importante</h4>
|
||||||
<p className="text-sm text-yellow-800">
|
<p className="text-sm text-yellow-800">
|
||||||
@ -574,7 +572,7 @@ function NewBookingPageContent() {
|
|||||||
{/* What happens next */}
|
{/* What happens next */}
|
||||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
|
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">
|
||||||
<Mail className="h-5 w-5 mr-2 inline-block" /> Que se passe-t-il ensuite ?
|
📧 Que se passe-t-il ensuite ?
|
||||||
</h3>
|
</h3>
|
||||||
<ul className="space-y-2 text-sm text-gray-700">
|
<ul className="space-y-2 text-sm text-gray-700">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
@ -609,9 +607,8 @@ function NewBookingPageContent() {
|
|||||||
<label className="flex items-start cursor-pointer">
|
<label className="flex items-start cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={termsAccepted}
|
|
||||||
onChange={(e) => setTermsAccepted(e.target.checked)}
|
|
||||||
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
<span className="ml-3 text-sm text-gray-700">
|
<span className="ml-3 text-sm text-gray-700">
|
||||||
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}
|
Je confirme que les informations fournies sont exactes et que j'accepte les{' '}
|
||||||
|
|||||||
@ -10,8 +10,6 @@ import { useState } from 'react';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listBookings, listCsvBookings } from '@/lib/api';
|
import { listBookings, listCsvBookings } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus } from 'lucide-react';
|
|
||||||
import ExportButton from '@/components/ExportButton';
|
|
||||||
|
|
||||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
@ -148,38 +146,14 @@ export default function BookingsListPage() {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Réservations</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
|
<p className="text-sm text-gray-500 mt-1">Gérez et suivez vos envois</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<ExportButton
|
|
||||||
data={filteredBookings}
|
|
||||||
filename="reservations"
|
|
||||||
columns={[
|
|
||||||
{ key: 'id', label: 'ID' },
|
|
||||||
{ key: 'palletCount', label: 'Palettes', format: (v) => `${v || 0}` },
|
|
||||||
{ key: 'weightKG', label: 'Poids (kg)', format: (v) => `${v || 0}` },
|
|
||||||
{ key: 'volumeCBM', label: 'Volume (CBM)', format: (v) => `${v || 0}` },
|
|
||||||
{ key: 'origin', label: 'Origine' },
|
|
||||||
{ key: 'destination', label: 'Destination' },
|
|
||||||
{ key: 'carrierName', label: 'Transporteur' },
|
|
||||||
{ key: 'status', label: 'Statut', format: (v) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
PENDING: 'En attente',
|
|
||||||
ACCEPTED: 'Accepté',
|
|
||||||
REJECTED: 'Refusé',
|
|
||||||
};
|
|
||||||
return labels[v] || v;
|
|
||||||
}},
|
|
||||||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/search-advanced"
|
href="/dashboard/search-advanced"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<span className="mr-2">➕</span>
|
||||||
Nouvelle Réservation
|
Nouvelle Réservation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-white rounded-lg shadow p-4">
|
<div className="bg-white rounded-lg shadow p-4">
|
||||||
@ -288,11 +262,8 @@ export default function BookingsListPage() {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
N° Devis
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
N° Booking
|
N° Devis
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -355,14 +326,11 @@ export default function BookingsListPage() {
|
|||||||
})
|
})
|
||||||
: 'N/A'}
|
: 'N/A'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
{booking.type === 'csv'
|
{booking.type === 'csv'
|
||||||
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
|
||||||
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
|
|
||||||
{booking.bookingNumber || '-'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -482,7 +450,7 @@ export default function BookingsListPage() {
|
|||||||
href="/dashboard/search-advanced"
|
href="/dashboard/search-advanced"
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<span className="mr-2">➕</span>
|
||||||
Nouvelle Réservation
|
Nouvelle Réservation
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,9 +2,6 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
import { listCsvBookings, CsvBookingResponse } from '@/lib/api/bookings';
|
||||||
import { FileText, Image as ImageIcon, FileEdit, FileSpreadsheet, Paperclip } from 'lucide-react';
|
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
import ExportButton from '@/components/ExportButton';
|
|
||||||
|
|
||||||
interface Document {
|
interface Document {
|
||||||
id: string;
|
id: string;
|
||||||
@ -167,31 +164,30 @@ export default function UserDocumentsPage() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
}, [searchTerm, filterStatus, filterQuoteNumber]);
|
||||||
|
|
||||||
const getDocumentIcon = (type: string): ReactNode => {
|
const getDocumentIcon = (type: string) => {
|
||||||
const typeLower = type.toLowerCase();
|
const typeLower = type.toLowerCase();
|
||||||
const cls = "h-6 w-6";
|
const icons: Record<string, string> = {
|
||||||
const iconMap: Record<string, ReactNode> = {
|
'application/pdf': '📄',
|
||||||
'application/pdf': <FileText className={`${cls} text-red-500`} />,
|
'image/jpeg': '🖼️',
|
||||||
'image/jpeg': <ImageIcon className={`${cls} text-green-500`} />,
|
'image/png': '🖼️',
|
||||||
'image/png': <ImageIcon className={`${cls} text-green-500`} />,
|
'image/jpg': '🖼️',
|
||||||
'image/jpg': <ImageIcon className={`${cls} text-green-500`} />,
|
pdf: '📄',
|
||||||
pdf: <FileText className={`${cls} text-red-500`} />,
|
jpeg: '🖼️',
|
||||||
jpeg: <ImageIcon className={`${cls} text-green-500`} />,
|
jpg: '🖼️',
|
||||||
jpg: <ImageIcon className={`${cls} text-green-500`} />,
|
png: '🖼️',
|
||||||
png: <ImageIcon className={`${cls} text-green-500`} />,
|
gif: '🖼️',
|
||||||
gif: <ImageIcon className={`${cls} text-green-500`} />,
|
image: '🖼️',
|
||||||
image: <ImageIcon className={`${cls} text-green-500`} />,
|
word: '📝',
|
||||||
word: <FileEdit className={`${cls} text-blue-500`} />,
|
doc: '📝',
|
||||||
doc: <FileEdit className={`${cls} text-blue-500`} />,
|
docx: '📝',
|
||||||
docx: <FileEdit className={`${cls} text-blue-500`} />,
|
excel: '📊',
|
||||||
excel: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
xls: '📊',
|
||||||
xls: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
xlsx: '📊',
|
||||||
xlsx: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
csv: '📊',
|
||||||
csv: <FileSpreadsheet className={`${cls} text-green-600`} />,
|
text: '📄',
|
||||||
text: <FileText className={`${cls} text-gray-500`} />,
|
txt: '📄',
|
||||||
txt: <FileText className={`${cls} text-gray-500`} />,
|
|
||||||
};
|
};
|
||||||
return iconMap[typeLower] || <Paperclip className={`${cls} text-gray-400`} />;
|
return icons[typeLower] || '📎';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@ -259,10 +255,8 @@ export default function UserDocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get bookings available for adding documents (PENDING or ACCEPTED)
|
// Get unique bookings for add document modal
|
||||||
const bookingsAvailableForDocuments = bookings.filter(
|
const bookingsWithPendingStatus = bookings.filter(b => b.status === 'PENDING');
|
||||||
b => b.status === 'PENDING' || b.status === 'ACCEPTED'
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleAddDocumentClick = () => {
|
const handleAddDocumentClick = () => {
|
||||||
setShowAddModal(true);
|
setShowAddModal(true);
|
||||||
@ -413,31 +407,9 @@ export default function UserDocumentsPage() {
|
|||||||
Gérez tous les documents de vos réservations
|
Gérez tous les documents de vos réservations
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<ExportButton
|
|
||||||
data={filteredDocuments}
|
|
||||||
filename="documents"
|
|
||||||
columns={[
|
|
||||||
{ key: 'fileName', label: 'Nom du fichier' },
|
|
||||||
{ key: 'fileType', label: 'Type' },
|
|
||||||
{ key: 'quoteNumber', label: 'N° de Devis' },
|
|
||||||
{ key: 'route', label: 'Route' },
|
|
||||||
{ key: 'carrierName', label: 'Transporteur' },
|
|
||||||
{ key: 'status', label: 'Statut', format: (v) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
PENDING: 'En attente',
|
|
||||||
ACCEPTED: 'Accepté',
|
|
||||||
REJECTED: 'Refusé',
|
|
||||||
CANCELLED: 'Annulé',
|
|
||||||
};
|
|
||||||
return labels[v] || v;
|
|
||||||
}},
|
|
||||||
{ key: 'uploadedAt', label: 'Date d\'ajout', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={handleAddDocumentClick}
|
onClick={handleAddDocumentClick}
|
||||||
disabled={bookingsAvailableForDocuments.length === 0}
|
disabled={bookingsWithPendingStatus.length === 0}
|
||||||
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
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">
|
||||||
@ -451,7 +423,6 @@ export default function UserDocumentsPage() {
|
|||||||
Ajouter un document
|
Ajouter un document
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
@ -562,7 +533,7 @@ export default function UserDocumentsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<span className="mr-2">
|
<span className="text-2xl mr-2">
|
||||||
{getDocumentIcon(doc.fileType || doc.type)}
|
{getDocumentIcon(doc.fileType || doc.type)}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
<div className="text-xs text-gray-500">{doc.fileType || doc.type}</div>
|
||||||
@ -815,7 +786,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
|
Sélectionner une réservation (en attente)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={selectedBookingId || ''}
|
value={selectedBookingId || ''}
|
||||||
@ -823,9 +794,9 @@ export default function UserDocumentsPage() {
|
|||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
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>
|
||||||
{bookingsAvailableForDocuments.map(booking => (
|
{bookingsWithPendingStatus.map(booking => (
|
||||||
<option key={booking.id} value={booking.id}>
|
<option key={booking.id} value={booking.id}>
|
||||||
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination} ({booking.status === 'PENDING' ? 'En attente' : 'Accepté'})
|
{getQuoteNumber(booking)} - {booking.origin} → {booking.destination}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -13,32 +13,25 @@ import { useState } from 'react';
|
|||||||
import NotificationDropdown from '@/components/NotificationDropdown';
|
import NotificationDropdown from '@/components/NotificationDropdown';
|
||||||
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
import AdminPanelDropdown from '@/components/admin/AdminPanelDropdown';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import {
|
|
||||||
BarChart3,
|
|
||||||
Package,
|
|
||||||
FileText,
|
|
||||||
Search,
|
|
||||||
BookOpen,
|
|
||||||
Building2,
|
|
||||||
Users,
|
|
||||||
LogOut,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// { name: 'Search Rates', href: '/dashboard/search-advanced', icon: '🔎' },
|
||||||
|
|
||||||
const navigation = [
|
const navigation = [
|
||||||
{ name: 'Tableau de bord', href: '/dashboard', icon: BarChart3 },
|
{ name: 'Dashboard', href: '/dashboard', icon: '📊' },
|
||||||
{ name: 'Réservations', href: '/dashboard/bookings', icon: Package },
|
{ name: 'Bookings', href: '/dashboard/bookings', icon: '📦' },
|
||||||
{ name: 'Documents', href: '/dashboard/documents', icon: FileText },
|
{ name: 'Documents', href: '/dashboard/documents', icon: '📄' },
|
||||||
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
|
{ name: 'Track & Trace', href: '/dashboard/track-trace', icon: '🔍' },
|
||||||
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
|
{ name: 'Wiki', href: '/dashboard/wiki', icon: '📚' },
|
||||||
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
|
{ name: 'My Profile', href: '/dashboard/profile', icon: '👤' },
|
||||||
|
{ name: 'Organization', href: '/dashboard/settings/organization', icon: '🏢' },
|
||||||
// ADMIN and MANAGER only navigation items
|
// ADMIN and MANAGER only navigation items
|
||||||
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
|
||||||
{ name: 'Utilisateurs', href: '/dashboard/settings/users', icon: Users },
|
{ name: 'Users', href: '/dashboard/settings/users', icon: '👥' },
|
||||||
] : []),
|
] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -105,7 +98,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<item.icon className="mr-3 h-5 w-5" />
|
<span className="mr-3 text-xl">{item.icon}</span>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@ -136,8 +129,15 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
className="w-full flex items-center justify-center px-4 py-2 text-sm font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 mr-2" />
|
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Déconnexion
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Logout
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -162,17 +162,17 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
|||||||
</button>
|
</button>
|
||||||
<div className="flex-1 lg:flex-none">
|
<div className="flex-1 lg:flex-none">
|
||||||
<h1 className="text-xl font-semibold text-gray-900">
|
<h1 className="text-xl font-semibold text-gray-900">
|
||||||
{navigation.find(item => isActive(item.href))?.name || 'Tableau de bord'}
|
{navigation.find(item => isActive(item.href))?.name || 'Dashboard'}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<NotificationDropdown />
|
<NotificationDropdown />
|
||||||
|
|
||||||
{/* User Initials */}
|
{/* User Role Badge */}
|
||||||
<Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
|
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
|
||||||
{user?.firstName?.[0]}{user?.lastName?.[0]}
|
{user?.role}
|
||||||
</Link>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,7 @@ import {
|
|||||||
deleteNotification,
|
deleteNotification,
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import type { NotificationResponse } from '@/types/api';
|
import type { NotificationResponse } from '@/types/api';
|
||||||
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight, Package, RefreshCw, XCircle, CheckCircle, Mail, Clock, FileText, Megaphone, User, Building2 } from 'lucide-react';
|
import { Trash2, CheckCheck, Filter, Bell, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
|
||||||
|
|
||||||
export default function NotificationsPage() {
|
export default function NotificationsPage() {
|
||||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'unread' | 'read'>('all');
|
||||||
@ -78,7 +77,7 @@ export default function NotificationsPage() {
|
|||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('Êtes-vous sûr de vouloir supprimer cette notification ?')) {
|
if (confirm('Are you sure you want to delete this notification?')) {
|
||||||
deleteNotificationMutation.mutate(notificationId);
|
deleteNotificationMutation.mutate(notificationId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -93,23 +92,22 @@ export default function NotificationsPage() {
|
|||||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
|
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300 hover:bg-gray-100';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationIcon = (type: string): ReactNode => {
|
const getNotificationIcon = (type: string) => {
|
||||||
const iconClass = "h-8 w-8";
|
const icons: Record<string, string> = {
|
||||||
const icons: Record<string, ReactNode> = {
|
booking_created: '📦',
|
||||||
booking_created: <Package className={`${iconClass} text-blue-600`} />,
|
booking_updated: '🔄',
|
||||||
booking_updated: <RefreshCw className={`${iconClass} text-orange-500`} />,
|
booking_cancelled: '❌',
|
||||||
booking_cancelled: <XCircle className={`${iconClass} text-red-500`} />,
|
booking_confirmed: '✅',
|
||||||
booking_confirmed: <CheckCircle className={`${iconClass} text-green-500`} />,
|
csv_booking_accepted: '✅',
|
||||||
csv_booking_accepted: <CheckCircle className={`${iconClass} text-green-500`} />,
|
csv_booking_rejected: '❌',
|
||||||
csv_booking_rejected: <XCircle className={`${iconClass} text-red-500`} />,
|
csv_booking_request_sent: '📧',
|
||||||
csv_booking_request_sent: <Mail className={`${iconClass} text-blue-500`} />,
|
rate_quote_expiring: '⏰',
|
||||||
rate_quote_expiring: <Clock className={`${iconClass} text-yellow-500`} />,
|
document_uploaded: '📄',
|
||||||
document_uploaded: <FileText className={`${iconClass} text-gray-600`} />,
|
system_announcement: '📢',
|
||||||
system_announcement: <Megaphone className={`${iconClass} text-purple-500`} />,
|
user_invited: '👤',
|
||||||
user_invited: <User className={`${iconClass} text-teal-500`} />,
|
organization_update: '🏢',
|
||||||
organization_update: <Building2 className={`${iconClass} text-indigo-500`} />,
|
|
||||||
};
|
};
|
||||||
return icons[type.toLowerCase()] || <Bell className={`${iconClass} text-gray-500`} />;
|
return icons[type.toLowerCase()] || '🔔';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
const formatTime = (dateString: string) => {
|
||||||
@ -120,11 +118,11 @@ export default function NotificationsPage() {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'A l\'instant';
|
if (diffMins < 1) return 'Just now';
|
||||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
return date.toLocaleDateString('fr-FR', {
|
return date.toLocaleDateString('en-US', {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
@ -146,8 +144,8 @@ export default function NotificationsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Notifications</h1>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{total} notification{total !== 1 ? 's' : ''} au total
|
{total} notification{total !== 1 ? 's' : ''} total
|
||||||
{unreadCount > 0 && ` • ${unreadCount} non lue${unreadCount > 1 ? 's' : ''}`}
|
{unreadCount > 0 && ` • ${unreadCount} unread`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -158,7 +156,7 @@ export default function NotificationsPage() {
|
|||||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<CheckCheck className="w-5 h-5" />
|
<CheckCheck className="w-5 h-5" />
|
||||||
<span>Tout marquer comme lu</span>
|
<span>Mark all as read</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -171,7 +169,7 @@ export default function NotificationsPage() {
|
|||||||
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
<div className="bg-white rounded-lg shadow-sm border p-4 mb-6">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Filter className="w-5 h-5 text-gray-500" />
|
<Filter className="w-5 h-5 text-gray-500" />
|
||||||
<span className="text-sm font-medium text-gray-700">Filtrer :</span>
|
<span className="text-sm font-medium text-gray-700">Filter:</span>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
{(['all', 'unread', 'read'] as const).map((filter) => (
|
{(['all', 'unread', 'read'] as const).map((filter) => (
|
||||||
<button
|
<button
|
||||||
@ -186,7 +184,7 @@ export default function NotificationsPage() {
|
|||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
{filter === 'unread' && unreadCount > 0 && (
|
{filter === 'unread' && unreadCount > 0 && (
|
||||||
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
<span className="ml-2 px-2 py-0.5 bg-white/20 rounded-full text-xs">
|
||||||
{unreadCount}
|
{unreadCount}
|
||||||
@ -204,18 +202,18 @@ export default function NotificationsPage() {
|
|||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
|
<div className="animate-spin rounded-full h-16 w-16 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||||
<p className="text-gray-500">Chargement des notifications...</p>
|
<p className="text-gray-500">Loading notifications...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-4 flex justify-center"><Bell className="h-16 w-16 text-gray-300" /></div>
|
<div className="text-7xl mb-4">🔔</div>
|
||||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">Aucune notification</h3>
|
<h3 className="text-xl font-semibold text-gray-900 mb-2">No notifications</h3>
|
||||||
<p className="text-gray-500">
|
<p className="text-gray-500">
|
||||||
{selectedFilter === 'unread'
|
{selectedFilter === 'unread'
|
||||||
? 'Vous êtes à jour !'
|
? "You're all caught up! Great job!"
|
||||||
: 'Aucune notification à afficher'}
|
: 'No notifications to display'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -231,7 +229,7 @@ export default function NotificationsPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="flex-shrink-0 flex items-center justify-center w-12 h-12">
|
<div className="flex-shrink-0 text-4xl">
|
||||||
{getNotificationIcon(notification.type)}
|
{getNotificationIcon(notification.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -245,14 +243,14 @@ export default function NotificationsPage() {
|
|||||||
{!notification.read && (
|
{!notification.read && (
|
||||||
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
|
<span className="flex items-center space-x-1 px-2 py-1 bg-blue-600 text-white text-xs font-medium rounded-full">
|
||||||
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
<span className="w-2 h-2 bg-white rounded-full animate-pulse" />
|
||||||
<span>NOUVEAU</span>
|
<span>NEW</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(e, notification.id)}
|
onClick={(e) => handleDelete(e, notification.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-red-100 rounded-lg"
|
||||||
title="Supprimer la notification"
|
title="Delete notification"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-5 h-5 text-red-600" />
|
<Trash2 className="w-5 h-5 text-red-600" />
|
||||||
</button>
|
</button>
|
||||||
@ -302,7 +300,7 @@ export default function NotificationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
{notification.actionUrl && (
|
{notification.actionUrl && (
|
||||||
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
|
<span className="text-sm text-blue-600 font-medium group-hover:underline flex items-center space-x-1">
|
||||||
<span>Voir les détails</span>
|
<span>View details</span>
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -332,10 +330,11 @@ export default function NotificationsPage() {
|
|||||||
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
<div className="mt-6 bg-white rounded-lg shadow-sm border p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Page <span className="font-semibold">{currentPage}</span> sur{' '}
|
Showing page <span className="font-semibold">{currentPage}</span> of{' '}
|
||||||
<span className="font-semibold">{totalPages}</span>
|
<span className="font-semibold">{totalPages}</span>
|
||||||
{' • '}
|
{' • '}
|
||||||
<span className="font-semibold">{total}</span> notification{total !== 1 ? 's' : ''} au total
|
<span className="font-semibold">{total}</span> total notification
|
||||||
|
{total !== 1 ? 's' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<button
|
<button
|
||||||
@ -344,7 +343,7 @@ export default function NotificationsPage() {
|
|||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4" />
|
<ChevronLeft className="w-4 h-4" />
|
||||||
<span>Précédent</span>
|
<span>Previous</span>
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
@ -379,7 +378,7 @@ export default function NotificationsPage() {
|
|||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
className="flex items-center space-x-1 px-4 py-2 text-sm font-medium bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
<span>Suivant</span>
|
<span>Next</span>
|
||||||
<ChevronRight className="w-4 h-4" />
|
<ChevronRight className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
|
||||||
import {
|
import {
|
||||||
PieChart,
|
PieChart,
|
||||||
Pie,
|
Pie,
|
||||||
@ -75,33 +74,17 @@ export default function DashboardPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
|
<h1 className="text-3xl font-semibold text-gray-900">Tableau de Bord</h1>
|
||||||
<p className="text-gray-600 mt-1 text-sm">
|
<p className="text-gray-600 mt-1 text-sm">
|
||||||
Vue d'ensemble de vos réservations et performances
|
Vue d'ensemble de vos bookings et performances
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<Link href="/dashboard/bookings">
|
||||||
<ExportButton
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-sm">
|
||||||
data={topCarriers || []}
|
<Plus className="h-4 w-4" />
|
||||||
filename="tableau-de-bord-transporteurs"
|
Nouveau Booking
|
||||||
columns={[
|
|
||||||
{ key: 'carrierName', label: 'Transporteur' },
|
|
||||||
{ key: 'totalBookings', label: 'Total Réservations' },
|
|
||||||
{ key: 'acceptedBookings', label: 'Acceptées' },
|
|
||||||
{ key: 'rejectedBookings', label: 'Refusées' },
|
|
||||||
{ key: 'totalWeightKG', label: 'Poids Total (KG)', format: (v) => v?.toLocaleString('fr-FR') || '0' },
|
|
||||||
{ key: 'totalVolumeCBM', label: 'Volume Total (CBM)', format: (v) => v?.toFixed(2) || '0' },
|
|
||||||
{ key: 'acceptanceRate', label: 'Taux d\'acceptation (%)', format: (v) => v?.toFixed(1) || '0' },
|
|
||||||
{ key: 'avgPriceUSD', label: 'Prix moyen ($)', format: (v) => v?.toFixed(2) || '0' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<Link href="/dashboard/search-advanced">
|
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white gap-2 shadow-lg text-base px-6 py-5 font-semibold">
|
|
||||||
<Plus className="h-5 w-5" />
|
|
||||||
Nouvelle Réservation
|
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* KPI Cards - Compact with Color */}
|
{/* KPI Cards - Compact with Color */}
|
||||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
@ -208,7 +191,7 @@ export default function DashboardPage() {
|
|||||||
<Card className="border border-gray-200 shadow-sm bg-white">
|
<Card className="border border-gray-200 shadow-sm bg-white">
|
||||||
<CardHeader className="pb-4 border-b border-gray-100">
|
<CardHeader className="pb-4 border-b border-gray-100">
|
||||||
<CardTitle className="text-base font-semibold text-gray-900">
|
<CardTitle className="text-base font-semibold text-gray-900">
|
||||||
Distribution des Réservations
|
Distribution des Bookings
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs text-gray-600">
|
<CardDescription className="text-xs text-gray-600">
|
||||||
Répartition par statut
|
Répartition par statut
|
||||||
@ -250,7 +233,7 @@ export default function DashboardPage() {
|
|||||||
Poids par Transporteur
|
Poids par Transporteur
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="text-xs text-gray-600">
|
<CardDescription className="text-xs text-gray-600">
|
||||||
Top 5 transporteurs par poids (KG)
|
Top 5 carriers par poids (KG)
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="pt-4">
|
<CardContent className="pt-4">
|
||||||
@ -299,7 +282,7 @@ export default function DashboardPage() {
|
|||||||
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
<div className="h-10 w-10 rounded-lg bg-blue-100 flex items-center justify-center mb-2">
|
||||||
<Package className="h-5 w-5 text-blue-600" />
|
<Package className="h-5 w-5 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs font-medium text-gray-600 mb-1">Total Réservations</p>
|
<p className="text-xs font-medium text-gray-600 mb-1">Total Bookings</p>
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{csvKpisLoading
|
{csvKpisLoading
|
||||||
? '--'
|
? '--'
|
||||||
@ -377,7 +360,7 @@ export default function DashboardPage() {
|
|||||||
{carrier.carrierName}
|
{carrier.carrierName}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
<div className="flex items-center gap-3 text-xs text-gray-500 mt-0.5">
|
||||||
<span>{carrier.totalBookings} réservations</span>
|
<span>{carrier.totalBookings} bookings</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
|
<span>{carrier.totalWeightKG.toLocaleString()} KG</span>
|
||||||
</div>
|
</div>
|
||||||
@ -417,15 +400,15 @@ export default function DashboardPage() {
|
|||||||
<Package className="h-6 w-6 text-gray-400" />
|
<Package className="h-6 w-6 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
<h3 className="text-sm font-semibold text-gray-900 mb-1">
|
||||||
Aucune réservation
|
Aucun booking
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
|
<p className="text-xs text-gray-500 mb-4 max-w-sm mx-auto">
|
||||||
Créez votre première réservation pour voir vos statistiques
|
Créez votre premier booking pour voir vos statistiques
|
||||||
</p>
|
</p>
|
||||||
<Link href="/dashboard/bookings">
|
<Link href="/dashboard/bookings">
|
||||||
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
<Button size="sm" className="bg-blue-600 hover:bg-blue-700">
|
||||||
<Plus className="mr-1.5 h-3 w-3" />
|
<Plus className="mr-1.5 h-3 w-3" />
|
||||||
Créer une réservation
|
Créer un booking
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -17,18 +17,18 @@ import { updateUser, changePassword } from '@/lib/api';
|
|||||||
// Password update schema
|
// Password update schema
|
||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
currentPassword: z.string().min(1, 'Le mot de passe actuel est requis'),
|
currentPassword: z.string().min(1, 'Current password is required'),
|
||||||
newPassword: z
|
newPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(12, 'Le mot de passe doit contenir au moins 12 caractères')
|
.min(12, 'Password must be at least 12 characters')
|
||||||
.regex(
|
.regex(
|
||||||
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||||
'Le mot de passe doit contenir une majuscule, une minuscule, un chiffre et un caractère spécial'
|
'Password must contain uppercase, lowercase, number, and special character'
|
||||||
),
|
),
|
||||||
confirmPassword: z.string().min(1, 'Veuillez confirmer votre mot de passe'),
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
})
|
})
|
||||||
.refine(data => data.newPassword === data.confirmPassword, {
|
.refine(data => data.newPassword === data.confirmPassword, {
|
||||||
message: 'Les mots de passe ne correspondent pas',
|
message: "Passwords don't match",
|
||||||
path: ['confirmPassword'],
|
path: ['confirmPassword'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -36,9 +36,9 @@ type PasswordFormData = z.infer<typeof passwordSchema>;
|
|||||||
|
|
||||||
// Profile update schema
|
// Profile update schema
|
||||||
const profileSchema = z.object({
|
const profileSchema = z.object({
|
||||||
firstName: z.string().min(2, 'Le prénom doit contenir au moins 2 caractères'),
|
firstName: z.string().min(2, 'First name must be at least 2 characters'),
|
||||||
lastName: z.string().min(2, 'Le nom doit contenir au moins 2 caractères'),
|
lastName: z.string().min(2, 'Last name must be at least 2 characters'),
|
||||||
email: z.string().email('Adresse email invalide'),
|
email: z.string().email('Invalid email address'),
|
||||||
});
|
});
|
||||||
|
|
||||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||||
@ -101,14 +101,14 @@ export default function ProfilePage() {
|
|||||||
return updateUser(user.id, data);
|
return updateUser(user.id, data);
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSuccessMessage('Profil mis à jour avec succès !');
|
setSuccessMessage('Profile updated successfully!');
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
refreshUser();
|
refreshUser();
|
||||||
queryClient.invalidateQueries({ queryKey: ['user'] });
|
queryClient.invalidateQueries({ queryKey: ['user'] });
|
||||||
setTimeout(() => setSuccessMessage(''), 3000);
|
setTimeout(() => setSuccessMessage(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setErrorMessage(error.message || 'Échec de la mise à jour du profil');
|
setErrorMessage(error.message || 'Failed to update profile');
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -122,7 +122,7 @@ export default function ProfilePage() {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setSuccessMessage('Mot de passe mis à jour avec succès !');
|
setSuccessMessage('Password updated successfully!');
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
passwordForm.reset({
|
passwordForm.reset({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
@ -132,7 +132,7 @@ export default function ProfilePage() {
|
|||||||
setTimeout(() => setSuccessMessage(''), 3000);
|
setTimeout(() => setSuccessMessage(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
setErrorMessage(error.message || 'Échec de la mise à jour du mot de passe');
|
setErrorMessage(error.message || 'Failed to update password');
|
||||||
setSuccessMessage('');
|
setSuccessMessage('');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -151,7 +151,7 @@ export default function ProfilePage() {
|
|||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
<p className="text-gray-600">Chargement du profil...</p>
|
<p className="text-gray-600">Loading profile...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -162,12 +162,12 @@ export default function ProfilePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-red-600 mb-4">Impossible de charger le profil</p>
|
<p className="text-red-600 mb-4">Unable to load user profile</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Réessayer
|
Retry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -178,8 +178,8 @@ export default function ProfilePage() {
|
|||||||
<div className="max-w-4xl mx-auto space-y-6">
|
<div className="max-w-4xl mx-auto space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-lg shadow-lg p-6 text-white">
|
||||||
<h1 className="text-3xl font-bold mb-2">Mon Profil</h1>
|
<h1 className="text-3xl font-bold mb-2">My Profile</h1>
|
||||||
<p className="text-blue-100">Gérez vos paramètres de compte et préférences</p>
|
<p className="text-blue-100">Manage your account settings and preferences</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Success/Error Messages */}
|
{/* Success/Error Messages */}
|
||||||
@ -230,7 +230,7 @@ export default function ProfilePage() {
|
|||||||
{user?.role}
|
{user?.role}
|
||||||
</span>
|
</span>
|
||||||
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
<span className="px-3 py-1 text-xs font-medium text-green-800 bg-green-100 rounded-full">
|
||||||
Actif
|
Active
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -249,7 +249,7 @@ export default function ProfilePage() {
|
|||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Informations personnelles
|
Profile Information
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('password')}
|
onClick={() => setActiveTab('password')}
|
||||||
@ -259,7 +259,7 @@ export default function ProfilePage() {
|
|||||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Modifier le mot de passe
|
Change Password
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@ -274,7 +274,7 @@ export default function ProfilePage() {
|
|||||||
htmlFor="firstName"
|
htmlFor="firstName"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
>
|
>
|
||||||
Prénom
|
First Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...profileForm.register('firstName')}
|
{...profileForm.register('firstName')}
|
||||||
@ -295,7 +295,7 @@ export default function ProfilePage() {
|
|||||||
htmlFor="lastName"
|
htmlFor="lastName"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
>
|
>
|
||||||
Nom
|
Last Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...profileForm.register('lastName')}
|
{...profileForm.register('lastName')}
|
||||||
@ -314,7 +314,7 @@ export default function ProfilePage() {
|
|||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Adresse email
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...profileForm.register('email')}
|
{...profileForm.register('email')}
|
||||||
@ -323,7 +323,7 @@ export default function ProfilePage() {
|
|||||||
disabled
|
disabled
|
||||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
className="w-full px-4 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-xs text-gray-500">L'adresse email ne peut pas être modifiée</p>
|
<p className="mt-1 text-xs text-gray-500">Email cannot be changed</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
@ -333,7 +333,7 @@ export default function ProfilePage() {
|
|||||||
disabled={updateProfileMutation.isPending}
|
disabled={updateProfileMutation.isPending}
|
||||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{updateProfileMutation.isPending ? 'Enregistrement...' : 'Enregistrer'}
|
{updateProfileMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -345,7 +345,7 @@ export default function ProfilePage() {
|
|||||||
htmlFor="currentPassword"
|
htmlFor="currentPassword"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
>
|
>
|
||||||
Mot de passe actuel
|
Current Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...passwordForm.register('currentPassword')}
|
{...passwordForm.register('currentPassword')}
|
||||||
@ -367,7 +367,7 @@ export default function ProfilePage() {
|
|||||||
htmlFor="newPassword"
|
htmlFor="newPassword"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
>
|
>
|
||||||
Nouveau mot de passe
|
New Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...passwordForm.register('newPassword')}
|
{...passwordForm.register('newPassword')}
|
||||||
@ -382,7 +382,8 @@ export default function ProfilePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Au moins 12 caractères avec majuscule, minuscule, chiffre et caractère spécial
|
Must be at least 12 characters with uppercase, lowercase, number, and special
|
||||||
|
character
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -392,7 +393,7 @@ export default function ProfilePage() {
|
|||||||
htmlFor="confirmPassword"
|
htmlFor="confirmPassword"
|
||||||
className="block text-sm font-medium text-gray-700 mb-2"
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
>
|
>
|
||||||
Confirmer le nouveau mot de passe
|
Confirm New Password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...passwordForm.register('confirmPassword')}
|
{...passwordForm.register('confirmPassword')}
|
||||||
@ -415,7 +416,7 @@ export default function ProfilePage() {
|
|||||||
disabled={updatePasswordMutation.isPending}
|
disabled={updatePasswordMutation.isPending}
|
||||||
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-6 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{updatePasswordMutation.isPending ? 'Mise à jour...' : 'Mettre à jour'}
|
{updatePasswordMutation.isPending ? 'Updating...' : 'Update Password'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -2,20 +2,14 @@
|
|||||||
* Advanced Rate Search Page
|
* Advanced Rate Search Page
|
||||||
*
|
*
|
||||||
* Complete search form with all filters and best options display
|
* Complete search form with all filters and best options display
|
||||||
* Uses only ports available in CSV rates for origin/destination selection
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { Search, Loader2 } from 'lucide-react';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import { searchPorts, Port } from '@/lib/api/ports';
|
||||||
getAvailableOrigins,
|
|
||||||
getAvailableDestinations,
|
|
||||||
RoutePortInfo,
|
|
||||||
} from '@/lib/api/rates';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Import dynamique pour éviter les erreurs SSR avec Leaflet
|
// Import dynamique pour éviter les erreurs SSR avec Leaflet
|
||||||
@ -98,60 +92,24 @@ export default function AdvancedSearchPage() {
|
|||||||
const [destinationSearch, setDestinationSearch] = useState('');
|
const [destinationSearch, setDestinationSearch] = useState('');
|
||||||
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
const [showOriginDropdown, setShowOriginDropdown] = useState(false);
|
||||||
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
const [showDestinationDropdown, setShowDestinationDropdown] = useState(false);
|
||||||
const [selectedOriginPort, setSelectedOriginPort] = useState<RoutePortInfo | null>(null);
|
const [selectedOriginPort, setSelectedOriginPort] = useState<Port | null>(null);
|
||||||
const [selectedDestinationPort, setSelectedDestinationPort] = useState<RoutePortInfo | null>(null);
|
const [selectedDestinationPort, setSelectedDestinationPort] = useState<Port | null>(null);
|
||||||
|
|
||||||
// Fetch available origins from CSV rates
|
// Port autocomplete queries
|
||||||
const { data: originsData, isLoading: isLoadingOrigins } = useQuery({
|
const { data: originPortsData } = useQuery({
|
||||||
queryKey: ['available-origins'],
|
queryKey: ['ports', originSearch],
|
||||||
queryFn: getAvailableOrigins,
|
queryFn: () => searchPorts({ query: originSearch, limit: 10 }),
|
||||||
|
enabled: originSearch.length >= 2 && showOriginDropdown,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch available destinations based on selected origin
|
const { data: destinationPortsData } = useQuery({
|
||||||
const { data: destinationsData, isLoading: isLoadingDestinations } = useQuery({
|
queryKey: ['ports', destinationSearch],
|
||||||
queryKey: ['available-destinations', searchForm.origin],
|
queryFn: () => searchPorts({ query: destinationSearch, limit: 10 }),
|
||||||
queryFn: () => getAvailableDestinations(searchForm.origin),
|
enabled: destinationSearch.length >= 2 && showDestinationDropdown,
|
||||||
enabled: !!searchForm.origin,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter origins based on search input
|
const originPorts = originPortsData?.ports || [];
|
||||||
const filteredOrigins = (originsData?.origins || []).filter(port => {
|
const destinationPorts = destinationPortsData?.ports || [];
|
||||||
if (!originSearch || originSearch.length < 1) return true;
|
|
||||||
const searchLower = originSearch.toLowerCase();
|
|
||||||
return (
|
|
||||||
port.code.toLowerCase().includes(searchLower) ||
|
|
||||||
port.name.toLowerCase().includes(searchLower) ||
|
|
||||||
port.city.toLowerCase().includes(searchLower) ||
|
|
||||||
port.countryName.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter destinations based on search input
|
|
||||||
const filteredDestinations = (destinationsData?.destinations || []).filter(port => {
|
|
||||||
if (!destinationSearch || destinationSearch.length < 1) return true;
|
|
||||||
const searchLower = destinationSearch.toLowerCase();
|
|
||||||
return (
|
|
||||||
port.code.toLowerCase().includes(searchLower) ||
|
|
||||||
port.name.toLowerCase().includes(searchLower) ||
|
|
||||||
port.city.toLowerCase().includes(searchLower) ||
|
|
||||||
port.countryName.toLowerCase().includes(searchLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset destination when origin changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (searchForm.origin && selectedDestinationPort) {
|
|
||||||
// Check if current destination is still valid for new origin
|
|
||||||
const isValidDestination = destinationsData?.destinations?.some(
|
|
||||||
d => d.code === searchForm.destination
|
|
||||||
);
|
|
||||||
if (!isValidDestination) {
|
|
||||||
setSearchForm(prev => ({ ...prev, destination: '' }));
|
|
||||||
setSelectedDestinationPort(null);
|
|
||||||
setDestinationSearch('');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchForm.origin, destinationsData]);
|
|
||||||
|
|
||||||
// Calculate total volume and weight
|
// Calculate total volume and weight
|
||||||
const calculateTotals = () => {
|
const calculateTotals = () => {
|
||||||
@ -230,51 +188,37 @@ export default function AdvancedSearchPage() {
|
|||||||
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
<h2 className="text-xl font-semibold text-gray-900">1. Informations Générales</h2>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
{/* Origin Port with Autocomplete - Limited to CSV routes */}
|
{/* Origin Port with Autocomplete */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
Port d'origine * {searchForm.origin && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={originSearch}
|
value={originSearch}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setOriginSearch(e.target.value);
|
setOriginSearch(e.target.value);
|
||||||
setShowOriginDropdown(true);
|
setShowOriginDropdown(true);
|
||||||
// Clear selection if user modifies the input
|
if (e.target.value.length < 2) {
|
||||||
if (selectedOriginPort && e.target.value !== selectedOriginPort.displayName) {
|
setSearchForm({ ...searchForm, origin: '' });
|
||||||
setSearchForm({ ...searchForm, origin: '', destination: '' });
|
|
||||||
setSelectedOriginPort(null);
|
|
||||||
setSelectedDestinationPort(null);
|
|
||||||
setDestinationSearch('');
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setShowOriginDropdown(true)}
|
onFocus={() => setShowOriginDropdown(true)}
|
||||||
onBlur={() => setTimeout(() => setShowOriginDropdown(false), 200)}
|
placeholder="ex: Rotterdam, Paris, FRPAR"
|
||||||
placeholder="Rechercher un port d'origine..."
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
||||||
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
searchForm.origin ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isLoadingOrigins && (
|
{showOriginDropdown && originPorts && originPorts.length > 0 && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{showOriginDropdown && filteredOrigins.length > 0 && (
|
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
||||||
{filteredOrigins.slice(0, 15).map((port: RoutePortInfo) => (
|
{originPorts.map((port: Port) => (
|
||||||
<button
|
<button
|
||||||
key={port.code}
|
key={port.code}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchForm({ ...searchForm, origin: port.code, destination: '' });
|
setSearchForm({ ...searchForm, origin: port.code });
|
||||||
setOriginSearch(port.displayName);
|
setOriginSearch(port.displayName);
|
||||||
setSelectedOriginPort(port);
|
setSelectedOriginPort(port);
|
||||||
setSelectedDestinationPort(null);
|
|
||||||
setDestinationSearch('');
|
|
||||||
setShowOriginDropdown(false);
|
setShowOriginDropdown(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
className="w-full text-left px-4 py-3 hover:bg-blue-50 border-b border-gray-100 last:border-b-0"
|
||||||
@ -285,60 +229,34 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filteredOrigins.length > 15 && (
|
|
||||||
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
|
||||||
+{filteredOrigins.length - 15} autres résultats. Affinez votre recherche.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showOriginDropdown && filteredOrigins.length === 0 && !isLoadingOrigins && originsData && (
|
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
|
||||||
<p className="text-sm text-gray-500">Aucun port d'origine trouvé pour "{originSearch}"</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Destination Port with Autocomplete - Limited to routes from selected origin */}
|
{/* Destination Port with Autocomplete */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
Port de destination * {searchForm.destination && <span className="text-green-600 text-xs">✓ Sélectionné</span>}
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={destinationSearch}
|
value={destinationSearch}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
setDestinationSearch(e.target.value);
|
setDestinationSearch(e.target.value);
|
||||||
setShowDestinationDropdown(true);
|
setShowDestinationDropdown(true);
|
||||||
// Clear selection if user modifies the input
|
if (e.target.value.length < 2) {
|
||||||
if (selectedDestinationPort && e.target.value !== selectedDestinationPort.displayName) {
|
|
||||||
setSearchForm({ ...searchForm, destination: '' });
|
setSearchForm({ ...searchForm, destination: '' });
|
||||||
setSelectedDestinationPort(null);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onFocus={() => setShowDestinationDropdown(true)}
|
onFocus={() => setShowDestinationDropdown(true)}
|
||||||
onBlur={() => setTimeout(() => setShowDestinationDropdown(false), 200)}
|
placeholder="ex: Shanghai, New York, CNSHA"
|
||||||
disabled={!searchForm.origin}
|
|
||||||
placeholder={searchForm.origin ? 'Rechercher une destination...' : 'Sélectionnez d\'abord un port d\'origine'}
|
|
||||||
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
className={`w-full px-3 py-2 border rounded-md focus:ring-blue-500 focus:border-blue-500 ${
|
||||||
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
searchForm.destination ? 'border-green-500 bg-green-50' : 'border-gray-300'
|
||||||
} ${!searchForm.origin ? 'bg-gray-100 cursor-not-allowed' : ''}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
{isLoadingDestinations && searchForm.origin && (
|
{showDestinationDropdown && destinationPorts && destinationPorts.length > 0 && (
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{searchForm.origin && destinationsData?.total !== undefined && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{destinationsData.total} destination{destinationsData.total > 1 ? 's' : ''} disponible{destinationsData.total > 1 ? 's' : ''} depuis {selectedOriginPort?.name || searchForm.origin}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{showDestinationDropdown && filteredDestinations.length > 0 && (
|
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg max-h-60 overflow-y-auto z-50">
|
||||||
{filteredDestinations.slice(0, 15).map((port: RoutePortInfo) => (
|
{destinationPorts.map((port: Port) => (
|
||||||
<button
|
<button
|
||||||
key={port.code}
|
key={port.code}
|
||||||
type="button"
|
type="button"
|
||||||
@ -356,23 +274,13 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{filteredDestinations.length > 15 && (
|
|
||||||
<div className="px-4 py-2 text-xs text-gray-500 bg-gray-50">
|
|
||||||
+{filteredDestinations.length - 15} autres résultats. Affinez votre recherche.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{showDestinationDropdown && filteredDestinations.length === 0 && !isLoadingDestinations && searchForm.origin && destinationsData && (
|
|
||||||
<div className="absolute left-0 right-0 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-4 z-50">
|
|
||||||
<p className="text-sm text-gray-500">Aucune destination trouvée pour "{destinationSearch}"</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Carte interactive de la route maritime */}
|
{/* Carte interactive de la route maritime */}
|
||||||
{selectedOriginPort && selectedDestinationPort && selectedOriginPort.latitude && selectedDestinationPort.latitude && (
|
{selectedOriginPort && selectedDestinationPort && (
|
||||||
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
<div className="mt-6 border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
<div className="bg-gray-50 px-4 py-3 border-b border-gray-200">
|
||||||
<h3 className="text-sm font-semibold text-gray-900">
|
<h3 className="text-sm font-semibold text-gray-900">
|
||||||
@ -384,12 +292,12 @@ export default function AdvancedSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
<PortRouteMap
|
<PortRouteMap
|
||||||
portA={{
|
portA={{
|
||||||
lat: selectedOriginPort.latitude,
|
lat: selectedOriginPort.coordinates.latitude,
|
||||||
lng: selectedOriginPort.longitude!,
|
lng: selectedOriginPort.coordinates.longitude,
|
||||||
}}
|
}}
|
||||||
portB={{
|
portB={{
|
||||||
lat: selectedDestinationPort.latitude,
|
lat: selectedDestinationPort.coordinates.latitude,
|
||||||
lng: selectedDestinationPort.longitude!,
|
lng: selectedDestinationPort.coordinates.longitude,
|
||||||
}}
|
}}
|
||||||
height="400px"
|
height="400px"
|
||||||
/>
|
/>
|
||||||
@ -733,7 +641,7 @@ export default function AdvancedSearchPage() {
|
|||||||
disabled={!searchForm.origin || !searchForm.destination}
|
disabled={!searchForm.origin || !searchForm.destination}
|
||||||
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
className="px-6 py-3 text-base font-medium text-white bg-green-600 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center"
|
||||||
>
|
>
|
||||||
<Search className="h-5 w-5 mr-2" /> Rechercher les tarifs
|
🔍 Rechercher les tarifs
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import { useEffect, useState, useCallback } from 'react';
|
|||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
import { searchCsvRatesWithOffers } from '@/lib/api/rates';
|
||||||
import type { CsvRateSearchResult } from '@/types/rates';
|
import type { CsvRateSearchResult } from '@/types/rates';
|
||||||
import { Search, Lightbulb, DollarSign, Scale, Zap, Trophy, XCircle, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
interface BestOptions {
|
interface BestOptions {
|
||||||
eco: CsvRateSearchResult;
|
eco: CsvRateSearchResult;
|
||||||
@ -122,7 +121,7 @@ export default function SearchResultsPage() {
|
|||||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-12 px-4">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||||
<div className="mb-4 flex justify-center"><XCircle className="h-16 w-16 text-red-500" /></div>
|
<div className="text-6xl mb-4">❌</div>
|
||||||
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
|
<h3 className="text-xl font-bold text-red-900 mb-2">Erreur</h3>
|
||||||
<p className="text-red-700 mb-4">{error}</p>
|
<p className="text-red-700 mb-4">{error}</p>
|
||||||
<button
|
<button
|
||||||
@ -149,13 +148,13 @@ export default function SearchResultsPage() {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
<div className="bg-yellow-50 border-2 border-yellow-200 rounded-lg p-8 text-center">
|
||||||
<div className="mb-4 flex justify-center"><Search className="h-16 w-16 text-yellow-500" /></div>
|
<div className="text-6xl mb-4">🔍</div>
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
|
<h3 className="text-xl font-bold text-gray-900 mb-2">Aucun résultat trouvé</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Aucun tarif ne correspond à votre recherche pour le trajet {origin} → {destination}
|
Aucun tarif ne correspond à votre recherche pour le trajet {origin} → {destination}
|
||||||
</p>
|
</p>
|
||||||
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
<div className="bg-white border border-yellow-300 rounded-lg p-4 text-left max-w-2xl mx-auto mb-6">
|
||||||
<h4 className="font-semibold text-gray-900 mb-2 flex items-center"><Lightbulb className="h-5 w-5 mr-2 text-yellow-500" /> Suggestions :</h4>
|
<h4 className="font-semibold text-gray-900 mb-2">💡 Suggestions :</h4>
|
||||||
<ul className="text-sm text-gray-700 space-y-2">
|
<ul className="text-sm text-gray-700 space-y-2">
|
||||||
<li>
|
<li>
|
||||||
• <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX,
|
• <strong>Ports disponibles :</strong> NLRTM, DEHAM, FRLEH, BEGNE (origine) → USNYC, USLAX,
|
||||||
@ -191,7 +190,7 @@ export default function SearchResultsPage() {
|
|||||||
text: 'text-green-800',
|
text: 'text-green-800',
|
||||||
button: 'bg-green-600 hover:bg-green-700',
|
button: 'bg-green-600 hover:bg-green-700',
|
||||||
},
|
},
|
||||||
icon: <DollarSign className="h-10 w-10 text-green-600" />,
|
icon: '💰',
|
||||||
badge: 'Le moins cher',
|
badge: 'Le moins cher',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -203,7 +202,7 @@ export default function SearchResultsPage() {
|
|||||||
text: 'text-blue-800',
|
text: 'text-blue-800',
|
||||||
button: 'bg-blue-600 hover:bg-blue-700',
|
button: 'bg-blue-600 hover:bg-blue-700',
|
||||||
},
|
},
|
||||||
icon: <Scale className="h-10 w-10 text-blue-600" />,
|
icon: '⚖️',
|
||||||
badge: 'Équilibré',
|
badge: 'Équilibré',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -215,7 +214,7 @@ export default function SearchResultsPage() {
|
|||||||
text: 'text-purple-800',
|
text: 'text-purple-800',
|
||||||
button: 'bg-purple-600 hover:bg-purple-700',
|
button: 'bg-purple-600 hover:bg-purple-700',
|
||||||
},
|
},
|
||||||
icon: <Zap className="h-10 w-10 text-purple-600" />,
|
icon: '⚡',
|
||||||
badge: 'Le plus rapide',
|
badge: 'Le plus rapide',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -254,7 +253,7 @@ export default function SearchResultsPage() {
|
|||||||
{bestOptions && (
|
{bestOptions && (
|
||||||
<div className="mb-12">
|
<div className="mb-12">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
<h2 className="text-2xl font-bold text-gray-900 mb-6 flex items-center">
|
||||||
<Trophy className="h-8 w-8 mr-3 text-yellow-500" />
|
<span className="text-3xl mr-3">🏆</span>
|
||||||
Meilleurs choix pour votre recherche
|
Meilleurs choix pour votre recherche
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
@ -270,7 +269,7 @@ export default function SearchResultsPage() {
|
|||||||
<div className={`p-6 ${card.colors.bg}`}>
|
<div className={`p-6 ${card.colors.bg}`}>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<span>{card.icon}</span>
|
<span className="text-4xl">{card.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
|
<h3 className={`text-xl font-bold ${card.colors.text}`}>{card.type}</h3>
|
||||||
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
|
<span className="text-xs bg-white px-2 py-1 rounded-full text-gray-600 font-medium">
|
||||||
@ -367,7 +366,7 @@ export default function SearchResultsPage() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
<div className="flex items-center space-x-4 text-sm text-gray-600">
|
||||||
<span>✓ Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
|
<span>✓ Valide jusqu'au {new Date(result.validUntil).toLocaleDateString('fr-FR')}</span>
|
||||||
{result.hasSurcharges && <span className="text-orange-600 flex items-center"><AlertTriangle className="h-4 w-4 mr-1" /> Surcharges applicables</span>}
|
{result.hasSurcharges && <span className="text-orange-600">⚠️ Surcharges applicables</span>}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { searchRates } from '@/lib/api';
|
import { searchRates } from '@/lib/api';
|
||||||
import { searchPorts, Port } from '@/lib/api/ports';
|
import { searchPorts, Port } from '@/lib/api/ports';
|
||||||
import { Search, Leaf, Package } from 'lucide-react';
|
|
||||||
|
|
||||||
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
|
type ContainerType = '20GP' | '40GP' | '40HC' | '45HC' | '20RF' | '40RF';
|
||||||
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
|
type Mode = 'SEA' | 'AIR' | 'ROAD' | 'RAIL';
|
||||||
@ -123,9 +122,9 @@ export default function RateSearchPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Recherche de Tarifs Maritime</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Search Shipping Rates</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
Comparez les tarifs de plusieurs transporteurs en temps réel
|
Compare rates from multiple carriers in real-time
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -136,7 +135,7 @@ export default function RateSearchPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Origin Port */}
|
{/* Origin Port */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Port d'origine *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Origin Port *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -147,7 +146,7 @@ export default function RateSearchPage() {
|
|||||||
setSearchForm({ ...searchForm, originPort: '' });
|
setSearchForm({ ...searchForm, originPort: '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="ex : Rotterdam, Shanghai"
|
placeholder="e.g., Rotterdam, Shanghai"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
{originPorts && originPorts.length > 0 && (
|
{originPorts && originPorts.length > 0 && (
|
||||||
@ -175,7 +174,7 @@ export default function RateSearchPage() {
|
|||||||
{/* Destination Port */}
|
{/* Destination Port */}
|
||||||
<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">
|
||||||
Port de destination *
|
Destination Port *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -187,7 +186,7 @@ export default function RateSearchPage() {
|
|||||||
setSearchForm({ ...searchForm, destinationPort: '' });
|
setSearchForm({ ...searchForm, destinationPort: '' });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="ex : Los Angeles, Hambourg"
|
placeholder="e.g., Los Angeles, Hamburg"
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
{destinationPorts && destinationPorts.length > 0 && (
|
{destinationPorts && destinationPorts.length > 0 && (
|
||||||
@ -217,7 +216,7 @@ export default function RateSearchPage() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Type de conteneur *
|
Container Type *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={searchForm.containerType}
|
value={searchForm.containerType}
|
||||||
@ -236,7 +235,7 @@ export default function RateSearchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Quantité *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Quantity *</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
@ -251,7 +250,7 @@ export default function RateSearchPage() {
|
|||||||
|
|
||||||
<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">
|
||||||
Date de départ *
|
Departure Date *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
@ -265,7 +264,6 @@ export default function RateSearchPage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">Mode *</label>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={searchForm.mode}
|
value={searchForm.mode}
|
||||||
onChange={e => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
|
onChange={e => setSearchForm({ ...searchForm, mode: e.target.value as Mode })}
|
||||||
@ -287,7 +285,7 @@ export default function RateSearchPage() {
|
|||||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
|
<label htmlFor="hazmat" className="ml-2 block text-sm text-gray-900">
|
||||||
Marchandises dangereuses (manutention spéciale requise)
|
Hazardous Materials (requires special handling)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -301,12 +299,12 @@ export default function RateSearchPage() {
|
|||||||
{isSearching ? (
|
{isSearching ? (
|
||||||
<>
|
<>
|
||||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
Recherche en cours...
|
Searching...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Search className="h-5 w-5 mr-2" />
|
<span className="mr-2">🔍</span>
|
||||||
Rechercher des tarifs
|
Search Rates
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@ -317,7 +315,7 @@ export default function RateSearchPage() {
|
|||||||
{/* Error */}
|
{/* Error */}
|
||||||
{searchError && (
|
{searchError && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
<div className="bg-red-50 border border-red-200 rounded-md p-4">
|
||||||
<div className="text-sm text-red-800">La recherche de tarifs a échoué. Veuillez réessayer.</div>
|
<div className="text-sm text-red-800">Failed to search rates. Please try again.</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -328,20 +326,20 @@ export default function RateSearchPage() {
|
|||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
|
<div className="bg-white rounded-lg shadow p-4 space-y-6 sticky top-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Trier par</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Sort By</h3>
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={e => setSortBy(e.target.value as any)}
|
onChange={e => setSortBy(e.target.value as any)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
||||||
>
|
>
|
||||||
<option value="price">Prix (croissant)</option>
|
<option value="price">Price (Low to High)</option>
|
||||||
<option value="transitTime">Temps de transit</option>
|
<option value="transitTime">Transit Time</option>
|
||||||
<option value="co2">Émissions CO2</option>
|
<option value="co2">CO2 Emissions</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Fourchette de prix (USD)</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Price Range (USD)</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@ -353,14 +351,14 @@ export default function RateSearchPage() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Jusqu'à {priceRange[1].toLocaleString()} $
|
Up to ${priceRange[1].toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">
|
||||||
Temps de transit max (jours)
|
Max Transit Time (days)
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<input
|
||||||
@ -371,13 +369,13 @@ export default function RateSearchPage() {
|
|||||||
onChange={e => setTransitTimeMax(parseInt(e.target.value))}
|
onChange={e => setTransitTimeMax(parseInt(e.target.value))}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<div className="text-sm text-gray-600">{transitTimeMax} jours</div>
|
<div className="text-sm text-gray-600">{transitTimeMax} days</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{availableCarriers.length > 0 && (
|
{availableCarriers.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 mb-3">Transporteurs</h3>
|
<h3 className="text-sm font-semibold text-gray-900 mb-3">Carriers</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{availableCarriers.map(carrier => (
|
{availableCarriers.map(carrier => (
|
||||||
<label key={carrier} className="flex items-center">
|
<label key={carrier} className="flex items-center">
|
||||||
@ -400,7 +398,8 @@ export default function RateSearchPage() {
|
|||||||
<div className="lg:col-span-3 space-y-4">
|
<div className="lg:col-span-3 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-lg font-semibold text-gray-900">
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
{filteredAndSortedQuotes.length} tarif{filteredAndSortedQuotes.length !== 1 ? 's' : ''} trouvé{filteredAndSortedQuotes.length !== 1 ? 's' : ''}
|
{filteredAndSortedQuotes.length} Rate
|
||||||
|
{filteredAndSortedQuotes.length !== 1 ? 's' : ''} Found
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -419,9 +418,9 @@ export default function RateSearchPage() {
|
|||||||
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M12 12h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun tarif trouvé</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No rates found</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Essayez d'ajuster vos filtres ou vos critères de recherche
|
Try adjusting your filters or search criteria
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -468,19 +467,19 @@ export default function RateSearchPage() {
|
|||||||
{/* Route Info */}
|
{/* Route Info */}
|
||||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 uppercase">Départ</div>
|
<div className="text-xs text-gray-500 uppercase">Departure</div>
|
||||||
<div className="text-sm font-medium text-gray-900 mt-1">
|
<div className="text-sm font-medium text-gray-900 mt-1">
|
||||||
{new Date(quote.route.etd).toLocaleDateString()}
|
{new Date(quote.route.etd).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 uppercase">Temps de transit</div>
|
<div className="text-xs text-gray-500 uppercase">Transit Time</div>
|
||||||
<div className="text-sm font-medium text-gray-900 mt-1">
|
<div className="text-sm font-medium text-gray-900 mt-1">
|
||||||
{quote.route.transitDays} jours
|
{quote.route.transitDays} days
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 uppercase">Arrivée</div>
|
<div className="text-xs text-gray-500 uppercase">Arrival</div>
|
||||||
<div className="text-sm font-medium text-gray-900 mt-1">
|
<div className="text-sm font-medium text-gray-900 mt-1">
|
||||||
{new Date(quote.route.eta).toLocaleDateString()}
|
{new Date(quote.route.eta).toLocaleDateString()}
|
||||||
</div>
|
</div>
|
||||||
@ -531,14 +530,14 @@ export default function RateSearchPage() {
|
|||||||
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
|
<div className="mt-4 flex items-center space-x-4 text-sm text-gray-500">
|
||||||
{quote.co2Emissions && (
|
{quote.co2Emissions && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Leaf className="h-4 w-4 mr-1 text-green-500" />
|
<span className="mr-1">🌱</span>
|
||||||
{quote.co2Emissions.value} kg CO2
|
{quote.co2Emissions.value} kg CO2
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{quote.availability && (
|
{quote.availability && (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Package className="h-4 w-4 mr-1 text-blue-500" />
|
<span className="mr-1">📦</span>
|
||||||
{quote.availability} conteneurs disponibles
|
{quote.availability} containers available
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -546,7 +545,7 @@ export default function RateSearchPage() {
|
|||||||
{/* Surcharges */}
|
{/* Surcharges */}
|
||||||
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
|
{quote.pricing.surcharges && quote.pricing.surcharges.length > 0 && (
|
||||||
<div className="mt-4 text-sm">
|
<div className="mt-4 text-sm">
|
||||||
<div className="text-gray-500 mb-2">Surcharges incluses :</div>
|
<div className="text-gray-500 mb-2">Includes surcharges:</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
|
{quote.pricing.surcharges.map((surcharge: any, idx: number) => (
|
||||||
<span
|
<span
|
||||||
@ -566,7 +565,7 @@ export default function RateSearchPage() {
|
|||||||
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
|
href={`/dashboard/bookings/new?quoteId=${quote.id}`}
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Réserver
|
Book Now
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -592,9 +591,10 @@ export default function RateSearchPage() {
|
|||||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-4 text-lg font-medium text-gray-900">Rechercher des tarifs maritimes</h3>
|
<h3 className="mt-4 text-lg font-medium text-gray-900">Search for Shipping Rates</h3>
|
||||||
<p className="mt-2 text-sm text-gray-500">
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
Saisissez votre origine, destination et détails du conteneur pour comparer les tarifs de plusieurs transporteurs
|
Enter your origin, destination, and container details to compare rates from multiple
|
||||||
|
carriers
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
|||||||
import { createInvitation } from '@/lib/api/invitations';
|
import { createInvitation } from '@/lib/api/invitations';
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import ExportButton from '@/components/ExportButton';
|
|
||||||
|
|
||||||
export default function UsersManagementPage() {
|
export default function UsersManagementPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@ -54,7 +53,7 @@ export default function UsersManagementPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
setSuccess('Invitation envoyée avec succès ! L\'utilisateur recevra un email avec un lien d\'inscription.');
|
setSuccess('Invitation sent successfully! The user will receive an email with a registration link.');
|
||||||
setShowInviteModal(false);
|
setShowInviteModal(false);
|
||||||
setInviteForm({
|
setInviteForm({
|
||||||
email: '',
|
email: '',
|
||||||
@ -65,7 +64,7 @@ export default function UsersManagementPage() {
|
|||||||
setTimeout(() => setSuccess(''), 5000);
|
setTimeout(() => setSuccess(''), 5000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setError(err.response?.data?.message || 'Échec de l\'envoi de l\'invitation');
|
setError(err.response?.data?.message || 'Failed to send invitation');
|
||||||
setTimeout(() => setError(''), 5000);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -76,11 +75,11 @@ export default function UsersManagementPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
setSuccess('Rôle mis à jour avec succès');
|
setSuccess('Role updated successfully');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
|
setError(err.response?.data?.message || 'Failed to update role');
|
||||||
setTimeout(() => setError(''), 5000);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -92,11 +91,11 @@ export default function UsersManagementPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
setSuccess('Statut de l\'utilisateur mis à jour avec succès');
|
setSuccess('User status updated successfully');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
|
setError(err.response?.data?.message || 'Failed to update user status');
|
||||||
setTimeout(() => setError(''), 5000);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -106,11 +105,11 @@ export default function UsersManagementPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||||||
setSuccess('Utilisateur supprimé avec succès');
|
setSuccess('User deleted successfully');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
setError(err.response?.data?.message || 'Échec de la suppression de l\'utilisateur');
|
setError(err.response?.data?.message || 'Failed to delete user');
|
||||||
setTimeout(() => setError(''), 5000);
|
setTimeout(() => setError(''), 5000);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -144,7 +143,7 @@ export default function UsersManagementPage() {
|
|||||||
|
|
||||||
const handleToggleActive = (userId: string, isActive: boolean) => {
|
const handleToggleActive = (userId: string, isActive: boolean) => {
|
||||||
if (
|
if (
|
||||||
window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)
|
window.confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this user?`)
|
||||||
) {
|
) {
|
||||||
toggleActiveMutation.mutate({ id: userId, isActive });
|
toggleActiveMutation.mutate({ id: userId, isActive });
|
||||||
}
|
}
|
||||||
@ -152,7 +151,7 @@ export default function UsersManagementPage() {
|
|||||||
|
|
||||||
const handleDelete = (userId: string) => {
|
const handleDelete = (userId: string) => {
|
||||||
if (
|
if (
|
||||||
window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')
|
window.confirm('Are you sure you want to delete this user? This action cannot be undone.')
|
||||||
) {
|
) {
|
||||||
deleteMutation.mutate(userId);
|
deleteMutation.mutate(userId);
|
||||||
}
|
}
|
||||||
@ -180,17 +179,17 @@ export default function UsersManagementPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 flex-1">
|
<div className="ml-3 flex-1">
|
||||||
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
|
<h3 className="text-sm font-medium text-amber-800">License limit reached</h3>
|
||||||
<p className="mt-1 text-sm text-amber-700">
|
<p className="mt-1 text-sm text-amber-700">
|
||||||
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
Your organization has used all available licenses ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
||||||
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
|
Upgrade your subscription to invite more users.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/subscription"
|
href="/dashboard/settings/subscription"
|
||||||
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
className="text-sm font-medium text-amber-800 hover:text-amber-900 underline"
|
||||||
>
|
>
|
||||||
Mettre à niveau l'abonnement
|
Upgrade Subscription
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -207,14 +206,14 @@ export default function UsersManagementPage() {
|
|||||||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-blue-800">
|
<span className="text-sm text-blue-800">
|
||||||
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
|
{licenseStatus.availableLicenses} license{licenseStatus.availableLicenses !== 1 ? 's' : ''} remaining ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} used)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard/settings/subscription"
|
href="/dashboard/settings/subscription"
|
||||||
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
className="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||||
>
|
>
|
||||||
Gérer l'abonnement
|
Manage Subscription
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -223,37 +222,16 @@ export default function UsersManagementPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
|
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||||
<p className="text-sm text-gray-500 mt-1">Gérez les membres de l'équipe et leurs permissions</p>
|
<p className="text-sm text-gray-500 mt-1">Manage team members and their permissions</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<ExportButton
|
|
||||||
data={users?.users || []}
|
|
||||||
filename="utilisateurs"
|
|
||||||
columns={[
|
|
||||||
{ key: 'firstName', label: 'Prénom' },
|
|
||||||
{ key: 'lastName', label: 'Nom' },
|
|
||||||
{ key: 'email', label: 'Email' },
|
|
||||||
{ key: 'role', label: 'Rôle', format: (v) => {
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
ADMIN: 'Administrateur',
|
|
||||||
MANAGER: 'Manager',
|
|
||||||
USER: 'Utilisateur',
|
|
||||||
VIEWER: 'Lecteur',
|
|
||||||
};
|
|
||||||
return labels[v] || v;
|
|
||||||
}},
|
|
||||||
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
|
|
||||||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(true)}
|
onClick={() => setShowInviteModal(true)}
|
||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
<span className="mr-2">+</span>
|
<span className="mr-2">+</span>
|
||||||
Inviter un utilisateur
|
Invite User
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<Link
|
<Link
|
||||||
@ -261,11 +239,10 @@ export default function UsersManagementPage() {
|
|||||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
||||||
>
|
>
|
||||||
<span className="mr-2">+</span>
|
<span className="mr-2">+</span>
|
||||||
Mettre à niveau
|
Upgrade to Invite
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{success && (
|
{success && (
|
||||||
<div className="rounded-md bg-green-50 p-4">
|
<div className="rounded-md bg-green-50 p-4">
|
||||||
@ -284,7 +261,7 @@ export default function UsersManagementPage() {
|
|||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="px-6 py-12 text-center text-gray-500">
|
<div className="px-6 py-12 text-center text-gray-500">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
Chargement des utilisateurs...
|
Loading users...
|
||||||
</div>
|
</div>
|
||||||
) : users?.users && users.users.length > 0 ? (
|
) : users?.users && users.users.length > 0 ? (
|
||||||
<div className="overflow-x-auto overflow-y-visible">
|
<div className="overflow-x-auto overflow-y-visible">
|
||||||
@ -292,19 +269,19 @@ export default function UsersManagementPage() {
|
|||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Utilisateur
|
User
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Email
|
Email
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Rôle
|
Role
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Statut
|
Status
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Date de création
|
Last Login
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
@ -361,7 +338,7 @@ export default function UsersManagementPage() {
|
|||||||
: 'bg-red-100 text-red-800'
|
: 'bg-red-100 text-red-800'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{user.isActive ? 'Actif' : 'Inactif'}
|
{user.isActive ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
@ -409,8 +386,8 @@ export default function UsersManagementPage() {
|
|||||||
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No users</h3>
|
||||||
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by inviting a team member</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{licenseStatus?.canInvite ? (
|
{licenseStatus?.canInvite ? (
|
||||||
<button
|
<button
|
||||||
@ -513,7 +490,7 @@ export default function UsersManagementPage() {
|
|||||||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
|
<h3 className="text-lg font-medium text-gray-900">Invite User</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowInviteModal(false)}
|
onClick={() => setShowInviteModal(false)}
|
||||||
className="text-gray-400 hover:text-gray-500"
|
className="text-gray-400 hover:text-gray-500"
|
||||||
@ -533,7 +510,7 @@ export default function UsersManagementPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
Prénom *
|
First Name *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -544,7 +521,7 @@ export default function UsersManagementPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Nom *</label>
|
<label className="block text-sm font-medium text-gray-700">Last Name *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@ -556,7 +533,7 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
|
<label className="block text-sm font-medium text-gray-700">Email *</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
required
|
required
|
||||||
@ -567,20 +544,20 @@ export default function UsersManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
|
<label className="block text-sm font-medium text-gray-700">Role *</label>
|
||||||
<select
|
<select
|
||||||
value={inviteForm.role}
|
value={inviteForm.role}
|
||||||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
||||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
||||||
>
|
>
|
||||||
<option value="USER">Utilisateur</option>
|
<option value="USER">User</option>
|
||||||
<option value="MANAGER">Manager</option>
|
<option value="MANAGER">Manager</option>
|
||||||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Administrateur</option>}
|
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
||||||
<option value="VIEWER">Lecteur</option>
|
<option value="VIEWER">Viewer</option>
|
||||||
</select>
|
</select>
|
||||||
{currentUser?.role !== 'ADMIN' && (
|
{currentUser?.role !== 'ADMIN' && (
|
||||||
<p className="mt-1 text-xs text-gray-500">
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
Seuls les administrateurs peuvent attribuer le rôle ADMIN
|
Only platform administrators can assign the ADMIN role
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -591,14 +568,14 @@ export default function UsersManagementPage() {
|
|||||||
disabled={inviteMutation.isPending}
|
disabled={inviteMutation.isPending}
|
||||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||||||
>
|
>
|
||||||
{inviteMutation.isPending ? 'Envoi en cours...' : 'Envoyer l\'invitation'}
|
{inviteMutation.isPending ? 'Inviting...' : 'Send Invitation'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowInviteModal(false)}
|
onClick={() => setShowInviteModal(false)}
|
||||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
|
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||||||
>
|
>
|
||||||
Annuler
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -2,181 +2,107 @@
|
|||||||
* Track & Trace Page
|
* Track & Trace Page
|
||||||
*
|
*
|
||||||
* Allows users to track their shipments by entering tracking numbers
|
* Allows users to track their shipments by entering tracking numbers
|
||||||
* and selecting the carrier. Includes search history and vessel position map.
|
* and selecting the carrier. Redirects to carrier's tracking page.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
Package,
|
|
||||||
FileText,
|
|
||||||
ClipboardList,
|
|
||||||
Lightbulb,
|
|
||||||
History,
|
|
||||||
MapPin,
|
|
||||||
X,
|
|
||||||
Clock,
|
|
||||||
Ship,
|
|
||||||
ExternalLink,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
Globe,
|
|
||||||
Anchor,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
// Search history item type
|
// Carrier tracking URLs - the tracking number will be appended
|
||||||
interface SearchHistoryItem {
|
|
||||||
id: string;
|
|
||||||
trackingNumber: string;
|
|
||||||
carrierId: string;
|
|
||||||
carrierName: string;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Carrier tracking URLs with official brand colors
|
|
||||||
const carriers = [
|
const carriers = [
|
||||||
{
|
{
|
||||||
id: 'maersk',
|
id: 'maersk',
|
||||||
name: 'Maersk',
|
name: 'Maersk',
|
||||||
color: '#00243D', // Maersk dark blue
|
logo: '🚢',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.maersk.com/tracking/',
|
trackingUrl: 'https://www.maersk.com/tracking/',
|
||||||
placeholder: 'Ex: MSKU1234567',
|
placeholder: 'Ex: MSKU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/maersk.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'msc',
|
id: 'msc',
|
||||||
name: 'MSC',
|
name: 'MSC',
|
||||||
color: '#002B5C', // MSC blue
|
logo: '🛳️',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
|
trackingUrl: 'https://www.msc.com/track-a-shipment?query=',
|
||||||
placeholder: 'Ex: MSCU1234567',
|
placeholder: 'Ex: MSCU1234567',
|
||||||
description: 'N° conteneur, B/L ou réservation',
|
description: 'Container, B/L or Booking number',
|
||||||
logo: '/assets/logos/carriers/msc.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cma-cgm',
|
id: 'cma-cgm',
|
||||||
name: 'CMA CGM',
|
name: 'CMA CGM',
|
||||||
color: '#E30613', // CMA CGM red
|
logo: '⚓',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
|
trackingUrl: 'https://www.cma-cgm.com/ebusiness/tracking/search?SearchBy=Container&Reference=',
|
||||||
placeholder: 'Ex: CMAU1234567',
|
placeholder: 'Ex: CMAU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/cmacgm.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hapag-lloyd',
|
id: 'hapag-lloyd',
|
||||||
name: 'Hapag-Lloyd',
|
name: 'Hapag-Lloyd',
|
||||||
color: '#FF6600', // Hapag orange
|
logo: '🔷',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
|
trackingUrl: 'https://www.hapag-lloyd.com/en/online-business/track/track-by-container-solution.html?container=',
|
||||||
placeholder: 'Ex: HLCU1234567',
|
placeholder: 'Ex: HLCU1234567',
|
||||||
description: 'N° conteneur',
|
description: 'Container number',
|
||||||
logo: '/assets/logos/carriers/hapag.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'cosco',
|
id: 'cosco',
|
||||||
name: 'COSCO',
|
name: 'COSCO',
|
||||||
color: '#003A70', // COSCO blue
|
logo: '🌊',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
|
trackingUrl: 'https://elines.coscoshipping.com/ebusiness/cargoTracking?trackingNumber=',
|
||||||
placeholder: 'Ex: COSU1234567',
|
placeholder: 'Ex: COSU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/cosco.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'one',
|
id: 'one',
|
||||||
name: 'ONE',
|
name: 'ONE (Ocean Network Express)',
|
||||||
color: '#FF00FF', // ONE magenta
|
logo: '🟣',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
|
trackingUrl: 'https://ecomm.one-line.com/one-ecom/manage-shipment/cargo-tracking?trkNoParam=',
|
||||||
placeholder: 'Ex: ONEU1234567',
|
placeholder: 'Ex: ONEU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/one.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'evergreen',
|
id: 'evergreen',
|
||||||
name: 'Evergreen',
|
name: 'Evergreen',
|
||||||
color: '#006633', // Evergreen green
|
logo: '🌲',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
|
trackingUrl: 'https://www.shipmentlink.com/servlet/TDB1_CargoTracking.do?BL=',
|
||||||
placeholder: 'Ex: EGHU1234567',
|
placeholder: 'Ex: EGHU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/evergreen.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'yangming',
|
id: 'yangming',
|
||||||
name: 'Yang Ming',
|
name: 'Yang Ming',
|
||||||
color: '#FFD700', // Yang Ming yellow
|
logo: '🟡',
|
||||||
textColor: 'text-gray-900',
|
|
||||||
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
|
trackingUrl: 'https://www.yangming.com/e-service/Track_Trace/track_trace_cargo_tracking.aspx?rdolType=CT&str=',
|
||||||
placeholder: 'Ex: YMLU1234567',
|
placeholder: 'Ex: YMLU1234567',
|
||||||
description: 'N° conteneur',
|
description: 'Container number',
|
||||||
logo: '/assets/logos/carriers/yangming.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'zim',
|
id: 'zim',
|
||||||
name: 'ZIM',
|
name: 'ZIM',
|
||||||
color: '#1E3A8A', // ZIM blue
|
logo: '🔵',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
|
trackingUrl: 'https://www.zim.com/tools/track-a-shipment?consnumber=',
|
||||||
placeholder: 'Ex: ZIMU1234567',
|
placeholder: 'Ex: ZIMU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/zim.svg',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'hmm',
|
id: 'hmm',
|
||||||
name: 'HMM',
|
name: 'HMM (Hyundai)',
|
||||||
color: '#E65100', // HMM orange
|
logo: '🟠',
|
||||||
textColor: 'text-white',
|
|
||||||
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
|
trackingUrl: 'https://www.hmm21.com/cms/business/ebiz/trackTrace/trackTrace/index.jsp?type=1&number=',
|
||||||
placeholder: 'Ex: HDMU1234567',
|
placeholder: 'Ex: HDMU1234567',
|
||||||
description: 'N° conteneur ou B/L',
|
description: 'Container or B/L number',
|
||||||
logo: '/assets/logos/carriers/hmm.svg',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Local storage keys
|
|
||||||
const HISTORY_KEY = 'xpeditis_track_history';
|
|
||||||
|
|
||||||
export default function TrackTracePage() {
|
export default function TrackTracePage() {
|
||||||
const [trackingNumber, setTrackingNumber] = useState('');
|
const [trackingNumber, setTrackingNumber] = useState('');
|
||||||
const [selectedCarrier, setSelectedCarrier] = useState('');
|
const [selectedCarrier, setSelectedCarrier] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
|
|
||||||
const [showMap, setShowMap] = useState(false);
|
|
||||||
const [isMapFullscreen, setIsMapFullscreen] = useState(false);
|
|
||||||
const [isMapLoading, setIsMapLoading] = useState(true);
|
|
||||||
|
|
||||||
// Load history from localStorage on mount
|
|
||||||
useEffect(() => {
|
|
||||||
const savedHistory = localStorage.getItem(HISTORY_KEY);
|
|
||||||
if (savedHistory) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(savedHistory);
|
|
||||||
setSearchHistory(parsed.map((item: any) => ({
|
|
||||||
...item,
|
|
||||||
timestamp: new Date(item.timestamp)
|
|
||||||
})));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse search history:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
const saveHistory = (history: SearchHistoryItem[]) => {
|
|
||||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
|
|
||||||
setSearchHistory(history);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTrack = () => {
|
const handleTrack = () => {
|
||||||
|
// Validation
|
||||||
if (!trackingNumber.trim()) {
|
if (!trackingNumber.trim()) {
|
||||||
setError('Veuillez entrer un numéro de tracking');
|
setError('Veuillez entrer un numéro de tracking');
|
||||||
return;
|
return;
|
||||||
@ -188,43 +114,15 @@ export default function TrackTracePage() {
|
|||||||
|
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
|
// Find the carrier and build the tracking URL
|
||||||
const carrier = carriers.find(c => c.id === selectedCarrier);
|
const carrier = carriers.find(c => c.id === selectedCarrier);
|
||||||
if (carrier) {
|
if (carrier) {
|
||||||
// Add to history
|
|
||||||
const newHistoryItem: SearchHistoryItem = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
trackingNumber: trackingNumber.trim(),
|
|
||||||
carrierId: carrier.id,
|
|
||||||
carrierName: carrier.name,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep only last 10 unique searches
|
|
||||||
const updatedHistory = [newHistoryItem, ...searchHistory.filter(
|
|
||||||
h => !(h.trackingNumber === newHistoryItem.trackingNumber && h.carrierId === newHistoryItem.carrierId)
|
|
||||||
)].slice(0, 10);
|
|
||||||
|
|
||||||
saveHistory(updatedHistory);
|
|
||||||
|
|
||||||
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
|
const trackingUrl = carrier.trackingUrl + encodeURIComponent(trackingNumber.trim());
|
||||||
|
// Open in new tab
|
||||||
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
|
window.open(trackingUrl, '_blank', 'noopener,noreferrer');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHistoryClick = (item: SearchHistoryItem) => {
|
|
||||||
setTrackingNumber(item.trackingNumber);
|
|
||||||
setSelectedCarrier(item.carrierId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteHistory = (id: string) => {
|
|
||||||
const updatedHistory = searchHistory.filter(h => h.id !== id);
|
|
||||||
saveHistory(updatedHistory);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearHistory = () => {
|
|
||||||
saveHistory([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleTrack();
|
handleTrack();
|
||||||
@ -233,25 +131,11 @@ export default function TrackTracePage() {
|
|||||||
|
|
||||||
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
|
const selectedCarrierData = carriers.find(c => c.id === selectedCarrier);
|
||||||
|
|
||||||
const formatTimeAgo = (date: Date) => {
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return 'À l\'instant';
|
|
||||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
|
||||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
|
||||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
|
||||||
return date.toLocaleDateString('fr-FR');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Suivi des expéditions</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Track & Trace</h1>
|
||||||
<p className="mt-2 text-gray-600">
|
<p className="mt-2 text-gray-600">
|
||||||
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
|
Suivez vos expéditions en temps réel. Entrez votre numéro de tracking et sélectionnez le transporteur.
|
||||||
</p>
|
</p>
|
||||||
@ -261,15 +145,15 @@ export default function TrackTracePage() {
|
|||||||
<Card className="bg-white shadow-lg border-blue-100">
|
<Card className="bg-white shadow-lg border-blue-100">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-xl flex items-center gap-2">
|
<CardTitle className="text-xl flex items-center gap-2">
|
||||||
<Search className="h-5 w-5 text-blue-600" />
|
<span className="text-2xl">🔍</span>
|
||||||
Rechercher une expédition
|
Rechercher une expédition
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de réservation
|
Entrez votre numéro de conteneur, connaissement (B/L) ou référence de booking
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Carrier Selection - US 5.1: Professional carrier cards with brand colors */}
|
{/* Carrier Selection */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
Sélectionnez le transporteur
|
Sélectionnez le transporteur
|
||||||
@ -283,20 +167,14 @@ export default function TrackTracePage() {
|
|||||||
setSelectedCarrier(carrier.id);
|
setSelectedCarrier(carrier.id);
|
||||||
setError('');
|
setError('');
|
||||||
}}
|
}}
|
||||||
className={`flex flex-col items-center justify-center p-4 rounded-xl border-2 transition-all hover:scale-105 ${
|
className={`flex flex-col items-center justify-center p-3 rounded-lg border-2 transition-all ${
|
||||||
selectedCarrier === carrier.id
|
selectedCarrier === carrier.id
|
||||||
? 'border-blue-500 shadow-lg ring-2 ring-blue-200'
|
? 'border-blue-500 bg-blue-50 shadow-md'
|
||||||
: 'border-gray-200 hover:border-gray-300 hover:shadow-md'
|
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Carrier logo/badge with brand color */}
|
<span className="text-2xl mb-1">{carrier.logo}</span>
|
||||||
<div
|
<span className="text-xs font-medium text-gray-700 text-center">{carrier.name}</span>
|
||||||
className={`w-12 h-12 rounded-lg flex items-center justify-center text-sm font-bold mb-2 shadow-sm ${carrier.textColor}`}
|
|
||||||
style={{ backgroundColor: carrier.color }}
|
|
||||||
>
|
|
||||||
{carrier.name.length <= 3 ? carrier.name : carrier.name.substring(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-gray-800 text-center leading-tight">{carrier.name}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -319,42 +197,22 @@ export default function TrackTracePage() {
|
|||||||
}}
|
}}
|
||||||
onKeyPress={handleKeyPress}
|
onKeyPress={handleKeyPress}
|
||||||
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
|
placeholder={selectedCarrierData?.placeholder || 'Ex: MSKU1234567'}
|
||||||
className="text-lg font-mono border-gray-300 focus:border-blue-500 h-12"
|
className="text-lg font-mono border-gray-300 focus:border-blue-500"
|
||||||
/>
|
/>
|
||||||
{selectedCarrierData && (
|
{selectedCarrierData && (
|
||||||
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
|
<p className="mt-1 text-xs text-gray-500">{selectedCarrierData.description}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* US 5.2: Harmonized button color */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleTrack}
|
onClick={handleTrack}
|
||||||
size="lg"
|
className="bg-blue-600 hover:bg-blue-700 text-white px-6"
|
||||||
className="bg-blue-600 hover:bg-blue-700 text-white px-8 h-12 font-semibold shadow-md"
|
|
||||||
>
|
>
|
||||||
<Search className="mr-2 h-5 w-5" />
|
<span className="mr-2">🔍</span>
|
||||||
Rechercher
|
Rechercher
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Button - Map */}
|
|
||||||
<div className="flex flex-wrap gap-3 pt-2">
|
|
||||||
<Button
|
|
||||||
variant={showMap ? "default" : "outline"}
|
|
||||||
onClick={() => {
|
|
||||||
setShowMap(!showMap);
|
|
||||||
if (!showMap) setIsMapLoading(true);
|
|
||||||
}}
|
|
||||||
className={showMap
|
|
||||||
? "bg-blue-600 hover:bg-blue-700 text-white"
|
|
||||||
: "text-gray-700 border-gray-300 hover:bg-blue-50 hover:border-blue-300 hover:text-blue-700"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Globe className="mr-2 h-4 w-4" />
|
|
||||||
{showMap ? 'Masquer la carte maritime' : 'Afficher la carte maritime'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
@ -364,221 +222,12 @@ export default function TrackTracePage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Vessel Position Map - Large immersive display */}
|
|
||||||
{showMap && (
|
|
||||||
<div className={`${isMapFullscreen ? 'fixed inset-0 z-50 bg-gray-900' : ''}`}>
|
|
||||||
<Card className={`bg-white shadow-xl overflow-hidden ${isMapFullscreen ? 'h-full rounded-none' : ''}`}>
|
|
||||||
{/* Map Header */}
|
|
||||||
<div className={`flex items-center justify-between px-6 py-4 bg-gradient-to-r from-blue-600 to-blue-700 text-white ${isMapFullscreen ? '' : 'rounded-t-lg'}`}>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="p-2 bg-white/20 rounded-lg">
|
|
||||||
<Globe className="h-6 w-6" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Carte Maritime Mondiale</h3>
|
|
||||||
<p className="text-blue-100 text-sm">Position des navires en temps réel</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* Fullscreen Toggle */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsMapFullscreen(!isMapFullscreen)}
|
|
||||||
className="text-white hover:bg-white/20"
|
|
||||||
>
|
|
||||||
{isMapFullscreen ? (
|
|
||||||
<>
|
|
||||||
<Minimize2 className="h-4 w-4 mr-2" />
|
|
||||||
Réduire
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Maximize2 className="h-4 w-4 mr-2" />
|
|
||||||
Plein écran
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
{/* Close Button */}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setShowMap(false);
|
|
||||||
setIsMapFullscreen(false);
|
|
||||||
}}
|
|
||||||
className="text-white hover:bg-white/20"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map Container */}
|
|
||||||
<div className={`relative w-full ${isMapFullscreen ? 'h-[calc(100vh-80px)]' : 'h-[70vh] min-h-[500px] max-h-[800px]'}`}>
|
|
||||||
{/* Loading State */}
|
|
||||||
{isMapLoading && (
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-blue-50 to-blue-100 flex items-center justify-center z-10">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="relative">
|
|
||||||
<Ship className="h-16 w-16 text-blue-600 animate-pulse" />
|
|
||||||
<div className="absolute -bottom-1 left-1/2 transform -translate-x-1/2">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 text-blue-700 font-medium">Chargement de la carte...</p>
|
|
||||||
<p className="text-blue-500 text-sm">Connexion à MarineTraffic</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* MarineTraffic Map */}
|
|
||||||
<iframe
|
|
||||||
src="https://www.marinetraffic.com/en/ais/embed/zoom:3/centery:25/centerx:0/maptype:4/shownames:true/mmsi:0/shipid:0/fleet:/fleet_id:/vtypes:/showmenu:true/remember:false"
|
|
||||||
className="w-full h-full border-0"
|
|
||||||
title="Carte maritime en temps réel"
|
|
||||||
loading="lazy"
|
|
||||||
onLoad={() => setIsMapLoading(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Map Legend Overlay */}
|
|
||||||
<div className={`absolute bottom-4 left-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? 'max-w-xs' : 'max-w-[280px]'}`}>
|
|
||||||
<h4 className="font-semibold text-gray-800 text-sm mb-3 flex items-center gap-2">
|
|
||||||
<Anchor className="h-4 w-4 text-blue-600" />
|
|
||||||
Légende
|
|
||||||
</h4>
|
|
||||||
<div className="space-y-2 text-xs">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-green-500" />
|
|
||||||
<span className="text-gray-600">Cargos</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-red-500" />
|
|
||||||
<span className="text-gray-600">Tankers</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
|
||||||
<span className="text-gray-600">Passagers</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 rounded-full bg-yellow-500" />
|
|
||||||
<span className="text-gray-600">High Speed</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Stats Overlay */}
|
|
||||||
<div className={`absolute top-4 right-4 bg-white/95 backdrop-blur-sm rounded-xl shadow-lg p-4 ${isMapFullscreen ? '' : 'hidden lg:block'}`}>
|
|
||||||
<div className="flex items-center gap-4 text-sm">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-blue-600">90K+</p>
|
|
||||||
<p className="text-gray-500 text-xs">Navires actifs</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-10 bg-gray-200" />
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-2xl font-bold text-green-600">3,500+</p>
|
|
||||||
<p className="text-gray-500 text-xs">Ports mondiaux</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Map Footer */}
|
|
||||||
<div className="px-6 py-3 bg-gray-50 border-t border-gray-200 flex items-center justify-between">
|
|
||||||
<p className="text-xs text-gray-500 flex items-center gap-1">
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
Données fournies par MarineTraffic - Mise à jour en temps réel
|
|
||||||
</p>
|
|
||||||
<a
|
|
||||||
href="https://www.marinetraffic.com"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1"
|
|
||||||
>
|
|
||||||
Ouvrir sur MarineTraffic
|
|
||||||
<ExternalLink className="h-3 w-3" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Search History */}
|
|
||||||
<Card className="bg-white shadow">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
|
||||||
<History className="h-5 w-5 text-gray-600" />
|
|
||||||
Historique des recherches
|
|
||||||
</CardTitle>
|
|
||||||
{searchHistory.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleClearHistory}
|
|
||||||
className="text-gray-500 hover:text-red-600 text-xs"
|
|
||||||
>
|
|
||||||
Effacer tout
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{searchHistory.length === 0 ? (
|
|
||||||
<div className="text-center py-8 text-gray-500">
|
|
||||||
<Clock className="h-10 w-10 mx-auto mb-3 text-gray-300" />
|
|
||||||
<p className="text-sm">Aucune recherche récente</p>
|
|
||||||
<p className="text-xs text-gray-400 mt-1">Vos recherches apparaîtront ici</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{searchHistory.map(item => {
|
|
||||||
const carrier = carriers.find(c => c.id === item.carrierId);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.id}
|
|
||||||
className="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:bg-gray-50 group cursor-pointer transition-colors"
|
|
||||||
onClick={() => handleHistoryClick(item)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className={`w-8 h-8 rounded flex items-center justify-center text-xs font-bold ${carrier?.textColor || 'text-white'}`}
|
|
||||||
style={{ backgroundColor: carrier?.color || '#666' }}
|
|
||||||
>
|
|
||||||
{item.carrierName.substring(0, 2).toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-mono text-sm font-medium text-gray-900">{item.trackingNumber}</p>
|
|
||||||
<p className="text-xs text-gray-500">{item.carrierName} • {formatTimeAgo(item.timestamp)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleDeleteHistory(item.id);
|
|
||||||
}}
|
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-red-50 rounded transition-opacity"
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4 text-gray-400 hover:text-red-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Help Section */}
|
{/* Help Section */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<Package className="h-5 w-5 text-blue-600" />
|
<span>📦</span>
|
||||||
Numéro de conteneur
|
Numéro de conteneur
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -593,14 +242,14 @@ export default function TrackTracePage() {
|
|||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<FileText className="h-5 w-5 text-blue-600" />
|
<span>📋</span>
|
||||||
Connaissement (B/L)
|
Connaissement (B/L)
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
Le numéro de connaissement (Bill of Lading) est fourni par le transporteur lors de la confirmation de réservation.
|
Le numéro de Bill of Lading est fourni par le transporteur lors de la confirmation de booking.
|
||||||
Le format varie selon le transporteur.
|
Format variable selon le carrier.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -608,8 +257,8 @@ export default function TrackTracePage() {
|
|||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
<ClipboardList className="h-5 w-5 text-blue-600" />
|
<span>📝</span>
|
||||||
Référence de réservation
|
Référence de booking
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -623,7 +272,7 @@ export default function TrackTracePage() {
|
|||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
<div className="p-4 bg-blue-50 rounded-lg border border-blue-100">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Lightbulb className="h-5 w-5 text-blue-600 flex-shrink-0" />
|
<span className="text-xl">💡</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
|
<p className="text-sm font-medium text-blue-800">Comment fonctionne le suivi ?</p>
|
||||||
<p className="text-sm text-blue-700 mt-1">
|
<p className="text-sm text-blue-700 mt-1">
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Shield, ClipboardList, DollarSign, Pencil, Lightbulb, Plus } from 'lucide-react';
|
|
||||||
|
|
||||||
const clausesICC = [
|
const clausesICC = [
|
||||||
{
|
{
|
||||||
@ -65,7 +64,7 @@ export default function AssurancePage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Shield className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">🛡️</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Assurance Maritime (Cargo Insurance)</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -89,7 +88,7 @@ export default function AssurancePage() {
|
|||||||
|
|
||||||
{/* ICC Clauses */}
|
{/* ICC Clauses */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Clauses ICC (Institute Cargo Clauses)</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Clauses ICC (Institute Cargo Clauses)</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{clausesICC.map((clause) => (
|
{clausesICC.map((clause) => (
|
||||||
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
|
<Card key={clause.code} className={`bg-white ${clause.recommended ? 'border-green-300 border-2' : ''}`}>
|
||||||
@ -139,7 +138,7 @@ export default function AssurancePage() {
|
|||||||
{/* Valeur assurée */}
|
{/* Valeur assurée */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Calcul de la Valeur Assurée</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">💰 Calcul de la Valeur Assurée</h3>
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<div className="text-center mb-4">
|
<div className="text-center mb-4">
|
||||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
||||||
@ -166,7 +165,7 @@ export default function AssurancePage() {
|
|||||||
|
|
||||||
{/* Extensions */}
|
{/* Extensions */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Plus className="w-5 h-5" /> Extensions de Garantie</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">➕ Extensions de Garantie</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{extensionsGaranties.map((ext) => (
|
{extensionsGaranties.map((ext) => (
|
||||||
<Card key={ext.name} className="bg-white">
|
<Card key={ext.name} className="bg-white">
|
||||||
@ -182,7 +181,7 @@ export default function AssurancePage() {
|
|||||||
{/* Process */}
|
{/* Process */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Pencil className="w-5 h-5" /> En Cas de Sinistre</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">📝 En Cas de Sinistre</h3>
|
||||||
<ol className="list-decimal list-inside space-y-3 text-gray-700">
|
<ol className="list-decimal list-inside space-y-3 text-gray-700">
|
||||||
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
|
<li><strong>Constater</strong> : Émettre des réserves précises sur le bon de livraison</li>
|
||||||
<li><strong>Préserver</strong> : Ne pas modifier l'état des marchandises (photos, témoins)</li>
|
<li><strong>Préserver</strong> : Ne pas modifier l'état des marchandises (photos, témoins)</li>
|
||||||
@ -196,7 +195,7 @@ export default function AssurancePage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
|
<li>Toujours opter pour ICC A (All Risks) sauf marchandises très résistantes</li>
|
||||||
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>
|
<li>Vérifier les exclusions et souscrire les extensions nécessaires</li>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Calculator, Ruler, ClipboardList, Banknote, BarChart3, Lightbulb, Scale } from 'lucide-react';
|
|
||||||
|
|
||||||
const surcharges = [
|
const surcharges = [
|
||||||
{
|
{
|
||||||
@ -87,7 +86,7 @@ export default function CalculFretPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Calculator className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">🧮</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Calcul du Fret Maritime</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -99,7 +98,7 @@ export default function CalculFretPage() {
|
|||||||
{/* Base Calculation */}
|
{/* Base Calculation */}
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Principes de Base</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">📐 Principes de Base</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
|
<h4 className="font-medium text-blue-800">FCL (Conteneur Complet)</h4>
|
||||||
@ -122,7 +121,7 @@ export default function CalculFretPage() {
|
|||||||
{/* Weight Calculation */}
|
{/* Weight Calculation */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Scale className="w-5 h-5" /> Poids Taxable (LCL)</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">⚖️ Poids Taxable (LCL)</h3>
|
||||||
<div className="bg-white p-4 rounded-lg border mb-4">
|
<div className="bg-white p-4 rounded-lg border mb-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
<p className="text-lg font-mono bg-blue-100 text-blue-800 p-3 rounded inline-block">
|
||||||
@ -158,7 +157,7 @@ export default function CalculFretPage() {
|
|||||||
|
|
||||||
{/* Surcharges */}
|
{/* Surcharges */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Surcharges Courantes</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Surcharges Courantes</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{surcharges.map((sur) => (
|
{surcharges.map((sur) => (
|
||||||
<Card key={sur.code} className="bg-white">
|
<Card key={sur.code} className="bg-white">
|
||||||
@ -181,7 +180,7 @@ export default function CalculFretPage() {
|
|||||||
|
|
||||||
{/* Additional fees */}
|
{/* Additional fees */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Banknote className="w-5 h-5" /> Frais Additionnels</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">💵 Frais Additionnels</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -211,7 +210,7 @@ export default function CalculFretPage() {
|
|||||||
{/* Example calculation */}
|
{/* Example calculation */}
|
||||||
<Card className="mt-8 bg-green-50 border-green-200">
|
<Card className="mt-8 bg-green-50 border-green-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-green-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Exemple de Devis FCL</h3>
|
<h3 className="font-semibold text-green-900 mb-3">📊 Exemple de Devis FCL</h3>
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<p className="text-sm text-gray-600 mb-3">Conteneur 40' Shanghai → Le Havre</p>
|
<p className="text-sm text-gray-600 mb-3">Conteneur 40' Shanghai → Le Havre</p>
|
||||||
<div className="space-y-2 font-mono text-sm">
|
<div className="space-y-2 font-mono text-sm">
|
||||||
@ -255,7 +254,7 @@ export default function CalculFretPage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Demandez des devis "All-in" pour éviter les surprises de surcharges</li>
|
<li>Demandez des devis "All-in" pour éviter les surprises de surcharges</li>
|
||||||
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>
|
<li>Comparez les transitaires sur le total, pas seulement le fret de base</li>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Package, PackageOpen, Truck, Cylinder, Snowflake, type LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
const containers = [
|
const containers = [
|
||||||
{
|
{
|
||||||
@ -23,7 +22,7 @@ const containers = [
|
|||||||
tare: '2,300 kg',
|
tare: '2,300 kg',
|
||||||
},
|
},
|
||||||
usage: 'Marchandises générales sèches',
|
usage: 'Marchandises générales sèches',
|
||||||
icon: Package,
|
icon: '📦',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: '40\' Standard (40\' DRY)',
|
type: '40\' Standard (40\' DRY)',
|
||||||
@ -39,7 +38,7 @@ const containers = [
|
|||||||
tare: '3,800 kg',
|
tare: '3,800 kg',
|
||||||
},
|
},
|
||||||
usage: 'Marchandises générales, cargo volumineux',
|
usage: 'Marchandises générales, cargo volumineux',
|
||||||
icon: Package,
|
icon: '📦',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: '40\' High Cube (40\' HC)',
|
type: '40\' High Cube (40\' HC)',
|
||||||
@ -55,7 +54,7 @@ const containers = [
|
|||||||
tare: '4,020 kg',
|
tare: '4,020 kg',
|
||||||
},
|
},
|
||||||
usage: 'Cargo léger mais volumineux',
|
usage: 'Cargo léger mais volumineux',
|
||||||
icon: Package,
|
icon: '📦',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'Reefer (Réfrigéré)',
|
type: 'Reefer (Réfrigéré)',
|
||||||
@ -71,7 +70,7 @@ const containers = [
|
|||||||
temperature: '-30°C à +30°C',
|
temperature: '-30°C à +30°C',
|
||||||
},
|
},
|
||||||
usage: 'Produits périssables, pharmaceutiques',
|
usage: 'Produits périssables, pharmaceutiques',
|
||||||
icon: Snowflake,
|
icon: '❄️',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'Open Top',
|
type: 'Open Top',
|
||||||
@ -87,7 +86,7 @@ const containers = [
|
|||||||
tare: '2,400 kg / 4,100 kg',
|
tare: '2,400 kg / 4,100 kg',
|
||||||
},
|
},
|
||||||
usage: 'Cargo hors gabarit en hauteur, machinerie',
|
usage: 'Cargo hors gabarit en hauteur, machinerie',
|
||||||
icon: PackageOpen,
|
icon: '📭',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'Flat Rack',
|
type: 'Flat Rack',
|
||||||
@ -103,7 +102,7 @@ const containers = [
|
|||||||
tare: '2,700 kg / 4,700 kg',
|
tare: '2,700 kg / 4,700 kg',
|
||||||
},
|
},
|
||||||
usage: 'Cargo très lourd ou surdimensionné',
|
usage: 'Cargo très lourd ou surdimensionné',
|
||||||
icon: Truck,
|
icon: '🚛',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'Tank Container',
|
type: 'Tank Container',
|
||||||
@ -119,7 +118,7 @@ const containers = [
|
|||||||
tare: '3,500 kg',
|
tare: '3,500 kg',
|
||||||
},
|
},
|
||||||
usage: 'Liquides, gaz, produits chimiques',
|
usage: 'Liquides, gaz, produits chimiques',
|
||||||
icon: Cylinder,
|
icon: '🛢️',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -149,7 +148,7 @@ export default function ConteneursPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Package className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">📦</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Conteneurs et Types de Cargo</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -190,7 +189,7 @@ export default function ConteneursPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<container.icon className="w-6 h-6 text-blue-600" />
|
<span className="text-2xl">{container.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-lg">{container.type}</span>
|
<span className="text-lg">{container.type}</span>
|
||||||
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
|
<span className="ml-2 px-2 py-1 bg-gray-100 text-gray-700 text-xs font-mono rounded">
|
||||||
@ -261,35 +260,35 @@ export default function ConteneursPage() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Package className="w-5 h-5 text-green-700 mt-0.5" />
|
<span className="text-xl">📦</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-900">Marchandises générales</p>
|
<p className="font-medium text-green-900">Marchandises générales</p>
|
||||||
<p className="text-sm text-green-800">→ 20' ou 40' Standard (DRY)</p>
|
<p className="text-sm text-green-800">→ 20' ou 40' Standard (DRY)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Snowflake className="w-5 h-5 text-green-700 mt-0.5" />
|
<span className="text-xl">❄️</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
|
<p className="font-medium text-green-900">Produits réfrigérés/congelés</p>
|
||||||
<p className="text-sm text-green-800">→ Reefer 20' ou 40'</p>
|
<p className="text-sm text-green-800">→ Reefer 20' ou 40'</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<PackageOpen className="w-5 h-5 text-green-700 mt-0.5" />
|
<span className="text-xl">📭</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
|
<p className="font-medium text-green-900">Cargo hors gabarit (hauteur)</p>
|
||||||
<p className="text-sm text-green-800">→ Open Top</p>
|
<p className="text-sm text-green-800">→ Open Top</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Truck className="w-5 h-5 text-green-700 mt-0.5" />
|
<span className="text-xl">🚛</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
|
<p className="font-medium text-green-900">Machinerie lourde/surdimensionnée</p>
|
||||||
<p className="text-sm text-green-800">→ Flat Rack</p>
|
<p className="text-sm text-green-800">→ Flat Rack</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Cylinder className="w-5 h-5 text-green-700 mt-0.5" />
|
<span className="text-xl">🛢️</span>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-900">Liquides en vrac</p>
|
<p className="font-medium text-green-900">Liquides en vrac</p>
|
||||||
<p className="text-sm text-green-800">→ Tank Container ou Flexitank</p>
|
<p className="text-sm text-green-800">→ Tank Container ou Flexitank</p>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { FileText, ClipboardList, FileStack, Package, Receipt, Factory, type LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
const documents = [
|
const documents = [
|
||||||
{
|
{
|
||||||
@ -19,7 +18,7 @@ const documents = [
|
|||||||
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
|
{ name: 'B/L au porteur', desc: 'Propriété à celui qui le détient' },
|
||||||
],
|
],
|
||||||
importance: 'Critique',
|
importance: 'Critique',
|
||||||
icon: FileText,
|
icon: '📄',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Sea Waybill',
|
name: 'Sea Waybill',
|
||||||
@ -30,7 +29,7 @@ const documents = [
|
|||||||
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
|
{ name: 'Express', desc: 'Libération rapide sans documents originaux' },
|
||||||
],
|
],
|
||||||
importance: 'Important',
|
importance: 'Important',
|
||||||
icon: ClipboardList,
|
icon: '📋',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Manifest',
|
name: 'Manifest',
|
||||||
@ -41,7 +40,7 @@ const documents = [
|
|||||||
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
|
{ name: 'Freight Manifest', desc: 'Inclut les informations de fret' },
|
||||||
],
|
],
|
||||||
importance: 'Obligatoire',
|
importance: 'Obligatoire',
|
||||||
icon: FileStack,
|
icon: '📑',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Packing List',
|
name: 'Packing List',
|
||||||
@ -52,7 +51,7 @@ const documents = [
|
|||||||
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
|
{ name: 'Détaillée', desc: 'Avec poids, dimensions, marquage' },
|
||||||
],
|
],
|
||||||
importance: 'Important',
|
importance: 'Important',
|
||||||
icon: Package,
|
icon: '📦',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Commercial Invoice',
|
name: 'Commercial Invoice',
|
||||||
@ -63,7 +62,7 @@ const documents = [
|
|||||||
{ name: 'Définitive', desc: 'Document final de facturation' },
|
{ name: 'Définitive', desc: 'Document final de facturation' },
|
||||||
],
|
],
|
||||||
importance: 'Critique',
|
importance: 'Critique',
|
||||||
icon: Receipt,
|
icon: '🧾',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Certificate of Origin',
|
name: 'Certificate of Origin',
|
||||||
@ -75,7 +74,7 @@ const documents = [
|
|||||||
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
|
{ name: 'Non préférentiel', desc: 'Attestation simple d\'origine' },
|
||||||
],
|
],
|
||||||
importance: 'Selon destination',
|
importance: 'Selon destination',
|
||||||
icon: Factory,
|
icon: '🏭',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -121,7 +120,7 @@ export default function DocumentsTransportPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ClipboardList className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">📋</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Documents de Transport Maritime</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -150,7 +149,7 @@ export default function DocumentsTransportPage() {
|
|||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardTitle className="flex items-center justify-between">
|
<CardTitle className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<doc.icon className="w-6 h-6 text-blue-600" />
|
<span className="text-2xl">{doc.icon}</span>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-lg">{doc.name}</span>
|
<span className="text-lg">{doc.name}</span>
|
||||||
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>
|
<span className="text-gray-500 text-sm ml-2">({doc.french})</span>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { ShieldCheck, ClipboardList, FileText, DollarSign, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
const regimesDouaniers = [
|
const regimesDouaniers = [
|
||||||
{
|
{
|
||||||
@ -98,7 +97,7 @@ export default function DouanesPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ShieldCheck className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">🛃</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Procédures Douanières</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -135,7 +134,7 @@ export default function DouanesPage() {
|
|||||||
|
|
||||||
{/* Régimes douaniers */}
|
{/* Régimes douaniers */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Régimes Douaniers</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Régimes Douaniers</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{regimesDouaniers.map((regime) => (
|
{regimesDouaniers.map((regime) => (
|
||||||
<Card key={regime.code} className="bg-white">
|
<Card key={regime.code} className="bg-white">
|
||||||
@ -157,7 +156,7 @@ export default function DouanesPage() {
|
|||||||
|
|
||||||
{/* Documents requis */}
|
{/* Documents requis */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -180,7 +179,7 @@ export default function DouanesPage() {
|
|||||||
{/* Droits et taxes */}
|
{/* Droits et taxes */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Droits et Taxes</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">💰 Droits et Taxes</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">Droits de douane</h4>
|
<h4 className="font-medium text-gray-900">Droits de douane</h4>
|
||||||
@ -204,7 +203,7 @@ export default function DouanesPage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Points d'Attention</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">⚠️ Points d'Attention</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Toujours vérifier le classement tarifaire avant l'importation</li>
|
<li>Toujours vérifier le classement tarifaire avant l'importation</li>
|
||||||
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>
|
<li>Conserver tous les documents 3 ans minimum (contrôle a posteriori)</li>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { ClipboardList, Hash, Package, FileText, Tag, Shuffle, Lightbulb, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
const classesIMDG = [
|
const classesIMDG = [
|
||||||
{
|
{
|
||||||
@ -111,7 +110,7 @@ export default function IMDGPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<AlertTriangle className="w-10 h-10 text-orange-500" />
|
<span className="text-4xl">⚠️</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Marchandises Dangereuses (Code IMDG)</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -124,7 +123,7 @@ export default function IMDGPage() {
|
|||||||
{/* Key Info */}
|
{/* Key Info */}
|
||||||
<Card className="bg-red-50 border-red-200">
|
<Card className="bg-red-50 border-red-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Responsabilités de l'Expéditeur</h3>
|
<h3 className="font-semibold text-red-900 mb-3">⚠️ Responsabilités de l'Expéditeur</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-red-800">
|
<ul className="list-disc list-inside space-y-2 text-red-800">
|
||||||
<li>Classer correctement la marchandise selon le Code IMDG</li>
|
<li>Classer correctement la marchandise selon le Code IMDG</li>
|
||||||
<li>Utiliser des emballages homologués UN</li>
|
<li>Utiliser des emballages homologués UN</li>
|
||||||
@ -137,7 +136,7 @@ export default function IMDGPage() {
|
|||||||
|
|
||||||
{/* Classes */}
|
{/* Classes */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Les 9 Classes de Danger</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Les 9 Classes de Danger</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{classesIMDG.map((cls) => (
|
{classesIMDG.map((cls) => (
|
||||||
<Card key={cls.class} className="bg-white overflow-hidden">
|
<Card key={cls.class} className="bg-white overflow-hidden">
|
||||||
@ -172,7 +171,7 @@ export default function IMDGPage() {
|
|||||||
{/* UN Number */}
|
{/* UN Number */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Hash className="w-5 h-5" /> Numéro ONU (UN Number)</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">🔢 Numéro ONU (UN Number)</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
|
Chaque matière dangereuse est identifiée par un numéro ONU à 4 chiffres.
|
||||||
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
|
Ce numéro permet de retrouver toutes les informations dans le Code IMDG.
|
||||||
@ -196,7 +195,7 @@ export default function IMDGPage() {
|
|||||||
|
|
||||||
{/* Packaging Groups */}
|
{/* Packaging Groups */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Package className="w-5 h-5" /> Groupes d'Emballage</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📦 Groupes d'Emballage</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -220,7 +219,7 @@ export default function IMDGPage() {
|
|||||||
|
|
||||||
{/* Documents */}
|
{/* Documents */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Requis</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Requis</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -241,7 +240,7 @@ export default function IMDGPage() {
|
|||||||
{/* Labeling */}
|
{/* Labeling */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Tag className="w-5 h-5" /> Marquage et Étiquetage</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">🏷️ Marquage et Étiquetage</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">Colis</h4>
|
<h4 className="font-medium text-gray-900">Colis</h4>
|
||||||
@ -268,7 +267,7 @@ export default function IMDGPage() {
|
|||||||
{/* Segregation */}
|
{/* Segregation */}
|
||||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><Shuffle className="w-5 h-5" /> Ségrégation</h3>
|
<h3 className="font-semibold text-orange-900 mb-3">🔀 Ségrégation</h3>
|
||||||
<p className="text-orange-800 mb-3">
|
<p className="text-orange-800 mb-3">
|
||||||
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
|
Certaines classes de marchandises dangereuses ne peuvent pas être chargées ensemble.
|
||||||
Le Code IMDG définit des règles strictes de ségrégation :
|
Le Code IMDG définit des règles strictes de ségrégation :
|
||||||
@ -297,7 +296,7 @@ export default function IMDGPage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Vérifier l'acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
|
<li>Vérifier l'acceptation par la compagnie maritime (certaines refusent certaines classes)</li>
|
||||||
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>
|
<li>Anticiper les surcharges DG (dangerous goods) qui peuvent être significatives</li>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { ScrollText, Ship, ArrowUpFromLine, ArrowDownToLine } from 'lucide-react';
|
|
||||||
|
|
||||||
const incoterms = [
|
const incoterms = [
|
||||||
{
|
{
|
||||||
@ -120,7 +119,7 @@ export default function IncotermsPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<ScrollText className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">📜</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Incoterms 2020</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -147,11 +146,8 @@ export default function IncotermsPage() {
|
|||||||
{categories.map((category) => (
|
{categories.map((category) => (
|
||||||
<div key={category} className="mt-8">
|
<div key={category} className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
<h2 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
<span className="flex items-center gap-2">
|
{category === 'Maritime' ? '🚢 Incoterms Maritimes' :
|
||||||
{category === 'Maritime' ? <><Ship className="w-5 h-5" /> Incoterms Maritimes</> :
|
category === 'Départ' ? '📤 Incoterms de Départ' : '📥 Incoterms d\'Arrivée'}
|
||||||
category === 'Départ' ? <><ArrowUpFromLine className="w-5 h-5" /> Incoterms de Départ</> :
|
|
||||||
<><ArrowDownToLine className="w-5 h-5" /> Incoterms d'Arrivée</>}
|
|
||||||
</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{incoterms
|
{incoterms
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Package, Truck, Scale } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function LclVsFclPage() {
|
export default function LclVsFclPage() {
|
||||||
return (
|
return (
|
||||||
@ -27,7 +26,7 @@ export default function LclVsFclPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Scale className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">⚖️</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
|
<h1 className="text-3xl font-bold text-gray-900">LCL vs FCL</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -41,7 +40,7 @@ export default function LclVsFclPage() {
|
|||||||
<Card className="bg-blue-50 border-blue-200">
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-blue-900 flex items-center gap-2">
|
<CardTitle className="text-blue-900 flex items-center gap-2">
|
||||||
<Package className="w-6 h-6" />
|
<span className="text-2xl">📦</span>
|
||||||
LCL - Groupage
|
LCL - Groupage
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@ -65,7 +64,7 @@ export default function LclVsFclPage() {
|
|||||||
<Card className="bg-green-50 border-green-200">
|
<Card className="bg-green-50 border-green-200">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-green-900 flex items-center gap-2">
|
<CardTitle className="text-green-900 flex items-center gap-2">
|
||||||
<Truck className="w-6 h-6" />
|
<span className="text-2xl">🚛</span>
|
||||||
FCL - Complet
|
FCL - Complet
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { CreditCard, RefreshCw, Users, ClipboardList, FileText, Calendar, DollarSign, ScrollText, Lightbulb, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
const typesLC = [
|
const typesLC = [
|
||||||
{
|
{
|
||||||
@ -94,7 +93,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CreditCard className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">💳</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Lettre de Crédit (L/C)</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -107,7 +106,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* How it works */}
|
{/* How it works */}
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Fonctionnement Simplifié</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">🔄 Fonctionnement Simplifié</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-2">
|
||||||
{[
|
{[
|
||||||
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
|
{ step: '1', title: 'Demande', desc: 'Acheteur demande l\'ouverture à sa banque' },
|
||||||
@ -130,7 +129,7 @@ export default function LettreCreditPage() {
|
|||||||
|
|
||||||
{/* Parties */}
|
{/* Parties */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Users className="w-5 h-5" /> Parties Impliquées</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">👥 Parties Impliquées</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -150,7 +149,7 @@ export default function LettreCreditPage() {
|
|||||||
|
|
||||||
{/* Types */}
|
{/* Types */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Types de Lettres de Crédit</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Types de Lettres de Crédit</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{typesLC.map((lc) => (
|
{typesLC.map((lc) => (
|
||||||
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
|
<Card key={lc.type} className={`bg-white ${lc.recommended ? 'border-green-300 border-2' : ''}`}>
|
||||||
@ -173,7 +172,7 @@ export default function LettreCreditPage() {
|
|||||||
|
|
||||||
{/* Documents */}
|
{/* Documents */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><FileText className="w-5 h-5" /> Documents Typiquement Requis</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📄 Documents Typiquement Requis</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@ -193,7 +192,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* Key Dates */}
|
{/* Key Dates */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Calendar className="w-5 h-5" /> Dates Clés à Surveiller</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">📅 Dates Clés à Surveiller</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">Date d'expédition</h4>
|
<h4 className="font-medium text-gray-900">Date d'expédition</h4>
|
||||||
@ -220,7 +219,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* Costs */}
|
{/* Costs */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Coûts Typiques</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">💰 Coûts Typiques</h3>
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
@ -250,7 +249,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* Common Errors */}
|
{/* Common Errors */}
|
||||||
<Card className="mt-8 bg-red-50 border-red-200">
|
<Card className="mt-8 bg-red-50 border-red-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-red-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Erreurs Fréquentes (Réserves)</h3>
|
<h3 className="font-semibold text-red-900 mb-3">⚠️ Erreurs Fréquentes (Réserves)</h3>
|
||||||
<p className="text-red-800 mb-3">
|
<p className="text-red-800 mb-3">
|
||||||
Ces erreurs entraînent des "réserves" de la banque et peuvent bloquer le paiement :
|
Ces erreurs entraînent des "réserves" de la banque et peuvent bloquer le paiement :
|
||||||
</p>
|
</p>
|
||||||
@ -265,7 +264,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* UCP 600 */}
|
{/* UCP 600 */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><ScrollText className="w-5 h-5" /> Règles UCP 600</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">📜 Règles UCP 600</h3>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
|
Les Règles et Usances Uniformes (UCP 600) de la Chambre de Commerce Internationale (CCI)
|
||||||
régissent les lettres de crédit documentaires depuis 2007. Points clés :
|
régissent les lettres de crédit documentaires depuis 2007. Points clés :
|
||||||
@ -282,7 +281,7 @@ export default function LettreCreditPage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
|
<li>Vérifier minutieusement les termes de la L/C dès réception</li>
|
||||||
<li>Demander des modifications AVANT expédition si nécessaire</li>
|
<li>Demander des modifications AVANT expédition si nécessaire</li>
|
||||||
|
|||||||
@ -6,112 +6,89 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||||
import {
|
|
||||||
ScrollText,
|
|
||||||
ClipboardList,
|
|
||||||
Package,
|
|
||||||
Scale,
|
|
||||||
ShieldCheck,
|
|
||||||
Shield,
|
|
||||||
Calculator,
|
|
||||||
Globe,
|
|
||||||
Anchor,
|
|
||||||
AlertTriangle,
|
|
||||||
CreditCard,
|
|
||||||
Timer,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
interface WikiTopic {
|
const wikiTopics = [
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
href: string;
|
|
||||||
tags: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const wikiTopics: WikiTopic[] = [
|
|
||||||
{
|
{
|
||||||
title: 'Incoterms 2020',
|
title: 'Incoterms 2020',
|
||||||
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
|
description: 'Les règles internationales pour l\'interprétation des termes commerciaux',
|
||||||
icon: ScrollText,
|
icon: '📜',
|
||||||
href: '/dashboard/wiki/incoterms',
|
href: '/dashboard/wiki/incoterms',
|
||||||
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
|
tags: ['FOB', 'CIF', 'EXW', 'DDP'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Documents de Transport',
|
title: 'Documents de Transport',
|
||||||
description: 'Les documents essentiels pour le transport maritime',
|
description: 'Les documents essentiels pour le transport maritime',
|
||||||
icon: ClipboardList,
|
icon: '📋',
|
||||||
href: '/dashboard/wiki/documents-transport',
|
href: '/dashboard/wiki/documents-transport',
|
||||||
tags: ['B/L', 'Sea Waybill', 'Manifest'],
|
tags: ['B/L', 'Sea Waybill', 'Manifest'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Conteneurs et Types de Cargo',
|
title: 'Conteneurs et Types de Cargo',
|
||||||
description: 'Guide complet des types de conteneurs maritimes',
|
description: 'Guide complet des types de conteneurs maritimes',
|
||||||
icon: Package,
|
icon: '📦',
|
||||||
href: '/dashboard/wiki/conteneurs',
|
href: '/dashboard/wiki/conteneurs',
|
||||||
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
|
tags: ['20\'', '40\'', 'Reefer', 'Open Top'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'LCL vs FCL',
|
title: 'LCL vs FCL',
|
||||||
description: 'Différences entre groupage et conteneur complet',
|
description: 'Différences entre groupage et conteneur complet',
|
||||||
icon: Scale,
|
icon: '⚖️',
|
||||||
href: '/dashboard/wiki/lcl-vs-fcl',
|
href: '/dashboard/wiki/lcl-vs-fcl',
|
||||||
tags: ['Groupage', 'Complet', 'Coûts'],
|
tags: ['Groupage', 'Complet', 'Coûts'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Procédures Douanières',
|
title: 'Procédures Douanières',
|
||||||
description: 'Guide des formalités douanières import/export',
|
description: 'Guide des formalités douanières import/export',
|
||||||
icon: ShieldCheck,
|
icon: '🛃',
|
||||||
href: '/dashboard/wiki/douanes',
|
href: '/dashboard/wiki/douanes',
|
||||||
tags: ['Déclaration', 'Tarifs', 'Régimes'],
|
tags: ['Déclaration', 'Tarifs', 'Régimes'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Assurance Maritime',
|
title: 'Assurance Maritime',
|
||||||
description: 'Protection des marchandises en transit',
|
description: 'Protection des marchandises en transit',
|
||||||
icon: Shield,
|
icon: '🛡️',
|
||||||
href: '/dashboard/wiki/assurance',
|
href: '/dashboard/wiki/assurance',
|
||||||
tags: ['ICC A', 'ICC B', 'ICC C'],
|
tags: ['ICC A', 'ICC B', 'ICC C'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Calcul du Fret Maritime',
|
title: 'Calcul du Fret Maritime',
|
||||||
description: 'Comment sont calculés les coûts de transport',
|
description: 'Comment sont calculés les coûts de transport',
|
||||||
icon: Calculator,
|
icon: '🧮',
|
||||||
href: '/dashboard/wiki/calcul-fret',
|
href: '/dashboard/wiki/calcul-fret',
|
||||||
tags: ['CBM', 'THC', 'BAF', 'CAF'],
|
tags: ['CBM', 'THC', 'BAF', 'CAF'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Ports et Routes Maritimes',
|
title: 'Ports et Routes Maritimes',
|
||||||
description: 'Les principales routes commerciales mondiales',
|
description: 'Les principales routes commerciales mondiales',
|
||||||
icon: Globe,
|
icon: '🌍',
|
||||||
href: '/dashboard/wiki/ports-routes',
|
href: '/dashboard/wiki/ports-routes',
|
||||||
tags: ['Hub', 'Détroits', 'Canaux'],
|
tags: ['Hub', 'Détroits', 'Canaux'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'VGM (Verified Gross Mass)',
|
title: 'VGM (Verified Gross Mass)',
|
||||||
description: 'Obligation de pesée des conteneurs (SOLAS)',
|
description: 'Obligation de pesée des conteneurs (SOLAS)',
|
||||||
icon: Anchor,
|
icon: '⚓',
|
||||||
href: '/dashboard/wiki/vgm',
|
href: '/dashboard/wiki/vgm',
|
||||||
tags: ['SOLAS', 'Pesée', 'Certification'],
|
tags: ['SOLAS', 'Pesée', 'Certification'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Marchandises Dangereuses (IMDG)',
|
title: 'Marchandises Dangereuses (IMDG)',
|
||||||
description: 'Transport de matières dangereuses par mer',
|
description: 'Transport de matières dangereuses par mer',
|
||||||
icon: AlertTriangle,
|
icon: '⚠️',
|
||||||
href: '/dashboard/wiki/imdg',
|
href: '/dashboard/wiki/imdg',
|
||||||
tags: ['Classes', 'Étiquetage', 'Sécurité'],
|
tags: ['Classes', 'Étiquetage', 'Sécurité'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Lettre de Crédit (L/C)',
|
title: 'Lettre de Crédit (L/C)',
|
||||||
description: 'Instrument de paiement international sécurisé',
|
description: 'Instrument de paiement international sécurisé',
|
||||||
icon: CreditCard,
|
icon: '💳',
|
||||||
href: '/dashboard/wiki/lettre-credit',
|
href: '/dashboard/wiki/lettre-credit',
|
||||||
tags: ['Banque', 'Paiement', 'Sécurité'],
|
tags: ['Banque', 'Paiement', 'Sécurité'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Transit Time et Délais',
|
title: 'Transit Time et Délais',
|
||||||
description: 'Comprendre les délais en transport maritime',
|
description: 'Comprendre les délais en transport maritime',
|
||||||
icon: Timer,
|
icon: '⏱️',
|
||||||
href: '/dashboard/wiki/transit-time',
|
href: '/dashboard/wiki/transit-time',
|
||||||
tags: ['Cut-off', 'Free time', 'Demurrage'],
|
tags: ['Cut-off', 'Free time', 'Demurrage'],
|
||||||
},
|
},
|
||||||
@ -130,16 +107,12 @@ export default function WikiPage() {
|
|||||||
|
|
||||||
{/* Cards Grid */}
|
{/* Cards Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{wikiTopics.map((topic) => {
|
{wikiTopics.map((topic) => (
|
||||||
const IconComponent = topic.icon;
|
|
||||||
return (
|
|
||||||
<Link key={topic.href} href={topic.href} className="block group">
|
<Link key={topic.href} href={topic.href} className="block group">
|
||||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
|
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 bg-white">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="h-10 w-10 rounded-lg bg-blue-50 flex items-center justify-center">
|
<span className="text-4xl">{topic.icon}</span>
|
||||||
<IconComponent className="h-5 w-5 text-blue-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
|
<CardTitle className="text-xl mt-3 group-hover:text-blue-600 transition-colors">
|
||||||
{topic.title}
|
{topic.title}
|
||||||
@ -162,8 +135,7 @@ export default function WikiPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer info */}
|
{/* Footer info */}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Globe, BarChart3, Ship, Trophy, RefreshCw, Lightbulb, Anchor } from 'lucide-react';
|
|
||||||
|
|
||||||
const majorRoutes = [
|
const majorRoutes = [
|
||||||
{
|
{
|
||||||
@ -114,7 +113,7 @@ export default function PortsRoutesPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Globe className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">🌍</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Ports et Routes Maritimes</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -126,7 +125,7 @@ export default function PortsRoutesPage() {
|
|||||||
{/* Key Stats */}
|
{/* Key Stats */}
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BarChart3 className="w-5 h-5" /> Chiffres Clés du Maritime Mondial</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">📊 Chiffres Clés du Maritime Mondial</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-3xl font-bold text-blue-700">80%</p>
|
<p className="text-3xl font-bold text-blue-700">80%</p>
|
||||||
@ -150,7 +149,7 @@ export default function PortsRoutesPage() {
|
|||||||
|
|
||||||
{/* Major Routes */}
|
{/* Major Routes */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Routes Commerciales Majeures</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">🛳️ Routes Commerciales Majeures</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{majorRoutes.map((route) => (
|
{majorRoutes.map((route) => (
|
||||||
<Card key={route.name} className="bg-white">
|
<Card key={route.name} className="bg-white">
|
||||||
@ -185,7 +184,7 @@ export default function PortsRoutesPage() {
|
|||||||
|
|
||||||
{/* Strategic Passages */}
|
{/* Strategic Passages */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Anchor className="w-5 h-5" /> Passages Stratégiques</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">⚓ Passages Stratégiques</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{strategicPassages.map((passage) => (
|
{strategicPassages.map((passage) => (
|
||||||
<Card key={passage.name} className="bg-white">
|
<Card key={passage.name} className="bg-white">
|
||||||
@ -223,7 +222,7 @@ export default function PortsRoutesPage() {
|
|||||||
|
|
||||||
{/* Top Ports */}
|
{/* Top Ports */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Trophy className="w-5 h-5" /> Top 10 Ports Mondiaux (TEU)</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">🏆 Top 10 Ports Mondiaux (TEU)</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -262,7 +261,7 @@ export default function PortsRoutesPage() {
|
|||||||
{/* Hub Ports Info */}
|
{/* Hub Ports Info */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Ports Hub vs Ports Régionaux</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">🔄 Ports Hub vs Ports Régionaux</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
|
<h4 className="font-medium text-gray-900">Ports Hub (Transbordement)</h4>
|
||||||
@ -287,7 +286,7 @@ export default function PortsRoutesPage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils Pratiques</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils Pratiques</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
|
<li>Privilégiez les routes directes pour réduire les délais et risques</li>
|
||||||
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>
|
<li>Anticipez les congestions portuaires (Los Angeles, Rotterdam en haute saison)</li>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { BookOpen, Calendar, Ship, DollarSign, RefreshCw, Lightbulb, Clock, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
const etapesTimeline = [
|
const etapesTimeline = [
|
||||||
{
|
{
|
||||||
@ -133,7 +132,7 @@ export default function TransitTimePage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Clock className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">⏱️</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
|
<h1 className="text-3xl font-bold text-gray-900">Transit Time et Délais</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -146,7 +145,7 @@ export default function TransitTimePage() {
|
|||||||
{/* Key Terms */}
|
{/* Key Terms */}
|
||||||
<Card className="bg-blue-50 border-blue-200">
|
<Card className="bg-blue-50 border-blue-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-blue-900 mb-3 flex items-center gap-2"><BookOpen className="w-5 h-5" /> Termes Clés</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">📖 Termes Clés</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-blue-800">ETD</h4>
|
<h4 className="font-medium text-blue-800">ETD</h4>
|
||||||
@ -170,7 +169,7 @@ export default function TransitTimePage() {
|
|||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Calendar className="w-5 h-5" /> Timeline d'une Expédition FCL</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📅 Timeline d'une Expédition FCL</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{etapesTimeline.map((item, index) => (
|
{etapesTimeline.map((item, index) => (
|
||||||
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
|
<Card key={item.etape} className={`bg-white ${index === 5 || index === 7 ? 'border-blue-300 border-2' : ''}`}>
|
||||||
@ -196,7 +195,7 @@ export default function TransitTimePage() {
|
|||||||
|
|
||||||
{/* Transit Times */}
|
{/* Transit Times */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Ship className="w-5 h-5" /> Transit Times Indicatifs</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">🚢 Transit Times Indicatifs</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -229,7 +228,7 @@ export default function TransitTimePage() {
|
|||||||
{/* Free Time */}
|
{/* Free Time */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Clock className="w-5 h-5" /> Free Time (Jours Gratuits)</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">⏰ Free Time (Jours Gratuits)</h3>
|
||||||
<p className="text-gray-600 mb-4">
|
<p className="text-gray-600 mb-4">
|
||||||
Période pendant laquelle le conteneur peut rester au terminal ou chez l'importateur
|
Période pendant laquelle le conteneur peut rester au terminal ou chez l'importateur
|
||||||
sans frais supplémentaires.
|
sans frais supplémentaires.
|
||||||
@ -258,7 +257,7 @@ export default function TransitTimePage() {
|
|||||||
|
|
||||||
{/* Late Fees */}
|
{/* Late Fees */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><DollarSign className="w-5 h-5" /> Frais de Retard</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">💸 Frais de Retard</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{fraisRetard.map((frais) => (
|
{fraisRetard.map((frais) => (
|
||||||
<Card key={frais.nom} className="bg-white border-red-200">
|
<Card key={frais.nom} className="bg-white border-red-200">
|
||||||
@ -284,7 +283,7 @@ export default function TransitTimePage() {
|
|||||||
{/* Factors affecting transit */}
|
{/* Factors affecting transit */}
|
||||||
<Card className="mt-8 bg-orange-50 border-orange-200">
|
<Card className="mt-8 bg-orange-50 border-orange-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-orange-900 mb-3 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Facteurs Impactant les Délais</h3>
|
<h3 className="font-semibold text-orange-900 mb-3">⚡ Facteurs Impactant les Délais</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
|
<h4 className="font-medium text-orange-800">Retards potentiels</h4>
|
||||||
@ -313,7 +312,7 @@ export default function TransitTimePage() {
|
|||||||
{/* Roll-over */}
|
{/* Roll-over */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><RefreshCw className="w-5 h-5" /> Roll-over (Report)</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">🔄 Roll-over (Report)</h3>
|
||||||
<p className="text-gray-600 mb-3">
|
<p className="text-gray-600 mb-3">
|
||||||
Situation où un conteneur n'est pas chargé sur le navire prévu et est reporté
|
Situation où un conteneur n'est pas chargé sur le navire prévu et est reporté
|
||||||
sur le prochain départ.
|
sur le prochain départ.
|
||||||
@ -337,7 +336,7 @@ export default function TransitTimePage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Conseils pour Optimiser les Délais</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Conseils pour Optimiser les Délais</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Réserver tôt, surtout en haute saison (2-3 semaines d'avance)</li>
|
<li>Réserver tôt, surtout en haute saison (2-3 semaines d'avance)</li>
|
||||||
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>
|
<li>Respecter les cut-off avec une marge de sécurité (24h minimum)</li>
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
|
||||||
import { Shield, Construction, Truck, ClipboardList, Microscope, User, Ruler, Lightbulb, Anchor, Scale, AlertTriangle } from 'lucide-react';
|
|
||||||
|
|
||||||
const methodesPesee = [
|
const methodesPesee = [
|
||||||
{
|
{
|
||||||
@ -70,7 +69,7 @@ export default function VGMPage() {
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Anchor className="w-10 h-10 text-blue-600" />
|
<span className="text-4xl">⚓</span>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
|
<h1 className="text-3xl font-bold text-gray-900">VGM (Verified Gross Mass)</h1>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-gray-600 max-w-3xl">
|
<p className="mt-3 text-gray-600 max-w-3xl">
|
||||||
@ -86,19 +85,19 @@ export default function VGMPage() {
|
|||||||
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
|
<h3 className="font-semibold text-blue-900 mb-3">Pourquoi le VGM ?</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-blue-800">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium flex items-center gap-1"><Shield className="w-4 h-4" /> Sécurité</h4>
|
<h4 className="font-medium">🛡️ Sécurité</h4>
|
||||||
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
|
<p className="text-sm">Les conteneurs mal déclarés causent des accidents graves (chute de conteneurs, navires instables).</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium flex items-center gap-1"><Scale className="w-4 h-4" /> Stabilité du navire</h4>
|
<h4 className="font-medium">⚖️ Stabilité du navire</h4>
|
||||||
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
|
<p className="text-sm">Le capitaine doit connaître le poids exact pour calculer le plan de chargement.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium flex items-center gap-1"><Construction className="w-4 h-4" /> Équipements portuaires</h4>
|
<h4 className="font-medium">🏗️ Équipements portuaires</h4>
|
||||||
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
|
<p className="text-sm">Les grues et portiques sont dimensionnés pour des charges maximales.</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-medium flex items-center gap-1"><Truck className="w-4 h-4" /> Transport terrestre</h4>
|
<h4 className="font-medium">🚛 Transport terrestre</h4>
|
||||||
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
|
<p className="text-sm">Évite les surcharges sur les camions et wagons de pré/post-acheminement.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -107,7 +106,7 @@ export default function VGMPage() {
|
|||||||
|
|
||||||
{/* VGM Components */}
|
{/* VGM Components */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><ClipboardList className="w-5 h-5" /> Composants du VGM</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">📋 Composants du VGM</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
|
<div className="bg-gray-50 p-4 rounded-lg border mb-4">
|
||||||
@ -132,7 +131,7 @@ export default function VGMPage() {
|
|||||||
|
|
||||||
{/* Methods */}
|
{/* Methods */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><Microscope className="w-5 h-5" /> Méthodes de Détermination</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">🔬 Méthodes de Détermination</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{methodesPesee.map((method) => (
|
{methodesPesee.map((method) => (
|
||||||
<Card key={method.method} className="bg-white">
|
<Card key={method.method} className="bg-white">
|
||||||
@ -187,7 +186,7 @@ export default function VGMPage() {
|
|||||||
{/* Responsibility */}
|
{/* Responsibility */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><User className="w-5 h-5" /> Responsabilités</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">👤 Responsabilités</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<h4 className="font-medium text-gray-900">Expéditeur (Shipper)</h4>
|
<h4 className="font-medium text-gray-900">Expéditeur (Shipper)</h4>
|
||||||
@ -214,7 +213,7 @@ export default function VGMPage() {
|
|||||||
{/* Tolerances */}
|
{/* Tolerances */}
|
||||||
<Card className="mt-8 bg-gray-50">
|
<Card className="mt-8 bg-gray-50">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-gray-900 mb-3 flex items-center gap-2"><Ruler className="w-5 h-5" /> Tolérances</h3>
|
<h3 className="font-semibold text-gray-900 mb-3">📏 Tolérances</h3>
|
||||||
<div className="bg-white p-4 rounded-lg border">
|
<div className="bg-white p-4 rounded-lg border">
|
||||||
<p className="text-gray-600 mb-3">
|
<p className="text-gray-600 mb-3">
|
||||||
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
|
Les tolérances varient selon les compagnies maritimes et les ports, mais généralement :
|
||||||
@ -235,7 +234,7 @@ export default function VGMPage() {
|
|||||||
|
|
||||||
{/* Sanctions */}
|
{/* Sanctions */}
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center gap-2"><AlertTriangle className="w-5 h-5" /> Sanctions par Région</h2>
|
<h2 className="text-xl font-bold text-gray-900 mb-4">⚠️ Sanctions par Région</h2>
|
||||||
<Card className="bg-white">
|
<Card className="bg-white">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@ -253,7 +252,7 @@ export default function VGMPage() {
|
|||||||
{/* Tips */}
|
{/* Tips */}
|
||||||
<Card className="mt-8 bg-amber-50 border-amber-200">
|
<Card className="mt-8 bg-amber-50 border-amber-200">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<h3 className="font-semibold text-amber-900 mb-3 flex items-center gap-2"><Lightbulb className="w-5 h-5" /> Bonnes Pratiques</h3>
|
<h3 className="font-semibold text-amber-900 mb-3">💡 Bonnes Pratiques</h3>
|
||||||
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
<ul className="list-disc list-inside space-y-2 text-amber-800">
|
||||||
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
|
<li>Transmettre le VGM au moins 24-48h avant le cut-off</li>
|
||||||
<li>Utiliser des balances étalonnées et certifiées</li>
|
<li>Utiliser des balances étalonnées et certifiées</li>
|
||||||
|
|||||||
@ -13,66 +13,6 @@ import Link from 'next/link';
|
|||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useAuth } from '@/lib/context/auth-context';
|
import { useAuth } from '@/lib/context/auth-context';
|
||||||
|
|
||||||
interface FieldErrors {
|
|
||||||
email?: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map backend error messages to French user-friendly messages
|
|
||||||
function getErrorMessage(error: any): { message: string; field?: 'email' | 'password' | 'general' } {
|
|
||||||
const errorMessage = error?.message || error?.response?.message || '';
|
|
||||||
|
|
||||||
// Network or server errors
|
|
||||||
if (error?.name === 'TypeError' || errorMessage.includes('fetch')) {
|
|
||||||
return {
|
|
||||||
message: 'Impossible de se connecter au serveur. Vérifiez votre connexion internet.',
|
|
||||||
field: 'general'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backend error messages
|
|
||||||
if (errorMessage.includes('Invalid credentials') || errorMessage.includes('Identifiants')) {
|
|
||||||
return {
|
|
||||||
message: 'Email ou mot de passe incorrect',
|
|
||||||
field: 'general'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes('inactive') || errorMessage.includes('désactivé')) {
|
|
||||||
return {
|
|
||||||
message: 'Votre compte a été désactivé. Contactez le support pour plus d\'informations.',
|
|
||||||
field: 'general'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes('not found') || errorMessage.includes('introuvable')) {
|
|
||||||
return {
|
|
||||||
message: 'Aucun compte trouvé avec cet email',
|
|
||||||
field: 'email'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes('password') || errorMessage.includes('mot de passe')) {
|
|
||||||
return {
|
|
||||||
message: 'Mot de passe incorrect',
|
|
||||||
field: 'password'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (errorMessage.includes('Too many') || errorMessage.includes('rate limit')) {
|
|
||||||
return {
|
|
||||||
message: 'Trop de tentatives de connexion. Veuillez réessayer dans quelques minutes.',
|
|
||||||
field: 'general'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default error
|
|
||||||
return {
|
|
||||||
message: errorMessage || 'Une erreur est survenue. Veuillez réessayer.',
|
|
||||||
field: 'general'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const { login } = useAuth();
|
const { login } = useAuth();
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
@ -80,64 +20,17 @@ export default function LoginPage() {
|
|||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
|
||||||
|
|
||||||
// Validate form fields
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const errors: FieldErrors = {};
|
|
||||||
|
|
||||||
// Email validation
|
|
||||||
if (!email.trim()) {
|
|
||||||
errors.email = 'L\'adresse email est requise';
|
|
||||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
errors.email = 'L\'adresse email n\'est pas valide';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password validation
|
|
||||||
if (!password) {
|
|
||||||
errors.password = 'Le mot de passe est requis';
|
|
||||||
} else if (password.length < 6) {
|
|
||||||
errors.password = 'Le mot de passe doit contenir au moins 6 caractères';
|
|
||||||
}
|
|
||||||
|
|
||||||
setFieldErrors(errors);
|
|
||||||
return Object.keys(errors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle input changes - keep errors visible until successful login
|
|
||||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setEmail(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setPassword(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
setError('');
|
||||||
setFieldErrors({});
|
|
||||||
|
|
||||||
// Validate form before submission
|
|
||||||
if (!validateForm()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await login(email, password);
|
await login(email, password);
|
||||||
// Navigation is handled by the login function in auth context
|
// Navigation is handled by the login function in auth context
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const { message, field } = getErrorMessage(err);
|
setError(err.message || 'Identifiants incorrects');
|
||||||
|
|
||||||
if (field === 'email') {
|
|
||||||
setFieldErrors({ email: message });
|
|
||||||
} else if (field === 'password') {
|
|
||||||
setFieldErrors({ password: message });
|
|
||||||
} else {
|
|
||||||
setError(message);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@ -172,20 +65,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
|
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
<svg
|
|
||||||
className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p className="text-body-sm text-red-800">{error}</p>
|
<p className="text-body-sm text-red-800">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -194,74 +74,38 @@ export default function LoginPage() {
|
|||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className={`label ${fieldErrors.email ? 'text-red-600' : ''}`}>
|
<label htmlFor="email" className="label">
|
||||||
Adresse email
|
Adresse email
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={handleEmailChange}
|
onChange={e => setEmail(e.target.value)}
|
||||||
className={`input w-full ${
|
className="input w-full"
|
||||||
fieldErrors.email
|
|
||||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
placeholder="votre.email@entreprise.com"
|
placeholder="votre.email@entreprise.com"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-invalid={!!fieldErrors.email}
|
|
||||||
aria-describedby={fieldErrors.email ? 'email-error' : undefined}
|
|
||||||
/>
|
/>
|
||||||
{fieldErrors.email && (
|
|
||||||
<p id="email-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{fieldErrors.email}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="password" className={`label ${fieldErrors.password ? 'text-red-600' : ''}`}>
|
<label htmlFor="password" className="label">
|
||||||
Mot de passe
|
Mot de passe
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={handlePasswordChange}
|
onChange={e => setPassword(e.target.value)}
|
||||||
className={`input w-full ${
|
className="input w-full"
|
||||||
fieldErrors.password
|
|
||||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500 bg-red-50'
|
|
||||||
: ''
|
|
||||||
}`}
|
|
||||||
placeholder="••••••••••"
|
placeholder="••••••••••"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
aria-invalid={!!fieldErrors.password}
|
|
||||||
aria-describedby={fieldErrors.password ? 'password-error' : undefined}
|
|
||||||
/>
|
/>
|
||||||
{fieldErrors.password && (
|
|
||||||
<p id="password-error" className="mt-1.5 text-body-sm text-red-600 flex items-center gap-1">
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{fieldErrors.password}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Remember Me & Forgot Password */}
|
{/* Remember Me & Forgot Password */}
|
||||||
|
|||||||
@ -1,221 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef } from 'react';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { motion, useInView } from 'framer-motion';
|
|
||||||
import { Ship, Home, ArrowRight, LayoutDashboard, Anchor } from 'lucide-react';
|
|
||||||
import { LandingHeader, LandingFooter } from '@/components/layout';
|
|
||||||
|
|
||||||
export default function NotFound() {
|
|
||||||
const heroRef = useRef(null);
|
|
||||||
const isHeroInView = useInView(heroRef, { once: true });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-white flex flex-col">
|
|
||||||
<LandingHeader transparentOnTop={true} />
|
|
||||||
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section
|
|
||||||
ref={heroRef}
|
|
||||||
className="relative flex-1 flex items-center justify-center overflow-hidden"
|
|
||||||
>
|
|
||||||
{/* Background */}
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-navy via-brand-navy/95 to-brand-navy/90">
|
|
||||||
<div className="absolute inset-0 opacity-10">
|
|
||||||
<div className="absolute top-10 left-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-turquoise rounded-full blur-3xl" />
|
|
||||||
<div className="absolute bottom-32 right-10 w-72 h-72 lg:w-96 lg:h-96 bg-brand-green rounded-full blur-3xl" />
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-brand-turquoise/50 rounded-full blur-3xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center pt-32 pb-40">
|
|
||||||
{/* Badge */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
|
||||||
animate={isHeroInView ? { scale: 1, opacity: 1 } : {}}
|
|
||||||
transition={{ duration: 0.6, delay: 0.2 }}
|
|
||||||
className="inline-flex items-center space-x-2 bg-white/10 backdrop-blur-sm px-4 py-2 rounded-full mb-10 border border-white/20"
|
|
||||||
>
|
|
||||||
<Anchor className="w-5 h-5 text-brand-turquoise" />
|
|
||||||
<span className="text-white/90 text-sm font-medium font-heading">
|
|
||||||
Erreur 404
|
|
||||||
</span>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* Animated Ship + Waves illustration */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.8 }}
|
|
||||||
animate={isHeroInView ? { opacity: 1, scale: 1 } : {}}
|
|
||||||
transition={{ duration: 0.8, delay: 0.3 }}
|
|
||||||
className="relative mb-8 flex justify-center"
|
|
||||||
>
|
|
||||||
<div className="relative w-64 h-40 lg:w-80 lg:h-48">
|
|
||||||
{/* Ship */}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 200 120"
|
|
||||||
className="absolute inset-0 w-full h-full animate-float"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
{/* Hull */}
|
|
||||||
<path
|
|
||||||
d="M40 75 L55 95 L145 95 L160 75 Z"
|
|
||||||
fill="#34CCCD"
|
|
||||||
opacity="0.9"
|
|
||||||
/>
|
|
||||||
{/* Deck */}
|
|
||||||
<rect x="60" y="58" width="80" height="17" rx="2" fill="white" opacity="0.9" />
|
|
||||||
{/* Bridge */}
|
|
||||||
<rect x="85" y="35" width="35" height="23" rx="2" fill="white" opacity="0.85" />
|
|
||||||
{/* Window */}
|
|
||||||
<rect x="92" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
|
||||||
<rect x="105" y="40" width="8" height="6" rx="1" fill="#34CCCD" opacity="0.7" />
|
|
||||||
{/* Smokestack */}
|
|
||||||
<rect x="95" y="22" width="12" height="13" rx="1" fill="#10183A" opacity="0.8" />
|
|
||||||
<rect x="95" y="22" width="12" height="4" rx="1" fill="#34CCCD" opacity="0.6" />
|
|
||||||
{/* Mast */}
|
|
||||||
<line x1="102" y1="10" x2="102" y2="22" stroke="white" strokeWidth="1.5" opacity="0.7" />
|
|
||||||
{/* Flag */}
|
|
||||||
<path d="M102 10 L115 15 L102 20" fill="#34CCCD" opacity="0.8" />
|
|
||||||
{/* Containers on deck */}
|
|
||||||
<rect x="65" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.7" />
|
|
||||||
<rect x="79" y="60" width="12" height="10" rx="1" fill="#34CCCD" opacity="0.5" />
|
|
||||||
<rect x="130" y="60" width="12" height="10" rx="1" fill="#067224" opacity="0.5" />
|
|
||||||
<rect x="144" y="60" width="10" height="10" rx="1" fill="white" opacity="0.3" />
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Waves layer 1 */}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 400 30"
|
|
||||||
className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[150%] animate-wave"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0,15 C50,5 100,25 150,15 C200,5 250,25 300,15 C350,5 400,25 400,15 L400,30 L0,30 Z"
|
|
||||||
fill="#34CCCD"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{/* Waves layer 2 */}
|
|
||||||
<svg
|
|
||||||
viewBox="0 0 400 30"
|
|
||||||
className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-[160%] animate-wave-slow"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0,18 C60,8 120,28 180,18 C240,8 300,28 360,18 L400,18 L400,30 L0,30 Z"
|
|
||||||
fill="#34CCCD"
|
|
||||||
opacity="0.15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
{/* 404 */}
|
|
||||||
<motion.h1
|
|
||||||
initial={{ opacity: 0, y: 30 }}
|
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
|
||||||
className="text-8xl lg:text-[12rem] font-bold text-white mb-2 leading-none font-heading tracking-tight"
|
|
||||||
>
|
|
||||||
404
|
|
||||||
</motion.h1>
|
|
||||||
|
|
||||||
{/* Subtitle */}
|
|
||||||
<motion.h2
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.8, delay: 0.5 }}
|
|
||||||
className="text-3xl lg:text-5xl font-bold mb-6 font-heading"
|
|
||||||
>
|
|
||||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-brand-turquoise to-brand-green">
|
|
||||||
Page introuvable
|
|
||||||
</span>
|
|
||||||
</motion.h2>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<motion.p
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.8, delay: 0.6 }}
|
|
||||||
className="text-lg lg:text-xl text-white/70 mb-12 max-w-2xl mx-auto leading-relaxed font-body"
|
|
||||||
>
|
|
||||||
Ce navire a pris le large... La page que vous cherchez
|
|
||||||
n'existe pas ou a été déplacée.
|
|
||||||
</motion.p>
|
|
||||||
|
|
||||||
{/* CTA Buttons */}
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, y: 20 }}
|
|
||||||
animate={isHeroInView ? { opacity: 1, y: 0 } : {}}
|
|
||||||
transition={{ duration: 0.8, delay: 0.7 }}
|
|
||||||
className="flex flex-col sm:flex-row items-center justify-center space-y-4 sm:space-y-0 sm:space-x-6"
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
|
||||||
>
|
|
||||||
<Home className="w-5 h-5" />
|
|
||||||
<span>Retour à l'accueil</span>
|
|
||||||
<ArrowRight className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
href="/dashboard"
|
|
||||||
className="group px-8 py-4 bg-white/10 backdrop-blur-sm text-white border border-white/20 rounded-lg hover:bg-white/20 transition-all hover:shadow-xl font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2 font-heading"
|
|
||||||
>
|
|
||||||
<LayoutDashboard className="w-5 h-5" />
|
|
||||||
<span>Tableau de bord</span>
|
|
||||||
</Link>
|
|
||||||
</motion.div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom wave */}
|
|
||||||
<div className="absolute bottom-0 left-0 right-0">
|
|
||||||
<svg
|
|
||||||
className="w-full h-16 lg:h-24"
|
|
||||||
viewBox="0 0 1440 120"
|
|
||||||
preserveAspectRatio="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M0,60 C240,90 480,30 720,60 C960,90 1200,30 1440,60 L1440,120 L0,120 Z"
|
|
||||||
fill="white"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<LandingFooter />
|
|
||||||
|
|
||||||
<style jsx global>{`
|
|
||||||
@keyframes float {
|
|
||||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
|
||||||
25% { transform: translateY(-6px) rotate(1deg); }
|
|
||||||
75% { transform: translateY(4px) rotate(-1deg); }
|
|
||||||
}
|
|
||||||
@keyframes wave {
|
|
||||||
0% { transform: translateX(-50%) translateX(0); }
|
|
||||||
100% { transform: translateX(-50%) translateX(-50px); }
|
|
||||||
}
|
|
||||||
@keyframes wave-slow {
|
|
||||||
0% { transform: translateX(-50%) translateX(0); }
|
|
||||||
100% { transform: translateX(-50%) translateX(50px); }
|
|
||||||
}
|
|
||||||
.animate-float {
|
|
||||||
animation: float 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
.animate-wave {
|
|
||||||
animation: wave 3s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
.animate-wave-slow {
|
|
||||||
animation: wave-slow 4s ease-in-out infinite alternate;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -52,7 +52,7 @@ export default function LandingPage() {
|
|||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
title: 'Tableau de bord',
|
title: 'Dashboard Analytics',
|
||||||
description:
|
description:
|
||||||
'Suivez tous vos KPIs en temps réel : bookings, volumes, revenus et alertes personnalisées.',
|
'Suivez tous vos KPIs en temps réel : bookings, volumes, revenus et alertes personnalisées.',
|
||||||
color: 'from-blue-500 to-cyan-500',
|
color: 'from-blue-500 to-cyan-500',
|
||||||
@ -60,7 +60,7 @@ export default function LandingPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Package,
|
icon: Package,
|
||||||
title: 'Gestion des Réservations',
|
title: 'Gestion des Bookings',
|
||||||
description: 'Créez, gérez et suivez vos réservations maritimes LCL/FCL avec un historique complet.',
|
description: 'Créez, gérez et suivez vos réservations maritimes LCL/FCL avec un historique complet.',
|
||||||
color: 'from-purple-500 to-pink-500',
|
color: 'from-purple-500 to-pink-500',
|
||||||
link: '/dashboard/bookings',
|
link: '/dashboard/bookings',
|
||||||
@ -74,7 +74,7 @@ export default function LandingPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Search,
|
icon: Search,
|
||||||
title: 'Suivi des expéditions',
|
title: 'Track & Trace',
|
||||||
description:
|
description:
|
||||||
'Suivez vos conteneurs en temps réel auprès de 10+ transporteurs majeurs (Maersk, MSC, CMA CGM...).',
|
'Suivez vos conteneurs en temps réel auprès de 10+ transporteurs majeurs (Maersk, MSC, CMA CGM...).',
|
||||||
color: 'from-green-500 to-emerald-500',
|
color: 'from-green-500 to-emerald-500',
|
||||||
@ -101,13 +101,13 @@ export default function LandingPage() {
|
|||||||
const tools = [
|
const tools = [
|
||||||
{
|
{
|
||||||
icon: LayoutDashboard,
|
icon: LayoutDashboard,
|
||||||
title: 'Tableau de bord',
|
title: 'Dashboard',
|
||||||
description: 'Vue d\'ensemble de votre activité maritime',
|
description: 'Vue d\'ensemble de votre activité maritime',
|
||||||
link: '/dashboard',
|
link: '/dashboard',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Package,
|
icon: Package,
|
||||||
title: 'Mes Réservations',
|
title: 'Mes Bookings',
|
||||||
description: 'Gérez toutes vos réservations en un seul endroit',
|
description: 'Gérez toutes vos réservations en un seul endroit',
|
||||||
link: '/dashboard/bookings',
|
link: '/dashboard/bookings',
|
||||||
},
|
},
|
||||||
@ -119,7 +119,7 @@ export default function LandingPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Search,
|
icon: Search,
|
||||||
title: 'Suivi des expéditions',
|
title: 'Track & Trace',
|
||||||
description: 'Suivez vos conteneurs en temps réel',
|
description: 'Suivez vos conteneurs en temps réel',
|
||||||
link: '/dashboard/track-trace',
|
link: '/dashboard/track-trace',
|
||||||
},
|
},
|
||||||
@ -158,7 +158,7 @@ export default function LandingPage() {
|
|||||||
{ text: 'Support par email', included: true },
|
{ text: 'Support par email', included: true },
|
||||||
{ text: 'Gestion des documents', included: false },
|
{ text: 'Gestion des documents', included: false },
|
||||||
{ text: 'Notifications temps réel', included: false },
|
{ text: 'Notifications temps réel', included: false },
|
||||||
{ text: 'Accès API', included: false },
|
{ text: 'API access', included: false },
|
||||||
],
|
],
|
||||||
cta: 'Commencer gratuitement',
|
cta: 'Commencer gratuitement',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
@ -176,7 +176,7 @@ export default function LandingPage() {
|
|||||||
{ text: 'Support prioritaire', included: true },
|
{ text: 'Support prioritaire', included: true },
|
||||||
{ text: 'Gestion des documents', included: true },
|
{ text: 'Gestion des documents', included: true },
|
||||||
{ text: 'Notifications temps réel', included: true },
|
{ text: 'Notifications temps réel', included: true },
|
||||||
{ text: 'Accès API', included: false },
|
{ text: 'API access', included: false },
|
||||||
],
|
],
|
||||||
cta: 'Essai gratuit 14 jours',
|
cta: 'Essai gratuit 14 jours',
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
@ -187,10 +187,10 @@ export default function LandingPage() {
|
|||||||
period: '',
|
period: '',
|
||||||
description: 'Pour les grandes entreprises',
|
description: 'Pour les grandes entreprises',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Tout Professionnel +', included: true },
|
{ text: 'Tout Professional +', included: true },
|
||||||
{ text: 'Accès API complet', included: true },
|
{ text: 'API access complet', included: true },
|
||||||
{ text: 'Intégrations personnalisées', included: true },
|
{ text: 'Intégrations personnalisées', included: true },
|
||||||
{ text: 'Responsable de compte dédié', included: true },
|
{ text: 'Account manager dédié', included: true },
|
||||||
{ text: 'SLA garanti 99.9%', included: true },
|
{ text: 'SLA garanti 99.9%', included: true },
|
||||||
{ text: 'Formation sur site', included: true },
|
{ text: 'Formation sur site', included: true },
|
||||||
{ text: 'Multi-organisations', included: true },
|
{ text: 'Multi-organisations', included: true },
|
||||||
@ -323,7 +323,7 @@ export default function LandingPage() {
|
|||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<span>Accéder au tableau de bord</span>
|
<span>Accéder au Dashboard</span>
|
||||||
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
@ -709,13 +709,13 @@ export default function LandingPage() {
|
|||||||
{
|
{
|
||||||
step: '03',
|
step: '03',
|
||||||
title: 'Réservez',
|
title: 'Réservez',
|
||||||
description: 'Confirmez votre réservation en un clic',
|
description: 'Confirmez votre booking en un clic',
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
step: '04',
|
step: '04',
|
||||||
title: 'Suivez',
|
title: 'Suivez',
|
||||||
description: 'Suivez votre envoi en temps réel',
|
description: 'Trackez votre envoi en temps réel',
|
||||||
icon: Container,
|
icon: Container,
|
||||||
},
|
},
|
||||||
].map((step, index) => {
|
].map((step, index) => {
|
||||||
@ -833,7 +833,7 @@ export default function LandingPage() {
|
|||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
className="group px-8 py-4 bg-brand-turquoise text-white rounded-lg hover:bg-brand-turquoise/90 transition-all hover:shadow-2xl hover:scale-105 font-semibold text-lg w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<span>Accéder au tableau de bord</span>
|
<span>Accéder au Dashboard</span>
|
||||||
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
<LayoutDashboard className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -1,279 +1,265 @@
|
|||||||
/**
|
/**
|
||||||
* Cookie Consent Banner
|
* Cookie Consent Banner
|
||||||
* GDPR Compliant - French version
|
* GDPR Compliant
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { Cookie, X, Settings, Check, Shield } from 'lucide-react';
|
interface CookiePreferences {
|
||||||
import { useCookieConsent } from '@/lib/context/cookie-context';
|
essential: boolean; // Always true (required for functionality)
|
||||||
import type { CookiePreferences } from '@/lib/api/gdpr';
|
functional: boolean;
|
||||||
|
analytics: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export default function CookieConsent() {
|
export default function CookieConsent() {
|
||||||
const {
|
const [showBanner, setShowBanner] = useState(false);
|
||||||
preferences,
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
showBanner,
|
const [preferences, setPreferences] = useState<CookiePreferences>({
|
||||||
showSettings,
|
essential: true,
|
||||||
isLoading,
|
functional: true,
|
||||||
setShowBanner,
|
analytics: false,
|
||||||
setShowSettings,
|
marketing: false,
|
||||||
acceptAll,
|
});
|
||||||
acceptEssentialOnly,
|
|
||||||
savePreferences,
|
|
||||||
openPreferences,
|
|
||||||
} = useCookieConsent();
|
|
||||||
|
|
||||||
const [localPrefs, setLocalPrefs] = useState<CookiePreferences>(preferences);
|
useEffect(() => {
|
||||||
|
// Check if user has already made a choice
|
||||||
|
const consent = localStorage.getItem('cookieConsent');
|
||||||
|
if (!consent) {
|
||||||
|
setShowBanner(true);
|
||||||
|
} else {
|
||||||
|
const savedPreferences = JSON.parse(consent);
|
||||||
|
setPreferences(savedPreferences);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync local prefs when context changes
|
const acceptAll = () => {
|
||||||
React.useEffect(() => {
|
const allAccepted: CookiePreferences = {
|
||||||
setLocalPrefs(preferences);
|
essential: true,
|
||||||
}, [preferences]);
|
functional: true,
|
||||||
|
analytics: true,
|
||||||
const handleSaveCustom = async () => {
|
marketing: true,
|
||||||
await savePreferences(localPrefs);
|
};
|
||||||
|
savePreferences(allAccepted);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't render anything while loading
|
const acceptEssentialOnly = () => {
|
||||||
if (isLoading) {
|
const essentialOnly: CookiePreferences = {
|
||||||
|
essential: true,
|
||||||
|
functional: false,
|
||||||
|
analytics: false,
|
||||||
|
marketing: false,
|
||||||
|
};
|
||||||
|
savePreferences(essentialOnly);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveCustomPreferences = () => {
|
||||||
|
savePreferences(preferences);
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePreferences = (prefs: CookiePreferences) => {
|
||||||
|
localStorage.setItem('cookieConsent', JSON.stringify(prefs));
|
||||||
|
localStorage.setItem('cookieConsentDate', new Date().toISOString());
|
||||||
|
setShowBanner(false);
|
||||||
|
setShowSettings(false);
|
||||||
|
|
||||||
|
// Apply preferences
|
||||||
|
applyPreferences(prefs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPreferences = (prefs: CookiePreferences) => {
|
||||||
|
// Enable/disable analytics tracking
|
||||||
|
if (prefs.analytics) {
|
||||||
|
// Enable Google Analytics, Sentry, etc.
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('consent', 'update', {
|
||||||
|
analytics_storage: 'granted',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('consent', 'update', {
|
||||||
|
analytics_storage: 'denied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enable/disable marketing tracking
|
||||||
|
if (prefs.marketing) {
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('consent', 'update', {
|
||||||
|
ad_storage: 'granted',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||||
|
(window as any).gtag('consent', 'update', {
|
||||||
|
ad_storage: 'denied',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!showBanner) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Floating Cookie Button (shown when banner is closed) */}
|
|
||||||
<AnimatePresence>
|
|
||||||
{!showBanner && (
|
|
||||||
<motion.button
|
|
||||||
initial={{ scale: 0, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0, opacity: 0 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 260, damping: 20 }}
|
|
||||||
onClick={openPreferences}
|
|
||||||
className="fixed bottom-4 left-4 z-40 p-3 bg-brand-navy text-white rounded-full shadow-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-brand-turquoise focus:ring-offset-2 transition-colors"
|
|
||||||
aria-label="Ouvrir les paramètres de cookies"
|
|
||||||
>
|
|
||||||
<Cookie className="w-5 h-5" />
|
|
||||||
</motion.button>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
|
|
||||||
{/* Cookie Banner */}
|
{/* Cookie Banner */}
|
||||||
<AnimatePresence>
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t-2 border-gray-200 shadow-2xl">
|
||||||
{showBanner && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ y: 100, opacity: 0 }}
|
|
||||||
animate={{ y: 0, opacity: 1 }}
|
|
||||||
exit={{ y: 100, opacity: 0 }}
|
|
||||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
|
||||||
className="fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-gray-200 shadow-2xl"
|
|
||||||
>
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!showSettings ? (
|
{!showSettings ? (
|
||||||
// Simple banner
|
// Simple banner
|
||||||
<motion.div
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
key="simple"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">🍪 We use cookies</h3>
|
||||||
<Cookie className="w-5 h-5 text-brand-navy" />
|
<p className="text-sm text-gray-600">
|
||||||
<h3 className="text-lg font-semibold text-brand-navy">
|
We use cookies to improve your experience, analyze site traffic, and personalize
|
||||||
Nous utilisons des cookies
|
content. By clicking "Accept All", you consent to our use of cookies.{' '}
|
||||||
</h3>
|
<Link href="/privacy" className="text-blue-600 hover:text-blue-800 underline">
|
||||||
</div>
|
Learn more
|
||||||
<p className="text-sm text-gray-600 max-w-2xl">
|
|
||||||
Nous utilisons des cookies pour améliorer votre expérience, analyser le
|
|
||||||
trafic du site et personnaliser le contenu. En cliquant sur « Tout
|
|
||||||
accepter », vous consentez à notre utilisation des cookies.{' '}
|
|
||||||
<Link
|
|
||||||
href="/cookies"
|
|
||||||
className="text-brand-turquoise hover:text-brand-turquoise/80 underline"
|
|
||||||
>
|
|
||||||
En savoir plus
|
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3 w-full lg:w-auto">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(true)}
|
onClick={() => setShowSettings(true)}
|
||||||
className="flex items-center justify-center gap-2 px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<Settings className="w-4 h-4" />
|
Customize
|
||||||
Personnaliser
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={acceptEssentialOnly}
|
onClick={acceptEssentialOnly}
|
||||||
className="px-4 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
Essentiel uniquement
|
Essential Only
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={acceptAll}
|
onClick={acceptAll}
|
||||||
className="flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
className="px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
Accept All
|
||||||
Tout accepter
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// Detailed settings
|
// Detailed settings
|
||||||
<motion.div
|
<div>
|
||||||
key="settings"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-gray-900">Cookie Preferences</h3>
|
||||||
<Shield className="w-5 h-5 text-brand-navy" />
|
|
||||||
<h3 className="text-lg font-semibold text-brand-navy">
|
|
||||||
Préférences de cookies
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSettings(false)}
|
onClick={() => setShowSettings(false)}
|
||||||
className="p-1 text-gray-400 hover:text-gray-600 rounded-full hover:bg-gray-100 transition-colors"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
aria-label="Fermer les paramètres"
|
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 mb-6 max-h-[40vh] overflow-y-auto pr-2">
|
<div className="space-y-4 mb-6">
|
||||||
{/* Essential Cookies */}
|
{/* Essential Cookies */}
|
||||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center">
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<h4 className="text-sm font-semibold text-gray-900">Essential Cookies</h4>
|
||||||
Cookies essentiels
|
<span className="ml-2 px-2 py-1 text-xs font-medium text-gray-600 bg-gray-200 rounded">
|
||||||
</h4>
|
Always Active
|
||||||
<span className="px-2 py-0.5 text-xs font-medium text-brand-navy bg-brand-navy/10 rounded-full">
|
|
||||||
Toujours actif
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
Nécessaires au fonctionnement du site. Ne peuvent pas être désactivés.
|
Required for the website to function. Cannot be disabled.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex items-center">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={true}
|
checked={true}
|
||||||
disabled
|
disabled
|
||||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded cursor-not-allowed opacity-60"
|
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Functional Cookies */}
|
{/* Functional Cookies */}
|
||||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<h4 className="text-sm font-semibold text-gray-900">Functional Cookies</h4>
|
||||||
Cookies fonctionnels
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
Permettent de mémoriser vos préférences et paramètres (langue, région).
|
Remember your preferences and settings (e.g., language, region).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex items-center">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={localPrefs.functional}
|
checked={preferences.functional}
|
||||||
onChange={e =>
|
onChange={e => setPreferences({ ...preferences, functional: e.target.checked })}
|
||||||
setLocalPrefs({ ...localPrefs, functional: e.target.checked })
|
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
}
|
|
||||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Analytics Cookies */}
|
{/* Analytics Cookies */}
|
||||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<h4 className="text-sm font-semibold text-gray-900">Analytics Cookies</h4>
|
||||||
Cookies analytiques
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
Nous aident à comprendre comment les visiteurs interagissent avec notre
|
Help us understand how visitors interact with our website (Google Analytics,
|
||||||
site (Google Analytics, Sentry).
|
Sentry).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex items-center">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={localPrefs.analytics}
|
checked={preferences.analytics}
|
||||||
onChange={e =>
|
onChange={e => setPreferences({ ...preferences, analytics: e.target.checked })}
|
||||||
setLocalPrefs({ ...localPrefs, analytics: e.target.checked })
|
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
}
|
|
||||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Marketing Cookies */}
|
{/* Marketing Cookies */}
|
||||||
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg border border-gray-100">
|
<div className="flex items-start justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-sm font-semibold text-gray-900">
|
<h4 className="text-sm font-semibold text-gray-900">Marketing Cookies</h4>
|
||||||
Cookies marketing
|
|
||||||
</h4>
|
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
Utilisés pour afficher des publicités personnalisées et mesurer
|
Used to deliver personalized ads and measure campaign effectiveness.
|
||||||
l'efficacité des campagnes.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4 flex items-center">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={localPrefs.marketing}
|
checked={preferences.marketing}
|
||||||
onChange={e =>
|
onChange={e => setPreferences({ ...preferences, marketing: e.target.checked })}
|
||||||
setLocalPrefs({ ...localPrefs, marketing: e.target.checked })
|
className="mt-1 h-5 w-5 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||||
}
|
|
||||||
className="h-5 w-5 text-brand-navy border-gray-300 rounded focus:ring-brand-turquoise cursor-pointer"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-3">
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleSaveCustom}
|
onClick={saveCustomPreferences}
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-6 py-2.5 text-sm font-medium text-white bg-brand-navy rounded-lg hover:bg-brand-navy/90 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
className="flex-1 px-6 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
Save Preferences
|
||||||
Enregistrer mes préférences
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={acceptAll}
|
onClick={acceptAll}
|
||||||
className="flex-1 px-6 py-2.5 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-brand-turquoise transition-colors"
|
className="flex-1 px-6 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
Tout accepter
|
Accept All
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-gray-500 text-center">
|
<p className="mt-4 text-xs text-gray-500 text-center">
|
||||||
Vous pouvez modifier vos préférences à tout moment dans les paramètres de
|
You can change your preferences at any time in your account settings or by clicking
|
||||||
votre compte ou en cliquant sur l'icône cookie en bas à gauche.{' '}
|
the cookie icon in the footer.
|
||||||
<Link href="/cookies" className="text-brand-turquoise hover:underline">
|
|
||||||
Politique de cookies
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,244 +0,0 @@
|
|||||||
/**
|
|
||||||
* Export Button Component
|
|
||||||
*
|
|
||||||
* Reusable component for exporting data to CSV or Excel
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
|
||||||
import { Download, FileSpreadsheet, FileText, ChevronDown } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ExportButtonProps<T> {
|
|
||||||
data: T[];
|
|
||||||
filename: string;
|
|
||||||
columns: {
|
|
||||||
key: keyof T | string;
|
|
||||||
label: string;
|
|
||||||
format?: (value: any, row: T) => string;
|
|
||||||
}[];
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExportButton<T extends Record<string, any>>({
|
|
||||||
data,
|
|
||||||
filename,
|
|
||||||
columns,
|
|
||||||
disabled = false,
|
|
||||||
}: ExportButtonProps<T>) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [isExporting, setIsExporting] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const formatValue = (value: any): string => {
|
|
||||||
if (value === null || value === undefined) return '';
|
|
||||||
if (typeof value === 'boolean') return value ? 'Oui' : 'Non';
|
|
||||||
if (value instanceof Date) return value.toLocaleDateString('fr-FR');
|
|
||||||
if (typeof value === 'object') return JSON.stringify(value);
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNestedValue = (obj: T, path: string): any => {
|
|
||||||
return path.split('.').reduce((acc, part) => acc?.[part], obj as any);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateCSV = (): string => {
|
|
||||||
// Headers
|
|
||||||
const headers = columns.map(col => `"${col.label.replace(/"/g, '""')}"`).join(';');
|
|
||||||
|
|
||||||
// Rows
|
|
||||||
const rows = data.map(row => {
|
|
||||||
return columns
|
|
||||||
.map(col => {
|
|
||||||
const value = getNestedValue(row, col.key as string);
|
|
||||||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
|
||||||
// Escape quotes and wrap in quotes
|
|
||||||
return `"${formattedValue.replace(/"/g, '""')}"`;
|
|
||||||
})
|
|
||||||
.join(';');
|
|
||||||
});
|
|
||||||
|
|
||||||
return [headers, ...rows].join('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadFile = (content: string, mimeType: string, extension: string) => {
|
|
||||||
const BOM = '\uFEFF'; // UTF-8 BOM for Excel compatibility
|
|
||||||
const blob = new Blob([BOM + content], { type: `${mimeType};charset=utf-8` });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement('a');
|
|
||||||
link.href = url;
|
|
||||||
link.download = `${filename}_${new Date().toISOString().split('T')[0]}.${extension}`;
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportCSV = async () => {
|
|
||||||
setIsExporting(true);
|
|
||||||
try {
|
|
||||||
const csv = generateCSV();
|
|
||||||
downloadFile(csv, 'text/csv', 'csv');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Export CSV error:', error);
|
|
||||||
alert('Erreur lors de l\'export CSV');
|
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExportExcel = async () => {
|
|
||||||
setIsExporting(true);
|
|
||||||
try {
|
|
||||||
// Generate Excel-compatible XML (SpreadsheetML)
|
|
||||||
const xmlHeader = `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<?mso-application progid="Excel.Sheet"?>
|
|
||||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"
|
|
||||||
xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
|
||||||
<Styles>
|
|
||||||
<Style ss:ID="Header">
|
|
||||||
<Font ss:Bold="1" ss:Color="#FFFFFF"/>
|
|
||||||
<Interior ss:Color="#3B82F6" ss:Pattern="Solid"/>
|
|
||||||
<Alignment ss:Horizontal="Center" ss:Vertical="Center"/>
|
|
||||||
</Style>
|
|
||||||
<Style ss:ID="Default">
|
|
||||||
<Alignment ss:Vertical="Center"/>
|
|
||||||
</Style>
|
|
||||||
</Styles>
|
|
||||||
<Worksheet ss:Name="Export">
|
|
||||||
<Table>`;
|
|
||||||
|
|
||||||
// Column widths
|
|
||||||
const columnWidths = columns.map(() => '<Column ss:AutoFitWidth="1" ss:Width="120"/>').join('');
|
|
||||||
|
|
||||||
// Header row
|
|
||||||
const headerRow = `<Row ss:StyleID="Header">
|
|
||||||
${columns.map(col => `<Cell><Data ss:Type="String">${escapeXml(col.label)}</Data></Cell>`).join('')}
|
|
||||||
</Row>`;
|
|
||||||
|
|
||||||
// Data rows
|
|
||||||
const dataRows = data
|
|
||||||
.map(row => {
|
|
||||||
const cells = columns
|
|
||||||
.map(col => {
|
|
||||||
const value = getNestedValue(row, col.key as string);
|
|
||||||
const formattedValue = col.format ? col.format(value, row) : formatValue(value);
|
|
||||||
const type = typeof value === 'number' ? 'Number' : 'String';
|
|
||||||
return `<Cell ss:StyleID="Default"><Data ss:Type="${type}">${escapeXml(formattedValue)}</Data></Cell>`;
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
return `<Row>${cells}</Row>`;
|
|
||||||
})
|
|
||||||
.join('');
|
|
||||||
|
|
||||||
const xmlFooter = `</Table>
|
|
||||||
</Worksheet>
|
|
||||||
</Workbook>`;
|
|
||||||
|
|
||||||
const xmlContent = xmlHeader + columnWidths + headerRow + dataRows + xmlFooter;
|
|
||||||
downloadFile(xmlContent, 'application/vnd.ms-excel', 'xls');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Export Excel error:', error);
|
|
||||||
alert('Erreur lors de l\'export Excel');
|
|
||||||
} finally {
|
|
||||||
setIsExporting(false);
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const escapeXml = (str: string): string => {
|
|
||||||
return str
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
|
||||||
disabled={disabled || data.length === 0 || isExporting}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
{isExporting ? (
|
|
||||||
<>
|
|
||||||
<svg
|
|
||||||
className="animate-spin -ml-1 mr-2 h-4 w-4 text-gray-600"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
className="opacity-25"
|
|
||||||
cx="12"
|
|
||||||
cy="12"
|
|
||||||
r="10"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="4"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
className="opacity-75"
|
|
||||||
fill="currentColor"
|
|
||||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Export en cours...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Exporter
|
|
||||||
<ChevronDown className="ml-2 h-4 w-4" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Dropdown Menu */}
|
|
||||||
{isOpen && !isExporting && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 z-50">
|
|
||||||
<div className="py-1">
|
|
||||||
<button
|
|
||||||
onClick={handleExportCSV}
|
|
||||||
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<FileText className="mr-3 h-4 w-4 text-green-600" />
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-medium">Export CSV</div>
|
|
||||||
<div className="text-xs text-gray-500">Compatible Excel, Google Sheets</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleExportExcel}
|
|
||||||
className="flex items-center w-full px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
|
||||||
>
|
|
||||||
<FileSpreadsheet className="mr-3 h-4 w-4 text-blue-600" />
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-medium">Export Excel</div>
|
|
||||||
<div className="text-xs text-gray-500">Format .xls natif</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="border-t border-gray-100 px-4 py-2">
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
{data.length} ligne{data.length > 1 ? 's' : ''} à exporter
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -11,18 +11,6 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
|
import { listNotifications, markNotificationAsRead, markAllNotificationsAsRead } from '@/lib/api';
|
||||||
import type { NotificationResponse } from '@/types/api';
|
import type { NotificationResponse } from '@/types/api';
|
||||||
import NotificationPanel from './NotificationPanel';
|
import NotificationPanel from './NotificationPanel';
|
||||||
import {
|
|
||||||
CheckCircle,
|
|
||||||
RefreshCw,
|
|
||||||
XCircle,
|
|
||||||
DollarSign,
|
|
||||||
Ship,
|
|
||||||
Settings,
|
|
||||||
AlertTriangle,
|
|
||||||
Bell,
|
|
||||||
Megaphone,
|
|
||||||
type LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
export default function NotificationDropdown() {
|
export default function NotificationDropdown() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@ -95,17 +83,17 @@ export default function NotificationDropdown() {
|
|||||||
return colors[priority as keyof typeof colors] || colors.low;
|
return colors[priority as keyof typeof colors] || colors.low;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationIcon = (type: string): LucideIcon => {
|
const getNotificationIcon = (type: string) => {
|
||||||
const icons: Record<string, LucideIcon> = {
|
const icons: Record<string, string> = {
|
||||||
BOOKING_CONFIRMED: CheckCircle,
|
BOOKING_CONFIRMED: '✅',
|
||||||
BOOKING_UPDATED: RefreshCw,
|
BOOKING_UPDATED: '🔄',
|
||||||
BOOKING_CANCELLED: XCircle,
|
BOOKING_CANCELLED: '❌',
|
||||||
RATE_ALERT: DollarSign,
|
RATE_ALERT: '💰',
|
||||||
CARRIER_UPDATE: Ship,
|
CARRIER_UPDATE: '🚢',
|
||||||
SYSTEM: Settings,
|
SYSTEM: '⚙️',
|
||||||
WARNING: AlertTriangle,
|
WARNING: '⚠️',
|
||||||
};
|
};
|
||||||
return icons[type] || Megaphone;
|
return icons[type] || '📢';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
const formatTime = (dateString: string) => {
|
||||||
@ -116,10 +104,10 @@ export default function NotificationDropdown() {
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'À l\'instant';
|
if (diffMins < 1) return 'Just now';
|
||||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
return date.toLocaleDateString();
|
return date.toLocaleDateString();
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -158,7 +146,7 @@ export default function NotificationDropdown() {
|
|||||||
disabled={markAllAsReadMutation.isPending}
|
disabled={markAllAsReadMutation.isPending}
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
|
className="text-xs text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
Tout marquer comme lu
|
Mark all as read
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -166,11 +154,11 @@ export default function NotificationDropdown() {
|
|||||||
{/* Notifications List */}
|
{/* Notifications List */}
|
||||||
<div className="max-h-96 overflow-y-auto">
|
<div className="max-h-96 overflow-y-auto">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="p-4 text-center text-sm text-gray-500">Chargement des notifications...</div>
|
<div className="p-4 text-center text-sm text-gray-500">Loading notifications...</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="p-8 text-center">
|
<div className="p-8 text-center">
|
||||||
<Bell className="h-10 w-10 text-gray-300 mx-auto mb-2" />
|
<div className="text-4xl mb-2">🔔</div>
|
||||||
<p className="text-sm text-gray-500">Aucune nouvelle notification</p>
|
<p className="text-sm text-gray-500">No new notifications</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y">
|
<div className="divide-y">
|
||||||
@ -184,8 +172,8 @@ export default function NotificationDropdown() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 text-2xl">
|
||||||
{(() => { const Icon = getNotificationIcon(notification.type); return <Icon className="h-5 w-5 text-gray-500" />; })()}
|
{getNotificationIcon(notification.type)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@ -222,7 +210,7 @@ export default function NotificationDropdown() {
|
|||||||
}}
|
}}
|
||||||
className="w-full text-center text-sm text-blue-600 hover:text-blue-800 font-medium py-2 hover:bg-blue-50 rounded transition-colors"
|
className="w-full text-center text-sm text-blue-600 hover:text-blue-800 font-medium py-2 hover:bg-blue-50 rounded transition-colors"
|
||||||
>
|
>
|
||||||
Voir toutes les notifications
|
View all notifications
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
deleteNotification
|
deleteNotification
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import type { NotificationResponse } from '@/types/api';
|
import type { NotificationResponse } from '@/types/api';
|
||||||
import { X, Trash2, CheckCheck, Filter, Bell, Package, RefreshCw, XCircle, CheckCircle, Mail, Timer, FileText, Megaphone, User, Building2 } from 'lucide-react';
|
import { X, Trash2, CheckCheck, Filter } from 'lucide-react';
|
||||||
|
|
||||||
interface NotificationPanelProps {
|
interface NotificationPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -83,7 +83,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
const handleDelete = (e: React.MouseEvent, notificationId: string) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('Voulez-vous vraiment supprimer cette notification ?')) {
|
if (confirm('Are you sure you want to delete this notification?')) {
|
||||||
deleteNotificationMutation.mutate(notificationId);
|
deleteNotificationMutation.mutate(notificationId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -98,22 +98,22 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
|
return colors[priority as keyof typeof colors] || 'border-l-4 border-gray-300';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNotificationIconComponent = (type: string) => {
|
const getNotificationIcon = (type: string) => {
|
||||||
const icons: Record<string, typeof Bell> = {
|
const icons: Record<string, string> = {
|
||||||
booking_created: Package,
|
booking_created: '📦',
|
||||||
booking_updated: RefreshCw,
|
booking_updated: '🔄',
|
||||||
booking_cancelled: XCircle,
|
booking_cancelled: '❌',
|
||||||
booking_confirmed: CheckCircle,
|
booking_confirmed: '✅',
|
||||||
csv_booking_accepted: CheckCircle,
|
csv_booking_accepted: '✅',
|
||||||
csv_booking_rejected: XCircle,
|
csv_booking_rejected: '❌',
|
||||||
csv_booking_request_sent: Mail,
|
csv_booking_request_sent: '📧',
|
||||||
rate_quote_expiring: Timer,
|
rate_quote_expiring: '⏰',
|
||||||
document_uploaded: FileText,
|
document_uploaded: '📄',
|
||||||
system_announcement: Megaphone,
|
system_announcement: '📢',
|
||||||
user_invited: User,
|
user_invited: '👤',
|
||||||
organization_update: Building2,
|
organization_update: '🏢',
|
||||||
};
|
};
|
||||||
return icons[type.toLowerCase()] || Bell;
|
return icons[type.toLowerCase()] || '🔔';
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (dateString: string) => {
|
const formatTime = (dateString: string) => {
|
||||||
@ -124,13 +124,13 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
const diffHours = Math.floor(diffMs / 3600000);
|
const diffHours = Math.floor(diffMs / 3600000);
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
const diffDays = Math.floor(diffMs / 86400000);
|
||||||
|
|
||||||
if (diffMins < 1) return 'À l\'instant';
|
if (diffMins < 1) return 'Just now';
|
||||||
if (diffMins < 60) return `Il y a ${diffMins}min`;
|
if (diffMins < 60) return `${diffMins}m ago`;
|
||||||
if (diffHours < 24) return `Il y a ${diffHours}h`;
|
if (diffHours < 24) return `${diffHours}h ago`;
|
||||||
if (diffDays < 7) return `Il y a ${diffDays}j`;
|
if (diffDays < 7) return `${diffDays}d ago`;
|
||||||
return date.toLocaleDateString('fr-FR', {
|
return date.toLocaleDateString('en-US', {
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -158,7 +158,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-600"
|
||||||
aria-label="Fermer le panneau"
|
aria-label="Close panel"
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
@ -182,7 +182,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
: 'bg-white text-gray-700 hover:bg-gray-100 border border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{filter === 'all' ? 'Toutes' : filter === 'unread' ? 'Non lues' : 'Lues'}
|
{filter.charAt(0).toUpperCase() + filter.slice(1)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -194,7 +194,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
|
className="flex items-center space-x-1 text-sm text-blue-600 hover:text-blue-800 font-medium disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<CheckCheck className="w-4 h-4" />
|
<CheckCheck className="w-4 h-4" />
|
||||||
<span>Tout marquer comme lu</span>
|
<span>Mark all as read</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -205,18 +205,18 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||||
<p className="text-sm text-gray-500">Chargement des notifications...</p>
|
<p className="text-sm text-gray-500">Loading notifications...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : notifications.length === 0 ? (
|
) : notifications.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Bell className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
<div className="text-6xl mb-4">🔔</div>
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Aucune notification</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">No notifications</h3>
|
||||||
<p className="text-sm text-gray-500">
|
<p className="text-sm text-gray-500">
|
||||||
{selectedFilter === 'unread'
|
{selectedFilter === 'unread'
|
||||||
? 'Vous êtes à jour !'
|
? "You're all caught up!"
|
||||||
: 'Aucune notification à afficher'}
|
: 'No notifications to display'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -232,8 +232,8 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
>
|
>
|
||||||
<div className="flex items-start space-x-4">
|
<div className="flex items-start space-x-4">
|
||||||
{/* Icon */}
|
{/* Icon */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 text-3xl">
|
||||||
{(() => { const Icon = getNotificationIconComponent(notification.type); return <Icon className="h-6 w-6 text-gray-500" />; })()}
|
{getNotificationIcon(notification.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@ -250,7 +250,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
<button
|
<button
|
||||||
onClick={(e) => handleDelete(e, notification.id)}
|
onClick={(e) => handleDelete(e, notification.id)}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-red-100 rounded"
|
||||||
title="Supprimer la notification"
|
title="Delete notification"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 text-red-600" />
|
<Trash2 className="w-4 h-4 text-red-600" />
|
||||||
</button>
|
</button>
|
||||||
@ -287,7 +287,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
</div>
|
</div>
|
||||||
{notification.actionUrl && (
|
{notification.actionUrl && (
|
||||||
<span className="text-xs text-blue-600 font-medium group-hover:underline">
|
<span className="text-xs text-blue-600 font-medium group-hover:underline">
|
||||||
Voir les détails →
|
View details →
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -303,7 +303,7 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
|
<div className="flex items-center justify-between px-6 py-4 border-t bg-gray-50">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Page {currentPage} sur {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
@ -311,14 +311,14 @@ export default function NotificationPanel({ isOpen, onClose }: NotificationPanel
|
|||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Précédent
|
Previous
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-3 py-1.5 text-sm font-medium bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
Suivant
|
Next
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { MapContainer, TileLayer, Polyline, Marker } from "react-leaflet";
|
||||||
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";
|
||||||
|
|
||||||
@ -20,352 +19,22 @@ 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={2}
|
zoom={4}
|
||||||
style={{ height: "100%", width: "100%" }}
|
style={{ height: "100%", width: "100%" }}
|
||||||
scrollWheelZoom={false}
|
scrollWheelZoom={false}
|
||||||
>
|
>
|
||||||
@ -374,25 +43,10 @@ export default function PortRouteMap({ portA, portB, height = "500px" }: PortRou
|
|||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
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]} />
|
||||||
|
|
||||||
{/* Maritime route polyline */}
|
<Polyline positions={positions} pathOptions={{ color: "#2563eb", weight: 3, opacity: 0.7 }} />
|
||||||
<Polyline
|
|
||||||
positions={positions}
|
|
||||||
pathOptions={{
|
|
||||||
color: "#2563eb",
|
|
||||||
weight: 3,
|
|
||||||
opacity: 0.8,
|
|
||||||
dashArray: "10, 6",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,45 +3,44 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import { Users, Building2, Package, FileText, BarChart3, Settings, type LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface AdminMenuItem {
|
interface AdminMenuItem {
|
||||||
name: string;
|
name: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: LucideIcon;
|
icon: string;
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminMenuItems: AdminMenuItem[] = [
|
const adminMenuItems: AdminMenuItem[] = [
|
||||||
{
|
{
|
||||||
name: 'Utilisateurs',
|
name: 'Users',
|
||||||
href: '/dashboard/admin/users',
|
href: '/dashboard/admin/users',
|
||||||
icon: Users,
|
icon: '👥',
|
||||||
description: 'Gérer les utilisateurs et les permissions',
|
description: 'Manage users and permissions',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Organisations',
|
name: 'Organizations',
|
||||||
href: '/dashboard/admin/organizations',
|
href: '/dashboard/admin/organizations',
|
||||||
icon: Building2,
|
icon: '🏢',
|
||||||
description: 'Gérer les organisations et entreprises',
|
description: 'Manage organizations and companies',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Réservations',
|
name: 'Bookings',
|
||||||
href: '/dashboard/admin/bookings',
|
href: '/dashboard/admin/bookings',
|
||||||
icon: Package,
|
icon: '📦',
|
||||||
description: 'Consulter et gérer toutes les réservations',
|
description: 'View and manage all bookings',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Documents',
|
name: 'Documents',
|
||||||
href: '/dashboard/admin/documents',
|
href: '/dashboard/admin/documents',
|
||||||
icon: FileText,
|
icon: '📄',
|
||||||
description: 'Gérer les documents des organisations',
|
description: 'Manage organization documents',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Tarifs CSV',
|
name: 'CSV Rates',
|
||||||
href: '/dashboard/admin/csv-rates',
|
href: '/dashboard/admin/csv-rates',
|
||||||
icon: BarChart3,
|
icon: '📊',
|
||||||
description: 'Importer et gérer les tarifs CSV',
|
description: 'Upload and manage CSV rates',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -85,8 +84,8 @@ export default function AdminPanelDropdown() {
|
|||||||
: 'text-gray-700 hover:bg-gray-100'
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Settings className="mr-3 h-5 w-5" />
|
<span className="mr-3 text-xl">⚙️</span>
|
||||||
<span className="flex-1 text-left">Administration</span>
|
<span className="flex-1 text-left">Admin Panel</span>
|
||||||
<svg
|
<svg
|
||||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
fill="none"
|
fill="none"
|
||||||
@ -108,7 +107,6 @@ export default function AdminPanelDropdown() {
|
|||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
{adminMenuItems.map(item => {
|
{adminMenuItems.map(item => {
|
||||||
const isActive = pathname.startsWith(item.href);
|
const isActive = pathname.startsWith(item.href);
|
||||||
const IconComponent = item.icon;
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.name}
|
key={item.name}
|
||||||
@ -117,7 +115,7 @@ export default function AdminPanelDropdown() {
|
|||||||
isActive ? 'bg-blue-50' : ''
|
isActive ? 'bg-blue-50' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<IconComponent className="h-5 w-5 mr-3 mt-0.5 text-gray-500" />
|
<span className="text-2xl mr-3 mt-0.5">{item.icon}</span>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
|
<div className={`text-sm font-medium ${isActive ? 'text-blue-700' : 'text-gray-900'}`}>
|
||||||
{item.name}
|
{item.name}
|
||||||
|
|||||||
@ -9,8 +9,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
|
import { getSubscriptionOverview } from '@/lib/api/subscriptions';
|
||||||
import Link from 'next/link';
|
|
||||||
import { UserPlus } from 'lucide-react';
|
|
||||||
|
|
||||||
export default function LicensesTab() {
|
export default function LicensesTab() {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@ -112,17 +110,10 @@ export default function LicensesTab() {
|
|||||||
|
|
||||||
{/* Active Licenses */}
|
{/* Active Licenses */}
|
||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
Licences actives ({activeLicenses.length})
|
Licences actives ({activeLicenses.length})
|
||||||
</h3>
|
</h3>
|
||||||
<Link
|
|
||||||
href="/dashboard/settings/users"
|
|
||||||
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
|
|
||||||
>
|
|
||||||
<UserPlus className="w-4 h-4 mr-2" />
|
|
||||||
Inviter un utilisateur
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
{activeLicenses.length === 0 ? (
|
{activeLicenses.length === 0 ? (
|
||||||
<div className="px-6 py-8 text-center text-gray-500">
|
<div className="px-6 py-8 text-center text-gray-500">
|
||||||
@ -344,6 +335,9 @@ export default function LicensesTab() {
|
|||||||
<li>
|
<li>
|
||||||
Chaque utilisateur actif de votre organisation consomme une licence
|
Chaque utilisateur actif de votre organisation consomme une licence
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
|
||||||
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Les licences sont automatiquement assignées lors de l'ajout d'un
|
Les licences sont automatiquement assignées lors de l'ajout d'un
|
||||||
utilisateur
|
utilisateur
|
||||||
|
|||||||
@ -9,8 +9,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { AuthProvider } from '@/lib/context/auth-context';
|
import { AuthProvider } from '@/lib/context/auth-context';
|
||||||
import { CookieProvider } from '@/lib/context/cookie-context';
|
|
||||||
import CookieConsent from '@/components/CookieConsent';
|
|
||||||
|
|
||||||
export function Providers({ children }: { children: React.ReactNode }) {
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
// Create a client instance per component instance
|
// Create a client instance per component instance
|
||||||
@ -29,12 +27,7 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
<CookieProvider>
|
|
||||||
{children}
|
|
||||||
<CookieConsent />
|
|
||||||
</CookieProvider>
|
|
||||||
</AuthProvider>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -165,12 +165,7 @@ export async function apiRequest<T>(
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle 401 Unauthorized - token expired
|
// Handle 401 Unauthorized - token expired
|
||||||
// Skip auto-redirect for auth endpoints (login, register, refresh) - they handle their own errors
|
if (response.status === 401 && !isRetry && !endpoint.includes('/auth/refresh')) {
|
||||||
const isAuthEndpoint = endpoint.includes('/auth/login') ||
|
|
||||||
endpoint.includes('/auth/register') ||
|
|
||||||
endpoint.includes('/auth/refresh');
|
|
||||||
|
|
||||||
if (response.status === 401 && !isRetry && !isAuthEndpoint) {
|
|
||||||
// Check if we have a refresh token
|
// Check if we have a refresh token
|
||||||
const refreshToken = getRefreshToken();
|
const refreshToken = getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
|
|||||||
@ -5,38 +5,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { get, post, patch } from './client';
|
import { get, post, patch } from './client';
|
||||||
import type { SuccessResponse } from '@/types/api';
|
import type {
|
||||||
|
SuccessResponse,
|
||||||
|
} from '@/types/api';
|
||||||
|
|
||||||
/**
|
// TODO: These types should be moved to @/types/api.ts
|
||||||
* Cookie consent preferences
|
|
||||||
*/
|
|
||||||
export interface CookiePreferences {
|
|
||||||
essential: boolean;
|
|
||||||
functional: boolean;
|
|
||||||
analytics: boolean;
|
|
||||||
marketing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response from consent API
|
|
||||||
*/
|
|
||||||
export interface ConsentResponse extends CookiePreferences {
|
|
||||||
userId: string;
|
|
||||||
consentDate: string;
|
|
||||||
updatedAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Request to update consent
|
|
||||||
*/
|
|
||||||
export interface UpdateConsentRequest extends CookiePreferences {
|
|
||||||
ipAddress?: string;
|
|
||||||
userAgent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data export response
|
|
||||||
*/
|
|
||||||
export interface GdprDataExportResponse {
|
export interface GdprDataExportResponse {
|
||||||
exportId: string;
|
exportId: string;
|
||||||
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
status: 'PENDING' | 'COMPLETED' | 'FAILED';
|
||||||
@ -45,50 +18,49 @@ export interface GdprDataExportResponse {
|
|||||||
downloadUrl?: string;
|
downloadUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export interface GdprConsentResponse {
|
||||||
* Request data export (GDPR right to data portability)
|
userId: string;
|
||||||
* GET /api/v1/gdpr/export
|
marketingEmails: boolean;
|
||||||
* Triggers download of JSON file
|
dataProcessing: boolean;
|
||||||
*/
|
thirdPartySharing: boolean;
|
||||||
export async function requestDataExport(): Promise<Blob> {
|
updatedAt: string;
|
||||||
const response = await fetch(
|
}
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export`,
|
|
||||||
{
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
export interface UpdateGdprConsentRequest {
|
||||||
throw new Error(`Export failed: ${response.statusText}`);
|
marketingEmails?: boolean;
|
||||||
}
|
dataProcessing?: boolean;
|
||||||
|
thirdPartySharing?: boolean;
|
||||||
return response.blob();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request data export as CSV
|
* Request data export (GDPR right to data portability)
|
||||||
* GET /api/v1/gdpr/export/csv
|
* POST /api/v1/gdpr/export
|
||||||
|
* Generates export job and sends download link via email
|
||||||
*/
|
*/
|
||||||
export async function requestDataExportCSV(): Promise<Blob> {
|
export async function requestDataExport(): Promise<GdprDataExportResponse> {
|
||||||
|
return post<GdprDataExportResponse>('/api/v1/gdpr/export');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download exported data
|
||||||
|
* GET /api/v1/gdpr/export/:exportId/download
|
||||||
|
* Returns blob (JSON file)
|
||||||
|
*/
|
||||||
|
export async function downloadDataExport(exportId: string): Promise<Blob> {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/csv`,
|
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/export/${exportId}/download`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${
|
Authorization: `Bearer ${
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
typeof window !== 'undefined' ? localStorage.getItem('access_token') : ''
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Export failed: ${response.statusText}`);
|
throw new Error(`Download failed: ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.blob();
|
return response.blob();
|
||||||
@ -96,53 +68,35 @@ export async function requestDataExportCSV(): Promise<Blob> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Request account deletion (GDPR right to be forgotten)
|
* Request account deletion (GDPR right to be forgotten)
|
||||||
* DELETE /api/v1/gdpr/delete-account
|
* POST /api/v1/gdpr/delete-account
|
||||||
* Initiates account deletion process
|
* Initiates 30-day account deletion process
|
||||||
*/
|
*/
|
||||||
export async function requestAccountDeletion(confirmEmail: string, reason?: string): Promise<void> {
|
export async function requestAccountDeletion(): Promise<SuccessResponse> {
|
||||||
const response = await fetch(
|
return post<SuccessResponse>('/api/v1/gdpr/delete-account');
|
||||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/gdpr/delete-account`,
|
}
|
||||||
{
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: `Bearer ${
|
|
||||||
typeof window !== 'undefined' ? localStorage.getItem('accessToken') : ''
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ confirmEmail, reason }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
/**
|
||||||
throw new Error(`Deletion failed: ${response.statusText}`);
|
* Cancel pending account deletion
|
||||||
}
|
* POST /api/v1/gdpr/cancel-deletion
|
||||||
|
*/
|
||||||
|
export async function cancelAccountDeletion(): Promise<SuccessResponse> {
|
||||||
|
return post<SuccessResponse>('/api/v1/gdpr/cancel-deletion');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get user consent preferences
|
* Get user consent preferences
|
||||||
* GET /api/v1/gdpr/consent
|
* GET /api/v1/gdpr/consent
|
||||||
*/
|
*/
|
||||||
export async function getConsentPreferences(): Promise<ConsentResponse | null> {
|
export async function getConsentPreferences(): Promise<GdprConsentResponse> {
|
||||||
return get<ConsentResponse | null>('/api/v1/gdpr/consent');
|
return get<GdprConsentResponse>('/api/v1/gdpr/consent');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update consent preferences
|
* Update consent preferences
|
||||||
* POST /api/v1/gdpr/consent
|
* PATCH /api/v1/gdpr/consent
|
||||||
*/
|
*/
|
||||||
export async function updateConsentPreferences(
|
export async function updateConsentPreferences(
|
||||||
data: UpdateConsentRequest
|
data: UpdateGdprConsentRequest
|
||||||
): Promise<ConsentResponse> {
|
): Promise<GdprConsentResponse> {
|
||||||
return post<ConsentResponse>('/api/v1/gdpr/consent', data);
|
return patch<GdprConsentResponse>('/api/v1/gdpr/consent', data);
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Withdraw specific consent
|
|
||||||
* POST /api/v1/gdpr/consent/withdraw
|
|
||||||
*/
|
|
||||||
export async function withdrawConsent(
|
|
||||||
consentType: 'functional' | 'analytics' | 'marketing'
|
|
||||||
): Promise<ConsentResponse> {
|
|
||||||
return post<ConsentResponse>('/api/v1/gdpr/consent/withdraw', { consentType });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,14 +107,11 @@ export {
|
|||||||
// GDPR (6 endpoints)
|
// GDPR (6 endpoints)
|
||||||
export {
|
export {
|
||||||
requestDataExport,
|
requestDataExport,
|
||||||
requestDataExportCSV,
|
downloadDataExport,
|
||||||
requestAccountDeletion,
|
requestAccountDeletion,
|
||||||
|
cancelAccountDeletion,
|
||||||
getConsentPreferences,
|
getConsentPreferences,
|
||||||
updateConsentPreferences,
|
updateConsentPreferences,
|
||||||
withdrawConsent,
|
|
||||||
type CookiePreferences,
|
|
||||||
type ConsentResponse,
|
|
||||||
type UpdateConsentRequest,
|
|
||||||
} from './gdpr';
|
} from './gdpr';
|
||||||
|
|
||||||
// Admin CSV Rates (5 endpoints) - already exists
|
// Admin CSV Rates (5 endpoints) - already exists
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
* Endpoints for searching shipping rates (both API and CSV-based)
|
* Endpoints for searching shipping rates (both API and CSV-based)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { get, post } from './client';
|
import { post } from './client';
|
||||||
import type {
|
import type {
|
||||||
RateSearchRequest,
|
RateSearchRequest,
|
||||||
RateSearchResponse,
|
RateSearchResponse,
|
||||||
@ -14,37 +14,6 @@ import type {
|
|||||||
FilterOptionsResponse,
|
FilterOptionsResponse,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
|
|
||||||
/**
|
|
||||||
* Route Port Info - port details with coordinates
|
|
||||||
*/
|
|
||||||
export interface RoutePortInfo {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
city: string;
|
|
||||||
country: string;
|
|
||||||
countryName: string;
|
|
||||||
displayName: string;
|
|
||||||
latitude?: number;
|
|
||||||
longitude?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available Origins Response
|
|
||||||
*/
|
|
||||||
export interface AvailableOriginsResponse {
|
|
||||||
origins: RoutePortInfo[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Available Destinations Response
|
|
||||||
*/
|
|
||||||
export interface AvailableDestinationsResponse {
|
|
||||||
origin: string;
|
|
||||||
destinations: RoutePortInfo[];
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search shipping rates (API-based)
|
* Search shipping rates (API-based)
|
||||||
* POST /api/v1/rates/search
|
* POST /api/v1/rates/search
|
||||||
@ -89,29 +58,3 @@ export async function getAvailableCompanies(): Promise<AvailableCompaniesRespons
|
|||||||
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
export async function getFilterOptions(): Promise<FilterOptionsResponse> {
|
||||||
return post<FilterOptionsResponse>('/api/v1/rates/filters/options');
|
return post<FilterOptionsResponse>('/api/v1/rates/filters/options');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available origin ports from CSV rates
|
|
||||||
* GET /api/v1/rates/available-routes/origins
|
|
||||||
*
|
|
||||||
* Returns only ports that have shipping routes defined in CSV rate files.
|
|
||||||
* Use this to populate origin port selection dropdown.
|
|
||||||
*/
|
|
||||||
export async function getAvailableOrigins(): Promise<AvailableOriginsResponse> {
|
|
||||||
return get<AvailableOriginsResponse>('/api/v1/rates/available-routes/origins');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get available destination ports for a given origin
|
|
||||||
* GET /api/v1/rates/available-routes/destinations?origin=XXXX
|
|
||||||
*
|
|
||||||
* Returns only ports that have shipping routes from the specified origin port.
|
|
||||||
* Use this to populate destination port selection after origin is selected.
|
|
||||||
*/
|
|
||||||
export async function getAvailableDestinations(
|
|
||||||
origin: string
|
|
||||||
): Promise<AvailableDestinationsResponse> {
|
|
||||||
return get<AvailableDestinationsResponse>(
|
|
||||||
`/api/v1/rates/available-routes/destinations?origin=${encodeURIComponent(origin)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,228 +0,0 @@
|
|||||||
/**
|
|
||||||
* Cookie Consent Context
|
|
||||||
*
|
|
||||||
* Provides cookie consent state and methods to the application
|
|
||||||
* Syncs with backend for authenticated users
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
getConsentPreferences,
|
|
||||||
updateConsentPreferences,
|
|
||||||
type CookiePreferences,
|
|
||||||
} from '../api/gdpr';
|
|
||||||
import { getAuthToken } from '../api/client';
|
|
||||||
|
|
||||||
const STORAGE_KEY = 'cookieConsent';
|
|
||||||
const STORAGE_DATE_KEY = 'cookieConsentDate';
|
|
||||||
|
|
||||||
interface CookieContextType {
|
|
||||||
preferences: CookiePreferences;
|
|
||||||
showBanner: boolean;
|
|
||||||
showSettings: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
setShowBanner: (show: boolean) => void;
|
|
||||||
setShowSettings: (show: boolean) => void;
|
|
||||||
acceptAll: () => Promise<void>;
|
|
||||||
acceptEssentialOnly: () => Promise<void>;
|
|
||||||
savePreferences: (prefs: CookiePreferences) => Promise<void>;
|
|
||||||
openPreferences: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPreferences: CookiePreferences = {
|
|
||||||
essential: true,
|
|
||||||
functional: false,
|
|
||||||
analytics: false,
|
|
||||||
marketing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CookieContext = createContext<CookieContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
export function CookieProvider({ children }: { children: React.ReactNode }) {
|
|
||||||
const [preferences, setPreferences] = useState<CookiePreferences>(defaultPreferences);
|
|
||||||
const [showBanner, setShowBanner] = useState(false);
|
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [hasInitialized, setHasInitialized] = useState(false);
|
|
||||||
|
|
||||||
// Check if user is authenticated
|
|
||||||
const isAuthenticated = useCallback(() => {
|
|
||||||
return !!getAuthToken();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load preferences from localStorage
|
|
||||||
const loadFromLocalStorage = useCallback((): CookiePreferences | null => {
|
|
||||||
if (typeof window === 'undefined') return null;
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(stored);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Save preferences to localStorage
|
|
||||||
const saveToLocalStorage = useCallback((prefs: CookiePreferences) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
|
|
||||||
localStorage.setItem(STORAGE_DATE_KEY, new Date().toISOString());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Apply preferences (gtag, etc.)
|
|
||||||
const applyPreferences = useCallback((prefs: CookiePreferences) => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
|
|
||||||
// Google Analytics consent
|
|
||||||
const gtag = (window as any).gtag;
|
|
||||||
if (gtag) {
|
|
||||||
gtag('consent', 'update', {
|
|
||||||
analytics_storage: prefs.analytics ? 'granted' : 'denied',
|
|
||||||
ad_storage: prefs.marketing ? 'granted' : 'denied',
|
|
||||||
functionality_storage: prefs.functional ? 'granted' : 'denied',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentry (if analytics enabled)
|
|
||||||
const Sentry = (window as any).Sentry;
|
|
||||||
if (Sentry) {
|
|
||||||
if (prefs.analytics) {
|
|
||||||
Sentry.init && Sentry.getCurrentHub && Sentry.getCurrentHub().getClient()?.getOptions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Initialize preferences
|
|
||||||
useEffect(() => {
|
|
||||||
const initializePreferences = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
// First, check localStorage
|
|
||||||
const localPrefs = loadFromLocalStorage();
|
|
||||||
|
|
||||||
if (localPrefs) {
|
|
||||||
setPreferences(localPrefs);
|
|
||||||
applyPreferences(localPrefs);
|
|
||||||
setShowBanner(false);
|
|
||||||
} else {
|
|
||||||
// No local consent - show banner
|
|
||||||
setShowBanner(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If authenticated, try to sync with backend
|
|
||||||
if (isAuthenticated() && localPrefs) {
|
|
||||||
try {
|
|
||||||
const backendPrefs = await getConsentPreferences();
|
|
||||||
if (backendPrefs) {
|
|
||||||
// Backend has preferences - use them
|
|
||||||
const prefs: CookiePreferences = {
|
|
||||||
essential: backendPrefs.essential,
|
|
||||||
functional: backendPrefs.functional,
|
|
||||||
analytics: backendPrefs.analytics,
|
|
||||||
marketing: backendPrefs.marketing,
|
|
||||||
};
|
|
||||||
setPreferences(prefs);
|
|
||||||
saveToLocalStorage(prefs);
|
|
||||||
applyPreferences(prefs);
|
|
||||||
} else if (localPrefs) {
|
|
||||||
// No backend preferences but we have local - sync to backend
|
|
||||||
try {
|
|
||||||
await updateConsentPreferences({
|
|
||||||
...localPrefs,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
});
|
|
||||||
} catch (syncError) {
|
|
||||||
console.warn('Failed to sync consent to backend:', syncError);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to fetch consent from backend:', error);
|
|
||||||
// Continue with local preferences
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(false);
|
|
||||||
setHasInitialized(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
initializePreferences();
|
|
||||||
}, [isAuthenticated, loadFromLocalStorage, saveToLocalStorage, applyPreferences]);
|
|
||||||
|
|
||||||
// Save preferences (to localStorage and optionally backend)
|
|
||||||
const savePreferences = useCallback(
|
|
||||||
async (prefs: CookiePreferences) => {
|
|
||||||
// Ensure essential is always true
|
|
||||||
const safePrefs = { ...prefs, essential: true };
|
|
||||||
|
|
||||||
setPreferences(safePrefs);
|
|
||||||
saveToLocalStorage(safePrefs);
|
|
||||||
applyPreferences(safePrefs);
|
|
||||||
setShowBanner(false);
|
|
||||||
setShowSettings(false);
|
|
||||||
|
|
||||||
// Sync to backend if authenticated
|
|
||||||
if (isAuthenticated()) {
|
|
||||||
try {
|
|
||||||
await updateConsentPreferences({
|
|
||||||
...safePrefs,
|
|
||||||
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : undefined,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to sync consent to backend:', error);
|
|
||||||
// Local save succeeded, backend sync failed - that's okay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isAuthenticated, saveToLocalStorage, applyPreferences]
|
|
||||||
);
|
|
||||||
|
|
||||||
const acceptAll = useCallback(async () => {
|
|
||||||
await savePreferences({
|
|
||||||
essential: true,
|
|
||||||
functional: true,
|
|
||||||
analytics: true,
|
|
||||||
marketing: true,
|
|
||||||
});
|
|
||||||
}, [savePreferences]);
|
|
||||||
|
|
||||||
const acceptEssentialOnly = useCallback(async () => {
|
|
||||||
await savePreferences({
|
|
||||||
essential: true,
|
|
||||||
functional: false,
|
|
||||||
analytics: false,
|
|
||||||
marketing: false,
|
|
||||||
});
|
|
||||||
}, [savePreferences]);
|
|
||||||
|
|
||||||
const openPreferences = useCallback(() => {
|
|
||||||
setShowBanner(true);
|
|
||||||
setShowSettings(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value: CookieContextType = {
|
|
||||||
preferences,
|
|
||||||
showBanner,
|
|
||||||
showSettings,
|
|
||||||
isLoading,
|
|
||||||
setShowBanner,
|
|
||||||
setShowSettings,
|
|
||||||
acceptAll,
|
|
||||||
acceptEssentialOnly,
|
|
||||||
savePreferences,
|
|
||||||
openPreferences,
|
|
||||||
};
|
|
||||||
|
|
||||||
return <CookieContext.Provider value={value}>{children}</CookieContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCookieConsent() {
|
|
||||||
const context = useContext(CookieContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error('useCookieConsent must be used within a CookieProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user