59 KiB
Plan d'implémentation : Portail Transporteur avec Authentification
📋 Vue d'ensemble
Ce document détaille l'implémentation complète d'un portail transporteur B2B avec :
- ✅ Création automatique de compte au premier clic sur le lien email
- 📊 Dashboard avec statistiques et historique
- 📄 Gestion et téléchargement des documents par booking
- 🔐 Authentification sécurisée JWT
- 👤 Gestion du profil transporteur
🏗️ Architecture Technique
Stack Technologique
- Backend : NestJS + TypeORM + PostgreSQL
- Frontend : Next.js 14 + TanStack Query + Zustand
- Auth : JWT (même système que les utilisateurs clients)
- Storage : MinIO/S3 pour les documents
- Email : Nodemailer pour les notifications
Modèle de données
┌─────────────────┐ ┌──────────────────┐
│ User │ │ Organization │
│ (Carrier) │────────▶│ (Carrier Org) │
└─────────────────┘ └──────────────────┘
│ │
│ │
▼ ▼
┌─────────────────┐ ┌──────────────────┐
│ CsvBooking │────────▶│ BookingDocument │
│ │ │ │
└─────────────────┘ └──────────────────┘
│
▼
┌─────────────────┐
│CarrierActivity │ (historique actions)
└─────────────────┘
📅 PHASE 1 : Base de données et Migrations
Étape 1.1 : Créer la table carrier_profiles
Fichier : apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-CreateCarrierProfiles.ts
export class CreateCarrierProfiles1234567890 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE carrier_profiles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
-- Informations professionnelles
company_name VARCHAR(255) NOT NULL,
company_registration VARCHAR(100),
vat_number VARCHAR(50),
-- Contact
phone VARCHAR(50),
website VARCHAR(255),
-- Adresse
street_address TEXT,
city VARCHAR(100),
postal_code VARCHAR(20),
country VARCHAR(2), -- ISO code
-- Statistiques
total_bookings_accepted INT DEFAULT 0,
total_bookings_rejected INT DEFAULT 0,
acceptance_rate DECIMAL(5,2) DEFAULT 0.00,
total_revenue_usd DECIMAL(15,2) DEFAULT 0.00,
total_revenue_eur DECIMAL(15,2) DEFAULT 0.00,
-- Préférences
preferred_currency VARCHAR(3) DEFAULT 'USD',
notification_email VARCHAR(255),
auto_accept_enabled BOOLEAN DEFAULT false,
-- Métadonnées
is_verified BOOLEAN DEFAULT false,
is_active BOOLEAN DEFAULT true,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_carrier_user FOREIGN KEY (user_id) REFERENCES users(id),
CONSTRAINT fk_carrier_org FOREIGN KEY (organization_id) REFERENCES organizations(id)
);
CREATE INDEX idx_carrier_profiles_user_id ON carrier_profiles(user_id);
CREATE INDEX idx_carrier_profiles_org_id ON carrier_profiles(organization_id);
CREATE INDEX idx_carrier_profiles_company_name ON carrier_profiles(company_name);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS carrier_profiles CASCADE`);
}
}
Commande :
cd apps/backend
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateCarrierProfiles
npm run migration:run
Étape 1.2 : Créer la table carrier_activities
Fichier : apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-CreateCarrierActivities.ts
export class CreateCarrierActivities1234567891 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE carrier_activity_type AS ENUM (
'BOOKING_ACCEPTED',
'BOOKING_REJECTED',
'DOCUMENT_DOWNLOADED',
'PROFILE_UPDATED',
'LOGIN',
'PASSWORD_CHANGED'
);
CREATE TABLE carrier_activities (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
carrier_id UUID NOT NULL REFERENCES carrier_profiles(id) ON DELETE CASCADE,
booking_id UUID REFERENCES csv_bookings(id) ON DELETE SET NULL,
activity_type carrier_activity_type NOT NULL,
description TEXT,
metadata JSONB,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMP DEFAULT NOW(),
CONSTRAINT fk_activity_carrier FOREIGN KEY (carrier_id) REFERENCES carrier_profiles(id),
CONSTRAINT fk_activity_booking FOREIGN KEY (booking_id) REFERENCES csv_bookings(id)
);
CREATE INDEX idx_carrier_activities_carrier_id ON carrier_activities(carrier_id);
CREATE INDEX idx_carrier_activities_booking_id ON carrier_activities(booking_id);
CREATE INDEX idx_carrier_activities_type ON carrier_activities(activity_type);
CREATE INDEX idx_carrier_activities_created_at ON carrier_activities(created_at DESC);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS carrier_activities CASCADE`);
await queryRunner.query(`DROP TYPE IF EXISTS carrier_activity_type`);
}
}
Commande :
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateCarrierActivities
npm run migration:run
Étape 1.3 : Modifier la table csv_bookings
Ajouter les colonnes nécessaires pour lier les bookings aux transporteurs.
Fichier : apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-AddCarrierToCsvBookings.ts
export class AddCarrierToCsvBookings1234567892 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE csv_bookings
ADD COLUMN carrier_id UUID REFERENCES carrier_profiles(id) ON DELETE SET NULL,
ADD COLUMN carrier_viewed_at TIMESTAMP,
ADD COLUMN carrier_accepted_at TIMESTAMP,
ADD COLUMN carrier_rejected_at TIMESTAMP,
ADD COLUMN carrier_rejection_reason TEXT,
ADD COLUMN carrier_notes TEXT;
CREATE INDEX idx_csv_bookings_carrier_id ON csv_bookings(carrier_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE csv_bookings
DROP COLUMN IF EXISTS carrier_id,
DROP COLUMN IF EXISTS carrier_viewed_at,
DROP COLUMN IF EXISTS carrier_accepted_at,
DROP COLUMN IF EXISTS carrier_rejected_at,
DROP COLUMN IF EXISTS carrier_rejection_reason,
DROP COLUMN IF EXISTS carrier_notes;
`);
}
}
Commande :
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/AddCarrierToCsvBookings
npm run migration:run
Étape 1.4 : Modifier la table organizations
Ajouter un flag pour identifier les organisations transporteurs.
Fichier : apps/backend/src/infrastructure/persistence/typeorm/migrations/XXXX-AddCarrierFlagToOrganizations.ts
export class AddCarrierFlagToOrganizations1234567893 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE organizations
ADD COLUMN is_carrier BOOLEAN DEFAULT false,
ADD COLUMN carrier_type VARCHAR(50); -- 'LCL', 'FCL', 'BOTH', 'NVOCC', etc.
CREATE INDEX idx_organizations_is_carrier ON organizations(is_carrier);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE organizations
DROP COLUMN IF EXISTS is_carrier,
DROP COLUMN IF EXISTS carrier_type;
`);
}
}
Commande :
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/AddCarrierFlagToOrganizations
npm run migration:run
📅 PHASE 2 : Backend - Entités et Domain
Étape 2.1 : Créer l'entité ORM CarrierProfile
Fichier : apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { UserOrmEntity } from './user.orm-entity';
import { OrganizationOrmEntity } from './organization.orm-entity';
import { CsvBookingOrmEntity } from './csv-booking.orm-entity';
import { CarrierActivityOrmEntity } from './carrier-activity.orm-entity';
@Entity('carrier_profiles')
export class CarrierProfileOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id', type: 'uuid' })
userId: string;
@ManyToOne(() => UserOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: UserOrmEntity;
@Column({ name: 'organization_id', type: 'uuid' })
organizationId: string;
@ManyToOne(() => OrganizationOrmEntity, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'organization_id' })
organization: OrganizationOrmEntity;
@Column({ name: 'company_name', length: 255 })
companyName: string;
@Column({ name: 'company_registration', length: 100, nullable: true })
companyRegistration: string | null;
@Column({ name: 'vat_number', length: 50, nullable: true })
vatNumber: string | null;
@Column({ length: 50, nullable: true })
phone: string | null;
@Column({ length: 255, nullable: true })
website: string | null;
@Column({ name: 'street_address', type: 'text', nullable: true })
streetAddress: string | null;
@Column({ length: 100, nullable: true })
city: string | null;
@Column({ name: 'postal_code', length: 20, nullable: true })
postalCode: string | null;
@Column({ length: 2, nullable: true })
country: string | null;
@Column({ name: 'total_bookings_accepted', type: 'int', default: 0 })
totalBookingsAccepted: number;
@Column({ name: 'total_bookings_rejected', type: 'int', default: 0 })
totalBookingsRejected: number;
@Column({ name: 'acceptance_rate', type: 'decimal', precision: 5, scale: 2, default: 0 })
acceptanceRate: number;
@Column({ name: 'total_revenue_usd', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalRevenueUsd: number;
@Column({ name: 'total_revenue_eur', type: 'decimal', precision: 15, scale: 2, default: 0 })
totalRevenueEur: number;
@Column({ name: 'preferred_currency', length: 3, default: 'USD' })
preferredCurrency: string;
@Column({ name: 'notification_email', length: 255, nullable: true })
notificationEmail: string | null;
@Column({ name: 'auto_accept_enabled', default: false })
autoAcceptEnabled: boolean;
@Column({ name: 'is_verified', default: false })
isVerified: boolean;
@Column({ name: 'is_active', default: true })
isActive: boolean;
@Column({ name: 'last_login_at', type: 'timestamp', nullable: true })
lastLoginAt: Date | null;
@OneToMany(() => CsvBookingOrmEntity, (booking) => booking.carrier)
bookings: CsvBookingOrmEntity[];
@OneToMany(() => CarrierActivityOrmEntity, (activity) => activity.carrier)
activities: CarrierActivityOrmEntity[];
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
Étape 2.2 : Créer l'entité ORM CarrierActivity
Fichier : apps/backend/src/infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CarrierProfileOrmEntity } from './carrier-profile.orm-entity';
import { CsvBookingOrmEntity } from './csv-booking.orm-entity';
export enum CarrierActivityType {
BOOKING_ACCEPTED = 'BOOKING_ACCEPTED',
BOOKING_REJECTED = 'BOOKING_REJECTED',
DOCUMENT_DOWNLOADED = 'DOCUMENT_DOWNLOADED',
PROFILE_UPDATED = 'PROFILE_UPDATED',
LOGIN = 'LOGIN',
PASSWORD_CHANGED = 'PASSWORD_CHANGED',
}
@Entity('carrier_activities')
export class CarrierActivityOrmEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'carrier_id', type: 'uuid' })
carrierId: string;
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.activities, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'carrier_id' })
carrier: CarrierProfileOrmEntity;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId: string | null;
@ManyToOne(() => CsvBookingOrmEntity, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'booking_id' })
booking: CsvBookingOrmEntity | null;
@Column({
name: 'activity_type',
type: 'enum',
enum: CarrierActivityType,
})
activityType: CarrierActivityType;
@Column({ type: 'text', nullable: true })
description: string | null;
@Column({ type: 'jsonb', nullable: true })
metadata: Record<string, any> | null;
@Column({ name: 'ip_address', length: 45, nullable: true })
ipAddress: string | null;
@Column({ name: 'user_agent', type: 'text', nullable: true })
userAgent: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
}
Étape 2.3 : Mettre à jour l'entité CsvBooking
Fichier : apps/backend/src/infrastructure/persistence/typeorm/entities/csv-booking.orm-entity.ts
Ajouter les relations avec le transporteur :
// Ajouter ces imports
import { CarrierProfileOrmEntity } from './carrier-profile.orm-entity';
// Dans la classe CsvBookingOrmEntity, ajouter :
@Column({ name: 'carrier_id', type: 'uuid', nullable: true })
carrierId: string | null;
@ManyToOne(() => CarrierProfileOrmEntity, (carrier) => carrier.bookings, {
onDelete: 'SET NULL',
})
@JoinColumn({ name: 'carrier_id' })
carrier: CarrierProfileOrmEntity | null;
@Column({ name: 'carrier_viewed_at', type: 'timestamp', nullable: true })
carrierViewedAt: Date | null;
@Column({ name: 'carrier_accepted_at', type: 'timestamp', nullable: true })
carrierAcceptedAt: Date | null;
@Column({ name: 'carrier_rejected_at', type: 'timestamp', nullable: true })
carrierRejectedAt: Date | null;
@Column({ name: 'carrier_rejection_reason', type: 'text', nullable: true })
carrierRejectionReason: string | null;
@Column({ name: 'carrier_notes', type: 'text', nullable: true })
carrierNotes: string | null;
📅 PHASE 3 : Backend - Services et Repositories
Étape 3.1 : Créer le Repository CarrierProfileRepository
Fichier : apps/backend/src/infrastructure/persistence/typeorm/repositories/carrier-profile.repository.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CarrierProfileOrmEntity } from '../entities/carrier-profile.orm-entity';
@Injectable()
export class CarrierProfileRepository {
private readonly logger = new Logger(CarrierProfileRepository.name);
constructor(
@InjectRepository(CarrierProfileOrmEntity)
private readonly repository: Repository<CarrierProfileOrmEntity>
) {}
async findById(id: string): Promise<CarrierProfileOrmEntity | null> {
return await this.repository.findOne({
where: { id },
relations: ['user', 'organization'],
});
}
async findByUserId(userId: string): Promise<CarrierProfileOrmEntity | null> {
return await this.repository.findOne({
where: { userId },
relations: ['user', 'organization'],
});
}
async findByEmail(email: string): Promise<CarrierProfileOrmEntity | null> {
return await this.repository.findOne({
where: { user: { email } },
relations: ['user', 'organization'],
});
}
async create(data: Partial<CarrierProfileOrmEntity>): Promise<CarrierProfileOrmEntity> {
const profile = this.repository.create(data);
return await this.repository.save(profile);
}
async update(id: string, data: Partial<CarrierProfileOrmEntity>): Promise<CarrierProfileOrmEntity> {
await this.repository.update(id, data);
return await this.findById(id);
}
async updateStatistics(
id: string,
stats: {
totalBookingsAccepted?: number;
totalBookingsRejected?: number;
acceptanceRate?: number;
totalRevenueUsd?: number;
totalRevenueEur?: number;
}
): Promise<void> {
await this.repository.update(id, stats);
}
async updateLastLogin(id: string): Promise<void> {
await this.repository.update(id, { lastLoginAt: new Date() });
}
}
Étape 3.2 : Créer le Service CarrierAuthService
Fichier : apps/backend/src/application/services/carrier-auth.service.ts
import { Injectable, Logger, UnauthorizedException, ConflictException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
import { UserRepository } from '@infrastructure/persistence/typeorm/repositories/user.repository';
import { OrganizationRepository } from '@infrastructure/persistence/typeorm/repositories/organization.repository';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
@Injectable()
export class CarrierAuthService {
private readonly logger = new Logger(CarrierAuthService.name);
constructor(
private readonly carrierProfileRepository: CarrierProfileRepository,
private readonly userRepository: UserRepository,
private readonly organizationRepository: OrganizationRepository,
private readonly jwtService: JwtService
) {}
/**
* Créer automatiquement un compte transporteur lors du premier clic sur accept/reject
*/
async createCarrierAccountIfNotExists(
carrierEmail: string,
carrierName: string
): Promise<{
carrierId: string;
userId: string;
isNewAccount: boolean;
temporaryPassword?: string;
}> {
// Vérifier si le transporteur existe déjà
const existingCarrier = await this.carrierProfileRepository.findByEmail(carrierEmail);
if (existingCarrier) {
this.logger.log(`Carrier already exists: ${carrierEmail}`);
return {
carrierId: existingCarrier.id,
userId: existingCarrier.userId,
isNewAccount: false,
};
}
// Créer une nouvelle organisation pour le transporteur
const organization = await this.organizationRepository.create({
name: carrierName,
isCarrier: true,
carrierType: 'LCL', // Par défaut
});
// Générer un mot de passe temporaire
const temporaryPassword = this.generateTemporaryPassword();
const hashedPassword = await argon2.hash(temporaryPassword);
// Créer le compte utilisateur
const user = await this.userRepository.create({
email: carrierEmail,
password: hashedPassword,
firstName: carrierName.split(' ')[0] || 'Carrier',
lastName: carrierName.split(' ').slice(1).join(' ') || 'Account',
role: 'CARRIER', // Nouveau rôle
organizationId: organization.id,
isEmailVerified: true, // Auto-vérifié car créé via email
});
// Créer le profil transporteur
const carrierProfile = await this.carrierProfileRepository.create({
userId: user.id,
organizationId: organization.id,
companyName: carrierName,
notificationEmail: carrierEmail,
});
this.logger.log(`Created new carrier account: ${carrierEmail}`);
return {
carrierId: carrierProfile.id,
userId: user.id,
isNewAccount: true,
temporaryPassword,
};
}
/**
* Générer un token JWT pour auto-login
*/
async generateAutoLoginToken(userId: string, carrierId: string): Promise<string> {
const payload = {
sub: userId,
carrierId,
type: 'carrier',
autoLogin: true,
};
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
/**
* Login classique pour les transporteurs
*/
async login(email: string, password: string): Promise<{
accessToken: string;
refreshToken: string;
carrier: any;
}> {
const carrier = await this.carrierProfileRepository.findByEmail(email);
if (!carrier || !carrier.user) {
throw new UnauthorizedException('Invalid credentials');
}
const isPasswordValid = await argon2.verify(carrier.user.password, password);
if (!isPasswordValid) {
throw new UnauthorizedException('Invalid credentials');
}
// Mettre à jour last login
await this.carrierProfileRepository.updateLastLogin(carrier.id);
const payload = {
sub: carrier.userId,
email: carrier.user.email,
carrierId: carrier.id,
organizationId: carrier.organizationId,
role: 'CARRIER',
};
const accessToken = this.jwtService.sign(payload, { expiresIn: '15m' });
const refreshToken = this.jwtService.sign(payload, { expiresIn: '7d' });
return {
accessToken,
refreshToken,
carrier: {
id: carrier.id,
companyName: carrier.companyName,
email: carrier.user.email,
},
};
}
private generateTemporaryPassword(): string {
return randomBytes(16).toString('hex').slice(0, 12);
}
}
Étape 3.3 : Créer le Service CarrierDashboardService
Fichier : apps/backend/src/application/services/carrier-dashboard.service.ts
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
import { CsvBookingRepository } from '@infrastructure/persistence/typeorm/repositories/csv-booking.repository';
import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository';
import { DocumentService } from './document.service';
export interface CarrierDashboardStats {
totalBookings: number;
pendingBookings: number;
acceptedBookings: number;
rejectedBookings: number;
acceptanceRate: number;
totalRevenue: {
usd: number;
eur: number;
};
recentActivities: any[];
}
export interface CarrierBookingListItem {
id: string;
bookingNumber: string;
origin: string;
destination: string;
status: string;
priceUsd: number;
priceEur: number;
primaryCurrency: string;
createdAt: Date;
carrierViewedAt: Date | null;
documentsCount: number;
}
@Injectable()
export class CarrierDashboardService {
private readonly logger = new Logger(CarrierDashboardService.name);
constructor(
private readonly carrierProfileRepository: CarrierProfileRepository,
private readonly csvBookingRepository: CsvBookingRepository,
private readonly carrierActivityRepository: CarrierActivityRepository,
private readonly documentService: DocumentService
) {}
/**
* Obtenir les statistiques du transporteur
*/
async getCarrierStats(carrierId: string): Promise<CarrierDashboardStats> {
const carrier = await this.carrierProfileRepository.findById(carrierId);
if (!carrier) {
throw new NotFoundException('Carrier not found');
}
// Compter les bookings par statut
const bookings = await this.csvBookingRepository.findByCarrierId(carrierId);
const pendingCount = bookings.filter((b) => b.status === 'PENDING').length;
const acceptedCount = bookings.filter((b) => b.status === 'ACCEPTED').length;
const rejectedCount = bookings.filter((b) => b.status === 'REJECTED').length;
// Récupérer les activités récentes
const recentActivities = await this.carrierActivityRepository.findByCarrierId(
carrierId,
10
);
return {
totalBookings: bookings.length,
pendingBookings: pendingCount,
acceptedBookings: acceptedCount,
rejectedBookings: rejectedCount,
acceptanceRate: carrier.acceptanceRate,
totalRevenue: {
usd: carrier.totalRevenueUsd,
eur: carrier.totalRevenueEur,
},
recentActivities: recentActivities.map((activity) => ({
id: activity.id,
type: activity.activityType,
description: activity.description,
createdAt: activity.createdAt,
bookingId: activity.bookingId,
})),
};
}
/**
* Obtenir la liste des bookings du transporteur
*/
async getCarrierBookings(
carrierId: string,
page: number = 1,
limit: number = 10,
status?: string
): Promise<{
data: CarrierBookingListItem[];
total: number;
page: number;
limit: number;
}> {
const carrier = await this.carrierProfileRepository.findById(carrierId);
if (!carrier) {
throw new NotFoundException('Carrier not found');
}
const bookings = await this.csvBookingRepository.findByCarrierIdPaginated(
carrierId,
page,
limit,
status
);
const data = await Promise.all(
bookings.data.map(async (booking) => {
const documentsCount = await this.documentService.countDocumentsByBooking(booking.id);
return {
id: booking.id,
bookingNumber: booking.bookingNumber,
origin: booking.origin,
destination: booking.destination,
status: booking.status,
priceUsd: booking.priceUsd,
priceEur: booking.priceEur,
primaryCurrency: booking.primaryCurrency,
createdAt: booking.createdAt,
carrierViewedAt: booking.carrierViewedAt,
documentsCount,
};
})
);
return {
data,
total: bookings.total,
page,
limit,
};
}
/**
* Obtenir les détails d'un booking avec documents
*/
async getBookingDetails(carrierId: string, bookingId: string): Promise<any> {
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException('Booking not found');
}
// Vérifier que le booking appartient bien au transporteur
if (booking.carrierId !== carrierId) {
throw new ForbiddenException('Access denied to this booking');
}
// Récupérer les documents
const documents = await this.documentService.getBookingDocuments(bookingId);
// Marquer comme vu si pas encore fait
if (!booking.carrierViewedAt) {
await this.csvBookingRepository.update(bookingId, {
carrierViewedAt: new Date(),
});
}
return {
...booking,
documents,
};
}
/**
* Télécharger un document
*/
async downloadDocument(
carrierId: string,
bookingId: string,
documentId: string
): Promise<{ buffer: Buffer; fileName: string; mimeType: string }> {
// Vérifier l'accès
const booking = await this.csvBookingRepository.findById(bookingId);
if (!booking || booking.carrierId !== carrierId) {
throw new ForbiddenException('Access denied to this document');
}
// Enregistrer l'activité
await this.carrierActivityRepository.create({
carrierId,
bookingId,
activityType: 'DOCUMENT_DOWNLOADED',
description: `Downloaded document ${documentId}`,
metadata: { documentId },
});
// Télécharger depuis S3/MinIO
return await this.documentService.downloadDocument(documentId);
}
}
📅 PHASE 4 : Backend - Controllers
Étape 4.1 : Créer le Controller CarrierAuthController
Fichier : apps/backend/src/application/controllers/carrier-auth.controller.ts
import {
Controller,
Post,
Body,
HttpCode,
HttpStatus,
UseGuards,
Request,
Get,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { CarrierAuthService } from '../services/carrier-auth.service';
import { Public } from '../decorators/public.decorator';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
class CarrierLoginDto {
email: string;
password: string;
}
@ApiTags('Carrier Auth')
@Controller('carrier-auth')
export class CarrierAuthController {
constructor(private readonly carrierAuthService: CarrierAuthService) {}
@Public()
@Post('login')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Carrier login' })
@ApiResponse({ status: 200, description: 'Login successful' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
async login(@Body() dto: CarrierLoginDto) {
return await this.carrierAuthService.login(dto.email, dto.password);
}
@UseGuards(JwtAuthGuard)
@Get('me')
@ApiBearerAuth()
@ApiOperation({ summary: 'Get current carrier profile' })
@ApiResponse({ status: 200, description: 'Profile retrieved' })
async getProfile(@Request() req: any) {
// Le profil sera renvoyé par le guard
return req.user;
}
}
Étape 4.2 : Créer le Controller CarrierDashboardController
Fichier : apps/backend/src/application/controllers/carrier-dashboard.controller.ts
import {
Controller,
Get,
Param,
Query,
UseGuards,
Request,
Res,
StreamableFile,
ParseIntPipe,
DefaultValuePipe,
} from '@nestjs/common';
import { Response } from 'express';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
ApiQuery,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
import { CarrierDashboardService } from '../services/carrier-dashboard.service';
@ApiTags('Carrier Dashboard')
@Controller('carrier-dashboard')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class CarrierDashboardController {
constructor(private readonly carrierDashboardService: CarrierDashboardService) {}
@Get('stats')
@ApiOperation({ summary: 'Get carrier statistics' })
@ApiResponse({ status: 200, description: 'Statistics retrieved' })
async getStats(@Request() req: any) {
const carrierId = req.user.carrierId;
return await this.carrierDashboardService.getCarrierStats(carrierId);
}
@Get('bookings')
@ApiOperation({ summary: 'Get carrier bookings list' })
@ApiQuery({ name: 'page', required: false, type: Number })
@ApiQuery({ name: 'limit', required: false, type: Number })
@ApiQuery({ name: 'status', required: false, type: String })
@ApiResponse({ status: 200, description: 'Bookings retrieved' })
async getBookings(
@Request() req: any,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query('status') status?: string
) {
const carrierId = req.user.carrierId;
return await this.carrierDashboardService.getCarrierBookings(carrierId, page, limit, status);
}
@Get('bookings/:id')
@ApiOperation({ summary: 'Get booking details with documents' })
@ApiParam({ name: 'id', description: 'Booking ID' })
@ApiResponse({ status: 200, description: 'Booking details retrieved' })
@ApiResponse({ status: 404, description: 'Booking not found' })
@ApiResponse({ status: 403, description: 'Access denied' })
async getBookingDetails(@Request() req: any, @Param('id') bookingId: string) {
const carrierId = req.user.carrierId;
return await this.carrierDashboardService.getBookingDetails(carrierId, bookingId);
}
@Get('bookings/:bookingId/documents/:documentId/download')
@ApiOperation({ summary: 'Download booking document' })
@ApiParam({ name: 'bookingId', description: 'Booking ID' })
@ApiParam({ name: 'documentId', description: 'Document ID' })
@ApiResponse({ status: 200, description: 'Document downloaded' })
@ApiResponse({ status: 403, description: 'Access denied' })
@ApiResponse({ status: 404, description: 'Document not found' })
async downloadDocument(
@Request() req: any,
@Param('bookingId') bookingId: string,
@Param('documentId') documentId: string,
@Res({ passthrough: true }) res: Response
): Promise<StreamableFile> {
const carrierId = req.user.carrierId;
const { buffer, fileName, mimeType } = await this.carrierDashboardService.downloadDocument(
carrierId,
bookingId,
documentId
);
res.set({
'Content-Type': mimeType,
'Content-Disposition': `attachment; filename="${fileName}"`,
});
return new StreamableFile(buffer);
}
}
Étape 4.3 : Modifier le Controller CsvBookingsController
Mettre à jour les méthodes acceptBooking et rejectBooking pour créer le compte transporteur.
Fichier : apps/backend/src/application/controllers/csv-bookings.controller.ts
// Dans la méthode acceptBooking (ligne 258), REMPLACER par :
async acceptBooking(@Param('token') token: string, @Res() res: Response): Promise<void> {
// 1. Accepter le booking
const booking = await this.csvBookingService.acceptBooking(token);
// 2. Créer le compte transporteur si nécessaire
const { carrierId, userId, isNewAccount, temporaryPassword } =
await this.carrierAuthService.createCarrierAccountIfNotExists(
booking.carrierEmail,
booking.carrierName
);
// 3. Lier le booking au transporteur
await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId);
// 4. Générer un token auto-login
const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId);
// 5. Rediriger vers la page de confirmation avec auto-login
const frontendUrl = process.env.APP_URL || 'http://localhost:3000';
res.redirect(
HttpStatus.FOUND,
`${frontendUrl}/carrier/confirmed?token=${autoLoginToken}&action=accepted&bookingId=${booking.id}&new=${isNewAccount}`
);
}
// Dans la méthode rejectBooking (ligne 297), REMPLACER par :
async rejectBooking(
@Param('token') token: string,
@Query('reason') reason: string,
@Res() res: Response
): Promise<void> {
// 1. Rejeter le booking
const booking = await this.csvBookingService.rejectBooking(token, reason);
// 2. Créer le compte transporteur si nécessaire
const { carrierId, userId, isNewAccount, temporaryPassword } =
await this.carrierAuthService.createCarrierAccountIfNotExists(
booking.carrierEmail,
booking.carrierName
);
// 3. Lier le booking au transporteur
await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId);
// 4. Générer un token auto-login
const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId);
// 5. Rediriger vers la page de confirmation avec auto-login
const frontendUrl = process.env.APP_URL || 'http://localhost:3000';
res.redirect(
HttpStatus.FOUND,
`${frontendUrl}/carrier/confirmed?token=${autoLoginToken}&action=rejected&bookingId=${booking.id}&new=${isNewAccount}`
);
}
📅 PHASE 5 : Frontend - Pages d'authentification
Étape 5.1 : Créer la page de confirmation avec auto-login
Fichier : apps/frontend/app/carrier/confirmed/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
export default function CarrierConfirmedPage() {
const searchParams = useSearchParams();
const router = useRouter();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const token = searchParams.get('token');
const action = searchParams.get('action');
const bookingId = searchParams.get('bookingId');
const isNewAccount = searchParams.get('new') === 'true';
useEffect(() => {
const autoLogin = async () => {
if (!token) {
setError('Token manquant');
setLoading(false);
return;
}
try {
// Stocker le token JWT
localStorage.setItem('carrier_access_token', token);
// Rediriger vers le dashboard après 3 secondes
setTimeout(() => {
router.push(`/carrier/dashboard/bookings/${bookingId}`);
}, 3000);
setLoading(false);
} catch (err) {
setError('Erreur lors de la connexion automatique');
setLoading(false);
}
};
autoLogin();
}, [token, bookingId, router]);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<Loader2 className="w-12 h-12 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Connexion en cours...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-gray-900 text-center mb-4">Erreur</h1>
<p className="text-gray-600 text-center">{error}</p>
</div>
</div>
);
}
const isAccepted = action === 'accepted';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="bg-white p-8 rounded-lg shadow-lg max-w-2xl w-full">
{/* Success Icon */}
{isAccepted ? (
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
) : (
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
)}
{/* Title */}
<h1 className="text-3xl font-bold text-gray-900 text-center mb-4">
{isAccepted ? '✅ Demande acceptée avec succès' : '❌ Demande refusée'}
</h1>
{/* New Account Message */}
{isNewAccount && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<p className="text-blue-900 font-semibold mb-2">🎉 Bienvenue sur Xpeditis !</p>
<p className="text-blue-800 text-sm">
Un compte transporteur a été créé automatiquement pour vous. Vous recevrez un email
avec vos identifiants de connexion.
</p>
</div>
)}
{/* Confirmation Message */}
<div className="mb-6">
<p className="text-gray-700 text-center mb-4">
{isAccepted
? 'Votre acceptation a été enregistrée. Le client va être notifié automatiquement par email.'
: 'Votre refus a été enregistré. Le client va être notifié automatiquement.'}
</p>
</div>
{/* Redirection Notice */}
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4 mb-6">
<p className="text-gray-800 text-center">
<Loader2 className="w-4 h-4 animate-spin inline mr-2" />
Redirection vers votre tableau de bord dans quelques secondes...
</p>
</div>
{/* Next Steps */}
<div className="border-t pt-6">
<h2 className="text-lg font-semibold text-gray-900 mb-3">📋 Prochaines étapes</h2>
{isAccepted ? (
<ul className="space-y-2 text-gray-700">
<li className="flex items-start">
<span className="mr-2">1.</span>
<span>Le client va vous contacter directement par email</span>
</li>
<li className="flex items-start">
<span className="mr-2">2.</span>
<span>Envoyez-lui le numéro de réservation (booking number)</span>
</li>
<li className="flex items-start">
<span className="mr-2">3.</span>
<span>Organisez l'enlèvement de la marchandise</span>
</li>
<li className="flex items-start">
<span className="mr-2">4.</span>
<span>Suivez l'expédition depuis votre tableau de bord</span>
</li>
</ul>
) : (
<ul className="space-y-2 text-gray-700">
<li className="flex items-start">
<span className="mr-2">1.</span>
<span>Le client sera notifié de votre refus</span>
</li>
<li className="flex items-start">
<span className="mr-2">2.</span>
<span>Il pourra rechercher une alternative</span>
</li>
</ul>
)}
</div>
{/* Manual Link */}
<div className="mt-6 text-center">
<button
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
className="text-blue-600 hover:text-blue-800 font-medium"
>
Accéder maintenant au tableau de bord →
</button>
</div>
</div>
</div>
);
}
Étape 5.2 : Créer la page de login transporteur
Fichier : apps/frontend/app/carrier/login/page.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Ship, Mail, Lock, Loader2 } from 'lucide-react';
export default function CarrierLoginPage() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError(null);
try {
const response = await fetch('http://localhost:4000/api/v1/carrier-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
throw new Error('Identifiants invalides');
}
const data = await response.json();
// Stocker le token
localStorage.setItem('carrier_access_token', data.accessToken);
localStorage.setItem('carrier_refresh_token', data.refreshToken);
// Rediriger vers le dashboard
router.push('/carrier/dashboard');
} catch (err: any) {
setError(err.message || 'Erreur de connexion');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-blue-100">
<div className="bg-white p-8 rounded-lg shadow-xl max-w-md w-full">
{/* Header */}
<div className="text-center mb-8">
<Ship className="w-16 h-16 text-blue-600 mx-auto mb-4" />
<h1 className="text-3xl font-bold text-gray-900 mb-2">Portail Transporteur</h1>
<p className="text-gray-600">Connectez-vous à votre espace Xpeditis</p>
</div>
{/* Error Message */}
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
<p className="text-red-800 text-sm">{error}</p>
</div>
)}
{/* Login Form */}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Email
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="pl-10 w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="votre@email.com"
/>
</div>
</div>
{/* Password */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Mot de passe
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="pl-10 w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="••••••••"
/>
</div>
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin mr-2" />
Connexion...
</>
) : (
'Se connecter'
)}
</button>
</form>
{/* Footer Links */}
<div className="mt-6 text-center">
<a href="/carrier/forgot-password" className="text-blue-600 hover:text-blue-800 text-sm">
Mot de passe oublié ?
</a>
</div>
<div className="mt-4 text-center">
<p className="text-gray-600 text-sm">
Vous n'avez pas encore de compte ?<br />
<span className="text-blue-600 font-medium">
Un compte sera créé automatiquement lors de votre première acceptation de demande.
</span>
</p>
</div>
</div>
</div>
);
}
📅 PHASE 6 : Frontend - Dashboard Transporteur
Étape 6.1 : Créer le layout du dashboard
Fichier : apps/frontend/app/carrier/dashboard/layout.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import Link from 'next/link';
import {
Ship,
LayoutDashboard,
FileText,
BarChart3,
User,
LogOut,
Menu,
X,
} from 'lucide-react';
export default function CarrierDashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const [carrierName, setCarrierName] = useState('Transporteur');
useEffect(() => {
// Vérifier l'authentification
const token = localStorage.getItem('carrier_access_token');
if (!token) {
router.push('/carrier/login');
}
}, [router]);
const handleLogout = () => {
localStorage.removeItem('carrier_access_token');
localStorage.removeItem('carrier_refresh_token');
router.push('/carrier/login');
};
const menuItems = [
{
name: 'Tableau de bord',
href: '/carrier/dashboard',
icon: LayoutDashboard,
},
{
name: 'Réservations',
href: '/carrier/dashboard/bookings',
icon: FileText,
},
{
name: 'Statistiques',
href: '/carrier/dashboard/stats',
icon: BarChart3,
},
{
name: 'Mon profil',
href: '/carrier/dashboard/profile',
icon: User,
},
];
return (
<div className="min-h-screen bg-gray-50">
{/* Mobile Sidebar Toggle */}
<div className="lg:hidden fixed top-0 left-0 right-0 bg-white border-b z-20 p-4">
<button
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="text-gray-600 hover:text-gray-900"
>
{isSidebarOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
</div>
{/* Sidebar */}
<aside
className={`fixed top-0 left-0 h-full w-64 bg-white border-r z-30 transform transition-transform lg:transform-none ${
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* Logo */}
<div className="p-6 border-b">
<div className="flex items-center space-x-3">
<Ship className="w-8 h-8 text-blue-600" />
<div>
<h1 className="font-bold text-lg text-gray-900">Xpeditis</h1>
<p className="text-sm text-gray-600">Portail Transporteur</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="p-4">
<ul className="space-y-2">
{menuItems.map((item) => {
const Icon = item.icon;
const isActive = pathname === item.href || pathname.startsWith(item.href + '/');
return (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-blue-50 text-blue-600 font-medium'
: 'text-gray-700 hover:bg-gray-50'
}`}
onClick={() => setIsSidebarOpen(false)}
>
<Icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
{/* Logout Button */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t">
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-3 rounded-lg text-red-600 hover:bg-red-50 w-full"
>
<LogOut className="w-5 h-5" />
<span>Déconnexion</span>
</button>
</div>
</aside>
{/* Main Content */}
<main className="lg:ml-64 pt-16 lg:pt-0">
<div className="p-6">{children}</div>
</main>
{/* Mobile Overlay */}
{isSidebarOpen && (
<div
className="fixed inset-0 bg-black bg-opacity-50 z-20 lg:hidden"
onClick={() => setIsSidebarOpen(false)}
/>
)}
</div>
);
}
Étape 6.2 : Créer la page dashboard principal
Fichier : apps/frontend/app/carrier/dashboard/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
FileText,
CheckCircle,
XCircle,
Clock,
TrendingUp,
DollarSign,
Euro,
Activity,
} from 'lucide-react';
interface DashboardStats {
totalBookings: number;
pendingBookings: number;
acceptedBookings: number;
rejectedBookings: number;
acceptanceRate: number;
totalRevenue: {
usd: number;
eur: number;
};
recentActivities: any[];
}
export default function CarrierDashboardPage() {
const router = useRouter();
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchStats();
}, []);
const fetchStats = async () => {
try {
const token = localStorage.getItem('carrier_access_token');
const response = await fetch('http://localhost:4000/api/v1/carrier-dashboard/stats', {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) throw new Error('Failed to fetch stats');
const data = await response.json();
setStats(data);
} catch (error) {
console.error('Error fetching stats:', error);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Chargement...</p>
</div>
</div>
);
}
if (!stats) {
return <div>Erreur de chargement des statistiques</div>;
}
const statCards = [
{
title: 'Total Réservations',
value: stats.totalBookings,
icon: FileText,
color: 'blue',
},
{
title: 'En attente',
value: stats.pendingBookings,
icon: Clock,
color: 'yellow',
},
{
title: 'Acceptées',
value: stats.acceptedBookings,
icon: CheckCircle,
color: 'green',
},
{
title: 'Refusées',
value: stats.rejectedBookings,
icon: XCircle,
color: 'red',
},
];
return (
<div className="space-y-6">
{/* Header */}
<div>
<h1 className="text-3xl font-bold text-gray-900">Tableau de bord</h1>
<p className="text-gray-600 mt-1">Vue d'ensemble de votre activité</p>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{statCards.map((card) => {
const Icon = card.icon;
return (
<div key={card.title} className="bg-white p-6 rounded-lg shadow-sm border">
<div className="flex items-center justify-between mb-4">
<Icon className={`w-8 h-8 text-${card.color}-600`} />
</div>
<h3 className="text-gray-600 text-sm font-medium">{card.title}</h3>
<p className="text-3xl font-bold text-gray-900 mt-2">{card.value}</p>
</div>
);
})}
</div>
{/* Revenue & Acceptance Rate */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Revenue */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<TrendingUp className="w-5 h-5 mr-2 text-green-600" />
Revenus totaux
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<DollarSign className="w-5 h-5 text-green-600 mr-2" />
<span className="text-gray-700">USD</span>
</div>
<span className="text-2xl font-bold text-gray-900">
${stats.totalRevenue.usd.toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Euro className="w-5 h-5 text-blue-600 mr-2" />
<span className="text-gray-700">EUR</span>
</div>
<span className="text-2xl font-bold text-gray-900">
€{stats.totalRevenue.eur.toLocaleString()}
</span>
</div>
</div>
</div>
{/* Acceptance Rate */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<Activity className="w-5 h-5 mr-2 text-blue-600" />
Taux d'acceptation
</h2>
<div className="flex items-center justify-center h-32">
<div className="text-center">
<div className="text-5xl font-bold text-blue-600">
{stats.acceptanceRate.toFixed(1)}%
</div>
<p className="text-gray-600 mt-2">
{stats.acceptedBookings} acceptées / {stats.totalBookings} total
</p>
</div>
</div>
</div>
</div>
{/* Recent Activities */}
<div className="bg-white p-6 rounded-lg shadow-sm border">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Activité récente</h2>
{stats.recentActivities.length > 0 ? (
<div className="space-y-3">
{stats.recentActivities.map((activity) => (
<div
key={activity.id}
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
>
<div>
<p className="text-gray-900 font-medium">{activity.description}</p>
<p className="text-gray-600 text-sm">
{new Date(activity.createdAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-xs font-medium ${
activity.type === 'BOOKING_ACCEPTED'
? 'bg-green-100 text-green-800'
: activity.type === 'BOOKING_REJECTED'
? 'bg-red-100 text-red-800'
: 'bg-blue-100 text-blue-800'
}`}
>
{activity.type}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-600 text-center py-8">Aucune activité récente</p>
)}
</div>
</div>
);
}
📅 PHASE 7 : Tests et Documentation
Étape 7.1 : Tests unitaires
Créer des tests pour les services principaux.
Fichiers à créer :
apps/backend/src/application/services/carrier-auth.service.spec.tsapps/backend/src/application/services/carrier-dashboard.service.spec.ts
Étape 7.2 : Tests d'intégration
Fichier : apps/backend/test/carrier-portal.e2e-spec.ts
Étape 7.3 : Documentation
Fichier : apps/backend/docs/CARRIER_PORTAL_API.md
📋 Checklist finale
Backend
- Migrations de base de données exécutées
- Entités ORM créées et testées
- Repositories implémentés
- Services métier créés
- Controllers avec Swagger documentation
- Guards et authentification JWT
- Gestion des documents (upload/download)
- Emails de notification
Frontend
- Page de confirmation avec auto-login
- Page de login transporteur
- Layout dashboard avec sidebar
- Page dashboard principal avec stats
- Page liste des réservations
- Page détails d'une réservation
- Téléchargement de documents
- Page de profil transporteur
Tests
- Tests unitaires backend
- Tests d'intégration
- Tests E2E
- Tests de charge
Déploiement
- Variables d'environnement configurées
- Build frontend production
- Build backend production
- Documentation API complète
🚀 Commandes de déploiement
# Backend
cd apps/backend
npm run build
npm run migration:run
npm run start:prod
# Frontend
cd apps/frontend
npm run build
npm start
📊 Estimation du temps
| Phase | Estimation | Priorité |
|---|---|---|
| Phase 1 : BDD | 2 heures | ⭐⭐⭐ |
| Phase 2 : Entités | 3 heures | ⭐⭐⭐ |
| Phase 3 : Services | 5 heures | ⭐⭐⭐ |
| Phase 4 : Controllers | 3 heures | ⭐⭐⭐ |
| Phase 5 : Frontend Auth | 4 heures | ⭐⭐⭐ |
| Phase 6 : Frontend Dashboard | 8 heures | ⭐⭐⭐ |
| Phase 7 : Tests | 4 heures | ⭐⭐ |
Total estimé : 29 heures (environ 1 semaine de développement)
🎯 Prochaines améliorations (Phase 2)
- Notifications temps réel : WebSocket pour les nouvelles demandes
- Analytics avancés : Graphiques de performance mensuelle
- API mobile : Application mobile pour les transporteurs
- Multi-langue : Support FR/EN/ES
- Système de rating : Noter les clients après chaque transport
- Intégration calendrier : Synchroniser les réservations avec Google Calendar
- Chat intégré : Messagerie client ↔ transporteur
Document créé le : 2025-12-03 Auteur : Claude Code Version : 1.0