xpeditis2.0/CARRIER_PORTAL_IMPLEMENTATION_PLAN.md
2025-12-05 13:55:40 +01:00

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 é 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.ts
  • apps/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)

  1. Notifications temps réel : WebSocket pour les nouvelles demandes
  2. Analytics avancés : Graphiques de performance mensuelle
  3. API mobile : Application mobile pour les transporteurs
  4. Multi-langue : Support FR/EN/ES
  5. Système de rating : Noter les clients après chaque transport
  6. Intégration calendrier : Synchroniser les réservations avec Google Calendar
  7. Chat intégré : Messagerie client ↔ transporteur

Document créé le : 2025-12-03 Auteur : Claude Code Version : 1.0