fix booking validate
This commit is contained in:
parent
faf1207300
commit
49b02face6
258
CLAUDE.md
258
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.
|
**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
|
## Development Commands
|
||||||
|
|
||||||
### Local Development Setup
|
### Local Development Setup
|
||||||
@ -155,8 +191,17 @@ npm run migration:run
|
|||||||
|
|
||||||
# Revert last migration
|
# Revert last migration
|
||||||
npm run migration:revert
|
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
|
### Build & Production
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -179,6 +224,15 @@ The backend follows strict hexagonal architecture with three isolated layers:
|
|||||||
|
|
||||||
```
|
```
|
||||||
apps/backend/src/
|
apps/backend/src/
|
||||||
|
├── 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)
|
├── application/ # 🔌 Controllers & DTOs (depends ONLY on domain)
|
||||||
│ ├── auth/ # JWT authentication module
|
│ ├── auth/ # JWT authentication module
|
||||||
│ ├── rates/ # Rate search endpoints
|
│ ├── rates/ # Rate search endpoints
|
||||||
@ -207,6 +261,7 @@ apps/backend/src/
|
|||||||
│ ├── repositories/
|
│ ├── repositories/
|
||||||
│ │ ├── carrier-profile.repository.ts
|
│ │ ├── carrier-profile.repository.ts
|
||||||
│ │ └── carrier-activity.repository.ts
|
│ │ └── carrier-activity.repository.ts
|
||||||
|
│ ├── mappers/ # Domain ↔ ORM entity mappers
|
||||||
│ └── migrations/
|
│ └── migrations/
|
||||||
│ ├── 1733185000000-CreateCarrierProfiles.ts
|
│ ├── 1733185000000-CreateCarrierProfiles.ts
|
||||||
│ ├── 1733186000000-CreateCarrierActivities.ts
|
│ ├── 1733186000000-CreateCarrierActivities.ts
|
||||||
@ -222,10 +277,36 @@ apps/backend/src/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Critical Rules**:
|
**Critical Rules**:
|
||||||
1. **Domain layer**: No imports of NestJS, TypeORM, Redis, or any framework (domain layer not shown - pure business logic)
|
1. **Domain layer**: No imports of NestJS, TypeORM, Redis, or any framework - pure TypeScript only
|
||||||
2. **Dependencies flow inward**: Infrastructure → Application → Domain
|
2. **Dependencies flow inward**: Infrastructure → Application → Domain (never the reverse)
|
||||||
3. **TypeScript path aliases**: Use `@domain/*`, `@application/*`, `@infrastructure/*`
|
3. **TypeScript path aliases**: Use `@domain/*`, `@application/*`, `@infrastructure/*`
|
||||||
4. **Testing**: Domain tests must run without NestJS TestingModule
|
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)
|
### 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`
|
- ORM Entities: `booking.orm-entity.ts`, `carrier-profile.orm-entity.ts`
|
||||||
- Migrations: `1730000000001-CreateBookings.ts`, `1733185000000-CreateCarrierProfiles.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
|
## Common Pitfalls to Avoid
|
||||||
|
|
||||||
❌ **DO NOT**:
|
❌ **DO NOT**:
|
||||||
- Import NestJS/TypeORM in domain layer
|
- Import NestJS/TypeORM in domain layer
|
||||||
- Put business logic in controllers or repositories
|
- 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)
|
- Skip writing tests (coverage targets enforced)
|
||||||
- Use `DATABASE_SYNC=true` in production
|
- Use `DATABASE_SYNC=true` in production (always use migrations)
|
||||||
- Commit `.env` files
|
- Commit `.env` files (use `.env.example` templates)
|
||||||
- Expose sensitive data in API responses
|
- Expose sensitive data in API responses (passwords, tokens, internal IDs)
|
||||||
- Skip rate limiting on public endpoints
|
- 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
|
- Send emails without proper error handling
|
||||||
- Store plain text passwords (always use Argon2)
|
- Store plain text passwords (always use Argon2)
|
||||||
|
- Modify applied migrations (create new migration instead)
|
||||||
|
- Mix domain logic with framework code
|
||||||
|
|
||||||
✅ **DO**:
|
✅ **DO**:
|
||||||
- Follow hexagonal architecture strictly
|
- Follow hexagonal architecture strictly (Infrastructure → Application → Domain)
|
||||||
- Write tests for all new features (domain 90%+)
|
- Write tests for all new features (domain 90%+, application 80%+)
|
||||||
- Use TypeScript path aliases (`@domain/*`, `@application/*`, `@infrastructure/*`)
|
- Use TypeScript path aliases (`@domain/*`, `@application/*`, `@infrastructure/*`)
|
||||||
- Validate all DTOs with `class-validator`
|
- Validate all DTOs with `class-validator` decorators
|
||||||
- Implement circuit breakers for external APIs
|
- Implement circuit breakers for external APIs (carrier connectors)
|
||||||
- Cache frequently accessed data (Redis)
|
- Cache frequently accessed data (Redis with TTL)
|
||||||
- Use structured logging (Pino)
|
- Use structured logging (Pino JSON format)
|
||||||
- Document APIs with Swagger decorators
|
- Document APIs with Swagger decorators (`@ApiOperation`, `@ApiResponse`)
|
||||||
- Run migrations before deployment
|
- Run migrations before deployment (`npm run migration:run`)
|
||||||
- Test email sending in development with test accounts
|
- Test email sending in development with test accounts
|
||||||
- Use MJML for responsive email templates
|
- 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
|
## 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
|
- [docker/DOCKER_BUILD_GUIDE.md](docker/DOCKER_BUILD_GUIDE.md) - Docker build instructions
|
||||||
- [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) - Pre-deployment checklist
|
- [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
|
## Code Review Checklist
|
||||||
|
|
||||||
1. Hexagonal architecture principles followed
|
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
|
10. ESLint passes with no warnings
|
||||||
11. Email templates tested in development
|
11. Email templates tested in development
|
||||||
12. Carrier workflow tested end-to-end
|
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 { WebhooksModule } from './application/webhooks/webhooks.module';
|
||||||
import { GDPRModule } from './application/gdpr/gdpr.module';
|
import { GDPRModule } from './application/gdpr/gdpr.module';
|
||||||
import { CsvBookingsModule } from './application/csv-bookings.module';
|
import { CsvBookingsModule } from './application/csv-bookings.module';
|
||||||
import { CarrierPortalModule } from './application/modules/carrier-portal.module';
|
|
||||||
import { CacheModule } from './infrastructure/cache/cache.module';
|
import { CacheModule } from './infrastructure/cache/cache.module';
|
||||||
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
import { CarrierModule } from './infrastructure/carriers/carrier.module';
|
||||||
import { SecurityModule } from './infrastructure/security/security.module';
|
import { SecurityModule } from './infrastructure/security/security.module';
|
||||||
@ -109,7 +108,6 @@ import { CustomThrottlerGuard } from './application/guards/throttle.guard';
|
|||||||
PortsModule,
|
PortsModule,
|
||||||
BookingsModule,
|
BookingsModule,
|
||||||
CsvBookingsModule,
|
CsvBookingsModule,
|
||||||
CarrierPortalModule,
|
|
||||||
OrganizationsModule,
|
OrganizationsModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
DashboardModule,
|
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 { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
import { CarrierAuthService } from '../services/carrier-auth.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Booking Actions Controller (Public Routes)
|
* CSV Booking Actions Controller (Public Routes)
|
||||||
@ -14,8 +13,7 @@ import { CarrierAuthService } from '../services/carrier-auth.service';
|
|||||||
@Controller('csv-booking-actions')
|
@Controller('csv-booking-actions')
|
||||||
export class CsvBookingActionsController {
|
export class CsvBookingActionsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csvBookingService: CsvBookingService,
|
private readonly csvBookingService: CsvBookingService
|
||||||
private readonly carrierAuthService: CarrierAuthService
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -33,7 +31,7 @@ export class CsvBookingActionsController {
|
|||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -41,28 +39,13 @@ export class CsvBookingActionsController {
|
|||||||
description: 'Booking cannot be accepted (invalid status or expired)',
|
description: 'Booking cannot be accepted (invalid status or expired)',
|
||||||
})
|
})
|
||||||
async acceptBooking(@Param('token') token: string) {
|
async acceptBooking(@Param('token') token: string) {
|
||||||
// 1. Accept the booking
|
// Accept the booking
|
||||||
const booking = await this.csvBookingService.acceptBooking(token);
|
const booking = await this.csvBookingService.acceptBooking(token);
|
||||||
|
|
||||||
// 2. Create carrier account if it doesn't exist
|
// Return simple success response
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
autoLoginToken,
|
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
isNewAccount,
|
|
||||||
action: 'accepted',
|
action: 'accepted',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -88,7 +71,7 @@ export class CsvBookingActionsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -96,28 +79,13 @@ export class CsvBookingActionsController {
|
|||||||
description: 'Booking cannot be rejected (invalid status or expired)',
|
description: 'Booking cannot be rejected (invalid status or expired)',
|
||||||
})
|
})
|
||||||
async rejectBooking(@Param('token') token: string, @Query('reason') reason: string) {
|
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);
|
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||||
|
|
||||||
// 2. Create carrier account if it doesn't exist
|
// Return simple success response
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
autoLoginToken,
|
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
isNewAccount,
|
|
||||||
action: 'rejected',
|
action: 'rejected',
|
||||||
reason: reason || null,
|
reason: reason || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -31,7 +31,6 @@ import { Response } from 'express';
|
|||||||
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../guards/jwt-auth.guard';
|
||||||
import { Public } from '../decorators/public.decorator';
|
import { Public } from '../decorators/public.decorator';
|
||||||
import { CsvBookingService } from '../services/csv-booking.service';
|
import { CsvBookingService } from '../services/csv-booking.service';
|
||||||
import { CarrierAuthService } from '../services/carrier-auth.service';
|
|
||||||
import {
|
import {
|
||||||
CreateCsvBookingDto,
|
CreateCsvBookingDto,
|
||||||
CsvBookingResponseDto,
|
CsvBookingResponseDto,
|
||||||
@ -49,8 +48,7 @@ import {
|
|||||||
@Controller('csv-bookings')
|
@Controller('csv-bookings')
|
||||||
export class CsvBookingsController {
|
export class CsvBookingsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly csvBookingService: CsvBookingService,
|
private readonly csvBookingService: CsvBookingService
|
||||||
private readonly carrierAuthService: CarrierAuthService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -174,7 +172,7 @@ export class CsvBookingsController {
|
|||||||
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
@ApiParam({ name: 'token', description: 'Booking confirmation token (UUID)' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -182,28 +180,13 @@ export class CsvBookingsController {
|
|||||||
description: 'Booking cannot be accepted (invalid status or expired)',
|
description: 'Booking cannot be accepted (invalid status or expired)',
|
||||||
})
|
})
|
||||||
async acceptBooking(@Param('token') token: string) {
|
async acceptBooking(@Param('token') token: string) {
|
||||||
// 1. Accept the booking
|
// Accept the booking
|
||||||
const booking = await this.csvBookingService.acceptBooking(token);
|
const booking = await this.csvBookingService.acceptBooking(token);
|
||||||
|
|
||||||
// 2. Create carrier account if it doesn't exist
|
// Return simple success response
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
autoLoginToken,
|
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
isNewAccount,
|
|
||||||
action: 'accepted',
|
action: 'accepted',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -229,7 +212,7 @@ export class CsvBookingsController {
|
|||||||
})
|
})
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
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({ status: 404, description: 'Booking not found or invalid token' })
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
@ -240,28 +223,13 @@ export class CsvBookingsController {
|
|||||||
@Param('token') token: string,
|
@Param('token') token: string,
|
||||||
@Query('reason') reason: string
|
@Query('reason') reason: string
|
||||||
) {
|
) {
|
||||||
// 1. Reject the booking
|
// Reject the booking
|
||||||
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
const booking = await this.csvBookingService.rejectBooking(token, reason);
|
||||||
|
|
||||||
// 2. Create carrier account if it doesn't exist
|
// Return simple success response
|
||||||
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
autoLoginToken,
|
|
||||||
bookingId: booking.id,
|
bookingId: booking.id,
|
||||||
isNewAccount,
|
|
||||||
action: 'rejected',
|
action: 'rejected',
|
||||||
reason: reason || null,
|
reason: reason || null,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { CsvBookingsController } from './controllers/csv-bookings.controller';
|
import { CsvBookingsController } from './controllers/csv-bookings.controller';
|
||||||
import { CsvBookingActionsController } from './controllers/csv-booking-actions.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 { NotificationsModule } from './notifications/notifications.module';
|
||||||
import { EmailModule } from '../infrastructure/email/email.module';
|
import { EmailModule } from '../infrastructure/email/email.module';
|
||||||
import { StorageModule } from '../infrastructure/storage/storage.module';
|
import { StorageModule } from '../infrastructure/storage/storage.module';
|
||||||
import { CarrierPortalModule } from './modules/carrier-portal.module';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSV Bookings Module
|
* CSV Bookings Module
|
||||||
@ -17,14 +16,18 @@ import { CarrierPortalModule } from './modules/carrier-portal.module';
|
|||||||
*/
|
*/
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([CsvBookingOrmEntity]),
|
TypeOrmModule.forFeature([
|
||||||
NotificationsModule, // Import NotificationsModule to access NotificationRepository
|
CsvBookingOrmEntity,
|
||||||
|
]),
|
||||||
|
NotificationsModule,
|
||||||
EmailModule,
|
EmailModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
forwardRef(() => CarrierPortalModule), // Import CarrierPortalModule to access CarrierAuthService
|
|
||||||
],
|
],
|
||||||
controllers: [CsvBookingsController, CsvBookingActionsController],
|
controllers: [CsvBookingsController, CsvBookingActionsController],
|
||||||
providers: [CsvBookingService, TypeOrmCsvBookingRepository],
|
providers: [
|
||||||
|
CsvBookingService,
|
||||||
|
TypeOrmCsvBookingRepository,
|
||||||
|
],
|
||||||
exports: [CsvBookingService],
|
exports: [CsvBookingService],
|
||||||
})
|
})
|
||||||
export class CsvBookingsModule {}
|
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 { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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() {
|
export default function CarrierAcceptPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@ -11,8 +11,7 @@ export default function CarrierAcceptPage() {
|
|||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [bookingId, setBookingId] = useState<string | null>(null);
|
const [countdown, setCountdown] = useState(5);
|
||||||
const [isNewAccount, setIsNewAccount] = useState(false);
|
|
||||||
|
|
||||||
// Prevent double API calls (React 18 StrictMode issue)
|
// Prevent double API calls (React 18 StrictMode issue)
|
||||||
const hasCalledApi = useRef(false);
|
const hasCalledApi = useRef(false);
|
||||||
@ -24,6 +23,7 @@ export default function CarrierAcceptPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasCalledApi.current = true;
|
hasCalledApi.current = true;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Token manquant');
|
setError('Token manquant');
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -47,35 +47,34 @@ export default function CarrierAcceptPage() {
|
|||||||
errorData = { message: `Erreur HTTP ${response.status}` };
|
errorData = { message: `Erreur HTTP ${response.status}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Messages d'erreur personnalisés
|
|
||||||
let errorMessage = errorData.message || 'Erreur lors de l\'acceptation du booking';
|
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')) {
|
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')) {
|
} 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')) {
|
} 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);
|
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);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error accepting booking:', err);
|
console.error('Error accepting booking:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation');
|
setError(err instanceof Error ? err.message : 'Erreur lors de l\'acceptation');
|
||||||
@ -95,7 +94,7 @@ export default function CarrierAcceptPage() {
|
|||||||
Traitement en cours...
|
Traitement en cours...
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Nous acceptons votre réservation et créons votre compte.
|
Nous traitons votre acceptation.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -110,10 +109,10 @@ export default function CarrierAcceptPage() {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
||||||
<p className="text-gray-600 mb-6">{error}</p>
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/carrier/login')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -123,34 +122,29 @@ export default function CarrierAcceptPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-green-50 to-blue-50">
|
<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">
|
<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">
|
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
Réservation Acceptée !
|
Merci !
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-4 mb-6">
|
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-6 mb-6">
|
||||||
<p className="text-green-800 font-medium">
|
<p className="text-green-800 font-medium text-lg mb-2">
|
||||||
✅ La réservation a été acceptée avec succès
|
✅ 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isNewAccount && (
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
<div className="bg-blue-50 border-2 border-blue-200 rounded-lg p-4 mb-6">
|
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
|
||||||
<p className="text-blue-800 font-medium mb-2">
|
|
||||||
🎉 Compte transporteur créé !
|
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-blue-700">
|
|
||||||
Un email avec vos identifiants de connexion vous a été envoyé.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push(`/carrier/dashboard/bookings/${bookingId}`)}
|
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 flex items-center justify-center"
|
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" />
|
Retour à l'accueil
|
||||||
Voir les détails de la réservation
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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,45 +2,37 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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() {
|
export default function CarrierRejectPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const token = params.token as string;
|
const token = params.token as string;
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [bookingId, setBookingId] = useState<string | null>(null);
|
const [countdown, setCountdown] = useState(5);
|
||||||
const [isNewAccount, setIsNewAccount] = useState(false);
|
|
||||||
const [showSuccess, setShowSuccess] = useState(false);
|
|
||||||
const [reason, setReason] = useState('');
|
|
||||||
|
|
||||||
// Prevent double API calls (React 18 StrictMode issue)
|
// Prevent double API calls (React 18 StrictMode issue)
|
||||||
const hasCalledApi = useRef(false);
|
const hasCalledApi = useRef(false);
|
||||||
|
|
||||||
const handleReject = async () => {
|
useEffect(() => {
|
||||||
|
const rejectBooking = async () => {
|
||||||
// Protection contre les doubles appels
|
// Protection contre les doubles appels
|
||||||
if (hasCalledApi.current) {
|
if (hasCalledApi.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hasCalledApi.current = true;
|
hasCalledApi.current = true;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
setError('Token manquant');
|
setError('Token manquant');
|
||||||
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Construire l'URL avec la raison en query param si fournie
|
// Appeler l'API backend pour refuser le booking
|
||||||
const url = new URL(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`);
|
const response = await fetch(`http://localhost:4000/api/v1/csv-booking-actions/reject/${token}`, {
|
||||||
if (reason.trim()) {
|
|
||||||
url.searchParams.append('reason', reason.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(url.toString(), {
|
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@ -48,35 +40,41 @@ export default function CarrierRejectPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
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')) {
|
if (errorMessage.includes('status REJECTED')) {
|
||||||
errorMessage = 'Ce booking a déjà été refusé. Vous ne pouvez pas le refuser à nouveau.';
|
errorMessage = 'Ce booking a déjà été refusé.';
|
||||||
} else if (errorMessage.includes('status ACCEPTED')) {
|
} else if (errorMessage.includes('status ACCEPTED')) {
|
||||||
errorMessage = 'Ce booking a déjà été accepté. Vous ne pouvez plus le refuser.';
|
errorMessage = 'Ce booking a déjà été accepté.';
|
||||||
} else if (errorMessage.includes('not found')) {
|
} else if (errorMessage.includes('not found') || errorMessage.includes('Booking not found')) {
|
||||||
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
|
errorMessage = 'Booking introuvable. Le lien peut avoir expiré.';
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(errorMessage);
|
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);
|
|
||||||
setShowSuccess(true);
|
|
||||||
setLoading(false);
|
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) {
|
} catch (err) {
|
||||||
console.error('Error rejecting booking:', err);
|
console.error('Error rejecting booking:', err);
|
||||||
setError(err instanceof Error ? err.message : 'Erreur lors du refus');
|
setError(err instanceof Error ? err.message : 'Erreur lors du refus');
|
||||||
@ -84,65 +82,25 @@ export default function CarrierRejectPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
rejectBooking();
|
||||||
|
}, [token, router]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
<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">
|
<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">
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
Traitement en cours...
|
Traitement en cours...
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Nous traitons votre refus de la réservation.
|
Nous traitons votre refus.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50">
|
||||||
@ -151,72 +109,44 @@ export default function CarrierRejectPage() {
|
|||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
<h1 className="text-2xl font-bold text-gray-900 mb-4">Erreur</h1>
|
||||||
<p className="text-gray-600 mb-6">{error}</p>
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => router.push('/carrier/login')}
|
onClick={() => router.push('/')}
|
||||||
className="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formulaire de refus
|
|
||||||
return (
|
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="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">
|
<div className="bg-white p-8 rounded-lg shadow-lg max-w-md w-full text-center">
|
||||||
<div className="text-center mb-6">
|
<CheckCircle className="w-20 h-20 text-orange-500 mx-auto 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-4">
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
Merci de votre réponse
|
||||||
Refuser la Réservation
|
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-gray-600">
|
|
||||||
Vous êtes sur le point de refuser cette demande de réservation.
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6">
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
<label className="flex items-center text-sm font-medium text-gray-700 mb-2">
|
Redirection vers la page d'accueil dans {countdown} seconde{countdown > 1 ? 's' : ''}...
|
||||||
<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>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<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
|
<button
|
||||||
onClick={() => router.back()}
|
onClick={() => router.push('/')}
|
||||||
disabled={loading}
|
className="w-full px-6 py-3 bg-orange-600 text-white rounded-lg hover:bg-orange-700 font-semibold transition-colors"
|
||||||
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
|
Retour à l'accueil
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,6 +107,3 @@ export const organizationsApi = {
|
|||||||
return apiClient.get<Organization[]>('/api/v1/organizations');
|
return apiClient.get<Organization[]>('/api/v1/organizations');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export for use in components
|
|
||||||
export { organizationsApi };
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user