1947 lines
59 KiB
Markdown
1947 lines
59 KiB
Markdown
# 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`
|
|
|
|
```typescript
|
|
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** :
|
|
```bash
|
|
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`
|
|
|
|
```typescript
|
|
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** :
|
|
```bash
|
|
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`
|
|
|
|
```typescript
|
|
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** :
|
|
```bash
|
|
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`
|
|
|
|
```typescript
|
|
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** :
|
|
```bash
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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 :
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
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`
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```typescript
|
|
'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`
|
|
|
|
```typescript
|
|
'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`
|
|
|
|
```typescript
|
|
'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`
|
|
|
|
```typescript
|
|
'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
|
|
|
|
```bash
|
|
# 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
|