fix system payment and other missing
This commit is contained in:
parent
230d06dc98
commit
420e52311c
@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ConfigModule } from '@nestjs/config';
|
||||||
|
|
||||||
// Controller
|
// Controller
|
||||||
import { AdminController } from '../controllers/admin.controller';
|
import { AdminController } from '../controllers/admin.controller';
|
||||||
@ -18,6 +19,13 @@ import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm
|
|||||||
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
|
|
||||||
|
// SIRET verification
|
||||||
|
import { SIRET_VERIFICATION_PORT } from '@domain/ports/out/siret-verification.port';
|
||||||
|
import { PappersSiretAdapter } from '@infrastructure/external/pappers-siret.adapter';
|
||||||
|
|
||||||
|
// CSV Booking Service
|
||||||
|
import { CsvBookingsModule } from '../csv-bookings.module';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Module
|
* Admin Module
|
||||||
*
|
*
|
||||||
@ -25,7 +33,11 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
|
|||||||
* All endpoints require ADMIN role.
|
* All endpoints require ADMIN role.
|
||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity])],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([UserOrmEntity, OrganizationOrmEntity, CsvBookingOrmEntity]),
|
||||||
|
ConfigModule,
|
||||||
|
CsvBookingsModule,
|
||||||
|
],
|
||||||
controllers: [AdminController],
|
controllers: [AdminController],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@ -37,6 +49,10 @@ import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.reposito
|
|||||||
useClass: TypeOrmOrganizationRepository,
|
useClass: TypeOrmOrganizationRepository,
|
||||||
},
|
},
|
||||||
TypeOrmCsvBookingRepository,
|
TypeOrmCsvBookingRepository,
|
||||||
|
{
|
||||||
|
provide: SIRET_VERIFICATION_PORT,
|
||||||
|
useClass: PappersSiretAdapter,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
export class AdminModule {}
|
||||||
|
|||||||
@ -222,9 +222,22 @@ export class AuthService {
|
|||||||
* Generate access and refresh tokens
|
* Generate access and refresh tokens
|
||||||
*/
|
*/
|
||||||
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
private async generateTokens(user: User): Promise<{ accessToken: string; refreshToken: string }> {
|
||||||
// Fetch subscription plan for JWT payload
|
// ADMIN users always get PLATINIUM plan with no expiration
|
||||||
let plan = 'BRONZE';
|
let plan = 'BRONZE';
|
||||||
let planFeatures: string[] = [];
|
let planFeatures: string[] = [];
|
||||||
|
|
||||||
|
if (user.role === UserRole.ADMIN) {
|
||||||
|
plan = 'PLATINIUM';
|
||||||
|
planFeatures = [
|
||||||
|
'dashboard',
|
||||||
|
'wiki',
|
||||||
|
'user_management',
|
||||||
|
'csv_export',
|
||||||
|
'api_access',
|
||||||
|
'custom_interface',
|
||||||
|
'dedicated_kam',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
try {
|
try {
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
const subscription = await this.subscriptionService.getOrCreateSubscription(
|
||||||
user.organizationId
|
user.organizationId
|
||||||
@ -234,6 +247,7 @@ export class AuthService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
|
this.logger.warn(`Failed to fetch subscription for JWT: ${error}`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const accessPayload: JwtPayload = {
|
const accessPayload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
@ -321,6 +335,7 @@ export class AuthService {
|
|||||||
name: organizationData.name,
|
name: organizationData.name,
|
||||||
type: organizationData.type,
|
type: organizationData.type,
|
||||||
scac: organizationData.scac,
|
scac: organizationData.scac,
|
||||||
|
siren: organizationData.siren,
|
||||||
address: {
|
address: {
|
||||||
street: organizationData.street,
|
street: organizationData.street,
|
||||||
city: organizationData.city,
|
city: organizationData.city,
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
Post,
|
||||||
Patch,
|
Patch,
|
||||||
Delete,
|
Delete,
|
||||||
Param,
|
Param,
|
||||||
@ -44,6 +45,13 @@ import { OrganizationResponseDto, OrganizationListResponseDto } from '../dto/org
|
|||||||
|
|
||||||
// CSV Booking imports
|
// CSV Booking imports
|
||||||
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
|
|
||||||
|
// SIRET verification imports
|
||||||
|
import {
|
||||||
|
SiretVerificationPort,
|
||||||
|
SIRET_VERIFICATION_PORT,
|
||||||
|
} from '@domain/ports/out/siret-verification.port';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Admin Controller
|
* Admin Controller
|
||||||
@ -65,7 +73,10 @@ export class AdminController {
|
|||||||
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
@Inject(USER_REPOSITORY) private readonly userRepository: UserRepository,
|
||||||
@Inject(ORGANIZATION_REPOSITORY)
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
private readonly organizationRepository: OrganizationRepository,
|
private readonly organizationRepository: OrganizationRepository,
|
||||||
private readonly csvBookingRepository: TypeOrmCsvBookingRepository
|
private readonly csvBookingRepository: TypeOrmCsvBookingRepository,
|
||||||
|
private readonly csvBookingService: CsvBookingService,
|
||||||
|
@Inject(SIRET_VERIFICATION_PORT)
|
||||||
|
private readonly siretVerificationPort: SiretVerificationPort
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ==================== USERS ENDPOINTS ====================
|
// ==================== USERS ENDPOINTS ====================
|
||||||
@ -329,6 +340,163 @@ export class AdminController {
|
|||||||
return OrganizationMapper.toDto(organization);
|
return OrganizationMapper.toDto(organization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify SIRET number for an organization (admin only)
|
||||||
|
*
|
||||||
|
* Calls Pappers API to verify the SIRET, then marks the organization as verified.
|
||||||
|
*/
|
||||||
|
@Post('organizations/:id/verify-siret')
|
||||||
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true }))
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Verify organization SIRET (Admin only)',
|
||||||
|
description:
|
||||||
|
'Verify the SIRET number of an organization via Pappers API and mark it as verified. Required before the organization can make purchases.',
|
||||||
|
})
|
||||||
|
@ApiParam({
|
||||||
|
name: 'id',
|
||||||
|
description: 'Organization ID (UUID)',
|
||||||
|
})
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'SIRET verification result',
|
||||||
|
schema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
verified: { type: 'boolean' },
|
||||||
|
companyName: { type: 'string' },
|
||||||
|
address: { type: 'string' },
|
||||||
|
message: { type: 'string' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({
|
||||||
|
description: 'Organization not found',
|
||||||
|
})
|
||||||
|
async verifySiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Verifying SIRET for organization: ${id}`);
|
||||||
|
|
||||||
|
const organization = await this.organizationRepository.findById(id);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException(`Organization ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const siret = organization.siret;
|
||||||
|
if (!siret) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Organization has no SIRET number. Please set a SIRET number before verification.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.siretVerificationPort.verify(siret);
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
this.logger.warn(`[ADMIN] SIRET verification failed for ${siret}`);
|
||||||
|
return {
|
||||||
|
verified: false,
|
||||||
|
message: `Le numero SIRET ${siret} est invalide ou introuvable.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as verified and save
|
||||||
|
organization.markSiretVerified();
|
||||||
|
await this.organizationRepository.update(organization);
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] SIRET verified successfully for organization: ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
verified: true,
|
||||||
|
companyName: result.companyName,
|
||||||
|
address: result.address,
|
||||||
|
message: `SIRET ${siret} verifie avec succes.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually approve SIRET/SIREN for an organization (admin only)
|
||||||
|
*
|
||||||
|
* Marks the organization's SIRET as verified without calling the external API.
|
||||||
|
*/
|
||||||
|
@Post('organizations/:id/approve-siret')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Approve SIRET/SIREN (Admin only)',
|
||||||
|
description:
|
||||||
|
'Manually approve the SIRET/SIREN of an organization. Marks it as verified without calling Pappers API.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'SIRET approved successfully',
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({ description: 'Organization not found' })
|
||||||
|
async approveSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Manually approving SIRET for organization: ${id}`);
|
||||||
|
|
||||||
|
const organization = await this.organizationRepository.findById(id);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException(`Organization ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organization.siret && !organization.siren) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"L'organisation n'a ni SIRET ni SIREN. Veuillez en renseigner un avant l'approbation."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
organization.markSiretVerified();
|
||||||
|
await this.organizationRepository.update(organization);
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] SIRET manually approved for organization: ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
approved: true,
|
||||||
|
message: 'SIRET/SIREN approuve manuellement avec succes.',
|
||||||
|
organizationId: id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject SIRET/SIREN for an organization (admin only)
|
||||||
|
*
|
||||||
|
* Resets the verification flag to false.
|
||||||
|
*/
|
||||||
|
@Post('organizations/:id/reject-siret')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Reject SIRET/SIREN (Admin only)',
|
||||||
|
description:
|
||||||
|
'Reject the SIRET/SIREN of an organization. Resets the verification status to unverified.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Organization ID (UUID)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: HttpStatus.OK,
|
||||||
|
description: 'SIRET rejected successfully',
|
||||||
|
})
|
||||||
|
@ApiNotFoundResponse({ description: 'Organization not found' })
|
||||||
|
async rejectSiret(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: UserPayload) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Rejecting SIRET for organization: ${id}`);
|
||||||
|
|
||||||
|
const organization = await this.organizationRepository.findById(id);
|
||||||
|
if (!organization) {
|
||||||
|
throw new NotFoundException(`Organization ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset SIRET verification to false by updating the SIRET (which resets siretVerified)
|
||||||
|
// If no SIRET, just update directly
|
||||||
|
if (organization.siret) {
|
||||||
|
organization.updateSiret(organization.siret); // This resets siretVerified to false
|
||||||
|
}
|
||||||
|
await this.organizationRepository.update(organization);
|
||||||
|
|
||||||
|
this.logger.log(`[ADMIN] SIRET rejected for organization: ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rejected: true,
|
||||||
|
message: "SIRET/SIREN rejete. L'organisation ne pourra pas effectuer d'achats.",
|
||||||
|
organizationId: id,
|
||||||
|
organizationName: organization.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== CSV BOOKINGS ENDPOINTS ====================
|
// ==================== CSV BOOKINGS ENDPOINTS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -440,6 +608,28 @@ export class AdminController {
|
|||||||
return this.csvBookingToDto(updatedBooking);
|
return this.csvBookingToDto(updatedBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate bank transfer for a booking (admin only)
|
||||||
|
*
|
||||||
|
* Transitions booking from PENDING_BANK_TRANSFER → PENDING and sends email to carrier
|
||||||
|
*/
|
||||||
|
@Post('bookings/:id/validate-transfer')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Validate bank transfer (Admin only)',
|
||||||
|
description:
|
||||||
|
'Admin confirms that the bank wire transfer has been received. Activates the booking and sends email to carrier.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Bank transfer validated, booking activated' })
|
||||||
|
@ApiNotFoundResponse({ description: 'Booking not found' })
|
||||||
|
async validateBankTransfer(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: UserPayload
|
||||||
|
) {
|
||||||
|
this.logger.log(`[ADMIN: ${user.email}] Validating bank transfer for booking: ${id}`);
|
||||||
|
return this.csvBookingService.validateBankTransfer(id);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete csv booking (admin only)
|
* Delete csv booking (admin only)
|
||||||
*/
|
*/
|
||||||
@ -483,6 +673,7 @@ export class AdminController {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: booking.id,
|
id: booking.id,
|
||||||
|
bookingNumber: booking.bookingNumber || null,
|
||||||
userId: booking.userId,
|
userId: booking.userId,
|
||||||
organizationId: booking.organizationId,
|
organizationId: booking.organizationId,
|
||||||
carrierName: booking.carrierName,
|
carrierName: booking.carrierName,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
UploadedFiles,
|
UploadedFiles,
|
||||||
Request,
|
Request,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
DefaultValuePipe,
|
DefaultValuePipe,
|
||||||
Inject,
|
Inject,
|
||||||
@ -36,6 +37,10 @@ import {
|
|||||||
ShipmentCounterPort,
|
ShipmentCounterPort,
|
||||||
SHIPMENT_COUNTER_PORT,
|
SHIPMENT_COUNTER_PORT,
|
||||||
} from '@domain/ports/out/shipment-counter.port';
|
} from '@domain/ports/out/shipment-counter.port';
|
||||||
|
import {
|
||||||
|
OrganizationRepository,
|
||||||
|
ORGANIZATION_REPOSITORY,
|
||||||
|
} from '@domain/ports/out/organization.repository';
|
||||||
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
|
import { ShipmentLimitExceededException } from '@domain/exceptions/shipment-limit-exceeded.exception';
|
||||||
import {
|
import {
|
||||||
CreateCsvBookingDto,
|
CreateCsvBookingDto,
|
||||||
@ -61,7 +66,9 @@ export class CsvBookingsController {
|
|||||||
private readonly subscriptionService: SubscriptionService,
|
private readonly subscriptionService: SubscriptionService,
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@Inject(SHIPMENT_COUNTER_PORT)
|
@Inject(SHIPMENT_COUNTER_PORT)
|
||||||
private readonly shipmentCounter: ShipmentCounterPort
|
private readonly shipmentCounter: ShipmentCounterPort,
|
||||||
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
|
private readonly organizationRepository: OrganizationRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -157,6 +164,8 @@ export class CsvBookingsController {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const organizationId = req.user.organizationId;
|
const organizationId = req.user.organizationId;
|
||||||
|
|
||||||
|
// ADMIN users bypass shipment limits
|
||||||
|
if (req.user.role !== 'ADMIN') {
|
||||||
// Check shipment limit (Bronze plan = 12/year)
|
// Check shipment limit (Bronze plan = 12/year)
|
||||||
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
const subscription = await this.subscriptionService.getOrCreateSubscription(organizationId);
|
||||||
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
const maxShipments = subscription.plan.maxShipmentsPerYear;
|
||||||
@ -170,6 +179,7 @@ export class CsvBookingsController {
|
|||||||
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
|
throw new ShipmentLimitExceededException(organizationId, count, maxShipments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Convert string values to numbers (multipart/form-data sends everything as strings)
|
// Convert string values to numbers (multipart/form-data sends everything as strings)
|
||||||
const sanitizedDto: CreateCsvBookingDto = {
|
const sanitizedDto: CreateCsvBookingDto = {
|
||||||
@ -399,8 +409,20 @@ export class CsvBookingsController {
|
|||||||
async payCommission(@Param('id') id: string, @Request() req: any) {
|
async payCommission(@Param('id') id: string, @Request() req: any) {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const userEmail = req.user.email;
|
const userEmail = req.user.email;
|
||||||
|
const organizationId = req.user.organizationId;
|
||||||
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
|
const frontendUrl = this.configService.get<string>('FRONTEND_URL') || 'http://localhost:3000';
|
||||||
|
|
||||||
|
// ADMIN users bypass SIRET verification
|
||||||
|
if (req.user.role !== 'ADMIN') {
|
||||||
|
// SIRET verification gate: organization must have a verified SIRET before paying
|
||||||
|
const organization = await this.organizationRepository.findById(organizationId);
|
||||||
|
if (!organization || !organization.siretVerified) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un paiement. Contactez votre administrateur.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl);
|
return await this.csvBookingService.createCommissionPayment(id, userId, userEmail, frontendUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -447,6 +469,35 @@ export class CsvBookingsController {
|
|||||||
return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId);
|
return await this.csvBookingService.confirmCommissionPayment(id, sessionId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare bank transfer — user confirms they have sent the wire transfer
|
||||||
|
*
|
||||||
|
* POST /api/v1/csv-bookings/:id/declare-transfer
|
||||||
|
*/
|
||||||
|
@Post(':id/declare-transfer')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Declare bank transfer',
|
||||||
|
description:
|
||||||
|
'User confirms they have sent the bank wire transfer. Transitions booking to PENDING_BANK_TRANSFER awaiting admin validation.',
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||||
|
@ApiResponse({
|
||||||
|
status: 200,
|
||||||
|
description: 'Bank transfer declared, booking awaiting admin validation',
|
||||||
|
type: CsvBookingResponseDto,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 400, description: 'Booking not in PENDING_PAYMENT status' })
|
||||||
|
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||||
|
async declareTransfer(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Request() req: any
|
||||||
|
): Promise<CsvBookingResponseDto> {
|
||||||
|
const userId = req.user.id;
|
||||||
|
return await this.csvBookingService.declareBankTransfer(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// PARAMETERIZED ROUTES (must come LAST)
|
// PARAMETERIZED ROUTES (must come LAST)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@ -71,7 +71,8 @@ export class InvitationsController {
|
|||||||
dto.lastName,
|
dto.lastName,
|
||||||
dto.role as unknown as UserRole,
|
dto.role as unknown as UserRole,
|
||||||
user.organizationId,
|
user.organizationId,
|
||||||
user.id
|
user.id,
|
||||||
|
user.role
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -22,6 +22,8 @@ import {
|
|||||||
Headers,
|
Headers,
|
||||||
RawBodyRequest,
|
RawBodyRequest,
|
||||||
Req,
|
Req,
|
||||||
|
Inject,
|
||||||
|
ForbiddenException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiTags,
|
ApiTags,
|
||||||
@ -47,13 +49,21 @@ import { RolesGuard } from '../guards/roles.guard';
|
|||||||
import { Roles } from '../decorators/roles.decorator';
|
import { Roles } from '../decorators/roles.decorator';
|
||||||
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
import { CurrentUser, UserPayload } from '../decorators/current-user.decorator';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
|
import {
|
||||||
|
OrganizationRepository,
|
||||||
|
ORGANIZATION_REPOSITORY,
|
||||||
|
} from '@domain/ports/out/organization.repository';
|
||||||
|
|
||||||
@ApiTags('Subscriptions')
|
@ApiTags('Subscriptions')
|
||||||
@Controller('subscriptions')
|
@Controller('subscriptions')
|
||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
private readonly logger = new Logger(SubscriptionsController.name);
|
private readonly logger = new Logger(SubscriptionsController.name);
|
||||||
|
|
||||||
constructor(private readonly subscriptionService: SubscriptionService) {}
|
constructor(
|
||||||
|
private readonly subscriptionService: SubscriptionService,
|
||||||
|
@Inject(ORGANIZATION_REPOSITORY)
|
||||||
|
private readonly organizationRepository: OrganizationRepository
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subscription overview for current organization
|
* Get subscription overview for current organization
|
||||||
@ -80,7 +90,7 @@ export class SubscriptionsController {
|
|||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<SubscriptionOverviewResponseDto> {
|
): Promise<SubscriptionOverviewResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
|
this.logger.log(`[User: ${user.email}] Getting subscription overview`);
|
||||||
return this.subscriptionService.getSubscriptionOverview(user.organizationId);
|
return this.subscriptionService.getSubscriptionOverview(user.organizationId, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -126,7 +136,7 @@ export class SubscriptionsController {
|
|||||||
})
|
})
|
||||||
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
|
async canInvite(@CurrentUser() user: UserPayload): Promise<CanInviteResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Checking license availability`);
|
this.logger.log(`[User: ${user.email}] Checking license availability`);
|
||||||
return this.subscriptionService.canInviteUser(user.organizationId);
|
return this.subscriptionService.canInviteUser(user.organizationId, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -159,6 +169,18 @@ export class SubscriptionsController {
|
|||||||
@CurrentUser() user: UserPayload
|
@CurrentUser() user: UserPayload
|
||||||
): Promise<CheckoutSessionResponseDto> {
|
): Promise<CheckoutSessionResponseDto> {
|
||||||
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
|
this.logger.log(`[User: ${user.email}] Creating checkout session for plan: ${dto.plan}`);
|
||||||
|
|
||||||
|
// ADMIN users bypass all payment restrictions
|
||||||
|
if (user.role !== 'ADMIN') {
|
||||||
|
// SIRET verification gate: organization must have a verified SIRET before purchasing
|
||||||
|
const organization = await this.organizationRepository.findById(user.organizationId);
|
||||||
|
if (!organization || !organization.siretVerified) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Le numero SIRET de votre organisation doit etre verifie par un administrateur avant de pouvoir effectuer un achat. Contactez votre administrateur.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto);
|
return this.subscriptionService.createCheckoutSession(user.organizationId, user.id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,12 @@ import { CsvBookingOrmEntity } from '../infrastructure/persistence/typeorm/entit
|
|||||||
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeorm/repositories/csv-booking.repository';
|
||||||
import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
import { TypeOrmShipmentCounterRepository } from '../infrastructure/persistence/typeorm/repositories/shipment-counter.repository';
|
||||||
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
import { SHIPMENT_COUNTER_PORT } from '@domain/ports/out/shipment-counter.port';
|
||||||
|
import { ORGANIZATION_REPOSITORY } from '@domain/ports/out/organization.repository';
|
||||||
|
import { OrganizationOrmEntity } from '../infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||||
|
import { TypeOrmOrganizationRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-organization.repository';
|
||||||
|
import { USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
|
import { UserOrmEntity } from '../infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||||
|
import { TypeOrmUserRepository } from '../infrastructure/persistence/typeorm/repositories/typeorm-user.repository';
|
||||||
import { NotificationsModule } from './notifications/notifications.module';
|
import { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { EmailModule } from '../infrastructure/email/email.module';
|
import { EmailModule } from '../infrastructure/email/email.module';
|
||||||
import { StorageModule } from '../infrastructure/storage/storage.module';
|
import { StorageModule } from '../infrastructure/storage/storage.module';
|
||||||
@ -21,7 +27,7 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
|
TypeOrmModule.forFeature([CsvBookingOrmEntity, OrganizationOrmEntity, UserOrmEntity]),
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
@ -37,6 +43,14 @@ import { StripeModule } from '../infrastructure/stripe/stripe.module';
|
|||||||
provide: SHIPMENT_COUNTER_PORT,
|
provide: SHIPMENT_COUNTER_PORT,
|
||||||
useClass: TypeOrmShipmentCounterRepository,
|
useClass: TypeOrmShipmentCounterRepository,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: ORGANIZATION_REPOSITORY,
|
||||||
|
useClass: TypeOrmOrganizationRepository,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: USER_REPOSITORY,
|
||||||
|
useClass: TypeOrmUserRepository,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
exports: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -94,6 +94,18 @@ export class RegisterOrganizationDto {
|
|||||||
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
|
@Matches(/^[A-Z]{2}$/, { message: 'Country must be a 2-letter ISO code (e.g., NL, US, CN)' })
|
||||||
country: string;
|
country: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: '123456789',
|
||||||
|
description: 'French SIREN number (9 digits, required)',
|
||||||
|
minLength: 9,
|
||||||
|
maxLength: 9,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@MinLength(9, { message: 'SIREN must be exactly 9 digits' })
|
||||||
|
@MaxLength(9, { message: 'SIREN must be exactly 9 digits' })
|
||||||
|
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||||
|
siren: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'MAEU',
|
example: 'MAEU',
|
||||||
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
|
description: 'Standard Carrier Alpha Code (4 uppercase letters, required for carriers only)',
|
||||||
|
|||||||
@ -184,6 +184,19 @@ export class UpdateOrganizationDto {
|
|||||||
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
@Matches(/^[0-9]{9}$/, { message: 'SIREN must be 9 digits' })
|
||||||
siren?: string;
|
siren?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '12345678901234',
|
||||||
|
description: 'French SIRET number (14 digits)',
|
||||||
|
minLength: 14,
|
||||||
|
maxLength: 14,
|
||||||
|
})
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
@MinLength(14)
|
||||||
|
@MaxLength(14)
|
||||||
|
@Matches(/^[0-9]{14}$/, { message: 'SIRET must be 14 digits' })
|
||||||
|
siret?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
example: 'FR123456789',
|
example: 'FR123456789',
|
||||||
description: 'EU EORI number',
|
description: 'EU EORI number',
|
||||||
@ -344,6 +357,25 @@ export class OrganizationResponseDto {
|
|||||||
})
|
})
|
||||||
documents: OrganizationDocumentDto[];
|
documents: OrganizationDocumentDto[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: '12345678901234',
|
||||||
|
description: 'French SIRET number (14 digits)',
|
||||||
|
})
|
||||||
|
siret?: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: false,
|
||||||
|
description: 'Whether the SIRET has been verified by an admin',
|
||||||
|
})
|
||||||
|
siretVerified: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: 'none',
|
||||||
|
description: 'Organization status badge',
|
||||||
|
enum: ['none', 'silver', 'gold', 'platinium'],
|
||||||
|
})
|
||||||
|
statusBadge?: string;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
example: true,
|
example: true,
|
||||||
description: 'Active status',
|
description: 'Active status',
|
||||||
|
|||||||
@ -53,6 +53,11 @@ export class FeatureFlagGuard implements CanActivate {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ADMIN users have full access to all features — no plan check needed
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Fast path: check plan features from JWT payload
|
// Fast path: check plan features from JWT payload
|
||||||
if (user.planFeatures && Array.isArray(user.planFeatures)) {
|
if (user.planFeatures && Array.isArray(user.planFeatures)) {
|
||||||
const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature));
|
const hasAllFeatures = requiredFeatures.every(feature => user.planFeatures.includes(feature));
|
||||||
|
|||||||
@ -31,6 +31,9 @@ export class OrganizationMapper {
|
|||||||
address: this.mapAddressToDto(organization.address),
|
address: this.mapAddressToDto(organization.address),
|
||||||
logoUrl: organization.logoUrl,
|
logoUrl: organization.logoUrl,
|
||||||
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
|
documents: organization.documents.map(doc => this.mapDocumentToDto(doc)),
|
||||||
|
siret: organization.siret,
|
||||||
|
siretVerified: organization.siretVerified,
|
||||||
|
statusBadge: organization.statusBadge,
|
||||||
isActive: organization.isActive,
|
isActive: organization.isActive,
|
||||||
createdAt: organization.createdAt,
|
createdAt: organization.createdAt,
|
||||||
updatedAt: organization.updatedAt,
|
updatedAt: organization.updatedAt,
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
NOTIFICATION_REPOSITORY,
|
NOTIFICATION_REPOSITORY,
|
||||||
} from '@domain/ports/out/notification.repository';
|
} from '@domain/ports/out/notification.repository';
|
||||||
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
import { EmailPort, EMAIL_PORT } from '@domain/ports/out/email.port';
|
||||||
|
import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository';
|
||||||
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
import { StoragePort, STORAGE_PORT } from '@domain/ports/out/storage.port';
|
||||||
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
import { StripePort, STRIPE_PORT } from '@domain/ports/out/stripe.port';
|
||||||
import {
|
import {
|
||||||
@ -67,7 +68,9 @@ export class CsvBookingService {
|
|||||||
private readonly storageAdapter: StoragePort,
|
private readonly storageAdapter: StoragePort,
|
||||||
@Inject(STRIPE_PORT)
|
@Inject(STRIPE_PORT)
|
||||||
private readonly stripeAdapter: StripePort,
|
private readonly stripeAdapter: StripePort,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService,
|
||||||
|
@Inject(USER_REPOSITORY)
|
||||||
|
private readonly userRepository: UserRepository
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -341,6 +344,187 @@ export class CsvBookingService {
|
|||||||
return this.toResponseDto(updatedBooking);
|
return this.toResponseDto(updatedBooking);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare bank transfer — user confirms they have sent the wire transfer
|
||||||
|
* Transitions booking from PENDING_PAYMENT → PENDING_BANK_TRANSFER
|
||||||
|
* Sends an email notification to all ADMIN users
|
||||||
|
*/
|
||||||
|
async declareBankTransfer(bookingId: string, userId: string): Promise<CsvBookingResponseDto> {
|
||||||
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.userId !== userId) {
|
||||||
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking is not awaiting payment. Current status: ${booking.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get booking number before update
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
});
|
||||||
|
const bookingNumber = ormBooking?.bookingNumber || bookingId.slice(0, 8).toUpperCase();
|
||||||
|
|
||||||
|
booking.markBankTransferDeclared();
|
||||||
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
|
this.logger.log(`Booking ${bookingId} bank transfer declared, status now PENDING_BANK_TRANSFER`);
|
||||||
|
|
||||||
|
// Send email to all ADMIN users
|
||||||
|
try {
|
||||||
|
const allUsers = await this.userRepository.findAll();
|
||||||
|
const adminEmails = allUsers
|
||||||
|
.filter(u => u.role === 'ADMIN' && u.isActive)
|
||||||
|
.map(u => u.email);
|
||||||
|
|
||||||
|
if (adminEmails.length > 0) {
|
||||||
|
const commissionAmount = booking.commissionAmountEur
|
||||||
|
? new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR' }).format(booking.commissionAmountEur)
|
||||||
|
: 'N/A';
|
||||||
|
|
||||||
|
await this.emailAdapter.send({
|
||||||
|
to: adminEmails,
|
||||||
|
subject: `[XPEDITIS] Virement à valider — ${bookingNumber}`,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #10183A;">Nouveau virement à valider</h2>
|
||||||
|
<p>Un client a déclaré avoir effectué un virement bancaire pour le booking suivant :</p>
|
||||||
|
<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">
|
||||||
|
<tr style="background: #f5f5f5;">
|
||||||
|
<td style="padding: 8px 12px; font-weight: bold;">Numéro de booking</td>
|
||||||
|
<td style="padding: 8px 12px;">${bookingNumber}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; font-weight: bold;">Transporteur</td>
|
||||||
|
<td style="padding: 8px 12px;">${booking.carrierName}</td>
|
||||||
|
</tr>
|
||||||
|
<tr style="background: #f5f5f5;">
|
||||||
|
<td style="padding: 8px 12px; font-weight: bold;">Trajet</td>
|
||||||
|
<td style="padding: 8px 12px;">${booking.getRouteDescription()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 8px 12px; font-weight: bold;">Montant commission</td>
|
||||||
|
<td style="padding: 8px 12px; color: #10183A; font-weight: bold;">${commissionAmount}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p>Rendez-vous dans la <strong>console d'administration</strong> pour valider ce virement et activer le booking.</p>
|
||||||
|
<a href="${process.env.APP_URL || 'http://localhost:3000'}/dashboard/admin/bookings"
|
||||||
|
style="display: inline-block; background: #10183A; color: white; padding: 12px 24px; border-radius: 6px; text-decoration: none; margin-top: 8px;">
|
||||||
|
Voir les bookings en attente
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
this.logger.log(`Admin notification email sent to: ${adminEmails.join(', ')}`);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to send admin notification email: ${error?.message}`, error?.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app notification for the user
|
||||||
|
try {
|
||||||
|
const notification = Notification.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
userId: booking.userId,
|
||||||
|
organizationId: booking.organizationId,
|
||||||
|
type: NotificationType.BOOKING_UPDATED,
|
||||||
|
priority: NotificationPriority.MEDIUM,
|
||||||
|
title: 'Virement déclaré',
|
||||||
|
message: `Votre virement pour le booking ${bookingNumber} a été enregistré. Un administrateur va vérifier la réception et activer votre booking.`,
|
||||||
|
metadata: { bookingId: booking.id },
|
||||||
|
});
|
||||||
|
await this.notificationRepository.save(notification);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toResponseDto(updatedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin validates bank transfer — confirms receipt and activates booking
|
||||||
|
* Transitions booking from PENDING_BANK_TRANSFER → PENDING then sends email to carrier
|
||||||
|
*/
|
||||||
|
async validateBankTransfer(bookingId: string): Promise<CsvBookingResponseDto> {
|
||||||
|
const booking = await this.csvBookingRepository.findById(bookingId);
|
||||||
|
|
||||||
|
if (!booking) {
|
||||||
|
throw new NotFoundException(`Booking with ID ${bookingId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (booking.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Booking is not awaiting bank transfer validation. Current status: ${booking.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
booking.markBankTransferValidated();
|
||||||
|
const updatedBooking = await this.csvBookingRepository.update(booking);
|
||||||
|
this.logger.log(`Booking ${bookingId} bank transfer validated by admin, status now PENDING`);
|
||||||
|
|
||||||
|
// Get booking number for email
|
||||||
|
const ormBooking = await this.csvBookingRepository['repository'].findOne({
|
||||||
|
where: { id: bookingId },
|
||||||
|
});
|
||||||
|
const bookingNumber = ormBooking?.bookingNumber;
|
||||||
|
const documentPassword = bookingNumber
|
||||||
|
? this.extractPasswordFromBookingNumber(bookingNumber)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Send email to carrier
|
||||||
|
try {
|
||||||
|
await this.emailAdapter.sendCsvBookingRequest(booking.carrierEmail, {
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingNumber: bookingNumber || '',
|
||||||
|
documentPassword: documentPassword || '',
|
||||||
|
origin: booking.origin.getValue(),
|
||||||
|
destination: booking.destination.getValue(),
|
||||||
|
volumeCBM: booking.volumeCBM,
|
||||||
|
weightKG: booking.weightKG,
|
||||||
|
palletCount: booking.palletCount,
|
||||||
|
priceUSD: booking.priceUSD,
|
||||||
|
priceEUR: booking.priceEUR,
|
||||||
|
primaryCurrency: booking.primaryCurrency,
|
||||||
|
transitDays: booking.transitDays,
|
||||||
|
containerType: booking.containerType,
|
||||||
|
documents: booking.documents.map(doc => ({
|
||||||
|
type: doc.type,
|
||||||
|
fileName: doc.fileName,
|
||||||
|
})),
|
||||||
|
confirmationToken: booking.confirmationToken,
|
||||||
|
notes: booking.notes,
|
||||||
|
});
|
||||||
|
this.logger.log(`Email sent to carrier after bank transfer validation: ${booking.carrierEmail}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to send email to carrier: ${error?.message}`, error?.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-app notification for the user
|
||||||
|
try {
|
||||||
|
const notification = Notification.create({
|
||||||
|
id: uuidv4(),
|
||||||
|
userId: booking.userId,
|
||||||
|
organizationId: booking.organizationId,
|
||||||
|
type: NotificationType.BOOKING_CONFIRMED,
|
||||||
|
priority: NotificationPriority.HIGH,
|
||||||
|
title: 'Virement validé — Booking activé',
|
||||||
|
message: `Votre virement pour le booking ${bookingNumber || booking.id.slice(0, 8)} a été confirmé. Votre demande auprès de ${booking.carrierName} a été transmise au transporteur.`,
|
||||||
|
metadata: { bookingId: booking.id },
|
||||||
|
});
|
||||||
|
await this.notificationRepository.save(notification);
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`Failed to create user notification: ${error?.message}`, error?.stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toResponseDto(updatedBooking);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get booking by ID
|
* Get booking by ID
|
||||||
* Accessible by: booking owner OR assigned carrier
|
* Accessible by: booking owner OR assigned carrier
|
||||||
|
|||||||
@ -50,7 +50,8 @@ export class InvitationService {
|
|||||||
lastName: string,
|
lastName: string,
|
||||||
role: UserRole,
|
role: UserRole,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
invitedById: string
|
invitedById: string,
|
||||||
|
inviterRole?: string
|
||||||
): Promise<InvitationToken> {
|
): Promise<InvitationToken> {
|
||||||
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
|
this.logger.log(`Creating invitation for ${email} in organization ${organizationId}`);
|
||||||
|
|
||||||
@ -69,7 +70,7 @@ export class InvitationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if licenses are available for this organization
|
// Check if licenses are available for this organization
|
||||||
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId);
|
const canInviteResult = await this.subscriptionService.canInviteUser(organizationId, inviterRole);
|
||||||
if (!canInviteResult.canInvite) {
|
if (!canInviteResult.canInvite) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
`License limit reached for organization ${organizationId}: ${canInviteResult.usedLicenses}/${canInviteResult.maxLicenses}`
|
||||||
|
|||||||
@ -60,8 +60,12 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get subscription overview for an organization
|
* Get subscription overview for an organization
|
||||||
|
* ADMIN users always see a PLATINIUM plan with no expiration
|
||||||
*/
|
*/
|
||||||
async getSubscriptionOverview(organizationId: string): Promise<SubscriptionOverviewResponseDto> {
|
async getSubscriptionOverview(
|
||||||
|
organizationId: string,
|
||||||
|
userRole?: string
|
||||||
|
): Promise<SubscriptionOverviewResponseDto> {
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||||
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id);
|
const activeLicenses = await this.licenseRepository.findActiveBySubscriptionId(subscription.id);
|
||||||
|
|
||||||
@ -78,23 +82,27 @@ export class SubscriptionService {
|
|||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
subscription.id
|
subscription.id
|
||||||
);
|
);
|
||||||
const maxLicenses = subscription.maxLicenses;
|
|
||||||
const availableLicenses = subscription.isUnlimited()
|
// ADMIN users always have PLATINIUM plan with no expiration
|
||||||
|
const isAdmin = userRole === 'ADMIN';
|
||||||
|
const effectivePlan = isAdmin ? SubscriptionPlan.platinium() : subscription.plan;
|
||||||
|
const maxLicenses = effectivePlan.maxLicenses;
|
||||||
|
const availableLicenses = effectivePlan.isUnlimited()
|
||||||
? -1
|
? -1
|
||||||
: Math.max(0, maxLicenses - usedLicenses);
|
: Math.max(0, maxLicenses - usedLicenses);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
organizationId: subscription.organizationId,
|
organizationId: subscription.organizationId,
|
||||||
plan: subscription.plan.value as SubscriptionPlanDto,
|
plan: effectivePlan.value as SubscriptionPlanDto,
|
||||||
planDetails: this.mapPlanToDto(subscription.plan),
|
planDetails: this.mapPlanToDto(effectivePlan),
|
||||||
status: subscription.status.value as SubscriptionStatusDto,
|
status: subscription.status.value as SubscriptionStatusDto,
|
||||||
usedLicenses,
|
usedLicenses,
|
||||||
maxLicenses,
|
maxLicenses,
|
||||||
availableLicenses,
|
availableLicenses,
|
||||||
cancelAtPeriodEnd: subscription.cancelAtPeriodEnd,
|
cancelAtPeriodEnd: false,
|
||||||
currentPeriodStart: subscription.currentPeriodStart || undefined,
|
currentPeriodStart: isAdmin ? undefined : subscription.currentPeriodStart || undefined,
|
||||||
currentPeriodEnd: subscription.currentPeriodEnd || undefined,
|
currentPeriodEnd: isAdmin ? undefined : subscription.currentPeriodEnd || undefined,
|
||||||
createdAt: subscription.createdAt,
|
createdAt: subscription.createdAt,
|
||||||
updatedAt: subscription.updatedAt,
|
updatedAt: subscription.updatedAt,
|
||||||
licenses: enrichedLicenses,
|
licenses: enrichedLicenses,
|
||||||
@ -111,9 +119,20 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if organization can invite more users
|
* Check if organization can invite more users
|
||||||
* Note: ADMIN users don't count against the license quota
|
* Note: ADMIN users don't count against the license quota and always have unlimited licenses
|
||||||
*/
|
*/
|
||||||
async canInviteUser(organizationId: string): Promise<CanInviteResponseDto> {
|
async canInviteUser(organizationId: string, userRole?: string): Promise<CanInviteResponseDto> {
|
||||||
|
// ADMIN users always have unlimited invitations
|
||||||
|
if (userRole === 'ADMIN') {
|
||||||
|
return {
|
||||||
|
canInvite: true,
|
||||||
|
availableLicenses: -1,
|
||||||
|
usedLicenses: 0,
|
||||||
|
maxLicenses: -1,
|
||||||
|
message: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const subscription = await this.getOrCreateSubscription(organizationId);
|
const subscription = await this.getOrCreateSubscription(organizationId);
|
||||||
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
// Count only non-ADMIN licenses - ADMIN users have unlimited licenses
|
||||||
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
const usedLicenses = await this.licenseRepository.countActiveBySubscriptionIdExcludingAdmins(
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { PortCode } from '../value-objects/port-code.vo';
|
|||||||
*/
|
*/
|
||||||
export enum CsvBookingStatus {
|
export enum CsvBookingStatus {
|
||||||
PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment
|
PENDING_PAYMENT = 'PENDING_PAYMENT', // Awaiting commission payment
|
||||||
|
PENDING_BANK_TRANSFER = 'PENDING_BANK_TRANSFER', // Bank transfer declared, awaiting admin validation
|
||||||
PENDING = 'PENDING', // Awaiting carrier response
|
PENDING = 'PENDING', // Awaiting carrier response
|
||||||
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
|
ACCEPTED = 'ACCEPTED', // Carrier accepted the booking
|
||||||
REJECTED = 'REJECTED', // Carrier rejected the booking
|
REJECTED = 'REJECTED', // Carrier rejected the booking
|
||||||
@ -171,6 +172,38 @@ export class CsvBooking {
|
|||||||
this.status = CsvBookingStatus.PENDING;
|
this.status = CsvBookingStatus.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare bank transfer → transition to PENDING_BANK_TRANSFER
|
||||||
|
* Called when user confirms they have sent the bank transfer
|
||||||
|
*
|
||||||
|
* @throws Error if booking is not in PENDING_PAYMENT status
|
||||||
|
*/
|
||||||
|
markBankTransferDeclared(): void {
|
||||||
|
if (this.status !== CsvBookingStatus.PENDING_PAYMENT) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot declare bank transfer for booking with status ${this.status}. Only PENDING_PAYMENT bookings can transition.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = CsvBookingStatus.PENDING_BANK_TRANSFER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin validates bank transfer → transition to PENDING
|
||||||
|
* Called by admin once bank transfer has been received and verified
|
||||||
|
*
|
||||||
|
* @throws Error if booking is not in PENDING_BANK_TRANSFER status
|
||||||
|
*/
|
||||||
|
markBankTransferValidated(): void {
|
||||||
|
if (this.status !== CsvBookingStatus.PENDING_BANK_TRANSFER) {
|
||||||
|
throw new Error(
|
||||||
|
`Cannot validate bank transfer for booking with status ${this.status}. Only PENDING_BANK_TRANSFER bookings can transition.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.status = CsvBookingStatus.PENDING;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept the booking
|
* Accept the booking
|
||||||
*
|
*
|
||||||
|
|||||||
@ -75,11 +75,11 @@ export class CsvBookingOrmEntity {
|
|||||||
@Column({
|
@Column({
|
||||||
name: 'status',
|
name: 'status',
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: ['PENDING_PAYMENT', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
enum: ['PENDING_PAYMENT', 'PENDING_BANK_TRANSFER', 'PENDING', 'ACCEPTED', 'REJECTED', 'CANCELLED'],
|
||||||
default: 'PENDING_PAYMENT',
|
default: 'PENDING_PAYMENT',
|
||||||
})
|
})
|
||||||
@Index()
|
@Index()
|
||||||
status: 'PENDING_PAYMENT' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
status: 'PENDING_PAYMENT' | 'PENDING_BANK_TRANSFER' | 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'CANCELLED';
|
||||||
|
|
||||||
@Column({ name: 'documents', type: 'jsonb' })
|
@Column({ name: 'documents', type: 'jsonb' })
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Add PENDING_BANK_TRANSFER status to csv_bookings enum
|
||||||
|
*/
|
||||||
|
export class AddPendingBankTransferStatus1740000000005 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Drop default before modifying enum
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create new enum with PENDING_BANK_TRANSFER
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TYPE "csv_booking_status_new" AS ENUM (
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PENDING_BANK_TRANSFER',
|
||||||
|
'PENDING',
|
||||||
|
'ACCEPTED',
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Swap column to new enum type
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings"
|
||||||
|
ALTER COLUMN "status" TYPE "csv_booking_status_new"
|
||||||
|
USING "status"::text::"csv_booking_status_new"
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop old enum and rename new
|
||||||
|
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "csv_booking_status_new" RENAME TO "csv_booking_status"`);
|
||||||
|
|
||||||
|
// Restore default
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Move any PENDING_BANK_TRANSFER rows back to PENDING_PAYMENT
|
||||||
|
await queryRunner.query(`
|
||||||
|
UPDATE "csv_bookings" SET "status" = 'PENDING_PAYMENT' WHERE "status" = 'PENDING_BANK_TRANSFER'
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings" ALTER COLUMN "status" DROP DEFAULT
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TYPE "csv_booking_status_old" AS ENUM (
|
||||||
|
'PENDING_PAYMENT',
|
||||||
|
'PENDING',
|
||||||
|
'ACCEPTED',
|
||||||
|
'REJECTED',
|
||||||
|
'CANCELLED'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings"
|
||||||
|
ALTER COLUMN "status" TYPE "csv_booking_status_old"
|
||||||
|
USING "status"::text::"csv_booking_status_old"
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TYPE "csv_booking_status"`);
|
||||||
|
await queryRunner.query(`ALTER TYPE "csv_booking_status_old" RENAME TO "csv_booking_status"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE "csv_bookings" ALTER COLUMN "status" SET DEFAULT 'PENDING_PAYMENT'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,39 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getAllBookings } from '@/lib/api/admin';
|
import { getAllBookings, validateBankTransfer } from '@/lib/api/admin';
|
||||||
|
|
||||||
interface Booking {
|
interface Booking {
|
||||||
id: string;
|
id: string;
|
||||||
bookingNumber?: string;
|
bookingNumber?: string | null;
|
||||||
bookingId?: string;
|
|
||||||
type?: string;
|
type?: string;
|
||||||
status: string;
|
status: string;
|
||||||
// CSV bookings use these fields
|
|
||||||
origin?: string;
|
origin?: string;
|
||||||
destination?: string;
|
destination?: string;
|
||||||
carrierName?: string;
|
carrierName?: string;
|
||||||
// Regular bookings use these fields
|
|
||||||
originPort?: {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
destinationPort?: {
|
|
||||||
code: string;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
carrier?: string;
|
|
||||||
containerType: string;
|
containerType: string;
|
||||||
quantity?: number;
|
volumeCBM?: number;
|
||||||
price?: number;
|
weightKG?: number;
|
||||||
|
palletCount?: number;
|
||||||
|
priceEUR?: number;
|
||||||
|
priceUSD?: number;
|
||||||
primaryCurrency?: string;
|
primaryCurrency?: string;
|
||||||
totalPrice?: {
|
|
||||||
amount: number;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
|
||||||
requestedAt?: string;
|
requestedAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
organizationId?: string;
|
organizationId?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}
|
}
|
||||||
@ -42,23 +29,27 @@ export default function AdminBookingsPage() {
|
|||||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
|
||||||
const [showDetailsModal, setShowDetailsModal] = useState(false);
|
|
||||||
const [filterStatus, setFilterStatus] = useState('all');
|
const [filterStatus, setFilterStatus] = useState('all');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [validatingId, setValidatingId] = useState<string | null>(null);
|
||||||
// Helper function to get formatted quote number
|
|
||||||
const getQuoteNumber = (booking: Booking): string => {
|
|
||||||
if (booking.type === 'csv') {
|
|
||||||
return `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`;
|
|
||||||
}
|
|
||||||
return booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBookings();
|
fetchBookings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleValidateTransfer = async (bookingId: string) => {
|
||||||
|
if (!window.confirm('Confirmer la réception du virement et activer ce booking ?')) return;
|
||||||
|
setValidatingId(bookingId);
|
||||||
|
try {
|
||||||
|
await validateBankTransfer(bookingId);
|
||||||
|
await fetchBookings();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Erreur lors de la validation du virement');
|
||||||
|
} finally {
|
||||||
|
setValidatingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBookings = async () => {
|
const fetchBookings = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -66,7 +57,7 @@ export default function AdminBookingsPage() {
|
|||||||
setBookings(response.bookings || []);
|
setBookings(response.bookings || []);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message || 'Failed to load bookings');
|
setError(err.message || 'Impossible de charger les réservations');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -74,29 +65,45 @@ export default function AdminBookingsPage() {
|
|||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
const colors: Record<string, string> = {
|
const colors: Record<string, string> = {
|
||||||
draft: 'bg-gray-100 text-gray-800',
|
pending_payment: 'bg-orange-100 text-orange-800',
|
||||||
|
pending_bank_transfer: 'bg-amber-100 text-amber-900',
|
||||||
pending: 'bg-yellow-100 text-yellow-800',
|
pending: 'bg-yellow-100 text-yellow-800',
|
||||||
confirmed: 'bg-blue-100 text-blue-800',
|
accepted: 'bg-green-100 text-green-800',
|
||||||
in_transit: 'bg-purple-100 text-purple-800',
|
rejected: 'bg-red-100 text-red-800',
|
||||||
delivered: 'bg-green-100 text-green-800',
|
|
||||||
cancelled: 'bg-red-100 text-red-800',
|
cancelled: 'bg-red-100 text-red-800',
|
||||||
};
|
};
|
||||||
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
return colors[status.toLowerCase()] || 'bg-gray-100 text-gray-800';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
PENDING_PAYMENT: 'Paiement en attente',
|
||||||
|
PENDING_BANK_TRANSFER: 'Virement à valider',
|
||||||
|
PENDING: 'En attente transporteur',
|
||||||
|
ACCEPTED: 'Accepté',
|
||||||
|
REJECTED: 'Rejeté',
|
||||||
|
CANCELLED: 'Annulé',
|
||||||
|
};
|
||||||
|
return labels[status.toUpperCase()] || status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getShortId = (booking: Booking) => `#${booking.id.slice(0, 8).toUpperCase()}`;
|
||||||
|
|
||||||
const filteredBookings = bookings
|
const filteredBookings = bookings
|
||||||
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
.filter(booking => filterStatus === 'all' || booking.status.toLowerCase() === filterStatus)
|
||||||
.filter(booking => {
|
.filter(booking => {
|
||||||
if (searchTerm === '') return true;
|
if (searchTerm === '') return true;
|
||||||
const searchLower = searchTerm.toLowerCase();
|
const s = searchTerm.toLowerCase();
|
||||||
const quoteNumber = getQuoteNumber(booking).toLowerCase();
|
|
||||||
return (
|
return (
|
||||||
quoteNumber.includes(searchLower) ||
|
booking.bookingNumber?.toLowerCase().includes(s) ||
|
||||||
booking.bookingNumber?.toLowerCase().includes(searchLower) ||
|
booking.id.toLowerCase().includes(s) ||
|
||||||
booking.carrier?.toLowerCase().includes(searchLower) ||
|
booking.carrierName?.toLowerCase().includes(s) ||
|
||||||
booking.carrierName?.toLowerCase().includes(searchLower) ||
|
booking.origin?.toLowerCase().includes(s) ||
|
||||||
booking.origin?.toLowerCase().includes(searchLower) ||
|
booking.destination?.toLowerCase().includes(s) ||
|
||||||
booking.destination?.toLowerCase().includes(searchLower)
|
String(booking.palletCount || '').includes(s) ||
|
||||||
|
String(booking.weightKG || '').includes(s) ||
|
||||||
|
String(booking.volumeCBM || '').includes(s) ||
|
||||||
|
booking.containerType?.toLowerCase().includes(s)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -105,7 +112,7 @@ export default function AdminBookingsPage() {
|
|||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
|
||||||
<p className="mt-4 text-gray-600">Loading bookings...</p>
|
<p className="mt-4 text-gray-600">Chargement des réservations...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -114,300 +121,188 @@ export default function AdminBookingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Booking Management</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Gestion des réservations</h1>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
View and manage all bookings across the platform
|
Toutes les réservations de la plateforme
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500 uppercase tracking-wide">Total</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900 mt-1">{bookings.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-amber-50 rounded-lg shadow-sm border border-amber-200 p-4">
|
||||||
|
<div className="text-xs text-amber-700 uppercase tracking-wide">Virements à valider</div>
|
||||||
|
<div className="text-2xl font-bold text-amber-700 mt-1">
|
||||||
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING_BANK_TRANSFER').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500 uppercase tracking-wide">En attente transporteur</div>
|
||||||
|
<div className="text-2xl font-bold text-yellow-600 mt-1">
|
||||||
|
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500 uppercase tracking-wide">Acceptées</div>
|
||||||
|
<div className="text-2xl font-bold text-green-600 mt-1">
|
||||||
|
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div className="text-xs text-gray-500 uppercase tracking-wide">Rejetées</div>
|
||||||
|
<div className="text-2xl font-bold text-red-600 mt-1">
|
||||||
|
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Search</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Recherche</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by booking number or carrier..."
|
placeholder="N° booking, transporteur, route, palettes, poids, CBM..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">Status Filter</label>
|
<label className="block text-sm font-medium text-gray-700 mb-1">Statut</label>
|
||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={e => setFilterStatus(e.target.value)}
|
onChange={e => setFilterStatus(e.target.value)}
|
||||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none text-sm"
|
||||||
>
|
>
|
||||||
<option value="all">All Statuses</option>
|
<option value="all">Tous les statuts</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="pending_bank_transfer">Virement à valider</option>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending_payment">Paiement en attente</option>
|
||||||
<option value="confirmed">Confirmed</option>
|
<option value="pending">En attente transporteur</option>
|
||||||
<option value="in_transit">In Transit</option>
|
<option value="accepted">Accepté</option>
|
||||||
<option value="delivered">Delivered</option>
|
<option value="rejected">Rejeté</option>
|
||||||
<option value="cancelled">Cancelled</option>
|
<option value="cancelled">Annulé</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Total Réservations</div>
|
|
||||||
<div className="text-2xl font-bold text-gray-900">{bookings.length}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">En Attente</div>
|
|
||||||
<div className="text-2xl font-bold text-yellow-600">
|
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'PENDING').length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Acceptées</div>
|
|
||||||
<div className="text-2xl font-bold text-green-600">
|
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'ACCEPTED').length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div className="text-sm text-gray-500">Rejetées</div>
|
|
||||||
<div className="text-2xl font-bold text-red-600">
|
|
||||||
{bookings.filter(b => b.status.toUpperCase() === 'REJECTED').length}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg">
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bookings Table */}
|
{/* Bookings Table */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Numéro de devis
|
N° Booking
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Route
|
Route
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Cargo
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Transporteur
|
Transporteur
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Conteneur
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Statut
|
Statut
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Prix
|
Date
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{filteredBookings.map(booking => (
|
{filteredBookings.length === 0 ? (
|
||||||
<tr key={booking.id} className="hover:bg-gray-50">
|
<tr>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td colSpan={7} className="px-4 py-8 text-center text-sm text-gray-500">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
Aucune réservation trouvée
|
||||||
{getQuoteNumber(booking)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{new Date(booking.createdAt || booking.requestedAt || '').toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{booking.originPort ? `${booking.originPort.code} → ${booking.destinationPort?.code}` : `${booking.origin} → ${booking.destination}`}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{booking.originPort ? `${booking.originPort.name} → ${booking.destinationPort?.name}` : ''}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{booking.carrier || booking.carrierName || 'N/A'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="text-sm text-gray-900">{booking.containerType}</div>
|
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
{booking.quantity ? `Qty: ${booking.quantity}` : ''}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
|
||||||
{booking.status}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
|
||||||
{booking.totalPrice
|
|
||||||
? `${booking.totalPrice.amount.toLocaleString()} ${booking.totalPrice.currency}`
|
|
||||||
: booking.price
|
|
||||||
? `${booking.price.toLocaleString()} ${booking.primaryCurrency || 'USD'}`
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedBooking(booking);
|
|
||||||
setShowDetailsModal(true);
|
|
||||||
}}
|
|
||||||
className="text-blue-600 hover:text-blue-900"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
) : (
|
||||||
|
filteredBookings.map(booking => (
|
||||||
|
<tr key={booking.id} className="hover:bg-gray-50">
|
||||||
|
{/* N° Booking */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
{booking.bookingNumber && (
|
||||||
|
<div className="text-sm font-semibold text-gray-900">{booking.bookingNumber}</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-gray-400 font-mono">{getShortId(booking)}</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Route */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{booking.origin} → {booking.destination}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Cargo */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm text-gray-900">
|
||||||
|
{booking.containerType}
|
||||||
|
{booking.palletCount != null && (
|
||||||
|
<span className="ml-1 text-gray-500">· {booking.palletCount} pal.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 space-x-2">
|
||||||
|
{booking.weightKG != null && <span>{booking.weightKG.toLocaleString()} kg</span>}
|
||||||
|
{booking.volumeCBM != null && <span>{booking.volumeCBM} CBM</span>}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Transporteur */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{booking.carrierName || '—'}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Statut */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap">
|
||||||
|
<span className={`px-2 py-0.5 inline-flex text-xs leading-5 font-semibold rounded-full ${getStatusColor(booking.status)}`}>
|
||||||
|
{getStatusLabel(booking.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(booking.requestedAt || booking.createdAt || '').toLocaleDateString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<td className="px-4 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
{booking.status.toUpperCase() === 'PENDING_BANK_TRANSFER' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleValidateTransfer(booking.id)}
|
||||||
|
disabled={validatingId === booking.id}
|
||||||
|
className="px-3 py-1 bg-green-600 text-white text-xs font-semibold rounded-lg hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{validatingId === booking.id ? '...' : '✓ Valider virement'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Details Modal */}
|
|
||||||
{showDetailsModal && selectedBooking && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
|
|
||||||
<div className="bg-white rounded-lg p-6 max-w-2xl w-full m-4">
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<h2 className="text-xl font-bold">Booking Details</h2>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowDetailsModal(false);
|
|
||||||
setSelectedBooking(null);
|
|
||||||
}}
|
|
||||||
className="text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Numéro de devis</label>
|
|
||||||
<div className="mt-1 text-lg font-semibold">
|
|
||||||
{getQuoteNumber(selectedBooking)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Statut</label>
|
|
||||||
<span className={`mt-1 inline-block px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(selectedBooking.status)}`}>
|
|
||||||
{selectedBooking.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Route Information</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Origin</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
{selectedBooking.originPort ? (
|
|
||||||
<>
|
|
||||||
<div className="font-semibold">{selectedBooking.originPort.code}</div>
|
|
||||||
<div className="text-sm text-gray-600">{selectedBooking.originPort.name}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="font-semibold">{selectedBooking.origin}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Destination</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
{selectedBooking.destinationPort ? (
|
|
||||||
<>
|
|
||||||
<div className="font-semibold">{selectedBooking.destinationPort.code}</div>
|
|
||||||
<div className="text-sm text-gray-600">{selectedBooking.destinationPort.name}</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="font-semibold">{selectedBooking.destination}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Shipping Details</h3>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Carrier</label>
|
|
||||||
<div className="mt-1 font-semibold">
|
|
||||||
{selectedBooking.carrier || selectedBooking.carrierName || 'N/A'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Container Type</label>
|
|
||||||
<div className="mt-1 font-semibold">{selectedBooking.containerType}</div>
|
|
||||||
</div>
|
|
||||||
{selectedBooking.quantity && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-500">Quantity</label>
|
|
||||||
<div className="mt-1 font-semibold">{selectedBooking.quantity}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Pricing</h3>
|
|
||||||
<div className="text-2xl font-bold text-blue-600">
|
|
||||||
{selectedBooking.totalPrice
|
|
||||||
? `${selectedBooking.totalPrice.amount.toLocaleString()} ${selectedBooking.totalPrice.currency}`
|
|
||||||
: selectedBooking.price
|
|
||||||
? `${selectedBooking.price.toLocaleString()} ${selectedBooking.primaryCurrency || 'USD'}`
|
|
||||||
: 'N/A'
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-t pt-4">
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-3">Timeline</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-500">Created</label>
|
|
||||||
<div className="mt-1">
|
|
||||||
{new Date(selectedBooking.createdAt || selectedBooking.requestedAt || '').toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{selectedBooking.updatedAt && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-gray-500">Last Updated</label>
|
|
||||||
<div className="mt-1">{new Date(selectedBooking.updatedAt).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-6 pt-4 border-t">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowDetailsModal(false);
|
|
||||||
setSelectedBooking(null);
|
|
||||||
}}
|
|
||||||
className="px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getAllOrganizations } from '@/lib/api/admin';
|
import { getAllOrganizations, verifySiret, approveSiret, rejectSiret } from '@/lib/api/admin';
|
||||||
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
|
import { createOrganization, updateOrganization } from '@/lib/api/organizations';
|
||||||
|
|
||||||
interface Organization {
|
interface Organization {
|
||||||
@ -10,6 +10,9 @@ interface Organization {
|
|||||||
type: string;
|
type: string;
|
||||||
scac?: string;
|
scac?: string;
|
||||||
siren?: string;
|
siren?: string;
|
||||||
|
siret?: string;
|
||||||
|
siretVerified?: boolean;
|
||||||
|
statusBadge?: string;
|
||||||
eori?: string;
|
eori?: string;
|
||||||
contact_phone?: string;
|
contact_phone?: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
@ -32,6 +35,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [verifyingId, setVerifyingId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [formData, setFormData] = useState<{
|
const [formData, setFormData] = useState<{
|
||||||
@ -39,6 +43,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: string;
|
type: string;
|
||||||
scac: string;
|
scac: string;
|
||||||
siren: string;
|
siren: string;
|
||||||
|
siret: string;
|
||||||
eori: string;
|
eori: string;
|
||||||
contact_phone: string;
|
contact_phone: string;
|
||||||
contact_email: string;
|
contact_email: string;
|
||||||
@ -55,6 +60,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: 'FREIGHT_FORWARDER',
|
type: 'FREIGHT_FORWARDER',
|
||||||
scac: '',
|
scac: '',
|
||||||
siren: '',
|
siren: '',
|
||||||
|
siret: '',
|
||||||
eori: '',
|
eori: '',
|
||||||
contact_phone: '',
|
contact_phone: '',
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
@ -130,6 +136,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: 'FREIGHT_FORWARDER',
|
type: 'FREIGHT_FORWARDER',
|
||||||
scac: '',
|
scac: '',
|
||||||
siren: '',
|
siren: '',
|
||||||
|
siret: '',
|
||||||
eori: '',
|
eori: '',
|
||||||
contact_phone: '',
|
contact_phone: '',
|
||||||
contact_email: '',
|
contact_email: '',
|
||||||
@ -144,6 +151,51 @@ export default function AdminOrganizationsPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleVerifySiret = async (orgId: string) => {
|
||||||
|
try {
|
||||||
|
setVerifyingId(orgId);
|
||||||
|
const result = await verifySiret(orgId);
|
||||||
|
if (result.verified) {
|
||||||
|
alert(`SIRET verifie avec succes !\nEntreprise: ${result.companyName || 'N/A'}\nAdresse: ${result.address || 'N/A'}`);
|
||||||
|
await fetchOrganizations();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'SIRET invalide ou introuvable.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de la verification du SIRET');
|
||||||
|
} finally {
|
||||||
|
setVerifyingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApproveSiret = async (orgId: string) => {
|
||||||
|
if (!confirm('Confirmer l\'approbation manuelle du SIRET/SIREN de cette organisation ?')) return;
|
||||||
|
try {
|
||||||
|
setVerifyingId(orgId);
|
||||||
|
const result = await approveSiret(orgId);
|
||||||
|
alert(result.message);
|
||||||
|
await fetchOrganizations();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors de l\'approbation');
|
||||||
|
} finally {
|
||||||
|
setVerifyingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRejectSiret = async (orgId: string) => {
|
||||||
|
if (!confirm('Confirmer le refus du SIRET/SIREN ? L\'organisation ne pourra plus effectuer d\'achats.')) return;
|
||||||
|
try {
|
||||||
|
setVerifyingId(orgId);
|
||||||
|
const result = await rejectSiret(orgId);
|
||||||
|
alert(result.message);
|
||||||
|
await fetchOrganizations();
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message || 'Erreur lors du refus');
|
||||||
|
} finally {
|
||||||
|
setVerifyingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openEditModal = (org: Organization) => {
|
const openEditModal = (org: Organization) => {
|
||||||
setSelectedOrg(org);
|
setSelectedOrg(org);
|
||||||
setFormData({
|
setFormData({
|
||||||
@ -151,6 +203,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
type: org.type,
|
type: org.type,
|
||||||
scac: org.scac || '',
|
scac: org.scac || '',
|
||||||
siren: org.siren || '',
|
siren: org.siren || '',
|
||||||
|
siret: org.siret || '',
|
||||||
eori: org.eori || '',
|
eori: org.eori || '',
|
||||||
contact_phone: org.contact_phone || '',
|
contact_phone: org.contact_phone || '',
|
||||||
contact_email: org.contact_email || '',
|
contact_email: org.contact_email || '',
|
||||||
@ -229,6 +282,25 @@ export default function AdminOrganizationsPage() {
|
|||||||
<span className="font-medium">SIREN:</span> {org.siren}
|
<span className="font-medium">SIREN:</span> {org.siren}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">SIRET:</span>
|
||||||
|
{org.siret ? (
|
||||||
|
<>
|
||||||
|
<span>{org.siret}</span>
|
||||||
|
{org.siretVerified ? (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Verifie
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||||
|
Non verifie
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400">Non renseigne</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{org.contact_email && (
|
{org.contact_email && (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">Email:</span> {org.contact_email}
|
<span className="font-medium">Email:</span> {org.contact_email}
|
||||||
@ -239,6 +311,7 @@ export default function AdminOrganizationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => openEditModal(org)}
|
onClick={() => openEditModal(org)}
|
||||||
@ -246,6 +319,37 @@ export default function AdminOrganizationsPage() {
|
|||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
{org.siret && !org.siretVerified && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleVerifySiret(org.id)}
|
||||||
|
disabled={verifyingId === org.id}
|
||||||
|
className="flex-1 px-3 py-2 bg-purple-50 text-purple-700 rounded-md hover:bg-purple-100 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{verifyingId === org.id ? '...' : 'Verifier API'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(org.siret || org.siren) && (
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{!org.siretVerified ? (
|
||||||
|
<button
|
||||||
|
onClick={() => handleApproveSiret(org.id)}
|
||||||
|
disabled={verifyingId === org.id}
|
||||||
|
className="flex-1 px-3 py-2 bg-green-50 text-green-700 rounded-md hover:bg-green-100 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Approuver SIRET
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => handleRejectSiret(org.id)}
|
||||||
|
disabled={verifyingId === org.id}
|
||||||
|
className="flex-1 px-3 py-2 bg-red-50 text-red-700 rounded-md hover:bg-red-100 transition-colors text-sm font-medium disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Rejeter SIRET
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -309,6 +413,18 @@ export default function AdminOrganizationsPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700">SIRET (14 chiffres)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
maxLength={14}
|
||||||
|
value={formData.siret}
|
||||||
|
onChange={e => setFormData({ ...formData, siret: e.target.value.replace(/\D/g, '') })}
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
|
||||||
|
placeholder="12345678901234"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700">EORI</label>
|
<label className="block text-sm font-medium text-gray-700">EORI</label>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@ -1,15 +1,26 @@
|
|||||||
/**
|
/**
|
||||||
* Commission Payment Page
|
* Commission Payment Page
|
||||||
*
|
*
|
||||||
* Shows booking summary and commission amount, allows payment via Stripe or bank transfer
|
* 2-column layout:
|
||||||
|
* - Left: payment method selector + action
|
||||||
|
* - Right: booking summary
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
import { CreditCard, Building2, ArrowLeft, Loader2, AlertTriangle, CheckCircle } from 'lucide-react';
|
import {
|
||||||
import { getCsvBooking, payBookingCommission } from '@/lib/api/bookings';
|
CreditCard,
|
||||||
|
Building2,
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
Copy,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getCsvBooking, payBookingCommission, declareBankTransfer } from '@/lib/api/bookings';
|
||||||
|
|
||||||
interface BookingData {
|
interface BookingData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -31,6 +42,14 @@ interface BookingData {
|
|||||||
commissionAmountEur?: number;
|
commissionAmountEur?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaymentMethod = 'card' | 'transfer' | null;
|
||||||
|
|
||||||
|
const BANK_DETAILS = {
|
||||||
|
beneficiary: 'XPEDITIS SAS',
|
||||||
|
iban: 'FR76 XXXX XXXX XXXX XXXX XXXX XXX',
|
||||||
|
bic: 'XXXXXXXX',
|
||||||
|
};
|
||||||
|
|
||||||
export default function PayCommissionPage() {
|
export default function PayCommissionPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -39,59 +58,67 @@ export default function PayCommissionPage() {
|
|||||||
const [booking, setBooking] = useState<BookingData | null>(null);
|
const [booking, setBooking] = useState<BookingData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [paying, setPaying] = useState(false);
|
const [paying, setPaying] = useState(false);
|
||||||
|
const [declaring, setDeclaring] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod>(null);
|
||||||
|
const [copied, setCopied] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchBooking() {
|
async function fetchBooking() {
|
||||||
try {
|
try {
|
||||||
const data = await getCsvBooking(bookingId);
|
const data = await getCsvBooking(bookingId);
|
||||||
setBooking(data as any);
|
setBooking(data as any);
|
||||||
|
|
||||||
// If booking is not in PENDING_PAYMENT status, redirect
|
|
||||||
if (data.status !== 'PENDING_PAYMENT') {
|
if (data.status !== 'PENDING_PAYMENT') {
|
||||||
router.replace('/dashboard/bookings');
|
router.replace('/dashboard/bookings');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch booking:', err);
|
setError('Impossible de charger les détails du booking');
|
||||||
setError('Impossible de charger les details du booking');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (bookingId) fetchBooking();
|
||||||
if (bookingId) {
|
|
||||||
fetchBooking();
|
|
||||||
}
|
|
||||||
}, [bookingId, router]);
|
}, [bookingId, router]);
|
||||||
|
|
||||||
const handlePayByCard = async () => {
|
const handlePayByCard = async () => {
|
||||||
setPaying(true);
|
setPaying(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await payBookingCommission(bookingId);
|
const result = await payBookingCommission(bookingId);
|
||||||
// Redirect to Stripe Checkout
|
|
||||||
window.location.href = result.sessionUrl;
|
window.location.href = result.sessionUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Payment error:', err);
|
setError(err instanceof Error ? err.message : 'Erreur lors de la création du paiement');
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de la creation du paiement');
|
|
||||||
setPaying(false);
|
setPaying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPrice = (price: number, currency: string) => {
|
const handleDeclareTransfer = async () => {
|
||||||
return new Intl.NumberFormat('fr-FR', {
|
setDeclaring(true);
|
||||||
style: 'currency',
|
setError(null);
|
||||||
currency,
|
try {
|
||||||
}).format(price);
|
await declareBankTransfer(bookingId);
|
||||||
|
router.push('/dashboard/bookings?transfer=declared');
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Erreur lors de la déclaration du virement');
|
||||||
|
setDeclaring(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (value: string, key: string) => {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
setCopied(key);
|
||||||
|
setTimeout(() => setCopied(null), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPrice = (price: number, currency: string) =>
|
||||||
|
new Intl.NumberFormat('fr-FR', { style: 'currency', currency }).format(price);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
<Loader2 className="h-6 w-6 animate-spin text-blue-600" />
|
||||||
<span className="text-gray-700">Chargement...</span>
|
<span className="text-gray-600">Chargement...</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -99,8 +126,8 @@ export default function PayCommissionPage() {
|
|||||||
|
|
||||||
if (error && !booking) {
|
if (error && !booking) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-blue-50">
|
||||||
<div className="bg-white rounded-lg shadow-md p-8 max-w-md">
|
<div className="bg-white rounded-xl shadow-md p-8 max-w-md">
|
||||||
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
<AlertTriangle className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
<p className="text-center text-gray-700">{error}</p>
|
<p className="text-center text-gray-700">{error}</p>
|
||||||
<button
|
<button
|
||||||
@ -118,168 +145,292 @@ export default function PayCommissionPage() {
|
|||||||
|
|
||||||
const commissionAmount = booking.commissionAmountEur || 0;
|
const commissionAmount = booking.commissionAmountEur || 0;
|
||||||
const commissionRate = booking.commissionRate || 0;
|
const commissionRate = booking.commissionRate || 0;
|
||||||
|
const reference = booking.bookingNumber || booking.id.slice(0, 8).toUpperCase();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-slate-50 to-blue-50 py-10 px-4">
|
||||||
<div className="max-w-2xl mx-auto">
|
<div className="max-w-5xl mx-auto">
|
||||||
{/* Header */}
|
{/* Back button */}
|
||||||
<div className="mb-8">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/dashboard/bookings')}
|
onClick={() => router.push('/dashboard/bookings')}
|
||||||
className="mb-4 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
className="mb-6 flex items-center text-blue-600 hover:text-blue-800 font-medium"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||||
Retour aux bookings
|
Retour aux bookings
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<h1 className="text-2xl font-bold text-gray-900 mb-1">Paiement de la commission</h1>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Paiement de la commission</h1>
|
<p className="text-gray-500 mb-8">
|
||||||
<p className="text-gray-600">
|
Finalisez votre booking en réglant la commission de service
|
||||||
Finalisez votre booking en payant la commission de service
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{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 border-red-200 rounded-lg p-4 flex items-start space-x-3">
|
||||||
<div className="flex items-start">
|
<AlertTriangle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||||
<AlertTriangle className="h-5 w-5 mr-3 text-red-500 flex-shrink-0" />
|
<p className="text-red-700 text-sm">{error}</p>
|
||||||
<p className="text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Booking Summary */}
|
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
{/* LEFT — Payment method selector */}
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Recapitulatif du booking</h2>
|
<div className="lg:col-span-3 space-y-4">
|
||||||
|
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
Choisir le mode de paiement
|
||||||
|
</h2>
|
||||||
|
|
||||||
<div className="space-y-3 text-sm">
|
{/* Card option */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedMethod('card')}
|
||||||
|
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
||||||
|
selectedMethod === 'card'
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
selectedMethod === 'card' ? 'bg-blue-100' : 'bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CreditCard
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
selectedMethod === 'card' ? 'text-blue-600' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">Carte bancaire</p>
|
||||||
|
<p className="text-sm text-gray-500">Paiement immédiat via Stripe</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
selectedMethod === 'card' ? 'border-blue-500 bg-blue-500' : 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedMethod === 'card' && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Transfer option */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedMethod('transfer')}
|
||||||
|
className={`w-full text-left rounded-xl border-2 p-5 transition-all ${
|
||||||
|
selectedMethod === 'transfer'
|
||||||
|
? 'border-blue-500 bg-blue-50'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||||
|
selectedMethod === 'transfer' ? 'bg-blue-100' : 'bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Building2
|
||||||
|
className={`h-5 w-5 ${
|
||||||
|
selectedMethod === 'transfer' ? 'text-blue-600' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">Virement bancaire</p>
|
||||||
|
<p className="text-sm text-gray-500">Validation sous 1–3 jours ouvrables</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||||
|
selectedMethod === 'transfer'
|
||||||
|
? 'border-blue-500 bg-blue-500'
|
||||||
|
: 'border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectedMethod === 'transfer' && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Card action */}
|
||||||
|
{selectedMethod === 'card' && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5">
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Vous serez redirigé vers Stripe pour finaliser votre paiement en toute sécurité.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handlePayByCard}
|
||||||
|
disabled={paying}
|
||||||
|
className="w-full py-3 bg-blue-600 text-white rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
|
||||||
|
>
|
||||||
|
{paying ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Redirection vers Stripe...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CreditCard className="h-5 w-5" />
|
||||||
|
<span>Payer {formatPrice(commissionAmount, 'EUR')} par carte</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transfer action */}
|
||||||
|
{selectedMethod === 'transfer' && (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Effectuez le virement avec les coordonnées ci-dessous, puis cliquez sur
|
||||||
|
“J'ai effectué le virement”.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Bank details */}
|
||||||
|
<div className="bg-gray-50 rounded-lg divide-y divide-gray-200 text-sm">
|
||||||
|
{[
|
||||||
|
{ label: 'Bénéficiaire', value: BANK_DETAILS.beneficiary, key: 'beneficiary' },
|
||||||
|
{ label: 'IBAN', value: BANK_DETAILS.iban, key: 'iban', mono: true },
|
||||||
|
{ label: 'BIC / SWIFT', value: BANK_DETAILS.bic, key: 'bic', mono: true },
|
||||||
|
{
|
||||||
|
label: 'Montant',
|
||||||
|
value: formatPrice(commissionAmount, 'EUR'),
|
||||||
|
key: 'amount',
|
||||||
|
bold: true,
|
||||||
|
},
|
||||||
|
{ label: 'Référence', value: reference, key: 'ref', mono: true },
|
||||||
|
].map(({ label, value, key, mono, bold }) => (
|
||||||
|
<div key={key} className="flex items-center justify-between px-4 py-3">
|
||||||
|
<span className="text-gray-500">{label}</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span
|
||||||
|
className={`${mono ? 'font-mono' : ''} ${bold ? 'font-bold text-gray-900' : 'text-gray-800'}`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
{key !== 'amount' && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyToClipboard(value, key)}
|
||||||
|
className="text-gray-400 hover:text-blue-600 transition-colors"
|
||||||
|
title="Copier"
|
||||||
|
>
|
||||||
|
{copied === key ? (
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start space-x-2 text-xs text-amber-700 bg-amber-50 rounded-lg p-3">
|
||||||
|
<Clock className="h-4 w-4 flex-shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Mentionnez impérativement la référence <strong>{reference}</strong> dans le
|
||||||
|
libellé du virement.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDeclareTransfer}
|
||||||
|
disabled={declaring}
|
||||||
|
className="w-full py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700 disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center justify-center space-x-2 transition-colors"
|
||||||
|
>
|
||||||
|
{declaring ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
<span>Enregistrement...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CheckCircle className="h-5 w-5" />
|
||||||
|
<span>J'ai effectué le virement</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Placeholder when no method selected */}
|
||||||
|
{selectedMethod === null && (
|
||||||
|
<div className="bg-white rounded-xl border-2 border-dashed border-gray-200 p-6 text-center text-gray-400 text-sm">
|
||||||
|
Sélectionnez un mode de paiement ci-dessus
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT — Booking summary */}
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
<h2 className="text-base font-semibold text-gray-700 uppercase tracking-wide">
|
||||||
|
Récapitulatif
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-5 space-y-4">
|
||||||
{booking.bookingNumber && (
|
{booking.bookingNumber && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Numero :</span>
|
<span className="text-gray-500">Numéro</span>
|
||||||
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
|
<span className="font-semibold text-gray-900">{booking.bookingNumber}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Transporteur :</span>
|
<span className="text-gray-500">Transporteur</span>
|
||||||
<span className="font-semibold text-gray-900">{booking.carrierName}</span>
|
<span className="font-semibold text-gray-900 text-right max-w-[55%]">
|
||||||
</div>
|
{booking.carrierName}
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Trajet :</span>
|
|
||||||
<span className="font-semibold text-gray-900">
|
|
||||||
{booking.origin} → {booking.destination}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Volume / Poids :</span>
|
<span className="text-gray-500">Trajet</span>
|
||||||
<span className="font-semibold text-gray-900">
|
<span className="font-semibold text-gray-900">
|
||||||
{booking.volumeCBM} CBM / {booking.weightKG} kg
|
{booking.origin} → {booking.destination}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Transit :</span>
|
<span className="text-gray-500">Volume / Poids</span>
|
||||||
|
<span className="font-semibold text-gray-900">
|
||||||
|
{booking.volumeCBM} CBM · {booking.weightKG} kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Transit</span>
|
||||||
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
|
<span className="font-semibold text-gray-900">{booking.transitDays} jours</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between border-t pt-3">
|
<div className="border-t pt-3 flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Prix transport :</span>
|
<span className="text-gray-500">Prix transport</span>
|
||||||
<span className="font-bold text-gray-900">
|
<span className="font-bold text-gray-900">
|
||||||
{formatPrice(booking.priceEUR, 'EUR')}
|
{formatPrice(booking.priceEUR, 'EUR')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Commission Details */}
|
{/* Commission box */}
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
<div className="bg-blue-600 rounded-xl p-5 text-white">
|
||||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Commission de service</h2>
|
<p className="text-sm text-blue-100 mb-1">
|
||||||
|
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-4">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div>
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Commission ({commissionRate}% du prix transport)
|
Commission ({commissionRate}% du prix transport)
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-3xl font-bold">{formatPrice(commissionAmount, 'EUR')}</p>
|
||||||
{formatPrice(booking.priceEUR, 'EUR')} x {commissionRate}%
|
<p className="text-xs text-blue-200 mt-1">
|
||||||
|
{formatPrice(booking.priceEUR, 'EUR')} × {commissionRate}%
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-blue-600">
|
|
||||||
{formatPrice(commissionAmount, 'EUR')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-3">
|
<div className="bg-white rounded-xl border border-gray-200 p-4 flex items-start space-x-3">
|
||||||
<div className="flex items-start space-x-2">
|
|
||||||
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
<CheckCircle className="h-4 w-4 text-green-500 mt-0.5 flex-shrink-0" />
|
||||||
<p className="text-xs text-gray-600">
|
<p className="text-xs text-gray-500">
|
||||||
Apres le paiement, votre demande sera envoyee par email au transporteur ({booking.carrierEmail}).
|
Après validation du paiement, votre demande est envoyée au transporteur (
|
||||||
Vous recevrez une notification des que le transporteur repond.
|
{booking.carrierEmail}). Vous serez notifié de sa réponse.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payment Methods */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Pay by Card (Stripe) */}
|
|
||||||
<button
|
|
||||||
onClick={handlePayByCard}
|
|
||||||
disabled={paying}
|
|
||||||
className="w-full bg-blue-600 text-white rounded-lg p-4 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
{paying ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="h-5 w-5 mr-3 animate-spin" />
|
|
||||||
Redirection vers Stripe...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CreditCard className="h-5 w-5 mr-3" />
|
|
||||||
Payer {formatPrice(commissionAmount, 'EUR')} par carte
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Pay by Bank Transfer (informational) */}
|
|
||||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
|
||||||
<div className="flex items-center mb-4">
|
|
||||||
<Building2 className="h-5 w-5 mr-3 text-gray-600" />
|
|
||||||
<h3 className="font-semibold text-gray-900">Payer par virement bancaire</h3>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 space-y-2 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Beneficiaire :</span>
|
|
||||||
<span className="font-medium text-gray-900">XPEDITIS SAS</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">IBAN :</span>
|
|
||||||
<span className="font-mono text-gray-900">FR76 XXXX XXXX XXXX XXXX XXXX XXX</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">BIC :</span>
|
|
||||||
<span className="font-mono text-gray-900">XXXXXXXX</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Montant :</span>
|
|
||||||
<span className="font-bold text-gray-900">{formatPrice(commissionAmount, 'EUR')}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-gray-600">Reference :</span>
|
|
||||||
<span className="font-mono text-gray-900">{booking.bookingNumber || booking.id.slice(0, 8)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-3 text-xs text-gray-500">
|
|
||||||
Le traitement du virement peut prendre 1 a 3 jours ouvrables.
|
|
||||||
Votre booking sera active une fois le paiement recu et verifie.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -6,22 +6,31 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { listBookings, listCsvBookings } from '@/lib/api';
|
import { listBookings, listCsvBookings } from '@/lib/api';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Plus } from 'lucide-react';
|
import { Plus, Clock } from 'lucide-react';
|
||||||
import ExportButton from '@/components/ExportButton';
|
import ExportButton from '@/components/ExportButton';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
type SearchType = 'pallets' | 'weight' | 'route' | 'status' | 'date' | 'quote';
|
||||||
|
|
||||||
export default function BookingsListPage() {
|
export default function BookingsListPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [searchType, setSearchType] = useState<SearchType>('route');
|
const [searchType, setSearchType] = useState<SearchType>('route');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [showTransferBanner, setShowTransferBanner] = useState(false);
|
||||||
const ITEMS_PER_PAGE = 20;
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('transfer') === 'declared') {
|
||||||
|
setShowTransferBanner(true);
|
||||||
|
}
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
|
// Fetch CSV bookings (fetch all for client-side filtering and pagination)
|
||||||
const { data: csvData, isLoading, error: csvError } = useQuery({
|
const { data: csvData, isLoading, error: csvError } = useQuery({
|
||||||
queryKey: ['csv-bookings'],
|
queryKey: ['csv-bookings'],
|
||||||
@ -142,6 +151,21 @@ export default function BookingsListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* Bank transfer declared banner */}
|
||||||
|
{showTransferBanner && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 flex items-start justify-between">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<Clock className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-amber-800">Virement déclaré</p>
|
||||||
|
<p className="text-sm text-amber-700 mt-0.5">
|
||||||
|
Votre virement a été enregistré. Un administrateur va vérifier la réception et activer votre booking. Vous serez notifié dès la validation.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowTransferBanner(false)} className="text-amber-500 hover:text-amber-700 ml-4 flex-shrink-0">✕</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@ -85,6 +85,8 @@ export default function LandingPage() {
|
|||||||
const isCtaInView = useInView(ctaRef, { once: true });
|
const isCtaInView = useInView(ctaRef, { once: true });
|
||||||
const isHowInView = useInView(howRef, { once: true, amount: 0.2 });
|
const isHowInView = useInView(howRef, { once: true, amount: 0.2 });
|
||||||
|
|
||||||
|
const [billingYearly, setBillingYearly] = useState(false);
|
||||||
|
|
||||||
const { scrollYProgress } = useScroll();
|
const { scrollYProgress } = useScroll();
|
||||||
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
|
const backgroundY = useTransform(scrollYProgress, [0, 1], ['0%', '50%']);
|
||||||
|
|
||||||
@ -185,58 +187,120 @@ export default function LandingPage() {
|
|||||||
|
|
||||||
const pricingPlans = [
|
const pricingPlans = [
|
||||||
{
|
{
|
||||||
name: 'Starter',
|
key: 'bronze',
|
||||||
price: 'Gratuit',
|
name: 'Bronze',
|
||||||
period: '',
|
badge: null,
|
||||||
description: 'Idéal pour découvrir la plateforme',
|
monthlyPrice: 0,
|
||||||
|
yearlyPrice: 0,
|
||||||
|
yearlyMonthly: 0,
|
||||||
|
description: 'Pour découvrir la plateforme',
|
||||||
|
users: '1 utilisateur',
|
||||||
|
shipments: '12 expéditions / an',
|
||||||
|
commission: '5%',
|
||||||
|
support: 'Aucun support',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Jusqu\'à 5 bookings/mois', included: true },
|
{ text: 'Réservations maritimes LCL', included: true },
|
||||||
{ text: 'Track & Trace illimité', included: true },
|
{ text: 'Track & Trace conteneurs', included: true },
|
||||||
{ text: 'Wiki maritime complet', included: true },
|
{ text: 'Tableau de bord', included: false },
|
||||||
{ text: 'Dashboard basique', included: true },
|
{ text: 'Wiki maritime', included: false },
|
||||||
{ text: 'Support par email', included: true },
|
{ text: 'Gestion des utilisateurs', included: false },
|
||||||
{ text: 'Gestion des documents', included: false },
|
{ text: 'Export CSV', included: false },
|
||||||
{ text: 'Notifications temps réel', included: false },
|
|
||||||
{ text: 'Accès API', included: false },
|
{ text: 'Accès API', included: false },
|
||||||
|
{ text: 'KAM dédié', included: false },
|
||||||
],
|
],
|
||||||
cta: 'Commencer gratuitement',
|
cta: 'Commencer gratuitement',
|
||||||
|
ctaLink: '/register',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
|
accentColor: 'from-amber-600 to-yellow-500',
|
||||||
|
textAccent: 'text-amber-700',
|
||||||
|
badgeBg: 'bg-amber-100 text-amber-800',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Professional',
|
key: 'silver',
|
||||||
price: '99€',
|
name: 'Silver',
|
||||||
period: '/mois',
|
badge: 'Populaire',
|
||||||
|
monthlyPrice: 249,
|
||||||
|
yearlyPrice: 2739,
|
||||||
|
yearlyMonthly: 228,
|
||||||
description: 'Pour les transitaires en croissance',
|
description: 'Pour les transitaires en croissance',
|
||||||
|
users: 'Jusqu\'à 5 utilisateurs',
|
||||||
|
shipments: 'Expéditions illimitées',
|
||||||
|
commission: '3%',
|
||||||
|
support: 'Support par email',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Bookings illimités', included: true },
|
{ text: 'Réservations maritimes LCL', included: true },
|
||||||
{ text: 'Track & Trace illimité', included: true },
|
{ text: 'Track & Trace conteneurs', included: true },
|
||||||
|
{ text: 'Tableau de bord avancé', included: true },
|
||||||
{ text: 'Wiki maritime complet', included: true },
|
{ text: 'Wiki maritime complet', included: true },
|
||||||
{ text: 'Dashboard avancé + KPIs', included: true },
|
{ text: 'Gestion des utilisateurs', included: true },
|
||||||
{ text: 'Support prioritaire', included: true },
|
{ text: 'Export CSV', included: true },
|
||||||
{ text: 'Gestion des documents', included: true },
|
|
||||||
{ text: 'Notifications temps réel', included: true },
|
|
||||||
{ text: 'Accès API', included: false },
|
{ text: 'Accès API', included: false },
|
||||||
|
{ text: 'KAM dédié', included: false },
|
||||||
],
|
],
|
||||||
cta: 'Essai gratuit 14 jours',
|
cta: 'Essai gratuit 14 jours',
|
||||||
|
ctaLink: '/register',
|
||||||
highlighted: true,
|
highlighted: true,
|
||||||
|
accentColor: 'from-slate-400 to-slate-500',
|
||||||
|
textAccent: 'text-slate-600',
|
||||||
|
badgeBg: 'bg-slate-100 text-slate-700',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'Enterprise',
|
key: 'gold',
|
||||||
price: 'Sur mesure',
|
name: 'Gold',
|
||||||
period: '',
|
badge: null,
|
||||||
description: 'Pour les grandes entreprises',
|
monthlyPrice: 899,
|
||||||
|
yearlyPrice: 9889,
|
||||||
|
yearlyMonthly: 824,
|
||||||
|
description: 'Pour les équipes exigeantes',
|
||||||
|
users: 'Jusqu\'à 20 utilisateurs',
|
||||||
|
shipments: 'Expéditions illimitées',
|
||||||
|
commission: '2%',
|
||||||
|
support: 'Assistance commerciale directe',
|
||||||
features: [
|
features: [
|
||||||
{ text: 'Tout Professionnel +', included: true },
|
{ text: 'Réservations maritimes LCL', included: true },
|
||||||
|
{ text: 'Track & Trace conteneurs', included: true },
|
||||||
|
{ text: 'Tableau de bord avancé', included: true },
|
||||||
|
{ text: 'Wiki maritime complet', included: true },
|
||||||
|
{ text: 'Gestion des utilisateurs', included: true },
|
||||||
|
{ text: 'Export CSV', included: true },
|
||||||
{ text: 'Accès API complet', included: true },
|
{ text: 'Accès API complet', included: true },
|
||||||
{ text: 'Intégrations personnalisées', included: true },
|
{ text: 'KAM dédié', included: false },
|
||||||
{ text: 'Responsable de compte dédié', included: true },
|
|
||||||
{ text: 'SLA garanti 99.9%', included: true },
|
|
||||||
{ text: 'Formation sur site', included: true },
|
|
||||||
{ text: 'Multi-organisations', included: true },
|
|
||||||
{ text: 'Audit & conformité', included: true },
|
|
||||||
],
|
],
|
||||||
cta: 'Contactez-nous',
|
cta: 'Essai gratuit 14 jours',
|
||||||
|
ctaLink: '/register',
|
||||||
highlighted: false,
|
highlighted: false,
|
||||||
|
accentColor: 'from-yellow-400 to-amber-400',
|
||||||
|
textAccent: 'text-amber-600',
|
||||||
|
badgeBg: 'bg-yellow-50 text-amber-700',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'platinium',
|
||||||
|
name: 'Platinium',
|
||||||
|
badge: 'Sur mesure',
|
||||||
|
monthlyPrice: null,
|
||||||
|
yearlyPrice: null,
|
||||||
|
yearlyMonthly: null,
|
||||||
|
description: 'Pour les grandes entreprises',
|
||||||
|
users: 'Utilisateurs illimités',
|
||||||
|
shipments: 'Expéditions illimitées',
|
||||||
|
commission: '1%',
|
||||||
|
support: 'Key Account Manager dédié',
|
||||||
|
features: [
|
||||||
|
{ text: 'Réservations maritimes LCL', included: true },
|
||||||
|
{ text: 'Track & Trace conteneurs', included: true },
|
||||||
|
{ text: 'Tableau de bord avancé', included: true },
|
||||||
|
{ text: 'Wiki maritime complet', included: true },
|
||||||
|
{ text: 'Gestion des utilisateurs', included: true },
|
||||||
|
{ text: 'Export CSV', included: true },
|
||||||
|
{ text: 'Accès API complet', included: true },
|
||||||
|
{ text: 'KAM dédié + Interface personnalisée', included: true },
|
||||||
|
],
|
||||||
|
cta: 'Nous contacter',
|
||||||
|
ctaLink: '/contact',
|
||||||
|
highlighted: false,
|
||||||
|
accentColor: 'from-brand-navy to-brand-turquoise',
|
||||||
|
textAccent: 'text-brand-turquoise',
|
||||||
|
badgeBg: 'bg-brand-navy/10 text-brand-navy',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -655,76 +719,198 @@ export default function LandingPage() {
|
|||||||
<section
|
<section
|
||||||
ref={pricingRef}
|
ref={pricingRef}
|
||||||
id="pricing"
|
id="pricing"
|
||||||
className="py-20 lg:py-32 bg-gradient-to-br from-gray-50 to-white"
|
className="py-20 lg:py-32 bg-gradient-to-b from-white to-gray-50"
|
||||||
>
|
>
|
||||||
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
<div className="max-w-7xl mx-auto px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 30 }}
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8 }}
|
transition={{ duration: 0.8 }}
|
||||||
className="text-center mb-16"
|
className="text-center mb-12"
|
||||||
>
|
>
|
||||||
|
<span className="inline-block bg-brand-turquoise/10 text-brand-turquoise text-sm font-semibold px-4 py-1.5 rounded-full mb-4 uppercase tracking-wide">
|
||||||
|
Tarifs
|
||||||
|
</span>
|
||||||
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
<h2 className="text-4xl lg:text-5xl font-bold text-brand-navy mb-4">
|
||||||
Tarifs simples et transparents
|
Des plans adaptés à votre activité
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
<p className="text-xl text-gray-600 max-w-2xl mx-auto">
|
||||||
Choisissez le plan adapté à vos besoins. Évoluez à tout moment.
|
De l'accès découverte au partenariat sur mesure — évoluez à tout moment.
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Billing Toggle */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||||
|
transition={{ duration: 0.6, delay: 0.2 }}
|
||||||
|
className="flex items-center justify-center gap-4 mb-12"
|
||||||
|
>
|
||||||
|
<span className={`text-sm font-medium ${!billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
|
||||||
|
Mensuel
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setBillingYearly(v => !v)}
|
||||||
|
className={`relative inline-flex h-7 w-14 items-center rounded-full transition-colors focus:outline-none ${
|
||||||
|
billingYearly ? 'bg-brand-turquoise' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
|
||||||
|
billingYearly ? 'translate-x-8' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className={`text-sm font-medium ${billingYearly ? 'text-brand-navy' : 'text-gray-400'}`}>
|
||||||
|
Annuel
|
||||||
|
</span>
|
||||||
|
{billingYearly && (
|
||||||
|
<span className="bg-brand-green/10 text-brand-green text-xs font-bold px-2.5 py-1 rounded-full">
|
||||||
|
1 mois offert
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Plans Grid */}
|
||||||
<motion.div
|
<motion.div
|
||||||
variants={containerVariants}
|
variants={containerVariants}
|
||||||
initial="hidden"
|
initial="hidden"
|
||||||
animate={isPricingInView ? 'visible' : 'hidden'}
|
animate={isPricingInView ? 'visible' : 'hidden'}
|
||||||
className="grid grid-cols-1 md:grid-cols-3 gap-8"
|
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 items-stretch"
|
||||||
>
|
>
|
||||||
{pricingPlans.map((plan, index) => (
|
{pricingPlans.map((plan, index) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={index}
|
key={plan.key}
|
||||||
variants={itemVariants}
|
variants={itemVariants}
|
||||||
whileHover={{ y: -10 }}
|
whileHover={{ y: -6 }}
|
||||||
className={`relative bg-white rounded-2xl shadow-lg border-2 transition-all ${
|
className={`relative flex flex-col rounded-2xl transition-all overflow-hidden ${
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'border-brand-turquoise shadow-2xl scale-105'
|
? 'bg-brand-navy shadow-2xl ring-2 ring-brand-turquoise'
|
||||||
: 'border-gray-200 hover:border-brand-turquoise/50'
|
: 'bg-white shadow-lg border border-gray-100 hover:shadow-xl hover:border-brand-turquoise/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.highlighted && (
|
{/* Top gradient bar */}
|
||||||
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
<div className={`h-1.5 w-full bg-gradient-to-r ${plan.accentColor}`} />
|
||||||
<span className="bg-brand-turquoise text-white text-sm font-bold px-4 py-1 rounded-full">
|
|
||||||
Populaire
|
{/* Popular badge */}
|
||||||
|
{plan.badge && plan.key === 'silver' && (
|
||||||
|
<div className="absolute top-4 right-4">
|
||||||
|
<span className="bg-brand-turquoise text-white text-xs font-bold px-2.5 py-1 rounded-full">
|
||||||
|
{plan.badge}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="p-8">
|
{plan.badge && plan.key === 'platinium' && (
|
||||||
<h3 className="text-2xl font-bold text-brand-navy mb-2">{plan.name}</h3>
|
<div className="absolute top-4 right-4">
|
||||||
<p className="text-gray-600 mb-6">{plan.description}</p>
|
<span className="bg-gradient-to-r from-brand-navy to-brand-turquoise text-white text-xs font-bold px-2.5 py-1 rounded-full">
|
||||||
<div className="mb-6">
|
{plan.badge}
|
||||||
<span className="text-5xl font-bold text-brand-navy">{plan.price}</span>
|
</span>
|
||||||
<span className="text-gray-500">{plan.period}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-3 mb-8">
|
|
||||||
{plan.features.map((feature, featureIndex) => (
|
|
||||||
<li key={featureIndex} className="flex items-center">
|
|
||||||
{feature.included ? (
|
|
||||||
<Check className="w-5 h-5 text-brand-green mr-3 flex-shrink-0" />
|
|
||||||
) : (
|
|
||||||
<X className="w-5 h-5 text-gray-300 mr-3 flex-shrink-0" />
|
|
||||||
)}
|
)}
|
||||||
<span className={feature.included ? 'text-gray-700' : 'text-gray-400'}>
|
|
||||||
|
<div className="flex flex-col flex-1 p-6">
|
||||||
|
{/* Plan name */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className={`inline-flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider px-2.5 py-1 rounded-full mb-3 ${plan.highlighted ? 'bg-white/10 text-white/70' : plan.badgeBg}`}>
|
||||||
|
<div className={`w-1.5 h-1.5 rounded-full bg-gradient-to-r ${plan.accentColor}`} />
|
||||||
|
{plan.name}
|
||||||
|
</div>
|
||||||
|
<h3 className={`text-xl font-bold mb-1 ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
||||||
|
{plan.description}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price */}
|
||||||
|
<div className="mb-6">
|
||||||
|
{plan.monthlyPrice === null ? (
|
||||||
|
<div>
|
||||||
|
<span className={`text-3xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
||||||
|
Sur mesure
|
||||||
|
</span>
|
||||||
|
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
||||||
|
Tarification personnalisée
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : plan.monthlyPrice === 0 ? (
|
||||||
|
<div>
|
||||||
|
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
||||||
|
Gratuit
|
||||||
|
</span>
|
||||||
|
<p className={`text-sm mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
||||||
|
Pour toujours
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-1">
|
||||||
|
<span className={`text-4xl font-bold ${plan.highlighted ? 'text-white' : 'text-brand-navy'}`}>
|
||||||
|
{billingYearly ? plan.yearlyMonthly : plan.monthlyPrice}€
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm pb-1.5 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
||||||
|
/mois
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{billingYearly ? (
|
||||||
|
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-white/60' : 'text-gray-500'}`}>
|
||||||
|
Facturé {plan.yearlyPrice?.toLocaleString('fr-FR')}€/an
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className={`text-xs mt-1 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`}>
|
||||||
|
Économisez 1 mois avec l'annuel
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key stats */}
|
||||||
|
<div className={`rounded-xl p-3 mb-5 space-y-2 ${plan.highlighted ? 'bg-white/10' : 'bg-gray-50'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
|
||||||
|
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{plan.users}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Ship className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
|
||||||
|
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>{plan.shipments}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<BarChart3 className={`w-3.5 h-3.5 flex-shrink-0 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-turquoise'}`} />
|
||||||
|
<span className={`text-xs font-medium ${plan.highlighted ? 'text-white/80' : 'text-gray-700'}`}>
|
||||||
|
Commission {plan.commission}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<ul className="space-y-2.5 mb-6 flex-1">
|
||||||
|
{plan.features.map((feature, featureIndex) => (
|
||||||
|
<li key={featureIndex} className="flex items-start gap-2.5">
|
||||||
|
{feature.included ? (
|
||||||
|
<Check className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-brand-turquoise' : 'text-brand-green'}`} />
|
||||||
|
) : (
|
||||||
|
<X className={`w-4 h-4 flex-shrink-0 mt-0.5 ${plan.highlighted ? 'text-white/20' : 'text-gray-300'}`} />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm ${
|
||||||
|
feature.included
|
||||||
|
? plan.highlighted ? 'text-white/90' : 'text-gray-700'
|
||||||
|
: plan.highlighted ? 'text-white/30' : 'text-gray-400'
|
||||||
|
}`}>
|
||||||
{feature.text}
|
{feature.text}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
<Link
|
<Link
|
||||||
href={plan.name === 'Enterprise' ? '/contact' : '/register'}
|
href={plan.ctaLink}
|
||||||
target={plan.name === 'Enterprise' ? '_self' : '_blank'}
|
className={`block w-full text-center py-3 px-6 rounded-xl font-semibold text-sm transition-all ${
|
||||||
rel={plan.name === 'Enterprise' ? undefined : 'noopener noreferrer'}
|
|
||||||
className={`block w-full text-center py-3 px-6 rounded-lg font-semibold transition-all ${
|
|
||||||
plan.highlighted
|
plan.highlighted
|
||||||
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg hover:shadow-xl'
|
? 'bg-brand-turquoise text-white hover:bg-brand-turquoise/90 shadow-lg shadow-brand-turquoise/30 hover:shadow-xl'
|
||||||
: 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
: plan.key === 'bronze'
|
||||||
|
? 'bg-gray-100 text-brand-navy hover:bg-gray-200'
|
||||||
|
: 'bg-brand-navy text-white hover:bg-brand-navy/90 shadow-md hover:shadow-lg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{plan.cta}
|
{plan.cta}
|
||||||
@ -734,17 +920,21 @@ export default function LandingPage() {
|
|||||||
))}
|
))}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Bottom note */}
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
animate={isPricingInView ? { opacity: 1, y: 0 } : {}}
|
||||||
transition={{ duration: 0.8, delay: 0.4 }}
|
transition={{ duration: 0.8, delay: 0.5 }}
|
||||||
className="mt-12 text-center"
|
className="mt-12 text-center space-y-2"
|
||||||
>
|
>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600 text-sm">
|
||||||
Tous les plans incluent un essai gratuit de 14 jours. Aucune carte bancaire requise.
|
Plans Silver et Gold : essai gratuit 14 jours inclus · Aucune carte bancaire requise
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500 mt-2">
|
<p className="text-sm text-gray-500">
|
||||||
Des questions ? <Link href="/contact" className="text-brand-turquoise hover:underline">Contactez notre équipe commerciale</Link>
|
Des questions ?{' '}
|
||||||
|
<Link href="/contact" className="text-brand-turquoise font-medium hover:underline">
|
||||||
|
Contactez notre équipe commerciale
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ export default function RegisterPage() {
|
|||||||
// Organization fields
|
// Organization fields
|
||||||
const [organizationName, setOrganizationName] = useState('');
|
const [organizationName, setOrganizationName] = useState('');
|
||||||
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
const [organizationType, setOrganizationType] = useState<OrganizationType>('FREIGHT_FORWARDER');
|
||||||
|
const [siren, setSiren] = useState('');
|
||||||
const [street, setStreet] = useState('');
|
const [street, setStreet] = useState('');
|
||||||
const [city, setCity] = useState('');
|
const [city, setCity] = useState('');
|
||||||
const [state, setState] = useState('');
|
const [state, setState] = useState('');
|
||||||
@ -87,6 +88,11 @@ export default function RegisterPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!siren.trim() || !/^[0-9]{9}$/.test(siren)) {
|
||||||
|
setError('Le numero SIREN est requis (9 chiffres)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
|
if (!street.trim() || !city.trim() || !postalCode.trim() || !country.trim()) {
|
||||||
setError('Tous les champs d\'adresse sont requis');
|
setError('Tous les champs d\'adresse sont requis');
|
||||||
return;
|
return;
|
||||||
@ -108,6 +114,7 @@ export default function RegisterPage() {
|
|||||||
organization: {
|
organization: {
|
||||||
name: organizationName,
|
name: organizationName,
|
||||||
type: organizationType,
|
type: organizationType,
|
||||||
|
siren,
|
||||||
street,
|
street,
|
||||||
city,
|
city,
|
||||||
state: state || undefined,
|
state: state || undefined,
|
||||||
@ -309,6 +316,25 @@ export default function RegisterPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* SIREN */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label htmlFor="siren" className="label">
|
||||||
|
Numero SIREN *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="siren"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={siren}
|
||||||
|
onChange={e => setSiren(e.target.value.replace(/\D/g, ''))}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder="123456789"
|
||||||
|
maxLength={9}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="mt-1.5 text-body-xs text-neutral-500">9 chiffres, obligatoire pour toute organisation</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Street Address */}
|
{/* Street Address */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="street" className="label">
|
<label htmlFor="street" className="label">
|
||||||
|
|||||||
@ -80,21 +80,11 @@ export function LandingFooter() {
|
|||||||
Contact
|
Contact
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Link href="/careers" className="hover:text-brand-turquoise transition-colors">
|
|
||||||
Carrières
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<Link href="/blog" className="hover:text-brand-turquoise transition-colors">
|
<Link href="/blog" className="hover:text-brand-turquoise transition-colors">
|
||||||
Blog
|
Blog
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Link href="/press" className="hover:text-brand-turquoise transition-colors">
|
|
||||||
Presse
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -117,11 +107,6 @@ export function LandingFooter() {
|
|||||||
Politique de cookies
|
Politique de cookies
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Link href="/security" className="hover:text-brand-turquoise transition-colors">
|
|
||||||
Sécurité
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
|
<Link href="/compliance" className="hover:text-brand-turquoise transition-colors">
|
||||||
Conformité RGPD
|
Conformité RGPD
|
||||||
|
|||||||
@ -6,8 +6,6 @@ import Image from 'next/image';
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Briefcase,
|
|
||||||
Newspaper,
|
|
||||||
Info,
|
Info,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@ -26,14 +24,12 @@ export function LandingHeader({ transparentOnTop = false, activePage }: LandingH
|
|||||||
|
|
||||||
const companyMenuItems = [
|
const companyMenuItems = [
|
||||||
{ href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' },
|
{ href: '/about', label: 'À propos', icon: Info, description: 'Notre histoire et mission' },
|
||||||
{ href: '/careers', label: 'Carrières', icon: Briefcase, description: 'Rejoignez-nous' },
|
|
||||||
{ href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' },
|
{ href: '/blog', label: 'Blog', icon: BookOpen, description: 'Actualités et insights' },
|
||||||
{ href: '/press', label: 'Presse', icon: Newspaper, description: 'Espace presse' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// "Entreprise" dropdown is active only for its own sub-pages (not contact)
|
// "Entreprise" dropdown is active only for its own sub-pages (not contact)
|
||||||
const isCompanyMenuActive =
|
const isCompanyMenuActive =
|
||||||
activePage !== undefined && ['about', 'careers', 'blog', 'press'].includes(activePage);
|
activePage !== undefined && ['about', 'blog'].includes(activePage);
|
||||||
|
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
if (!user) return '';
|
if (!user) return '';
|
||||||
|
|||||||
@ -65,7 +65,6 @@ export default function LicensesTab() {
|
|||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
{subscription?.usedLicenses || 0}
|
{subscription?.usedLicenses || 0}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-400 mt-1">Hors ADMIN (illimité)</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
<div className="bg-white rounded-lg p-4 border border-gray-200">
|
||||||
<p className="text-sm text-gray-500">Licences disponibles</p>
|
<p className="text-sm text-gray-500">Licences disponibles</p>
|
||||||
|
|||||||
@ -80,6 +80,39 @@ export async function getAdminOrganization(id: string): Promise<OrganizationResp
|
|||||||
return get<OrganizationResponse>(`/api/v1/admin/organizations/${id}`);
|
return get<OrganizationResponse>(`/api/v1/admin/organizations/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify SIRET for an organization via Pappers API (admin only)
|
||||||
|
* POST /api/v1/admin/organizations/:id/verify-siret
|
||||||
|
* Requires: ADMIN role
|
||||||
|
*/
|
||||||
|
export async function verifySiret(
|
||||||
|
organizationId: string
|
||||||
|
): Promise<{ verified: boolean; companyName?: string; address?: string; message: string }> {
|
||||||
|
return post(`/api/v1/admin/organizations/${organizationId}/verify-siret`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually approve SIRET/SIREN for an organization (admin only)
|
||||||
|
* POST /api/v1/admin/organizations/:id/approve-siret
|
||||||
|
* Requires: ADMIN role
|
||||||
|
*/
|
||||||
|
export async function approveSiret(
|
||||||
|
organizationId: string
|
||||||
|
): Promise<{ approved: boolean; message: string; organizationName: string }> {
|
||||||
|
return post(`/api/v1/admin/organizations/${organizationId}/approve-siret`, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject SIRET/SIREN for an organization (admin only)
|
||||||
|
* POST /api/v1/admin/organizations/:id/reject-siret
|
||||||
|
* Requires: ADMIN role
|
||||||
|
*/
|
||||||
|
export async function rejectSiret(
|
||||||
|
organizationId: string
|
||||||
|
): Promise<{ rejected: boolean; message: string; organizationName: string }> {
|
||||||
|
return post(`/api/v1/admin/organizations/${organizationId}/reject-siret`, {});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== BOOKINGS ====================
|
// ==================== BOOKINGS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -101,6 +134,16 @@ export async function getAdminBooking(id: string): Promise<BookingResponse> {
|
|||||||
return get<BookingResponse>(`/api/v1/admin/bookings/${id}`);
|
return get<BookingResponse>(`/api/v1/admin/bookings/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate bank transfer for a booking (admin only)
|
||||||
|
* POST /api/v1/admin/bookings/:id/validate-transfer
|
||||||
|
* Confirms receipt of wire transfer and activates the booking
|
||||||
|
* Requires: ADMIN role
|
||||||
|
*/
|
||||||
|
export async function validateBankTransfer(bookingId: string): Promise<BookingResponse> {
|
||||||
|
return post<BookingResponse>(`/api/v1/admin/bookings/${bookingId}/validate-transfer`, {});
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== DOCUMENTS ====================
|
// ==================== DOCUMENTS ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -318,3 +318,11 @@ export async function confirmBookingPayment(
|
|||||||
sessionId,
|
sessionId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declare bank transfer — user confirms they have sent the wire transfer
|
||||||
|
* POST /api/v1/csv-bookings/:id/declare-transfer
|
||||||
|
*/
|
||||||
|
export async function declareBankTransfer(bookingId: string): Promise<CsvBookingResponse> {
|
||||||
|
return post<CsvBookingResponse>(`/api/v1/csv-bookings/${bookingId}/declare-transfer`, {});
|
||||||
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
export interface RegisterOrganizationData {
|
export interface RegisterOrganizationData {
|
||||||
name: string;
|
name: string;
|
||||||
type: OrganizationType;
|
type: OrganizationType;
|
||||||
|
siren: string;
|
||||||
street: string;
|
street: string;
|
||||||
city: string;
|
city: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
@ -120,6 +121,7 @@ export interface CreateOrganizationRequest {
|
|||||||
export interface UpdateOrganizationRequest {
|
export interface UpdateOrganizationRequest {
|
||||||
name?: string;
|
name?: string;
|
||||||
siren?: string;
|
siren?: string;
|
||||||
|
siret?: string;
|
||||||
eori?: string;
|
eori?: string;
|
||||||
contact_phone?: string;
|
contact_phone?: string;
|
||||||
contact_email?: string;
|
contact_email?: string;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user