fix booking validate
This commit is contained in:
parent
faf1207300
commit
49b02face6
290
CLAUDE.md
290
CLAUDE.md
@ -10,6 +10,42 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**Active Feature**: Carrier Portal (Branch: `feature_dashboard_transporteur`) - Dedicated portal for carriers to manage booking requests, view statistics, and download documents.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This is a **monorepo** containing both backend and frontend applications:
|
||||
|
||||
```
|
||||
/Users/david/Documents/xpeditis/dev/xpeditis2.0/
|
||||
├── apps/
|
||||
│ ├── backend/ # NestJS API (Node.js 20+, TypeScript 5+)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── domain/ # Pure business logic (no framework deps)
|
||||
│ │ │ ├── application/ # Controllers, DTOs, Guards
|
||||
│ │ │ └── infrastructure/ # ORM, Cache, External APIs
|
||||
│ │ ├── test/ # Integration & E2E tests
|
||||
│ │ ├── load-tests/ # K6 load testing scripts
|
||||
│ │ └── package.json # Backend dependencies
|
||||
│ └── frontend/ # Next.js 14 App Router (React 18)
|
||||
│ ├── app/ # Next.js App Router pages
|
||||
│ ├── src/ # Components, hooks, utilities
|
||||
│ ├── e2e/ # Playwright E2E tests
|
||||
│ └── package.json # Frontend dependencies
|
||||
├── infra/
|
||||
│ └── postgres/ # PostgreSQL init scripts
|
||||
├── docker/ # Docker build & deployment configs
|
||||
├── docker-compose.yml # Local development infrastructure
|
||||
├── package.json # Root monorepo package.json (workspace scripts)
|
||||
├── .prettierrc # Prettier configuration (shared)
|
||||
├── .github/workflows/ # GitHub Actions CI/CD pipelines
|
||||
└── CLAUDE.md # This file (architecture guide)
|
||||
```
|
||||
|
||||
**Workspace Management**:
|
||||
- Root `package.json` contains monorepo-level scripts
|
||||
- Each app has its own `package.json` with specific dependencies
|
||||
- Use root-level commands (`npm run backend:dev`) for convenience
|
||||
- Or navigate to specific app and run commands directly
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Local Development Setup
|
||||
@ -155,8 +191,17 @@ npm run migration:run
|
||||
|
||||
# Revert last migration
|
||||
npm run migration:revert
|
||||
|
||||
# Show migration status
|
||||
npm run migration:show
|
||||
```
|
||||
|
||||
**Important Migration Notes**:
|
||||
- Migration files use Unix timestamp format: `1733185000000-DescriptiveName.ts`
|
||||
- Always test migrations in development before running in production
|
||||
- Migrations run automatically via TypeORM DataSource configuration
|
||||
- Never modify existing migrations that have been applied to production
|
||||
|
||||
### Build & Production
|
||||
|
||||
```bash
|
||||
@ -179,25 +224,34 @@ The backend follows strict hexagonal architecture with three isolated layers:
|
||||
|
||||
```
|
||||
apps/backend/src/
|
||||
├── application/ # 🔌 Controllers & DTOs (depends ONLY on domain)
|
||||
│ ├── auth/ # JWT authentication module
|
||||
│ ├── rates/ # Rate search endpoints
|
||||
│ ├── bookings/ # Booking management
|
||||
├── domain/ # 🔵 CORE - Pure business logic (NO framework dependencies)
|
||||
│ ├── entities/ # Business entities (Booking, RateQuote, User, CarrierProfile)
|
||||
│ ├── value-objects/ # Immutable VOs (Money, Email, BookingNumber, Port)
|
||||
│ ├── services/ # Domain services (pure TypeScript)
|
||||
│ ├── ports/
|
||||
│ │ ├── in/ # API Ports (use cases exposed by domain)
|
||||
│ │ └── out/ # SPI Ports (interfaces required by domain)
|
||||
│ └── exceptions/ # Domain exceptions
|
||||
│
|
||||
├── application/ # 🔌 Controllers & DTOs (depends ONLY on domain)
|
||||
│ ├── auth/ # JWT authentication module
|
||||
│ ├── rates/ # Rate search endpoints
|
||||
│ ├── bookings/ # Booking management
|
||||
│ ├── csv-bookings.module.ts # CSV booking imports
|
||||
│ ├── modules/
|
||||
│ │ └── carrier-portal.module.ts # Carrier portal feature
|
||||
│ ├── controllers/ # REST endpoints
|
||||
│ ├── controllers/ # REST endpoints
|
||||
│ │ ├── carrier-auth.controller.ts
|
||||
│ │ └── carrier-dashboard.controller.ts
|
||||
│ ├── dto/ # Data transfer objects with validation
|
||||
│ ├── dto/ # Data transfer objects with validation
|
||||
│ │ └── carrier-auth.dto.ts
|
||||
│ ├── services/ # Application services
|
||||
│ ├── services/ # Application services
|
||||
│ │ ├── carrier-auth.service.ts
|
||||
│ │ └── carrier-dashboard.service.ts
|
||||
│ ├── guards/ # Auth guards, rate limiting, RBAC
|
||||
│ └── mappers/ # DTO ↔ Domain entity mapping
|
||||
│ ├── guards/ # Auth guards, rate limiting, RBAC
|
||||
│ └── mappers/ # DTO ↔ Domain entity mapping
|
||||
│
|
||||
└── infrastructure/ # 🏗️ External integrations (depends ONLY on domain)
|
||||
└── infrastructure/ # 🏗️ External integrations (depends ONLY on domain)
|
||||
├── persistence/typeorm/ # PostgreSQL repositories
|
||||
│ ├── entities/
|
||||
│ │ ├── carrier-profile.orm-entity.ts
|
||||
@ -207,25 +261,52 @@ apps/backend/src/
|
||||
│ ├── repositories/
|
||||
│ │ ├── carrier-profile.repository.ts
|
||||
│ │ └── carrier-activity.repository.ts
|
||||
│ ├── mappers/ # Domain ↔ ORM entity mappers
|
||||
│ └── migrations/
|
||||
│ ├── 1733185000000-CreateCarrierProfiles.ts
|
||||
│ ├── 1733186000000-CreateCarrierActivities.ts
|
||||
│ ├── 1733187000000-AddCarrierToCsvBookings.ts
|
||||
│ └── 1733188000000-AddCarrierFlagToOrganizations.ts
|
||||
├── cache/ # Redis adapter
|
||||
├── carriers/ # Maersk, MSC, CMA CGM connectors
|
||||
├── cache/ # Redis adapter
|
||||
├── carriers/ # Maersk, MSC, CMA CGM connectors
|
||||
│ └── csv-loader/ # CSV-based rate connector
|
||||
├── email/ # MJML email service (carrier notifications)
|
||||
├── storage/ # S3 storage adapter
|
||||
├── websocket/ # Real-time carrier updates
|
||||
└── security/ # Helmet.js, rate limiting, CORS
|
||||
├── email/ # MJML email service (carrier notifications)
|
||||
├── storage/ # S3 storage adapter
|
||||
├── websocket/ # Real-time carrier updates
|
||||
└── security/ # Helmet.js, rate limiting, CORS
|
||||
```
|
||||
|
||||
**Critical Rules**:
|
||||
1. **Domain layer**: No imports of NestJS, TypeORM, Redis, or any framework (domain layer not shown - pure business logic)
|
||||
2. **Dependencies flow inward**: Infrastructure → Application → Domain
|
||||
1. **Domain layer**: No imports of NestJS, TypeORM, Redis, or any framework - pure TypeScript only
|
||||
2. **Dependencies flow inward**: Infrastructure → Application → Domain (never the reverse)
|
||||
3. **TypeScript path aliases**: Use `@domain/*`, `@application/*`, `@infrastructure/*`
|
||||
4. **Testing**: Domain tests must run without NestJS TestingModule
|
||||
5. **Mappers**: Use dedicated mapper classes for Domain ↔ ORM and Domain ↔ DTO conversions
|
||||
|
||||
**Example - Domain Entity Structure**:
|
||||
```typescript
|
||||
// apps/backend/src/domain/entities/booking.entity.ts
|
||||
export class Booking {
|
||||
private readonly props: BookingProps;
|
||||
|
||||
static create(props: Omit<BookingProps, 'bookingNumber' | 'status'>): Booking {
|
||||
const bookingProps: BookingProps = {
|
||||
...props,
|
||||
bookingNumber: BookingNumber.generate(),
|
||||
status: BookingStatus.create('draft'),
|
||||
};
|
||||
Booking.validate(bookingProps);
|
||||
return new Booking(bookingProps);
|
||||
}
|
||||
|
||||
updateStatus(newStatus: BookingStatus): Booking {
|
||||
if (!this.status.canTransitionTo(newStatus)) {
|
||||
throw new InvalidStatusTransitionException();
|
||||
}
|
||||
return new Booking({ ...this.props, status: newStatus });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Frontend Architecture (Next.js 14 App Router)
|
||||
|
||||
@ -632,33 +713,69 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md)
|
||||
- ORM Entities: `booking.orm-entity.ts`, `carrier-profile.orm-entity.ts`
|
||||
- Migrations: `1730000000001-CreateBookings.ts`, `1733185000000-CreateCarrierProfiles.ts`
|
||||
|
||||
## Key Architectural Patterns Used
|
||||
|
||||
### 1. Domain-Driven Design (DDD)
|
||||
- **Entities**: Mutable objects with identity (e.g., `Booking`, `User`)
|
||||
- **Value Objects**: Immutable, identity-less objects (e.g., `Money`, `Email`, `BookingNumber`)
|
||||
- **Aggregates**: Cluster of entities/VOs treated as a unit (e.g., `Booking` with `Container` items)
|
||||
- **Domain Services**: Stateless operations that don't belong to entities
|
||||
- **Domain Events**: Not yet implemented (planned for Phase 5)
|
||||
|
||||
### 2. Repository Pattern
|
||||
- **Interface in Domain**: `apps/backend/src/domain/ports/out/booking.repository.port.ts`
|
||||
- **Implementation in Infrastructure**: `apps/backend/src/infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts`
|
||||
- **Mapper Pattern**: Separate mappers for Domain ↔ ORM entity conversion
|
||||
|
||||
### 3. DTO Pattern
|
||||
- **Request DTOs**: Validate incoming API requests with `class-validator`
|
||||
- **Response DTOs**: Control API response shape
|
||||
- **Mappers**: Convert between DTOs and Domain entities in application layer
|
||||
|
||||
### 4. Circuit Breaker Pattern
|
||||
- Used for external carrier API calls (Maersk, MSC, CMA CGM)
|
||||
- Library: `opossum`
|
||||
- Timeout: 5 seconds per carrier
|
||||
- Location: `apps/backend/src/infrastructure/carriers/*/`
|
||||
|
||||
### 5. Caching Strategy
|
||||
- **Redis for rate quotes**: 15-minute TTL
|
||||
- **Cache-aside pattern**: Check cache first, fetch from carriers on miss
|
||||
- **Cache key format**: `rate:{origin}:{destination}:{containerType}`
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
❌ **DO NOT**:
|
||||
- Import NestJS/TypeORM in domain layer
|
||||
- Put business logic in controllers or repositories
|
||||
- Use `any` type (strict mode enabled)
|
||||
- Use `any` type (strict mode enabled in backend)
|
||||
- Skip writing tests (coverage targets enforced)
|
||||
- Use `DATABASE_SYNC=true` in production
|
||||
- Commit `.env` files
|
||||
- Expose sensitive data in API responses
|
||||
- Use `DATABASE_SYNC=true` in production (always use migrations)
|
||||
- Commit `.env` files (use `.env.example` templates)
|
||||
- Expose sensitive data in API responses (passwords, tokens, internal IDs)
|
||||
- Skip rate limiting on public endpoints
|
||||
- Use circular imports (leverage barrel exports)
|
||||
- Use circular imports (leverage barrel exports with `index.ts`)
|
||||
- Send emails without proper error handling
|
||||
- Store plain text passwords (always use Argon2)
|
||||
- Modify applied migrations (create new migration instead)
|
||||
- Mix domain logic with framework code
|
||||
|
||||
✅ **DO**:
|
||||
- Follow hexagonal architecture strictly
|
||||
- Write tests for all new features (domain 90%+)
|
||||
- Follow hexagonal architecture strictly (Infrastructure → Application → Domain)
|
||||
- Write tests for all new features (domain 90%+, application 80%+)
|
||||
- Use TypeScript path aliases (`@domain/*`, `@application/*`, `@infrastructure/*`)
|
||||
- Validate all DTOs with `class-validator`
|
||||
- Implement circuit breakers for external APIs
|
||||
- Cache frequently accessed data (Redis)
|
||||
- Use structured logging (Pino)
|
||||
- Document APIs with Swagger decorators
|
||||
- Run migrations before deployment
|
||||
- Validate all DTOs with `class-validator` decorators
|
||||
- Implement circuit breakers for external APIs (carrier connectors)
|
||||
- Cache frequently accessed data (Redis with TTL)
|
||||
- Use structured logging (Pino JSON format)
|
||||
- Document APIs with Swagger decorators (`@ApiOperation`, `@ApiResponse`)
|
||||
- Run migrations before deployment (`npm run migration:run`)
|
||||
- Test email sending in development with test accounts
|
||||
- Use MJML for responsive email templates
|
||||
- Create dedicated mappers for Domain ↔ ORM conversions
|
||||
- Use Value Objects for domain concepts (Money, Email, etc.)
|
||||
- Implement proper error handling with domain exceptions
|
||||
- Use immutability in domain entities (return new instances on updates)
|
||||
|
||||
## Documentation
|
||||
|
||||
@ -688,6 +805,114 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md)
|
||||
- [docker/DOCKER_BUILD_GUIDE.md](docker/DOCKER_BUILD_GUIDE.md) - Docker build instructions
|
||||
- [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) - Pre-deployment checklist
|
||||
|
||||
## Quick Reference - Common Tasks
|
||||
|
||||
### Running a Single Test File
|
||||
```bash
|
||||
# Backend unit test
|
||||
cd apps/backend
|
||||
npm test -- booking.entity.spec.ts
|
||||
|
||||
# Backend integration test
|
||||
npm run test:integration -- booking.repository.spec.ts
|
||||
|
||||
# Backend E2E test
|
||||
npm run test:e2e -- carrier-portal.e2e-spec.ts
|
||||
|
||||
# Frontend test
|
||||
cd apps/frontend
|
||||
npm test -- BookingForm.test.tsx
|
||||
```
|
||||
|
||||
### Debugging TypeScript Path Aliases
|
||||
If imports like `@domain/*` don't resolve:
|
||||
1. Check `apps/backend/tsconfig.json` has correct `paths` configuration
|
||||
2. Verify VS Code is using workspace TypeScript version
|
||||
3. Restart TypeScript server in VS Code: `Cmd+Shift+P` → "TypeScript: Restart TS Server"
|
||||
|
||||
### Common Environment Issues
|
||||
|
||||
**PostgreSQL connection fails**:
|
||||
```bash
|
||||
# Verify PostgreSQL container is running
|
||||
docker ps | grep xpeditis-postgres
|
||||
|
||||
# Check PostgreSQL logs
|
||||
docker logs xpeditis-postgres
|
||||
|
||||
# Restart PostgreSQL
|
||||
docker-compose restart postgres
|
||||
```
|
||||
|
||||
**Redis connection fails**:
|
||||
```bash
|
||||
# Verify Redis container is running
|
||||
docker ps | grep xpeditis-redis
|
||||
|
||||
# Test Redis connection
|
||||
docker exec -it xpeditis-redis redis-cli -a xpeditis_redis_password ping
|
||||
# Expected: PONG
|
||||
|
||||
# Restart Redis
|
||||
docker-compose restart redis
|
||||
```
|
||||
|
||||
**Migrations fail**:
|
||||
```bash
|
||||
# Check migration status
|
||||
npm run migration:show
|
||||
|
||||
# If stuck, revert and try again
|
||||
npm run migration:revert
|
||||
npm run migration:run
|
||||
```
|
||||
|
||||
### Adding a New Feature (Step-by-Step)
|
||||
|
||||
1. **Create Domain Entity** (if needed):
|
||||
- Location: `apps/backend/src/domain/entities/`
|
||||
- Pure TypeScript, no framework imports
|
||||
- Write unit tests: `*.entity.spec.ts`
|
||||
|
||||
2. **Create Value Objects** (if needed):
|
||||
- Location: `apps/backend/src/domain/value-objects/`
|
||||
- Immutable, validated in constructor
|
||||
- Write unit tests: `*.vo.spec.ts`
|
||||
|
||||
3. **Define Domain Port Interface**:
|
||||
- Location: `apps/backend/src/domain/ports/out/`
|
||||
- Interface only, no implementation
|
||||
|
||||
4. **Create ORM Entity**:
|
||||
- Location: `apps/backend/src/infrastructure/persistence/typeorm/entities/`
|
||||
- File naming: `*.orm-entity.ts`
|
||||
- Add `@Entity()` decorator
|
||||
|
||||
5. **Generate Migration**:
|
||||
```bash
|
||||
npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/CreateFeatureName
|
||||
```
|
||||
|
||||
6. **Implement Repository**:
|
||||
- Location: `apps/backend/src/infrastructure/persistence/typeorm/repositories/`
|
||||
- Implements domain port interface
|
||||
- Write integration tests
|
||||
|
||||
7. **Create DTOs**:
|
||||
- Location: `apps/backend/src/application/dto/`
|
||||
- Add `class-validator` decorators
|
||||
|
||||
8. **Create Controller**:
|
||||
- Location: `apps/backend/src/application/controllers/`
|
||||
- Add Swagger decorators
|
||||
- Write E2E tests
|
||||
|
||||
9. **Create Application Module**:
|
||||
- Location: `apps/backend/src/application/modules/`
|
||||
- Register controllers, services, repositories
|
||||
|
||||
10. **Import Module in App.module.ts**
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
1. Hexagonal architecture principles followed
|
||||
@ -702,3 +927,6 @@ See [docker/PORTAINER_DEPLOYMENT_GUIDE.md](docker/PORTAINER_DEPLOYMENT_GUIDE.md)
|
||||
10. ESLint passes with no warnings
|
||||
11. Email templates tested in development
|
||||
12. Carrier workflow tested end-to-end
|
||||
13. Database migrations tested in development
|
||||
14. ORM entities have corresponding domain entities
|
||||
15. Mappers created for Domain ↔ ORM conversions
|
||||
|
||||
@ -18,7 +18,6 @@ import { NotificationsModule } from './application/notifications/notifications.m
|
||||
import { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||
import { CarrierPortalModule } from './application/modules/carrier-portal.module';
|
||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||
import { SecurityModule } from './infrastructure/security/security.module';
|
||||
@ -109,7 +108,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
||||
PortsModule,
|
||||
BookingsModule,
|
||||
CsvBookingsModule,
|
||||
CarrierPortalModule,
|
||||
OrganizationsModule,
|
||||
UsersModule,
|
||||
DashboardModule,
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
/**
|
||||
* Carrier Auth Controller
|
||||
*
|
||||
* Handles carrier authentication endpoints
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Request,
|
||||
Get,
|
||||
Patch,
|
||||
Logger,
|
||||
} 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';
|
||||
import {
|
||||
CarrierLoginDto,
|
||||
CarrierChangePasswordDto,
|
||||
CarrierPasswordResetRequestDto,
|
||||
CarrierLoginResponseDto,
|
||||
CarrierProfileResponseDto,
|
||||
} from '../dto/carrier-auth.dto';
|
||||
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
|
||||
|
||||
@ApiTags('Carrier Auth')
|
||||
@Controller('carrier-auth')
|
||||
export class CarrierAuthController {
|
||||
private readonly logger = new Logger(CarrierAuthController.name);
|
||||
|
||||
constructor(
|
||||
private readonly carrierAuthService: CarrierAuthService,
|
||||
private readonly carrierProfileRepository: CarrierProfileRepository,
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Carrier login with email and password' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Login successful',
|
||||
type: CarrierLoginResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Invalid credentials' })
|
||||
async login(@Body() dto: CarrierLoginDto): Promise<CarrierLoginResponseDto> {
|
||||
this.logger.log(`Carrier login attempt: ${dto.email}`);
|
||||
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',
|
||||
type: CarrierProfileResponseDto,
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
async getProfile(@Request() req: any): Promise<any> {
|
||||
this.logger.log(`Getting profile for carrier: ${req.user.carrierId}`);
|
||||
|
||||
const carrier = await this.carrierProfileRepository.findById(req.user.carrierId);
|
||||
|
||||
if (!carrier) {
|
||||
throw new Error('Carrier profile not found');
|
||||
}
|
||||
|
||||
return {
|
||||
id: carrier.id,
|
||||
userId: carrier.userId,
|
||||
companyName: carrier.companyName,
|
||||
email: carrier.user?.email,
|
||||
role: 'CARRIER',
|
||||
organizationId: carrier.organizationId,
|
||||
phone: carrier.phone,
|
||||
website: carrier.website,
|
||||
city: carrier.city,
|
||||
country: carrier.country,
|
||||
isVerified: carrier.isVerified,
|
||||
isActive: carrier.isActive,
|
||||
totalBookingsAccepted: carrier.totalBookingsAccepted,
|
||||
totalBookingsRejected: carrier.totalBookingsRejected,
|
||||
acceptanceRate: carrier.acceptanceRate,
|
||||
totalRevenueUsd: carrier.totalRevenueUsd,
|
||||
totalRevenueEur: carrier.totalRevenueEur,
|
||||
preferredCurrency: carrier.preferredCurrency,
|
||||
lastLoginAt: carrier.lastLoginAt,
|
||||
};
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('change-password')
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Change carrier password' })
|
||||
@ApiResponse({ status: 200, description: 'Password changed successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid old password' })
|
||||
async changePassword(
|
||||
@Request() req: any,
|
||||
@Body() dto: CarrierChangePasswordDto
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Password change request for carrier: ${req.user.carrierId}`);
|
||||
|
||||
await this.carrierAuthService.changePassword(
|
||||
req.user.carrierId,
|
||||
dto.oldPassword,
|
||||
dto.newPassword
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Password changed successfully',
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('request-password-reset')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Request password reset (sends temporary password)' })
|
||||
@ApiResponse({ status: 200, description: 'Password reset email sent' })
|
||||
async requestPasswordReset(
|
||||
@Body() dto: CarrierPasswordResetRequestDto
|
||||
): Promise<{ message: string }> {
|
||||
this.logger.log(`Password reset requested for: ${dto.email}`);
|
||||
|
||||
await this.carrierAuthService.requestPasswordReset(dto.email);
|
||||
|
||||
return {
|
||||
message: 'If this email exists, a password reset will be sent',
|
||||
};
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('verify-auto-login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Verify auto-login token from email link' })
|
||||
@ApiResponse({ status: 200, description: 'Token verified' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid or expired token' })
|
||||
async verifyAutoLoginToken(
|
||||
@Body() body: { token: string }
|
||||
): Promise<{ userId: string; carrierId: string }> {
|
||||
this.logger.log('Verifying auto-login token');
|
||||
|
||||
return await this.carrierAuthService.verifyAutoLoginToken(body.token);
|
||||
}
|
||||
}
|
||||
@ -1,219 +0,0 @@
|
||||
/**
|
||||
* Carrier Dashboard Controller
|
||||
*
|
||||
* Handles carrier dashboard, bookings, and document endpoints
|
||||
*/
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
ParseIntPipe,
|
||||
DefaultValuePipe,
|
||||
Logger,
|
||||
HttpStatus,
|
||||
HttpCode,
|
||||
} 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,
|
||||
CarrierDashboardStats,
|
||||
CarrierBookingListItem,
|
||||
} from '../services/carrier-dashboard.service';
|
||||
|
||||
@ApiTags('Carrier Dashboard')
|
||||
@Controller('carrier-dashboard')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiBearerAuth()
|
||||
export class CarrierDashboardController {
|
||||
private readonly logger = new Logger(CarrierDashboardController.name);
|
||||
|
||||
constructor(private readonly carrierDashboardService: CarrierDashboardService) {}
|
||||
|
||||
@Get('stats')
|
||||
@ApiOperation({ summary: 'Get carrier dashboard statistics' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Statistics retrieved successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
totalBookings: { type: 'number' },
|
||||
pendingBookings: { type: 'number' },
|
||||
acceptedBookings: { type: 'number' },
|
||||
rejectedBookings: { type: 'number' },
|
||||
acceptanceRate: { type: 'number' },
|
||||
totalRevenue: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
usd: { type: 'number' },
|
||||
eur: { type: 'number' },
|
||||
},
|
||||
},
|
||||
recentActivities: { type: 'array' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
@ApiResponse({ status: 404, description: 'Carrier not found' })
|
||||
async getStats(@Request() req: any): Promise<CarrierDashboardStats> {
|
||||
const carrierId = req.user.carrierId;
|
||||
this.logger.log(`Fetching stats for carrier: ${carrierId}`);
|
||||
|
||||
return await this.carrierDashboardService.getCarrierStats(carrierId);
|
||||
}
|
||||
|
||||
@Get('bookings')
|
||||
@ApiOperation({ summary: 'Get carrier bookings list with pagination' })
|
||||
@ApiQuery({ name: 'page', required: false, type: Number, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'limit', required: false, type: Number, description: 'Items per page (default: 10)' })
|
||||
@ApiQuery({ name: 'status', required: false, type: String, description: 'Filter by status (PENDING, ACCEPTED, REJECTED)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Bookings retrieved successfully',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: { type: 'array' },
|
||||
total: { type: 'number' },
|
||||
page: { type: 'number' },
|
||||
limit: { type: 'number' },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||
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
|
||||
): Promise<{
|
||||
data: CarrierBookingListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
const carrierId = req.user.carrierId;
|
||||
this.logger.log(`Fetching bookings for carrier: ${carrierId} (page: ${page}, limit: ${limit}, status: ${status})`);
|
||||
|
||||
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 (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Booking details retrieved' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
@ApiResponse({ status: 403, description: 'Access denied to this booking' })
|
||||
async getBookingDetails(@Request() req: any, @Param('id') bookingId: string): Promise<any> {
|
||||
const carrierId = req.user.carrierId;
|
||||
this.logger.log(`Fetching booking details: ${bookingId} for carrier: ${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 (UUID)' })
|
||||
@ApiParam({ name: 'documentId', description: 'Document ID' })
|
||||
@ApiResponse({ status: 200, description: 'Document downloaded successfully' })
|
||||
@ApiResponse({ status: 403, description: 'Access denied to this document' })
|
||||
@ApiResponse({ status: 404, description: 'Document not found' })
|
||||
async downloadDocument(
|
||||
@Request() req: any,
|
||||
@Param('bookingId') bookingId: string,
|
||||
@Param('documentId') documentId: string,
|
||||
@Res() res: Response
|
||||
): Promise<void> {
|
||||
const carrierId = req.user.carrierId;
|
||||
this.logger.log(`Downloading document ${documentId} from booking ${bookingId} for carrier ${carrierId}`);
|
||||
|
||||
const { document } = await this.carrierDashboardService.downloadDocument(
|
||||
carrierId,
|
||||
bookingId,
|
||||
documentId
|
||||
);
|
||||
|
||||
// For now, return document metadata as JSON
|
||||
// TODO: Implement actual file download from S3/MinIO
|
||||
res.status(HttpStatus.OK).json({
|
||||
message: 'Document download not yet implemented',
|
||||
document,
|
||||
// When S3/MinIO is implemented, set headers and stream:
|
||||
// res.set({
|
||||
// 'Content-Type': mimeType,
|
||||
// 'Content-Disposition': `attachment; filename="${fileName}"`,
|
||||
// });
|
||||
// return new StreamableFile(buffer);
|
||||
});
|
||||
}
|
||||
|
||||
@Post('bookings/:id/accept')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Accept a booking' })
|
||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Booking accepted successfully' })
|
||||
@ApiResponse({ status: 403, description: 'Access denied or booking not in pending status' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
async acceptBooking(
|
||||
@Request() req: any,
|
||||
@Param('id') bookingId: string,
|
||||
@Body() body: { notes?: string }
|
||||
): Promise<{ message: string }> {
|
||||
const carrierId = req.user.carrierId;
|
||||
this.logger.log(`Accepting booking ${bookingId} by carrier ${carrierId}`);
|
||||
|
||||
await this.carrierDashboardService.acceptBooking(carrierId, bookingId, body.notes);
|
||||
|
||||
return {
|
||||
message: 'Booking accepted successfully',
|
||||
};
|
||||
}
|
||||
|
||||
@Post('bookings/:id/reject')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: 'Reject a booking' })
|
||||
@ApiParam({ name: 'id', description: 'Booking ID (UUID)' })
|
||||
@ApiResponse({ status: 200, description: 'Booking rejected successfully' })
|
||||
@ApiResponse({ status: 403, description: 'Access denied or booking not in pending status' })
|
||||
@ApiResponse({ status: 404, description: 'Booking not found' })
|
||||
async rejectBooking(
|
||||
@Request() req: any,
|
||||
@Param('id') bookingId: string,
|
||||
@Body() body: { reason?: string; notes?: string }
|
||||
): Promise<{ message: string }> {
|
||||
const carrierId = req.user.carrierId;
|
||||
this.logger.log(`Rejecting booking ${bookingId} by carrier ${carrierId}`);
|
||||
|
||||
await this.carrierDashboardService.rejectBooking(
|
||||
carrierId,
|
||||
bookingId,
|
||||
body.reason,
|
||||
body.notes
|
||||
);
|
||||
|
||||
return {
|
||||
message: 'Booking rejected successfully',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,6 @@ import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { CsvBookingService } from '../services/csv-booking.service';
|
||||
import { CarrierAuthService } from '../services/carrier-auth.service';
|
||||
|
||||
/**
|
||||
* CSV Booking Actions Controller (Public Routes)
|
||||
@ -14,8 +13,7 @@ import { CarrierAuthService } from '../services/carrier-auth.service';
|
||||
@Controller('csv-booking-actions')
|
||||
export class CsvBookingActionsController {
|
||||
constructor(
|
||||
private readonly csvBookingService: CsvBookingService,
|
||||
private readonly carrierAuthService: CarrierAuthService
|
||||
private readonly csvBookingService: CsvBookingService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -33,7 +31,7 @@ export class CsvBookingActionsController {
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking accepted successfully. Returns auto-login token and booking details.',
|
||||
description: 'Booking accepted successfully.',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
@ApiResponse({
|
||||
@ -41,28 +39,13 @@ export class CsvBookingActionsController {
|
||||
description: 'Booking cannot be accepted (invalid status or expired)',
|
||||
})
|
||||
async acceptBooking(@Param('token') token: string) {
|
||||
// 1. Accept the booking
|
||||
// Accept the booking
|
||||
const booking = await this.csvBookingService.acceptBooking(token);
|
||||
|
||||
// 2. Create carrier account if it doesn't exist
|
||||
const { carrierId, userId, isNewAccount } =
|
||||
await this.carrierAuthService.createCarrierAccountIfNotExists(
|
||||
booking.carrierEmail,
|
||||
booking.carrierName
|
||||
);
|
||||
|
||||
// 3. Link the booking to the carrier
|
||||
await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId);
|
||||
|
||||
// 4. Generate auto-login token
|
||||
const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId);
|
||||
|
||||
// 5. Return JSON response for frontend to handle
|
||||
// Return simple success response
|
||||
return {
|
||||
success: true,
|
||||
autoLoginToken,
|
||||
bookingId: booking.id,
|
||||
isNewAccount,
|
||||
action: 'accepted',
|
||||
};
|
||||
}
|
||||
@ -88,7 +71,7 @@ export class CsvBookingActionsController {
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking rejected successfully. Returns auto-login token and booking details.',
|
||||
description: 'Booking rejected successfully.',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
@ApiResponse({
|
||||
@ -96,28 +79,13 @@ export class CsvBookingActionsController {
|
||||
description: 'Booking cannot be rejected (invalid status or expired)',
|
||||
})
|
||||
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
|
||||
// 1. Reject the booking
|
||||
// Reject the booking
|
||||
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||
|
||||
// 2. Create carrier account if it doesn't exist
|
||||
const { carrierId, userId, isNewAccount } =
|
||||
await this.carrierAuthService.createCarrierAccountIfNotExists(
|
||||
booking.carrierEmail,
|
||||
booking.carrierName
|
||||
);
|
||||
|
||||
// 3. Link the booking to the carrier
|
||||
await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId);
|
||||
|
||||
// 4. Generate auto-login token
|
||||
const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId);
|
||||
|
||||
// 5. Return JSON response for frontend to handle
|
||||
// Return simple success response
|
||||
return {
|
||||
success: true,
|
||||
autoLoginToken,
|
||||
bookingId: booking.id,
|
||||
isNewAccount,
|
||||
action: 'rejected',
|
||||
reason: reason || null,
|
||||
};
|
||||
|
||||
@ -31,7 +31,6 @@ import { Response } from 'express';
|
||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||
import { Public } from '../decorators/public.decorator';
|
||||
import { CsvBookingService } from '../services/csv-booking.service';
|
||||
import { CarrierAuthService } from '../services/carrier-auth.service';
|
||||
import {
|
||||
CreateCsvBookingDto,
|
||||
CsvBookingResponseDto,
|
||||
@ -49,8 +48,7 @@ import {
|
||||
@Controller('csv-bookings')
|
||||
export class CsvBookingsController {
|
||||
constructor(
|
||||
private readonly csvBookingService: CsvBookingService,
|
||||
private readonly carrierAuthService: CarrierAuthService,
|
||||
private readonly csvBookingService: CsvBookingService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -174,7 +172,7 @@ export class CsvBookingsController {
|
||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking accepted successfully. Returns auto-login token and booking details.',
|
||||
description: 'Booking accepted successfully.',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
@ApiResponse({
|
||||
@ -182,28 +180,13 @@ export class CsvBookingsController {
|
||||
description: 'Booking cannot be accepted (invalid status or expired)',
|
||||
})
|
||||
async acceptBooking(@Param('token') token: string) {
|
||||
// 1. Accept the booking
|
||||
// Accept the booking
|
||||
const booking = await this.csvBookingService.acceptBooking(token);
|
||||
|
||||
// 2. Create carrier account if it doesn't exist
|
||||
const { carrierId, userId, isNewAccount, temporaryPassword } =
|
||||
await this.carrierAuthService.createCarrierAccountIfNotExists(
|
||||
booking.carrierEmail,
|
||||
booking.carrierName
|
||||
);
|
||||
|
||||
// 3. Link the booking to the carrier
|
||||
await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId);
|
||||
|
||||
// 4. Generate auto-login token
|
||||
const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId);
|
||||
|
||||
// 5. Return JSON response for frontend to handle
|
||||
// Return simple success response
|
||||
return {
|
||||
success: true,
|
||||
autoLoginToken,
|
||||
bookingId: booking.id,
|
||||
isNewAccount,
|
||||
action: 'accepted',
|
||||
};
|
||||
}
|
||||
@ -229,7 +212,7 @@ export class CsvBookingsController {
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Booking rejected successfully. Returns auto-login token and booking details.',
|
||||
description: 'Booking rejected successfully.',
|
||||
})
|
||||
@ApiResponse({ status: 404, description: 'Booking not found or invalid token' })
|
||||
@ApiResponse({
|
||||
@ -240,28 +223,13 @@ export class CsvBookingsController {
|
||||
@Param('token') token: string,
|
||||
@Query('reason') reason: string
|
||||
) {
|
||||
// 1. Reject the booking
|
||||
// Reject the booking
|
||||
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||
|
||||
// 2. Create carrier account if it doesn't exist
|
||||
const { carrierId, userId, isNewAccount, temporaryPassword } =
|
||||
await this.carrierAuthService.createCarrierAccountIfNotExists(
|
||||
booking.carrierEmail,
|
||||
booking.carrierName
|
||||
);
|
||||
|
||||
// 3. Link the booking to the carrier
|
||||
await this.csvBookingService.linkBookingToCarrier(booking.id, carrierId);
|
||||
|
||||
// 4. Generate auto-login token
|
||||
const autoLoginToken = await this.carrierAuthService.generateAutoLoginToken(userId, carrierId);
|
||||
|
||||
// 5. Return JSON response for frontend to handle
|
||||
// Return simple success response
|
||||
return {
|
||||
success: true,
|
||||
autoLoginToken,
|
||||
bookingId: booking.id,
|
||||
isNewAccount,
|
||||
action: 'rejected',
|
||||
reason: reason || null,
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CsvBookingsController } from './controllers/csv-bookings.controller';
|
||||
import { CsvBookingActionsController } from './controllers/csv-booking-actions.controller';
|
||||
@ -8,7 +8,6 @@ import { TypeOrmCsvBookingRepository } from '../infrastructure/persistence/typeo
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { EmailModule } from '../infrastructure/email/email.module';
|
||||
import { StorageModule } from '../infrastructure/storage/storage.module';
|
||||
import { CarrierPortalModule } from './modules/carrier-portal.module';
|
||||
|
||||
/**
|
||||
* CSV Bookings Module
|
||||
@ -17,14 +16,18 @@ import { CarrierPortalModule } from './modules/carrier-portal.module';
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
|
||||
NotificationsModule, // Import NotificationsModule to access NotificationRepository
|
||||
TypeOrmModule.forFeature([
|
||||
CsvBookingOrmEntity,
|
||||
]),
|
||||
NotificationsModule,
|
||||
EmailModule,
|
||||
StorageModule,
|
||||
forwardRef(() => CarrierPortalModule), // Import CarrierPortalModule to access CarrierAuthService
|
||||
],
|
||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
|
||||
providers: [
|
||||
CsvBookingService,
|
||||
TypeOrmCsvBookingRepository,
|
||||
],
|
||||
exports: [CsvBookingService],
|
||||
})
|
||||
export class CsvBookingsModule {}
|
||||
|
||||
@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Carrier Authentication DTOs
|
||||
*
|
||||
* Data transfer objects for carrier authentication endpoints
|
||||
*/
|
||||
|
||||
import { IsEmail, IsString, IsNotEmpty, MinLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class CarrierLoginDto {
|
||||
@ApiProperty({
|
||||
description: 'Carrier email address',
|
||||
example: 'carrier@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Carrier password',
|
||||
example: 'SecurePassword123!',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(6)
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class CarrierChangePasswordDto {
|
||||
@ApiProperty({
|
||||
description: 'Current password',
|
||||
example: 'OldPassword123!',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
oldPassword: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'New password (minimum 12 characters)',
|
||||
example: 'NewSecurePassword123!',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(12)
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export class CarrierPasswordResetRequestDto {
|
||||
@ApiProperty({
|
||||
description: 'Carrier email address',
|
||||
example: 'carrier@example.com',
|
||||
})
|
||||
@IsEmail()
|
||||
@IsNotEmpty()
|
||||
email: string;
|
||||
}
|
||||
|
||||
export class CarrierLoginResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'JWT access token (15min expiry)',
|
||||
})
|
||||
accessToken: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'JWT refresh token (7 days expiry)',
|
||||
})
|
||||
refreshToken: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Carrier profile information',
|
||||
})
|
||||
carrier: {
|
||||
id: string;
|
||||
companyName: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class CarrierProfileResponseDto {
|
||||
@ApiProperty({
|
||||
description: 'Carrier profile ID',
|
||||
})
|
||||
id: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'User ID',
|
||||
})
|
||||
userId: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Company name',
|
||||
})
|
||||
companyName: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Email address',
|
||||
})
|
||||
email: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Carrier role',
|
||||
example: 'CARRIER',
|
||||
})
|
||||
role: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Organization ID',
|
||||
})
|
||||
organizationId: string;
|
||||
}
|
||||
@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Carrier Portal Module
|
||||
*
|
||||
* Module for carrier (transporteur) portal functionality
|
||||
* Includes authentication, dashboard, and booking management for carriers
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
|
||||
// Controllers
|
||||
import { CarrierAuthController } from '../controllers/carrier-auth.controller';
|
||||
import { CarrierDashboardController } from '../controllers/carrier-dashboard.controller';
|
||||
|
||||
// Services
|
||||
import { CarrierAuthService } from '../services/carrier-auth.service';
|
||||
import { CarrierDashboardService } from '../services/carrier-dashboard.service';
|
||||
|
||||
// Repositories
|
||||
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
|
||||
import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository';
|
||||
|
||||
// ORM Entities
|
||||
import { CarrierProfileOrmEntity } from '@infrastructure/persistence/typeorm/entities/carrier-profile.orm-entity';
|
||||
import { CarrierActivityOrmEntity } from '@infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity';
|
||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
||||
|
||||
// Infrastructure modules
|
||||
import { EmailModule } from '@infrastructure/email/email.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// TypeORM entities
|
||||
TypeOrmModule.forFeature([
|
||||
CarrierProfileOrmEntity,
|
||||
CarrierActivityOrmEntity,
|
||||
UserOrmEntity,
|
||||
OrganizationOrmEntity,
|
||||
CsvBookingOrmEntity,
|
||||
]),
|
||||
|
||||
// JWT module for authentication
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
useFactory: async (configService: ConfigService) => ({
|
||||
secret: configService.get<string>('JWT_SECRET'),
|
||||
signOptions: {
|
||||
expiresIn: configService.get<string>('JWT_ACCESS_EXPIRATION', '15m'),
|
||||
},
|
||||
}),
|
||||
inject: [ConfigService],
|
||||
}),
|
||||
|
||||
// Email module for sending carrier emails
|
||||
EmailModule,
|
||||
],
|
||||
|
||||
controllers: [
|
||||
CarrierAuthController,
|
||||
CarrierDashboardController,
|
||||
],
|
||||
|
||||
providers: [
|
||||
// Services
|
||||
CarrierAuthService,
|
||||
CarrierDashboardService,
|
||||
|
||||
// Repositories
|
||||
CarrierProfileRepository,
|
||||
CarrierActivityRepository,
|
||||
],
|
||||
|
||||
exports: [
|
||||
// Export services for use in other modules (e.g., CsvBookingsModule)
|
||||
CarrierAuthService,
|
||||
CarrierDashboardService,
|
||||
CarrierProfileRepository,
|
||||
CarrierActivityRepository,
|
||||
],
|
||||
})
|
||||
export class CarrierPortalModule {}
|
||||
@ -1,346 +0,0 @@
|
||||
/**
|
||||
* CarrierAuthService Unit Tests
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { CarrierAuthService } from './carrier-auth.service';
|
||||
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
|
||||
import { UserOrmEntity } from '@infrastructure/persistence/typeorm/entities/user.orm-entity';
|
||||
import { OrganizationOrmEntity } from '@infrastructure/persistence/typeorm/entities/organization.orm-entity';
|
||||
import * as argon2 from 'argon2';
|
||||
|
||||
describe('CarrierAuthService', () => {
|
||||
let service: CarrierAuthService;
|
||||
let carrierProfileRepository: jest.Mocked<CarrierProfileRepository>;
|
||||
let userRepository: any;
|
||||
let organizationRepository: any;
|
||||
let jwtService: jest.Mocked<JwtService>;
|
||||
|
||||
const mockCarrierProfile = {
|
||||
id: 'carrier-1',
|
||||
userId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
companyName: 'Test Carrier',
|
||||
notificationEmail: 'carrier@test.com',
|
||||
isActive: true,
|
||||
isVerified: true,
|
||||
user: {
|
||||
id: 'user-1',
|
||||
email: 'carrier@test.com',
|
||||
passwordHash: 'hashed-password',
|
||||
firstName: 'Test',
|
||||
lastName: 'Carrier',
|
||||
role: 'CARRIER',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CarrierAuthService,
|
||||
{
|
||||
provide: CarrierProfileRepository,
|
||||
useValue: {
|
||||
findByEmail: jest.fn(),
|
||||
findById: jest.fn(),
|
||||
create: jest.fn(),
|
||||
updateLastLogin: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserOrmEntity),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(OrganizationOrmEntity),
|
||||
useValue: {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: JwtService,
|
||||
useValue: {
|
||||
sign: jest.fn(),
|
||||
verify: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CarrierAuthService>(CarrierAuthService);
|
||||
carrierProfileRepository = module.get(CarrierProfileRepository);
|
||||
userRepository = module.get(getRepositoryToken(UserOrmEntity));
|
||||
organizationRepository = module.get(getRepositoryToken(OrganizationOrmEntity));
|
||||
jwtService = module.get(JwtService);
|
||||
});
|
||||
|
||||
describe('createCarrierAccountIfNotExists', () => {
|
||||
it('should return existing carrier if already exists', async () => {
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrierProfile as any);
|
||||
|
||||
const result = await service.createCarrierAccountIfNotExists(
|
||||
'carrier@test.com',
|
||||
'Test Carrier'
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
carrierId: 'carrier-1',
|
||||
userId: 'user-1',
|
||||
isNewAccount: false,
|
||||
});
|
||||
expect(carrierProfileRepository.findByEmail).toHaveBeenCalledWith('carrier@test.com');
|
||||
});
|
||||
|
||||
it('should create new carrier account if not exists', async () => {
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
const mockOrganization = { id: 'org-1', name: 'Test Carrier' };
|
||||
const mockUser = { id: 'user-1', email: 'carrier@test.com' };
|
||||
const mockCarrier = {
|
||||
id: 'carrier-1',
|
||||
userId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
companyName: 'Test Carrier',
|
||||
companyRegistration: null,
|
||||
vatNumber: null,
|
||||
phone: null,
|
||||
website: null,
|
||||
streetAddress: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
country: null,
|
||||
totalBookingsAccepted: 0,
|
||||
totalBookingsRejected: 0,
|
||||
acceptanceRate: 0,
|
||||
totalRevenueUsd: 0,
|
||||
totalRevenueEur: 0,
|
||||
preferredCurrency: 'USD',
|
||||
notificationEmail: null,
|
||||
autoAcceptEnabled: false,
|
||||
isVerified: false,
|
||||
isActive: true,
|
||||
lastLoginAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
user: mockUser,
|
||||
organization: mockOrganization,
|
||||
bookings: [],
|
||||
activities: [],
|
||||
};
|
||||
|
||||
organizationRepository.create.mockReturnValue(mockOrganization);
|
||||
organizationRepository.save.mockResolvedValue(mockOrganization);
|
||||
userRepository.create.mockReturnValue(mockUser);
|
||||
userRepository.save.mockResolvedValue(mockUser);
|
||||
carrierProfileRepository.create.mockResolvedValue(mockCarrier as any);
|
||||
|
||||
const result = await service.createCarrierAccountIfNotExists(
|
||||
'carrier@test.com',
|
||||
'Test Carrier'
|
||||
);
|
||||
|
||||
expect(result.isNewAccount).toBe(true);
|
||||
expect(result.carrierId).toBe('carrier-1');
|
||||
expect(result.userId).toBe('user-1');
|
||||
expect(result.temporaryPassword).toBeDefined();
|
||||
expect(result.temporaryPassword).toHaveLength(12);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should login successfully with valid credentials', async () => {
|
||||
const hashedPassword = await argon2.hash('password123');
|
||||
const mockCarrier = {
|
||||
...mockCarrierProfile,
|
||||
user: {
|
||||
...mockCarrierProfile.user,
|
||||
passwordHash: hashedPassword,
|
||||
},
|
||||
};
|
||||
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrier as any);
|
||||
jwtService.sign.mockReturnValueOnce('access-token').mockReturnValueOnce('refresh-token');
|
||||
|
||||
const result = await service.login('carrier@test.com', 'password123');
|
||||
|
||||
expect(result).toEqual({
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
carrier: {
|
||||
id: 'carrier-1',
|
||||
companyName: 'Test Carrier',
|
||||
email: 'carrier@test.com',
|
||||
},
|
||||
});
|
||||
expect(carrierProfileRepository.updateLastLogin).toHaveBeenCalledWith('carrier-1');
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for non-existent carrier', async () => {
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.login('nonexistent@test.com', 'password123')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid password', async () => {
|
||||
const hashedPassword = await argon2.hash('correctPassword');
|
||||
const mockCarrier = {
|
||||
...mockCarrierProfile,
|
||||
user: {
|
||||
...mockCarrierProfile.user,
|
||||
passwordHash: hashedPassword,
|
||||
},
|
||||
};
|
||||
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrier as any);
|
||||
|
||||
await expect(
|
||||
service.login('carrier@test.com', 'wrongPassword')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for inactive carrier', async () => {
|
||||
const hashedPassword = await argon2.hash('password123');
|
||||
const mockCarrier = {
|
||||
...mockCarrierProfile,
|
||||
isActive: false,
|
||||
user: {
|
||||
...mockCarrierProfile.user,
|
||||
passwordHash: hashedPassword,
|
||||
},
|
||||
};
|
||||
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrier as any);
|
||||
|
||||
await expect(
|
||||
service.login('carrier@test.com', 'password123')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateAutoLoginToken', () => {
|
||||
it('should generate auto-login token with correct payload', async () => {
|
||||
jwtService.sign.mockReturnValue('auto-login-token');
|
||||
|
||||
const token = await service.generateAutoLoginToken('user-1', 'carrier-1');
|
||||
|
||||
expect(token).toBe('auto-login-token');
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
{
|
||||
sub: 'user-1',
|
||||
carrierId: 'carrier-1',
|
||||
type: 'carrier',
|
||||
autoLogin: true,
|
||||
},
|
||||
{ expiresIn: '1h' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyAutoLoginToken', () => {
|
||||
it('should verify valid auto-login token', async () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
carrierId: 'carrier-1',
|
||||
type: 'carrier',
|
||||
autoLogin: true,
|
||||
});
|
||||
|
||||
const result = await service.verifyAutoLoginToken('valid-token');
|
||||
|
||||
expect(result).toEqual({
|
||||
userId: 'user-1',
|
||||
carrierId: 'carrier-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid token type', async () => {
|
||||
jwtService.verify.mockReturnValue({
|
||||
sub: 'user-1',
|
||||
carrierId: 'carrier-1',
|
||||
type: 'user',
|
||||
autoLogin: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyAutoLoginToken('invalid-token')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for expired token', async () => {
|
||||
jwtService.verify.mockImplementation(() => {
|
||||
throw new Error('Token expired');
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.verifyAutoLoginToken('expired-token')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changePassword', () => {
|
||||
it('should change password successfully', async () => {
|
||||
const oldHashedPassword = await argon2.hash('oldPassword');
|
||||
const mockCarrier = {
|
||||
...mockCarrierProfile,
|
||||
user: {
|
||||
...mockCarrierProfile.user,
|
||||
passwordHash: oldHashedPassword,
|
||||
},
|
||||
};
|
||||
|
||||
carrierProfileRepository.findById.mockResolvedValue(mockCarrier as any);
|
||||
userRepository.save.mockResolvedValue(mockCarrier.user);
|
||||
|
||||
await service.changePassword('carrier-1', 'oldPassword', 'newPassword');
|
||||
|
||||
expect(userRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for invalid old password', async () => {
|
||||
const oldHashedPassword = await argon2.hash('correctOldPassword');
|
||||
const mockCarrier = {
|
||||
...mockCarrierProfile,
|
||||
user: {
|
||||
...mockCarrierProfile.user,
|
||||
passwordHash: oldHashedPassword,
|
||||
},
|
||||
};
|
||||
|
||||
carrierProfileRepository.findById.mockResolvedValue(mockCarrier as any);
|
||||
|
||||
await expect(
|
||||
service.changePassword('carrier-1', 'wrongOldPassword', 'newPassword')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestPasswordReset', () => {
|
||||
it('should generate temporary password for existing carrier', async () => {
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(mockCarrierProfile as any);
|
||||
userRepository.save.mockResolvedValue(mockCarrierProfile.user);
|
||||
|
||||
const result = await service.requestPasswordReset('carrier@test.com');
|
||||
|
||||
expect(result.temporaryPassword).toBeDefined();
|
||||
expect(result.temporaryPassword).toHaveLength(12);
|
||||
expect(userRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw UnauthorizedException for non-existent carrier', async () => {
|
||||
carrierProfileRepository.findByEmail.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.requestPasswordReset('nonexistent@test.com')
|
||||
).rejects.toThrow(UnauthorizedException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,309 +0,0 @@
|
||||
/**
|
||||
* CarrierDashboardService Unit Tests
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { CarrierDashboardService } from './carrier-dashboard.service';
|
||||
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
|
||||
import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository';
|
||||
import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
||||
|
||||
describe('CarrierDashboardService', () => {
|
||||
let service: CarrierDashboardService;
|
||||
let carrierProfileRepository: jest.Mocked<CarrierProfileRepository>;
|
||||
let carrierActivityRepository: jest.Mocked<CarrierActivityRepository>;
|
||||
let csvBookingRepository: any;
|
||||
|
||||
const mockCarrierProfile = {
|
||||
id: 'carrier-1',
|
||||
userId: 'user-1',
|
||||
organizationId: 'org-1',
|
||||
companyName: 'Test Carrier',
|
||||
notificationEmail: 'carrier@test.com',
|
||||
isActive: true,
|
||||
isVerified: true,
|
||||
acceptanceRate: 85.5,
|
||||
totalRevenueUsd: 50000,
|
||||
totalRevenueEur: 45000,
|
||||
totalBookingsAccepted: 10,
|
||||
totalBookingsRejected: 2,
|
||||
};
|
||||
|
||||
const mockBooking = {
|
||||
id: 'booking-1',
|
||||
carrierId: 'carrier-1',
|
||||
carrierName: 'Test Carrier',
|
||||
carrierEmail: 'carrier@test.com',
|
||||
origin: 'Rotterdam',
|
||||
destination: 'New York',
|
||||
volumeCBM: 10,
|
||||
weightKG: 1000,
|
||||
palletCount: 5,
|
||||
priceUSD: 1500,
|
||||
priceEUR: 1350,
|
||||
primaryCurrency: 'USD',
|
||||
transitDays: 15,
|
||||
containerType: '40HC',
|
||||
status: 'PENDING',
|
||||
documents: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
fileName: 'invoice.pdf',
|
||||
type: 'INVOICE',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
},
|
||||
],
|
||||
confirmationToken: 'test-token',
|
||||
requestedAt: new Date(),
|
||||
carrierViewedAt: null,
|
||||
carrierAcceptedAt: null,
|
||||
carrierRejectedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CarrierDashboardService,
|
||||
{
|
||||
provide: CarrierProfileRepository,
|
||||
useValue: {
|
||||
findById: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CarrierActivityRepository,
|
||||
useValue: {
|
||||
findByCarrierId: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(CsvBookingOrmEntity),
|
||||
useValue: {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CarrierDashboardService>(CarrierDashboardService);
|
||||
carrierProfileRepository = module.get(CarrierProfileRepository);
|
||||
carrierActivityRepository = module.get(CarrierActivityRepository);
|
||||
csvBookingRepository = module.get(getRepositoryToken(CsvBookingOrmEntity));
|
||||
});
|
||||
|
||||
describe('getCarrierStats', () => {
|
||||
it('should return carrier dashboard statistics', async () => {
|
||||
carrierProfileRepository.findById.mockResolvedValue(mockCarrierProfile as any);
|
||||
csvBookingRepository.find.mockResolvedValue([
|
||||
{ ...mockBooking, status: 'PENDING' },
|
||||
{ ...mockBooking, status: 'ACCEPTED' },
|
||||
{ ...mockBooking, status: 'REJECTED' },
|
||||
]);
|
||||
carrierActivityRepository.findByCarrierId.mockResolvedValue([
|
||||
{
|
||||
id: 'activity-1',
|
||||
activityType: 'BOOKING_ACCEPTED',
|
||||
description: 'Booking accepted',
|
||||
createdAt: new Date(),
|
||||
bookingId: 'booking-1',
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await service.getCarrierStats('carrier-1');
|
||||
|
||||
expect(result).toEqual({
|
||||
totalBookings: 3,
|
||||
pendingBookings: 1,
|
||||
acceptedBookings: 1,
|
||||
rejectedBookings: 1,
|
||||
acceptanceRate: 85.5,
|
||||
totalRevenue: {
|
||||
usd: 50000,
|
||||
eur: 45000,
|
||||
},
|
||||
recentActivities: [
|
||||
{
|
||||
id: 'activity-1',
|
||||
type: 'BOOKING_ACCEPTED',
|
||||
description: 'Booking accepted',
|
||||
createdAt: expect.any(Date),
|
||||
bookingId: 'booking-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent carrier', async () => {
|
||||
carrierProfileRepository.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCarrierStats('non-existent')).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCarrierBookings', () => {
|
||||
it('should return paginated bookings for carrier', async () => {
|
||||
carrierProfileRepository.findById.mockResolvedValue(mockCarrierProfile as any);
|
||||
|
||||
const queryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(15),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockBooking]),
|
||||
};
|
||||
|
||||
csvBookingRepository.createQueryBuilder.mockReturnValue(queryBuilder);
|
||||
|
||||
const result = await service.getCarrierBookings('carrier-1', 1, 10);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{
|
||||
id: 'booking-1',
|
||||
origin: 'Rotterdam',
|
||||
destination: 'New York',
|
||||
status: 'PENDING',
|
||||
priceUsd: 1500,
|
||||
priceEur: 1350,
|
||||
primaryCurrency: 'USD',
|
||||
requestedAt: expect.any(Date),
|
||||
carrierViewedAt: null,
|
||||
documentsCount: 1,
|
||||
volumeCBM: 10,
|
||||
weightKG: 1000,
|
||||
palletCount: 5,
|
||||
transitDays: 15,
|
||||
containerType: '40HC',
|
||||
},
|
||||
],
|
||||
total: 15,
|
||||
page: 1,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter bookings by status', async () => {
|
||||
carrierProfileRepository.findById.mockResolvedValue(mockCarrierProfile as any);
|
||||
|
||||
const queryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
getCount: jest.fn().mockResolvedValue(5),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockBooking]),
|
||||
};
|
||||
|
||||
csvBookingRepository.createQueryBuilder.mockReturnValue(queryBuilder);
|
||||
|
||||
await service.getCarrierBookings('carrier-1', 1, 10, 'ACCEPTED');
|
||||
|
||||
expect(queryBuilder.andWhere).toHaveBeenCalledWith('booking.status = :status', {
|
||||
status: 'ACCEPTED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent carrier', async () => {
|
||||
carrierProfileRepository.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getCarrierBookings('non-existent', 1, 10)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBookingDetails', () => {
|
||||
it('should return booking details and mark as viewed', async () => {
|
||||
const booking = { ...mockBooking, carrierViewedAt: null };
|
||||
csvBookingRepository.findOne.mockResolvedValue(booking);
|
||||
csvBookingRepository.save.mockResolvedValue({ ...booking, carrierViewedAt: new Date() });
|
||||
carrierActivityRepository.create.mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.getBookingDetails('carrier-1', 'booking-1');
|
||||
|
||||
expect(result.id).toBe('booking-1');
|
||||
expect(result.origin).toBe('Rotterdam');
|
||||
expect(csvBookingRepository.save).toHaveBeenCalled();
|
||||
expect(carrierActivityRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not update view if already viewed', async () => {
|
||||
const booking = { ...mockBooking, carrierViewedAt: new Date() };
|
||||
csvBookingRepository.findOne.mockResolvedValue(booking);
|
||||
|
||||
await service.getBookingDetails('carrier-1', 'booking-1');
|
||||
|
||||
expect(csvBookingRepository.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent booking', async () => {
|
||||
csvBookingRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.getBookingDetails('carrier-1', 'non-existent')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for unauthorized access', async () => {
|
||||
csvBookingRepository.findOne.mockResolvedValue(mockBooking);
|
||||
|
||||
await expect(
|
||||
service.getBookingDetails('other-carrier', 'booking-1')
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadDocument', () => {
|
||||
it('should allow authorized carrier to download document', async () => {
|
||||
csvBookingRepository.findOne.mockResolvedValue(mockBooking);
|
||||
carrierActivityRepository.create.mockResolvedValue({} as any);
|
||||
|
||||
const result = await service.downloadDocument('carrier-1', 'booking-1', 'doc-1');
|
||||
|
||||
expect(result.document).toEqual({
|
||||
id: 'doc-1',
|
||||
fileName: 'invoice.pdf',
|
||||
type: 'INVOICE',
|
||||
url: 'https://example.com/doc.pdf',
|
||||
});
|
||||
expect(carrierActivityRepository.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw ForbiddenException for unauthorized carrier', async () => {
|
||||
csvBookingRepository.findOne.mockResolvedValue(mockBooking);
|
||||
|
||||
await expect(
|
||||
service.downloadDocument('other-carrier', 'booking-1', 'doc-1')
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent booking', async () => {
|
||||
csvBookingRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.downloadDocument('carrier-1', 'booking-1', 'doc-1')
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException for non-existent document', async () => {
|
||||
csvBookingRepository.findOne.mockResolvedValue(mockBooking);
|
||||
|
||||
await expect(
|
||||
service.downloadDocument('carrier-1', 'booking-1', 'non-existent-doc')
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,408 +0,0 @@
|
||||
/**
|
||||
* Carrier Dashboard Service
|
||||
*
|
||||
* Handles carrier dashboard statistics, bookings, and document management
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { CarrierProfileRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-profile.repository';
|
||||
import { CarrierActivityRepository } from '@infrastructure/persistence/typeorm/repositories/carrier-activity.repository';
|
||||
import { CsvBookingOrmEntity } from '@infrastructure/persistence/typeorm/entities/csv-booking.orm-entity';
|
||||
import { CarrierActivityType } from '@infrastructure/persistence/typeorm/entities/carrier-activity.orm-entity';
|
||||
|
||||
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;
|
||||
origin: string;
|
||||
destination: string;
|
||||
status: string;
|
||||
priceUsd: number;
|
||||
priceEur: number;
|
||||
primaryCurrency: string;
|
||||
requestedAt: Date;
|
||||
carrierViewedAt: Date | null;
|
||||
documentsCount: number;
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
palletCount: number;
|
||||
transitDays: number;
|
||||
containerType: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CarrierDashboardService {
|
||||
private readonly logger = new Logger(CarrierDashboardService.name);
|
||||
|
||||
constructor(
|
||||
private readonly carrierProfileRepository: CarrierProfileRepository,
|
||||
private readonly carrierActivityRepository: CarrierActivityRepository,
|
||||
@InjectRepository(CsvBookingOrmEntity)
|
||||
private readonly csvBookingRepository: Repository<CsvBookingOrmEntity>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get carrier dashboard statistics
|
||||
*/
|
||||
async getCarrierStats(carrierId: string): Promise<CarrierDashboardStats> {
|
||||
this.logger.log(`Fetching dashboard stats for carrier: ${carrierId}`);
|
||||
|
||||
const carrier = await this.carrierProfileRepository.findById(carrierId);
|
||||
|
||||
if (!carrier) {
|
||||
throw new NotFoundException('Carrier not found');
|
||||
}
|
||||
|
||||
// Get bookings for the carrier
|
||||
const bookings = await this.csvBookingRepository.find({
|
||||
where: { carrierId },
|
||||
});
|
||||
|
||||
// Count bookings by status
|
||||
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;
|
||||
|
||||
// Get recent activities
|
||||
const recentActivities = await this.carrierActivityRepository.findByCarrierId(carrierId, 10);
|
||||
|
||||
const stats: CarrierDashboardStats = {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
|
||||
this.logger.log(`Dashboard stats retrieved for carrier: ${carrierId}`);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get carrier bookings with pagination
|
||||
*/
|
||||
async getCarrierBookings(
|
||||
carrierId: string,
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
status?: string
|
||||
): Promise<{
|
||||
data: CarrierBookingListItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}> {
|
||||
this.logger.log(`Fetching bookings for carrier: ${carrierId} (page: ${page}, limit: ${limit})`);
|
||||
|
||||
const carrier = await this.carrierProfileRepository.findById(carrierId);
|
||||
|
||||
if (!carrier) {
|
||||
throw new NotFoundException('Carrier not found');
|
||||
}
|
||||
|
||||
// Build query
|
||||
const queryBuilder = this.csvBookingRepository
|
||||
.createQueryBuilder('booking')
|
||||
.where('booking.carrierId = :carrierId', { carrierId });
|
||||
|
||||
if (status) {
|
||||
queryBuilder.andWhere('booking.status = :status', { status });
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await queryBuilder.getCount();
|
||||
|
||||
// Get paginated results
|
||||
const bookings = await queryBuilder
|
||||
.orderBy('booking.requestedAt', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
const data: CarrierBookingListItem[] = bookings.map((booking) => ({
|
||||
id: booking.id,
|
||||
origin: booking.origin,
|
||||
destination: booking.destination,
|
||||
status: booking.status,
|
||||
priceUsd: booking.priceUSD,
|
||||
priceEur: booking.priceEUR,
|
||||
primaryCurrency: booking.primaryCurrency,
|
||||
requestedAt: booking.requestedAt,
|
||||
carrierViewedAt: booking.carrierViewedAt,
|
||||
documentsCount: booking.documents?.length || 0,
|
||||
volumeCBM: booking.volumeCBM,
|
||||
weightKG: booking.weightKG,
|
||||
palletCount: booking.palletCount,
|
||||
transitDays: booking.transitDays,
|
||||
containerType: booking.containerType,
|
||||
}));
|
||||
|
||||
this.logger.log(`Found ${data.length} bookings for carrier: ${carrierId} (total: ${total})`);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get booking details with documents
|
||||
*/
|
||||
async getBookingDetails(carrierId: string, bookingId: string): Promise<any> {
|
||||
this.logger.log(`Fetching booking details: ${bookingId} for carrier: ${carrierId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Booking not found');
|
||||
}
|
||||
|
||||
// Verify the booking belongs to this carrier
|
||||
if (booking.carrierId !== carrierId) {
|
||||
this.logger.warn(`Access denied: Carrier ${carrierId} attempted to access booking ${bookingId}`);
|
||||
throw new ForbiddenException('Access denied to this booking');
|
||||
}
|
||||
|
||||
// Mark as viewed if not already
|
||||
if (!booking.carrierViewedAt) {
|
||||
booking.carrierViewedAt = new Date();
|
||||
await this.csvBookingRepository.save(booking);
|
||||
|
||||
// Log the view activity
|
||||
await this.carrierActivityRepository.create({
|
||||
carrierId,
|
||||
bookingId,
|
||||
activityType: CarrierActivityType.BOOKING_ACCEPTED, // TODO: Add BOOKING_VIEWED type
|
||||
description: `Viewed booking ${bookingId}`,
|
||||
metadata: { bookingId },
|
||||
});
|
||||
|
||||
this.logger.log(`Marked booking ${bookingId} as viewed by carrier ${carrierId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
carrierName: booking.carrierName,
|
||||
carrierEmail: booking.carrierEmail,
|
||||
origin: booking.origin,
|
||||
destination: booking.destination,
|
||||
volumeCBM: booking.volumeCBM,
|
||||
weightKG: booking.weightKG,
|
||||
palletCount: booking.palletCount,
|
||||
priceUSD: booking.priceUSD,
|
||||
priceEUR: booking.priceEUR,
|
||||
primaryCurrency: booking.primaryCurrency,
|
||||
transitDays: booking.transitDays,
|
||||
containerType: booking.containerType,
|
||||
status: booking.status,
|
||||
documents: booking.documents || [],
|
||||
confirmationToken: booking.confirmationToken,
|
||||
requestedAt: booking.requestedAt,
|
||||
respondedAt: booking.respondedAt,
|
||||
notes: booking.notes,
|
||||
rejectionReason: booking.rejectionReason,
|
||||
carrierViewedAt: booking.carrierViewedAt,
|
||||
carrierAcceptedAt: booking.carrierAcceptedAt,
|
||||
carrierRejectedAt: booking.carrierRejectedAt,
|
||||
carrierRejectionReason: booking.carrierRejectionReason,
|
||||
carrierNotes: booking.carrierNotes,
|
||||
createdAt: booking.createdAt,
|
||||
updatedAt: booking.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a document from a booking
|
||||
*/
|
||||
async downloadDocument(
|
||||
carrierId: string,
|
||||
bookingId: string,
|
||||
documentId: string
|
||||
): Promise<{ document: any }> {
|
||||
this.logger.log(`Downloading document ${documentId} from booking ${bookingId} for carrier ${carrierId}`);
|
||||
|
||||
// Verify access
|
||||
const booking = await this.csvBookingRepository.findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (!booking || booking.carrierId !== carrierId) {
|
||||
this.logger.warn(`Access denied: Carrier ${carrierId} attempted to access document from booking ${bookingId}`);
|
||||
throw new ForbiddenException('Access denied to this document');
|
||||
}
|
||||
|
||||
// Find the document in the booking's documents array
|
||||
const document = booking.documents?.find((doc: any) => doc.id === documentId);
|
||||
|
||||
if (!document) {
|
||||
throw new NotFoundException(`Document not found: ${documentId}`);
|
||||
}
|
||||
|
||||
// Log the download activity
|
||||
await this.carrierActivityRepository.create({
|
||||
carrierId,
|
||||
bookingId,
|
||||
activityType: CarrierActivityType.DOCUMENT_DOWNLOADED,
|
||||
description: `Downloaded document ${document.fileName}`,
|
||||
metadata: {
|
||||
documentId,
|
||||
fileName: document.fileName,
|
||||
fileType: document.type,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Document ${documentId} downloaded by carrier ${carrierId}`);
|
||||
|
||||
// TODO: Implement actual file download from S3/MinIO
|
||||
// For now, return the document metadata
|
||||
return {
|
||||
document,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a booking
|
||||
*/
|
||||
async acceptBooking(
|
||||
carrierId: string,
|
||||
bookingId: string,
|
||||
notes?: string
|
||||
): Promise<void> {
|
||||
this.logger.log(`Accepting booking ${bookingId} by carrier ${carrierId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (!booking || booking.carrierId !== carrierId) {
|
||||
throw new ForbiddenException('Access denied to this booking');
|
||||
}
|
||||
|
||||
if (booking.status !== 'PENDING') {
|
||||
throw new ForbiddenException('Booking is not in pending status');
|
||||
}
|
||||
|
||||
// Update booking status
|
||||
booking.status = 'ACCEPTED';
|
||||
booking.carrierAcceptedAt = new Date();
|
||||
booking.carrierNotes = notes || null;
|
||||
booking.respondedAt = new Date();
|
||||
|
||||
await this.csvBookingRepository.save(booking);
|
||||
|
||||
// Update carrier statistics
|
||||
const carrier = await this.carrierProfileRepository.findById(carrierId);
|
||||
if (carrier) {
|
||||
const newAcceptedCount = carrier.totalBookingsAccepted + 1;
|
||||
const totalBookings = newAcceptedCount + carrier.totalBookingsRejected;
|
||||
const newAcceptanceRate = totalBookings > 0 ? (newAcceptedCount / totalBookings) * 100 : 0;
|
||||
|
||||
// Add revenue
|
||||
const newRevenueUsd = carrier.totalRevenueUsd + booking.priceUSD;
|
||||
const newRevenueEur = carrier.totalRevenueEur + booking.priceEUR;
|
||||
|
||||
await this.carrierProfileRepository.updateStatistics(carrierId, {
|
||||
totalBookingsAccepted: newAcceptedCount,
|
||||
acceptanceRate: newAcceptanceRate,
|
||||
totalRevenueUsd: newRevenueUsd,
|
||||
totalRevenueEur: newRevenueEur,
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await this.carrierActivityRepository.create({
|
||||
carrierId,
|
||||
bookingId,
|
||||
activityType: CarrierActivityType.BOOKING_ACCEPTED,
|
||||
description: `Accepted booking ${bookingId}`,
|
||||
metadata: { bookingId, notes },
|
||||
});
|
||||
|
||||
this.logger.log(`Booking ${bookingId} accepted by carrier ${carrierId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a booking
|
||||
*/
|
||||
async rejectBooking(
|
||||
carrierId: string,
|
||||
bookingId: string,
|
||||
reason?: string,
|
||||
notes?: string
|
||||
): Promise<void> {
|
||||
this.logger.log(`Rejecting booking ${bookingId} by carrier ${carrierId}`);
|
||||
|
||||
const booking = await this.csvBookingRepository.findOne({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
|
||||
if (!booking || booking.carrierId !== carrierId) {
|
||||
throw new ForbiddenException('Access denied to this booking');
|
||||
}
|
||||
|
||||
if (booking.status !== 'PENDING') {
|
||||
throw new ForbiddenException('Booking is not in pending status');
|
||||
}
|
||||
|
||||
// Update booking status
|
||||
booking.status = 'REJECTED';
|
||||
booking.carrierRejectedAt = new Date();
|
||||
booking.carrierRejectionReason = reason || null;
|
||||
booking.carrierNotes = notes || null;
|
||||
booking.respondedAt = new Date();
|
||||
|
||||
await this.csvBookingRepository.save(booking);
|
||||
|
||||
// Update carrier statistics
|
||||
const carrier = await this.carrierProfileRepository.findById(carrierId);
|
||||
if (carrier) {
|
||||
const newRejectedCount = carrier.totalBookingsRejected + 1;
|
||||
const totalBookings = carrier.totalBookingsAccepted + newRejectedCount;
|
||||
const newAcceptanceRate = totalBookings > 0 ? (carrier.totalBookingsAccepted / totalBookings) * 100 : 0;
|
||||
|
||||
await this.carrierProfileRepository.updateStatistics(carrierId, {
|
||||
totalBookingsRejected: newRejectedCount,
|
||||
acceptanceRate: newAcceptanceRate,
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await this.carrierActivityRepository.create({
|
||||
carrierId,
|
||||
bookingId,
|
||||
activityType: CarrierActivityType.BOOKING_REJECTED,
|
||||
description: `Rejected booking ${bookingId}`,
|
||||
metadata: { bookingId, reason, notes },
|
||||
});
|
||||
|
||||
this.logger.log(`Booking ${bookingId} rejected by carrier ${carrierId}`);
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { CheckCircle, Loader2, XCircle, Truck } from 'lucide-react';
|
||||
import { CheckCircle, Loader2, XCircle } from 'lucide-react';
|
||||
|
||||
export default function CarrierAcceptPage() {
|
||||
const params = useParams();
|
||||
@ -11,8 +11,7 @@ export default function CarrierAcceptPage() {
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bookingId, setBookingId] = useState<string | null>(null);
|
||||
const [isNewAccount, setIsNewAccount] = useState(false);
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
// Prevent double API calls (React 18 StrictMode issue)
|
||||
const hasCalledApi = useRef(false);
|
||||
@ -24,6 +23,7 @@ export default function CarrierAcceptPage() {
|
||||
return;
|
||||
}
|
||||
hasCalledApi.current = true;
|
||||
|
||||
if (!token) {
|
||||
setError('Token manquant');
|
||||
setLoading(false);
|
||||
@ -47,35 +47,34 @@ export default function CarrierAcceptPage() {
|
||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
// Messages d'erreur personnalisés
|
||||
let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking';
|
||||
|
||||
// Log pour debug
|
||||
console.error('API Error:', errorMessage);
|
||||
|
||||
if (errorMessage.includes('status ACCEPTED') || errorMessage.includes('ACCEPTED')) {
|
||||
errorMessage = '⚠️ Ce booking a déjà été accepté.\n\nVous devez créer un NOUVEAU booking avec:\ncd apps/backend\nnode create-test-booking.js';
|
||||
errorMessage = 'Ce booking a déjà été accepté.';
|
||||
} else if (errorMessage.includes('status REJECTED')) {
|
||||
errorMessage = 'Ce booking a déjà été refusé. Vous ne pouvez pas l\'accepter.';
|
||||
errorMessage = 'Ce booking a déjà été refusé.';
|
||||
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
||||
errorMessage = 'Booking introuvable. Le lien peut avoir expiré ou le token est invalide.';
|
||||
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Stocker le token JWT pour l'auto-login
|
||||
if (data.autoLoginToken) {
|
||||
localStorage.setItem('carrier_access_token', data.autoLoginToken);
|
||||
}
|
||||
|
||||
setBookingId(data.bookingId);
|
||||
setIsNewAccount(data.isNewAccount);
|
||||
setLoading(false);
|
||||
|
||||
// Redirection manuelle - plus de redirection automatique
|
||||
// Démarrer le compte à rebours
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
router.push('/');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
} catch (err) {
|
||||
console.error('Error accepting booking:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation');
|
||||
@ -95,7 +94,7 @@ export default function CarrierAcceptPage() {
|
||||
Traitement en cours...
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Nous acceptons votre réservation et créons votre compte.
|
||||
Nous traitons votre acceptation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@ -110,10 +109,10 @@ export default function CarrierAcceptPage() {
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/carrier/login')}
|
||||
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Retour à la connexion
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -123,34 +122,29 @@ export default function CarrierAcceptPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-500 mx-auto mb-4" />
|
||||
<CheckCircle className="w-20 h-20 text-green-500 mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Réservation Acceptée !
|
||||
Merci !
|
||||
</h1>
|
||||
|
||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-green-800 font-medium">
|
||||
✅ La réservation a été acceptée avec succès
|
||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
|
||||
<p className="text-green-800 font-medium text-lg mb-2">
|
||||
✅ Votre acceptation a bien été prise en compte
|
||||
</p>
|
||||
<p className="text-green-700 text-sm">
|
||||
Nous vous remercions d'avoir accepté cette demande de transport.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isNewAccount && (
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-blue-800 font-medium mb-2">
|
||||
🎉 Compte transporteur créé !
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
Un email avec vos identifiants de connexion vous a été envoyé.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
|
||||
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors flex items-center justify-center"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-6 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold transition-colors"
|
||||
>
|
||||
<Truck className="w-5 h-5 mr-2" />
|
||||
Voir les détails de la réservation
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,161 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,409 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Package,
|
||||
MapPin,
|
||||
Calendar,
|
||||
DollarSign,
|
||||
FileText,
|
||||
Download,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Truck,
|
||||
Weight,
|
||||
Box
|
||||
} from 'lucide-react';
|
||||
|
||||
interface BookingDocument {
|
||||
id: string;
|
||||
type: string;
|
||||
fileName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface BookingDetails {
|
||||
id: string;
|
||||
bookingId?: string;
|
||||
carrierName: string;
|
||||
carrierEmail: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
volumeCBM: number;
|
||||
weightKG: number;
|
||||
palletCount: number;
|
||||
priceUSD: number;
|
||||
priceEUR: number;
|
||||
primaryCurrency: string;
|
||||
transitDays: number;
|
||||
containerType: string;
|
||||
status: 'PENDING' | 'ACCEPTED' | 'REJECTED';
|
||||
documents: BookingDocument[];
|
||||
notes?: string;
|
||||
requestedAt: string;
|
||||
respondedAt?: string;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export default function CarrierBookingDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const bookingId = params.id as string;
|
||||
|
||||
const [booking, setBooking] = useState<BookingDetails | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchBookingDetails = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('carrier_access_token');
|
||||
|
||||
if (!token) {
|
||||
setError('Non autorisé - veuillez vous connecter');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`http://localhost:4000/api/v1/csv-bookings/${bookingId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setBooking(data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching booking:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du chargement');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (bookingId) {
|
||||
fetchBookingDetails();
|
||||
}
|
||||
}, [bookingId]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'ACCEPTED':
|
||||
return (
|
||||
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-green-100 text-green-800">
|
||||
<CheckCircle className="w-4 h-4 mr-2" />
|
||||
Accepté
|
||||
</span>
|
||||
);
|
||||
case 'REJECTED':
|
||||
return (
|
||||
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-red-100 text-red-800">
|
||||
<XCircle className="w-4 h-4 mr-2" />
|
||||
Refusé
|
||||
</span>
|
||||
);
|
||||
case 'PENDING':
|
||||
return (
|
||||
<span className="inline-flex items-center px-4 py-2 rounded-full text-sm font-semibold bg-yellow-100 text-yellow-800">
|
||||
<Clock className="w-4 h-4 mr-2" />
|
||||
En attente
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatPrice = (price: number, currency: string) => {
|
||||
return new Intl.NumberFormat('fr-FR', {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
}).format(price);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Chargement des détails...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !booking) {
|
||||
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 mb-6">{error || 'Réservation introuvable'}</p>
|
||||
<button
|
||||
onClick={() => router.push('/carrier/dashboard')}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Retour au tableau de bord
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 py-8 px-4">
|
||||
<div className="max-w-5xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={() => router.push('/carrier/dashboard')}
|
||||
className="flex items-center text-blue-600 hover:text-blue-800 font-medium mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 mr-2" />
|
||||
Retour au tableau de bord
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Détails de la Réservation
|
||||
</h1>
|
||||
<p className="text-gray-600">Référence: {booking.bookingId || booking.id}</p>
|
||||
</div>
|
||||
{getStatusBadge(booking.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Route Information */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<MapPin className="w-6 h-6 mr-2 text-blue-600" />
|
||||
Itinéraire
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-1">Origine</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{booking.origin}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 mx-8">
|
||||
<div className="border-t-2 border-dashed border-gray-300 relative">
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white px-3">
|
||||
<Truck className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-sm text-gray-600 mt-2">
|
||||
{booking.transitDays} jours de transit
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600 mb-1">Destination</p>
|
||||
<p className="text-2xl font-bold text-blue-600">{booking.destination}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shipment Details */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<Package className="w-6 h-6 mr-2 text-blue-600" />
|
||||
Détails de la Marchandise
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<Box className="w-5 h-5 text-gray-600 mr-2" />
|
||||
<p className="text-sm text-gray-600">Volume</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{booking.volumeCBM} CBM</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<Weight className="w-5 h-5 text-gray-600 mr-2" />
|
||||
<p className="text-sm text-gray-600">Poids</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{booking.weightKG} kg</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<Package className="w-5 h-5 text-gray-600 mr-2" />
|
||||
<p className="text-sm text-gray-600">Palettes</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{booking.palletCount || 'N/A'}</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center mb-2">
|
||||
<Truck className="w-5 h-5 text-gray-600 mr-2" />
|
||||
<p className="text-sm text-gray-600">Type</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-gray-900">{booking.containerType}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<DollarSign className="w-6 h-6 mr-2 text-blue-600" />
|
||||
Prix
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center justify-between bg-green-50 rounded-lg p-6">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 mb-1">Prix total</p>
|
||||
<p className="text-4xl font-bold text-green-600">
|
||||
{formatPrice(
|
||||
booking.primaryCurrency === 'USD' ? booking.priceUSD : booking.priceEUR,
|
||||
booking.primaryCurrency
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{booking.primaryCurrency === 'USD' && booking.priceEUR > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 mb-1">Équivalent EUR</p>
|
||||
<p className="text-2xl font-semibold text-gray-700">
|
||||
{formatPrice(booking.priceEUR, 'EUR')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.primaryCurrency === 'EUR' && booking.priceUSD > 0 && (
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-gray-600 mb-1">Équivalent USD</p>
|
||||
<p className="text-2xl font-semibold text-gray-700">
|
||||
{formatPrice(booking.priceUSD, 'USD')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents */}
|
||||
{booking.documents && booking.documents.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<FileText className="w-6 h-6 mr-2 text-blue-600" />
|
||||
Documents ({booking.documents.length})
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{booking.documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between bg-gray-50 rounded-lg p-4 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<FileText className="w-5 h-5 text-blue-600 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{doc.fileName}</p>
|
||||
<p className="text-sm text-gray-600">{doc.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Télécharger
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{booking.notes && (
|
||||
<div className="bg-white rounded-lg shadow-md p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4">📝 Notes</h2>
|
||||
<p className="text-gray-700 whitespace-pre-wrap">{booking.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rejection Reason */}
|
||||
{booking.status === 'REJECTED' && booking.rejectionReason && (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-red-900 mb-4">❌ Raison du refus</h2>
|
||||
<p className="text-red-800">{booking.rejectionReason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-4 flex items-center">
|
||||
<Calendar className="w-6 h-6 mr-2 text-blue-600" />
|
||||
Chronologie
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start">
|
||||
<div className="w-2 h-2 bg-blue-600 rounded-full mt-2 mr-4"></div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">Demande reçue</p>
|
||||
<p className="text-sm text-gray-600">{formatDate(booking.requestedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{booking.respondedAt && (
|
||||
<div className="flex items-start">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 mr-4 ${
|
||||
booking.status === 'ACCEPTED' ? 'bg-green-600' : 'bg-red-600'
|
||||
}`}></div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{booking.status === 'ACCEPTED' ? 'Acceptée' : 'Refusée'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{formatDate(booking.respondedAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/carrier/dashboard')}
|
||||
className="flex-1 px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold"
|
||||
>
|
||||
Retour au tableau de bord
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.print()}
|
||||
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-semibold"
|
||||
>
|
||||
Imprimer les détails
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,142 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,214 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -1,137 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@ -2,147 +2,105 @@
|
||||
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { XCircle, Loader2, CheckCircle, Truck, MessageSquare } from 'lucide-react';
|
||||
import { XCircle, Loader2, CheckCircle } from 'lucide-react';
|
||||
|
||||
export default function CarrierRejectPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const token = params.token as string;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bookingId, setBookingId] = useState<string | null>(null);
|
||||
const [isNewAccount, setIsNewAccount] = useState(false);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [reason, setReason] = useState('');
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
// Prevent double API calls (React 18 StrictMode issue)
|
||||
const hasCalledApi = useRef(false);
|
||||
|
||||
const handleReject = async () => {
|
||||
// Protection contre les doubles appels
|
||||
if (hasCalledApi.current) {
|
||||
return;
|
||||
}
|
||||
hasCalledApi.current = true;
|
||||
if (!token) {
|
||||
setError('Token manquant');
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const rejectBooking = async () => {
|
||||
// Protection contre les doubles appels
|
||||
if (hasCalledApi.current) {
|
||||
return;
|
||||
}
|
||||
hasCalledApi.current = true;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Construire l'URL avec la raison en query param si fournie
|
||||
const url = new URL(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`);
|
||||
if (reason.trim()) {
|
||||
url.searchParams.append('reason', reason.trim());
|
||||
if (!token) {
|
||||
setError('Token manquant');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
try {
|
||||
// Appeler l'API backend pour refuser le booking
|
||||
const response = await fetch(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
if (!response.ok) {
|
||||
let errorData;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch (e) {
|
||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
||||
}
|
||||
|
||||
// Messages d'erreur personnalisés
|
||||
let errorMessage = errorData.message || 'Erreur lors du refus du booking';
|
||||
let errorMessage = errorData.message || 'Erreur lors du refus du booking';
|
||||
|
||||
if (errorMessage.includes('status REJECTED')) {
|
||||
errorMessage = 'Ce booking a déjà été refusé. Vous ne pouvez pas le refuser à nouveau.';
|
||||
} else if (errorMessage.includes('status ACCEPTED')) {
|
||||
errorMessage = 'Ce booking a déjà été accepté. Vous ne pouvez plus le refuser.';
|
||||
} else if (errorMessage.includes('not found')) {
|
||||
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
|
||||
if (errorMessage.includes('status REJECTED')) {
|
||||
errorMessage = 'Ce booking a déjà été refusé.';
|
||||
} else if (errorMessage.includes('status ACCEPTED')) {
|
||||
errorMessage = 'Ce booking a déjà été accepté.';
|
||||
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
||||
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
setLoading(false);
|
||||
|
||||
// Démarrer le compte à rebours
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
router.push('/');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
} catch (err) {
|
||||
console.error('Error rejecting booking:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du refus');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Stocker le token JWT pour l'auto-login
|
||||
if (data.autoLoginToken) {
|
||||
localStorage.setItem('carrier_access_token', data.autoLoginToken);
|
||||
}
|
||||
|
||||
setBookingId(data.bookingId);
|
||||
setIsNewAccount(data.isNewAccount);
|
||||
setShowSuccess(true);
|
||||
setLoading(false);
|
||||
|
||||
// Redirection manuelle - plus de redirection automatique
|
||||
} catch (err) {
|
||||
console.error('Error rejecting booking:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur lors du refus');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
rejectBooking();
|
||||
}, [token, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<Loader2 className="w-16 h-16 text-red-600 mx-auto mb-4 animate-spin" />
|
||||
<Loader2 className="w-16 h-16 text-orange-600 mx-auto mb-4 animate-spin" />
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Traitement en cours...
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Nous traitons votre refus de la réservation.
|
||||
Nous traitons votre refus.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showSuccess) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<CheckCircle className="w-16 h-16 text-orange-500 mx-auto mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Réservation Refusée
|
||||
</h1>
|
||||
|
||||
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-orange-800 font-medium">
|
||||
❌ La réservation a été refusée
|
||||
</p>
|
||||
{reason && (
|
||||
<p className="text-sm text-orange-700 mt-2">
|
||||
Raison : {reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isNewAccount && (
|
||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
||||
<p className="text-blue-800 font-medium mb-2">
|
||||
🎉 Compte transporteur créé !
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
Un email avec vos identifiants de connexion vous a été envoyé.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
|
||||
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Truck className="w-5 h-5 mr-2" />
|
||||
Voir les détails de la réservation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||
@ -151,71 +109,43 @@ export default function CarrierRejectPage() {
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<button
|
||||
onClick={() => router.push('/carrier/login')}
|
||||
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
Retour à la connexion
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Formulaire de refus
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 p-4">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full">
|
||||
<div className="text-center mb-6">
|
||||
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Refuser la Réservation
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Vous êtes sur le point de refuser cette demande de réservation.
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 to-red-50">
|
||||
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto mb-6" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Merci de votre réponse
|
||||
</h1>
|
||||
|
||||
<div className="bg-orange-50 border-2 border-orange-200 rounded-lg p-6 mb-6">
|
||||
<p className="text-orange-800 font-medium text-lg mb-2">
|
||||
✓ Votre refus a bien été pris en compte
|
||||
</p>
|
||||
<p className="text-orange-700 text-sm">
|
||||
Nous avons bien enregistré votre décision concernant cette demande de transport.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label className="flex items-center text-sm font-medium text-gray-700 mb-2">
|
||||
<MessageSquare className="w-4 h-4 mr-2" />
|
||||
Raison du refus (optionnel)
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
placeholder="Ex: Capacité insuffisante, date non disponible..."
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent resize-none"
|
||||
rows={4}
|
||||
maxLength={500}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{reason.length}/500 caractères
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-gray-500 text-sm mb-4">
|
||||
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={handleReject}
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Confirmer le Refus
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
disabled={loading}
|
||||
className="w-full px-6 py-3 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
⚠️ Le client sera notifié de votre refus{reason.trim() ? ' avec la raison fournie' : ''}.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors"
|
||||
>
|
||||
Retour à l'accueil
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -107,6 +107,3 @@ export const organizationsApi = {
|
||||
return apiClient.get<Organization[]>('/api/v1/organizations');
|
||||
},
|
||||
};
|
||||
|
||||
// Export for use in components
|
||||
export { organizationsApi };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user