fix improve

This commit is contained in:
David 2026-02-10 17:16:35 +01:00
parent fd1f57dd1d
commit baf5981847
13 changed files with 201 additions and 107 deletions

182
CLAUDE.md
View File

@ -4,21 +4,27 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview ## Project Overview
**Xpeditis** is a B2B SaaS maritime freight booking platform. Freight forwarders search and compare real-time shipping rates, book containers, and manage shipments. Built as a monorepo with NestJS backend (Hexagonal Architecture) and Next.js 14 frontend. **Xpeditis** is a B2B SaaS maritime freight booking platform. Freight forwarders search and compare real-time shipping rates, book containers, and manage shipments. Monorepo with NestJS 10 backend (Hexagonal Architecture) and Next.js 14 frontend.
## Development Commands ## Development Commands
All commands run from repo root unless noted otherwise.
```bash ```bash
# Start infrastructure (PostgreSQL + Redis + MinIO) # Infrastructure (PostgreSQL 15 + Redis 7 + MinIO)
docker-compose up -d docker-compose up -d
# Install dependencies # Install all dependencies
npm install && cd apps/backend && npm install && cd ../frontend && npm install npm run install:all
# Run database migrations # Environment setup (required on first run)
cp apps/backend/.env.example apps/backend/.env
cp apps/frontend/.env.example apps/frontend/.env
# Database migrations (from apps/backend/)
cd apps/backend && npm run migration:run cd apps/backend && npm run migration:run
# Start development servers # Development servers
npm run backend:dev # http://localhost:4000, Swagger: /api/docs npm run backend:dev # http://localhost:4000, Swagger: /api/docs
npm run frontend:dev # http://localhost:3000 npm run frontend:dev # http://localhost:3000
``` ```
@ -27,15 +33,32 @@ npm run frontend:dev # http://localhost:3000
```bash ```bash
# Backend (from apps/backend/) # Backend (from apps/backend/)
npm test # Unit tests npm test # Unit tests (Jest)
npm test -- booking.entity.spec.ts # Single file npm test -- booking.entity.spec.ts # Single file
npm run test:cov # With coverage npm run test:cov # With coverage
npm run test:integration # Integration tests (needs DB/Redis) npm run test:integration # Integration tests (needs DB/Redis, 30s timeout)
npm run test:e2e # E2E tests npm run test:e2e # E2E tests
# Frontend (from apps/frontend/) # Frontend (from apps/frontend/)
npm test npm test
npx playwright test # E2E tests npm run test:e2e # Playwright (chromium, firefox, webkit + mobile)
# From root
npm run backend:test
npm run frontend:test
```
Backend test config is in `apps/backend/package.json` (Jest). Integration test config: `apps/backend/jest-integration.json` (covers infrastructure layer, setup in `test/setup-integration.ts`). Frontend E2E config: `apps/frontend/playwright.config.ts`.
### Linting, Formatting & Type Checking
```bash
npm run backend:lint # ESLint backend
npm run frontend:lint # ESLint frontend
npm run format # Prettier (all files)
npm run format:check # Check formatting
# From apps/frontend/
npm run type-check # TypeScript checking (frontend only)
``` ```
### Database Migrations ### Database Migrations
@ -50,10 +73,17 @@ npm run migration:revert
### Build ### Build
```bash ```bash
npm run backend:build # Compiles TS with path alias resolution (tsc-alias) npm run backend:build # NestJS build with tsc-alias for path resolution
npm run frontend:build # Next.js production build npm run frontend:build # Next.js production build (standalone output)
``` ```
## Local Infrastructure
Docker-compose defaults (no `.env` changes needed for local dev):
- **PostgreSQL**: `xpeditis:xpeditis_dev_password@localhost:5432/xpeditis_dev`
- **Redis**: password `xpeditis_redis_password`, port 6379
- **MinIO** (S3-compatible storage): `minioadmin:minioadmin`, API port 9000, console port 9001
## Architecture ## Architecture
### Hexagonal Architecture (Backend) ### Hexagonal Architecture (Backend)
@ -61,74 +91,108 @@ npm run frontend:build # Next.js production build
``` ```
apps/backend/src/ apps/backend/src/
├── domain/ # CORE - Pure TypeScript, NO framework imports ├── domain/ # CORE - Pure TypeScript, NO framework imports
│ ├── entities/ # Booking, RateQuote, User (with private props, static create()) │ ├── entities/ # Booking, RateQuote, User, Carrier, Port, Container, CsvBooking, etc.
│ ├── value-objects/# Money, Email, BookingNumber (immutable, validated) │ ├── value-objects/ # Money, Email, BookingNumber, BookingStatus, PortCode, ContainerType, Volume, LicenseStatus, SubscriptionPlan, etc.
│ ├── services/ # Domain services │ ├── services/ # Pure domain services (rate-search, csv-rate-price-calculator, booking, port-search, etc.)
│ ├── ports/ │ ├── ports/
│ │ ├── in/ # Use case interfaces │ │ ├── in/ # Use case interfaces with execute() method
│ │ └── out/ # Repository interfaces (SPIs) │ │ └── out/ # Repository/SPI interfaces (token constants like BOOKING_REPOSITORY = 'BookingRepository')
│ └── exceptions/ # Domain exceptions │ └── exceptions/ # Domain-specific exceptions
├── application/ # Controllers, DTOs, Guards (depends ONLY on domain) ├── application/ # Controllers, DTOs (class-validator), Guards, Decorators, Mappers
└── infrastructure/ # TypeORM, Redis, Carrier APIs (depends ONLY on domain) └── infrastructure/ # TypeORM entities/repos/mappers, Redis cache, carrier APIs, MinIO/S3, email (MJML+Nodemailer), Sentry
``` ```
**Critical Rules**: **Critical dependency rules**:
- Domain layer: Zero imports from NestJS, TypeORM, Redis - Domain layer: zero imports from NestJS, TypeORM, Redis, or any framework
- Dependencies flow inward: Infrastructure → Application → Domain - Dependencies flow inward only: Infrastructure → Application → Domain
- Use path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` - Path aliases: `@domain/*`, `@application/*`, `@infrastructure/*` (defined in `apps/backend/tsconfig.json`)
- Domain tests run without NestJS TestingModule - Domain tests run without NestJS TestingModule
- Backend has strict TypeScript: `strict: true`, `strictNullChecks: true` (but `strictPropertyInitialization: false`)
### NestJS Modules (app.module.ts)
Global guards: JwtAuthGuard (all routes protected by default), CustomThrottlerGuard.
Feature modules: Auth, Rates, Ports, Bookings, CsvBookings, Organizations, Users, Dashboard, Audit, Notifications, Webhooks, GDPR, Admin, Subscriptions.
Infrastructure modules: CacheModule, CarrierModule, SecurityModule, CsvRateModule.
Swagger plugin enabled in `nest-cli.json` — DTOs auto-documented.
### Frontend (Next.js 14 App Router) ### Frontend (Next.js 14 App Router)
``` ```
apps/frontend/ apps/frontend/
├── app/ # App Router pages ├── app/ # App Router pages
│ ├── dashboard/ # Protected routes │ ├── dashboard/ # Protected routes (bookings, admin, settings)
│ │ ├── bookings/ # Booking management
│ │ ├── admin/ # Admin features (ADMIN role)
│ │ └── settings/ # User/org settings
│ └── carrier/ # Carrier portal (magic link auth) │ └── carrier/ # Carrier portal (magic link auth)
└── src/ └── src/
├── components/ # React components (shadcn/ui in ui/) ├── components/ # React components (shadcn/ui in ui/, layout/, bookings/, admin/)
├── hooks/ # useBookings, useNotifications ├── hooks/ # useBookings, useNotifications, useCsvRateSearch, useCompanies, useFilterOptions
├── lib/api/ # API client modules ├── lib/
└── types/ # TypeScript definitions │ ├── api/ # Fetch-based API client with auto token refresh (client.ts + per-module files)
│ ├── context/ # Auth context
│ └── fonts.ts # Manrope (headings) + Montserrat (body)
├── types/ # TypeScript type definitions
└── utils/ # Export utilities (Excel, PDF)
``` ```
Path aliases: `@/*``./src/*`, `@/components/*`, `@/lib/*`, `@/hooks/*` Path aliases: `@/*``./src/*`, `@/components/*`, `@/lib/*`, `@/app/*``./app/*`, `@/types/*`, `@/hooks/*`, `@/utils/*`
**Note**: Frontend tsconfig has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false` (unlike backend which is strict).
### Brand Design
Colors: Navy `#10183A` (primary), Turquoise `#34CCCD` (accent), Green `#067224` (success), Gray `#F2F2F2`.
Fonts: Manrope (headings), Montserrat (body).
Landing page is in French.
## Key Patterns ## Key Patterns
### Entity Pattern (Domain) ### Entity Pattern (Domain)
Private constructor + static `create()` factory. Immutable — mutation methods return new instances. Some entities also have `fromPersistence()` for reconstitution and `toObject()` for serialization.
```typescript ```typescript
export class Booking { export class Booking {
private readonly props: BookingProps; private readonly props: BookingProps;
static create(props: Omit<BookingProps, 'bookingNumber'>): Booking { ... } static create(props: Omit<BookingProps, 'bookingNumber' | 'status'>): Booking { ... }
updateStatus(newStatus: BookingStatus): Booking { // Returns new instance updateStatus(newStatus: BookingStatus): Booking { // Returns new instance
return new Booking({ ...this.props, status: newStatus }); return new Booking({ ...this.props, status: newStatus });
} }
} }
``` ```
### Repository Pattern ### Value Object Pattern
- Interface in `domain/ports/out/booking.repository.ts` Immutable, self-validating via static `create()`. E.g. `Money` supports USD, EUR, GBP, CNY, JPY with arithmetic and formatting methods.
- Implementation in `infrastructure/persistence/typeorm/repositories/typeorm-booking.repository.ts`
- Separate mappers for Domain ↔ ORM entity conversions
### Circuit Breaker (External APIs) ### Repository Pattern
- Library: `opossum`, Timeout: 5s - Interface in `domain/ports/out/` with token constant (e.g. `BOOKING_REPOSITORY = 'BookingRepository'`)
- Used for carrier API calls (Maersk, MSC, CMA CGM) - Implementation in `infrastructure/persistence/typeorm/repositories/`
- ORM entities: `infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
- Separate mapper classes (`infrastructure/persistence/typeorm/mappers/`) with static `toOrm()`, `toDomain()`, `toDomainMany()` methods
### Frontend API Client
Custom Fetch wrapper in `src/lib/api/client.ts` — exports `get()`, `post()`, `patch()`, `del()`, `upload()`, `download()`. Auto-refreshes JWT on 401. Tokens stored in localStorage. Per-module files (auth.ts, bookings.ts, rates.ts, etc.) import from client.
### Application Decorators
- `@Public()` — skip JWT auth
- `@Roles()` — role-based access control
- `@CurrentUser()` — inject authenticated user
### Carrier Connectors
Five carrier connectors (Maersk, MSC, CMA CGM, Hapag-Lloyd, ONE) extending `base-carrier.connector.ts`, each with request/response mappers. Circuit breaker via `opossum` (5s timeout).
### Caching ### Caching
- Redis with 15-min TTL for rate quotes Redis with 15-min TTL for rate quotes. Key format: `rate:{origin}:{destination}:{containerType}`.
- Cache key format: `rate:{origin}:{destination}:{containerType}`
## Business Rules ## Business Rules
- Booking number format: `WCM-YYYY-XXXXXX` - Booking number format: `WCM-YYYY-XXXXXX`
- Booking status flow: draft → confirmed → shipped → delivered
- Rate quotes expire after 15 minutes - Rate quotes expire after 15 minutes
- Multi-currency: USD, EUR - Multi-currency: USD, EUR, GBP, CNY, JPY
- RBAC Roles: ADMIN, MANAGER, USER, VIEWER, CARRIER - RBAC Roles: ADMIN, MANAGER, USER, VIEWER, CARRIER
- JWT: access token 15min, refresh token 7d
- Password hashing: Argon2
### Carrier Portal Workflow ### Carrier Portal Workflow
1. Admin creates CSV booking → assigns carrier 1. Admin creates CSV booking → assigns carrier
@ -136,35 +200,33 @@ export class Booking {
3. Carrier auto-login → accept/reject booking 3. Carrier auto-login → accept/reject booking
4. Activity logged in `carrier_activities` table 4. Activity logged in `carrier_activities` table
## Tech Stack
**Backend**: NestJS 10, TypeORM 0.3, PostgreSQL 15, Redis 7, Argon2, Pino, Sentry
**Frontend**: Next.js 14, React 18, TanStack Query/Table, React Hook Form + Zod, Tailwind + shadcn/ui, Socket.IO
## Common Pitfalls ## Common Pitfalls
- Never import NestJS/TypeORM in domain layer - Never import NestJS/TypeORM in domain layer
- Never use `any` type (strict mode enabled) - Never use `any` type in backend (strict mode enabled)
- Never use `DATABASE_SYNC=true` in production - Never modify applied migrations — create new ones
- Never modify applied migrations - create new ones - Always validate DTOs with `class-validator` decorators
- Always validate DTOs with `class-validator` - Always create separate mappers for Domain ↔ ORM conversions
- Always create mappers for Domain ↔ ORM conversions - ORM entity files must match pattern `*.orm-entity.{ts,js}` (auto-discovered by data-source)
- Migration files must be in `infrastructure/persistence/typeorm/migrations/`
- Database synchronize is hard-coded to `false` — always use migrations
## Adding a New Feature ## Adding a New Feature
1. **Domain Entity**`domain/entities/*.entity.ts` (pure TS, unit tests) 1. **Domain Entity**`domain/entities/*.entity.ts` (pure TS, unit tests)
2. **Value Objects**`domain/value-objects/*.vo.ts` (immutable) 2. **Value Objects**`domain/value-objects/*.vo.ts` (immutable)
3. **Port Interface**`domain/ports/out/*.repository.ts` 3. **Port Interface**`domain/ports/out/*.repository.ts` (with token constant)
4. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts` 4. **ORM Entity**`infrastructure/persistence/typeorm/entities/*.orm-entity.ts`
5. **Generate Migration** → `npm run migration:generate -- ...` 5. **Migration** → `npm run migration:generate -- src/infrastructure/persistence/typeorm/migrations/MigrationName`
6. **Repository Impl**`infrastructure/persistence/typeorm/repositories/` 6. **Repository Impl**`infrastructure/persistence/typeorm/repositories/`
7. **DTOs**`application/dto/` (with class-validator decorators) 7. **Mapper**`infrastructure/persistence/typeorm/mappers/` (static toOrm/toDomain/toDomainMany)
8. **Controller**`application/controllers/` (with Swagger decorators) 8. **DTOs**`application/dto/` (with class-validator decorators)
9. **Module** → Register and import in `app.module.ts` 9. **Controller**`application/controllers/` (with Swagger decorators)
10. **Module** → Register and import in `app.module.ts`
## Documentation ## Documentation
- API Docs: http://localhost:4000/api/docs (Swagger) - API Docs: http://localhost:4000/api/docs (Swagger, when running)
- Architecture: `docs/architecture.md` - Setup guide: `docs/installation/START-HERE.md`
- Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md` - Carrier Portal API: `apps/backend/docs/CARRIER_PORTAL_API.md`
- Full docs index: `docs/README.md` - Full docs index: `docs/README.md`

View File

@ -201,6 +201,12 @@ export class CsvBookingResponseDto {
}) })
id: string; id: string;
@ApiPropertyOptional({
description: 'Booking number (e.g. XPD-2026-W75VPT)',
example: 'XPD-2026-W75VPT',
})
bookingNumber?: string;
@ApiProperty({ @ApiProperty({
description: 'User ID who created the booking', description: 'User ID who created the booking',
example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f', example: '987fcdeb-51a2-43e8-9c6d-8b9a1c2d3e4f',

View File

@ -176,6 +176,7 @@ export class CsvBookingService {
fileName: doc.fileName, fileName: doc.fileName,
})), })),
confirmationToken, confirmationToken,
notes: dto.notes,
}); });
this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`); this.logger.log(`Email sent to carrier: ${dto.carrierEmail}`);
} catch (error: any) { } catch (error: any) {
@ -921,6 +922,7 @@ export class CsvBookingService {
return { return {
id: booking.id, id: booking.id,
bookingNumber: booking.bookingNumber,
userId: booking.userId, userId: booking.userId,
organizationId: booking.organizationId, organizationId: booking.organizationId,
carrierName: booking.carrierName, carrierName: booking.carrierName,

View File

@ -79,7 +79,8 @@ export class CsvBooking {
public readonly requestedAt: Date, public readonly requestedAt: Date,
public respondedAt?: Date, public respondedAt?: Date,
public notes?: string, public notes?: string,
public rejectionReason?: string public rejectionReason?: string,
public readonly bookingNumber?: string
) { ) {
this.validate(); this.validate();
} }
@ -361,7 +362,8 @@ export class CsvBooking {
requestedAt: Date, requestedAt: Date,
respondedAt?: Date, respondedAt?: Date,
notes?: string, notes?: string,
rejectionReason?: string rejectionReason?: string,
bookingNumber?: string
): CsvBooking { ): CsvBooking {
// Create instance without calling constructor validation // Create instance without calling constructor validation
const booking = Object.create(CsvBooking.prototype); const booking = Object.create(CsvBooking.prototype);
@ -389,6 +391,7 @@ export class CsvBooking {
booking.respondedAt = respondedAt; booking.respondedAt = respondedAt;
booking.notes = notes; booking.notes = notes;
booking.rejectionReason = rejectionReason; booking.rejectionReason = rejectionReason;
booking.bookingNumber = bookingNumber;
return booking; return booking;
} }

View File

@ -102,6 +102,7 @@ export interface EmailPort {
fileName: string; fileName: string;
}>; }>;
confirmationToken: string; confirmationToken: string;
notes?: string;
} }
): Promise<void>; ): Promise<void>;

View File

@ -256,6 +256,7 @@ export class EmailAdapter implements EmailPort {
fileName: string; fileName: string;
}>; }>;
confirmationToken: string; confirmationToken: string;
notes?: string;
} }
): Promise<void> { ): Promise<void> {
// Use APP_URL (frontend) for accept/reject links // Use APP_URL (frontend) for accept/reject links

View File

@ -277,6 +277,7 @@ export class EmailTemplates {
type: string; type: string;
fileName: string; fileName: string;
}>; }>;
notes?: string;
acceptUrl: string; acceptUrl: string;
rejectUrl: string; rejectUrl: string;
}): Promise<string> { }): Promise<string> {
@ -557,6 +558,14 @@ export class EmailTemplates {
</ul> </ul>
</div> </div>
{{#if notes}}
<!-- Notes -->
<div style="background-color: #f0f9ff; border-left: 4px solid #0284c7; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0 0 5px 0; font-weight: 700; color: #045a8d; font-size: 14px;">📝 Notes du client</p>
<p style="margin: 0; font-size: 14px; color: #333; line-height: 1.6;">{{notes}}</p>
</div>
{{/if}}
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="action-buttons"> <div class="action-buttons">
<p>Veuillez confirmer votre décision :</p> <p>Veuillez confirmer votre décision :</p>

View File

@ -41,7 +41,8 @@ export class CsvBookingMapper {
ormEntity.requestedAt, ormEntity.requestedAt,
ormEntity.respondedAt, ormEntity.respondedAt,
ormEntity.notes, ormEntity.notes,
ormEntity.rejectionReason ormEntity.rejectionReason,
ormEntity.bookingNumber ?? undefined
); );
} }

View File

@ -2,6 +2,7 @@
import { useEffect, useState, useRef } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import Image from 'next/image';
import { import {
FileText, FileText,
Download, Download,
@ -296,9 +297,9 @@ export default function CarrierDocumentsPage() {
// Loading state // Loading state
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-sky-50"> <div className="min-h-screen flex items-center justify-center bg-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<Loader2 className="w-16 h-16 text-sky-600 mx-auto mb-4 animate-spin" /> <Loader2 className="w-16 h-16 text-brand-turquoise mx-auto mb-4 animate-spin" />
<h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">Chargement...</h1>
<p className="text-gray-600">Veuillez patienter</p> <p className="text-gray-600">Veuillez patienter</p>
</div> </div>
@ -309,14 +310,14 @@ export default function CarrierDocumentsPage() {
// Error state // Error state
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-brand-gray">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center"> <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full text-center">
<XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" /> <XCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<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={handleRefresh} onClick={handleRefresh}
className="w-full px-4 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-900 font-medium transition-colors" className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 font-medium transition-colors"
> >
Réessayer Réessayer
</button> </button>
@ -328,11 +329,11 @@ export default function CarrierDocumentsPage() {
// Password form state // Password form state
if (requirements?.requiresPassword && !data) { if (requirements?.requiresPassword && !data) {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 via-sky-50 to-cyan-50 p-4"> <div className="min-h-screen flex items-center justify-center bg-brand-gray p-4">
<div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full"> <div className="bg-white p-8 rounded-xl shadow-lg max-w-md w-full">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="mx-auto w-16 h-16 bg-sky-100 rounded-full flex items-center justify-center mb-4"> <div className="mx-auto w-16 h-16 bg-brand-turquoise/10 rounded-full flex items-center justify-center mb-4">
<Lock className="w-8 h-8 text-sky-600" /> <Lock className="w-8 h-8 text-brand-turquoise" />
</div> </div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1> <h1 className="text-2xl font-bold text-gray-900 mb-2">Accès sécurisé</h1>
<p className="text-gray-600"> <p className="text-gray-600">
@ -358,7 +359,7 @@ export default function CarrierDocumentsPage() {
value={password} value={password}
onChange={e => setPassword(e.target.value.toUpperCase())} onChange={e => setPassword(e.target.value.toUpperCase())}
placeholder="Ex: A3B7K9" placeholder="Ex: A3B7K9"
className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-sky-500 focus:border-sky-500 text-center text-xl tracking-widest font-mono uppercase" className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-turquoise focus:border-brand-turquoise text-center text-xl tracking-widest font-mono uppercase"
autoComplete="off" autoComplete="off"
autoFocus autoFocus
/> />
@ -381,7 +382,7 @@ export default function CarrierDocumentsPage() {
<button <button
type="submit" type="submit"
disabled={verifying} disabled={verifying}
className="w-full px-4 py-3 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2" className="w-full px-4 py-3 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors flex items-center justify-center gap-2"
> >
{verifying ? ( {verifying ? (
<> <>
@ -415,17 +416,22 @@ export default function CarrierDocumentsPage() {
const { booking, documents } = data; const { booking, documents } = data;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 via-sky-50 to-cyan-50"> <div className="min-h-screen bg-brand-gray">
{/* Header */} {/* Header */}
<header className="bg-white shadow-sm border-b"> <header className="bg-white shadow-sm border-b">
<div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between"> <div className="max-w-4xl mx-auto px-4 py-4 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Ship className="w-8 h-8 text-sky-600" /> <Image
<span className="text-xl font-bold text-gray-900">Xpeditis</span> src="/assets/logos/logo-black.svg"
alt="Xpeditis"
width={40}
height={48}
className="h-auto"
/>
</div> </div>
<button <button
onClick={handleRefresh} onClick={handleRefresh}
className="text-sm text-sky-600 hover:text-sky-700 font-medium" className="text-sm text-brand-turquoise hover:text-brand-navy font-medium"
> >
Actualiser Actualiser
</button> </button>
@ -435,14 +441,14 @@ export default function CarrierDocumentsPage() {
<main className="max-w-4xl mx-auto px-4 py-8"> <main className="max-w-4xl mx-auto px-4 py-8">
{/* Booking Summary Card */} {/* Booking Summary Card */}
<div className="bg-white rounded-xl shadow-md overflow-hidden mb-6"> <div className="bg-white rounded-xl shadow-md overflow-hidden mb-6">
<div className="bg-gradient-to-r from-sky-600 to-cyan-600 px-6 py-4"> <div className="bg-gradient-to-r from-brand-navy to-brand-navy/80 px-6 py-4">
<div className="flex items-center justify-center gap-4 text-white"> <div className="flex items-center justify-center gap-4 text-white">
<span className="text-2xl font-bold">{booking.origin}</span> <span className="text-2xl font-bold">{booking.origin}</span>
<ArrowRight className="w-6 h-6" /> <ArrowRight className="w-6 h-6" />
<span className="text-2xl font-bold">{booking.destination}</span> <span className="text-2xl font-bold">{booking.destination}</span>
</div> </div>
{booking.bookingNumber && ( {booking.bookingNumber && (
<p className="text-center text-sky-100 text-sm mt-1"> <p className="text-center text-white/70 text-sm mt-1">
N° {booking.bookingNumber} N° {booking.bookingNumber}
</p> </p>
)} )}
@ -491,7 +497,7 @@ export default function CarrierDocumentsPage() {
<div className="bg-white rounded-xl shadow-md overflow-hidden"> <div className="bg-white rounded-xl shadow-md overflow-hidden">
<div className="px-6 py-4 border-b border-gray-100"> <div className="px-6 py-4 border-b border-gray-100">
<h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2"> <h2 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
<FileText className="w-5 h-5 text-sky-600" /> <FileText className="w-5 h-5 text-brand-turquoise" />
Documents ({documents.length}) Documents ({documents.length})
</h2> </h2>
</div> </div>
@ -516,7 +522,7 @@ export default function CarrierDocumentsPage() {
<div> <div>
<p className="font-medium text-gray-900">{doc.fileName}</p> <p className="font-medium text-gray-900">{doc.fileName}</p>
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<span className="text-xs px-2 py-0.5 bg-sky-100 text-sky-700 rounded-full"> <span className="text-xs px-2 py-0.5 bg-brand-turquoise/10 text-brand-navy rounded-full">
{documentTypeLabels[doc.type] || doc.type} {documentTypeLabels[doc.type] || doc.type}
</span> </span>
<span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span> <span className="text-xs text-gray-500">{formatFileSize(doc.size)}</span>
@ -527,7 +533,7 @@ export default function CarrierDocumentsPage() {
<button <button
onClick={() => handleDownload(doc)} onClick={() => handleDownload(doc)}
disabled={downloading === doc.id} disabled={downloading === doc.id}
className="flex items-center gap-2 px-4 py-2 bg-sky-600 text-white rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium" className="flex items-center gap-2 px-4 py-2 bg-brand-navy text-white rounded-lg hover:bg-brand-navy/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
> >
{downloading === doc.id ? ( {downloading === doc.id ? (
<> <>
@ -554,7 +560,7 @@ export default function CarrierDocumentsPage() {
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="mt-auto py-6 text-center text-sm text-gray-500"> <footer className="mt-auto py-6 text-center text-sm text-brand-navy/50">
<p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p> <p>© {new Date().getFullYear()} Xpeditis - Plateforme de fret maritime</p>
</footer> </footer>
</div> </div>

View File

@ -50,6 +50,7 @@ function NewBookingPageContent() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(1);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [termsAccepted, setTermsAccepted] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [formData, setFormData] = useState<BookingForm>({ const [formData, setFormData] = useState<BookingForm>({
@ -188,7 +189,7 @@ function NewBookingPageContent() {
const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination; const canProceedToStep2 = formData.carrierName && formData.origin && formData.destination;
const canProceedToStep3 = formData.documents.length >= 1; const canProceedToStep3 = formData.documents.length >= 1;
const canSubmit = canProceedToStep3; const canSubmit = canProceedToStep3 && termsAccepted;
const formatPrice = (price: number, currency: string) => { const formatPrice = (price: number, currency: string) => {
return new Intl.NumberFormat('fr-FR', { return new Intl.NumberFormat('fr-FR', {
@ -218,10 +219,10 @@ function NewBookingPageContent() {
</div> </div>
{/* Progress Steps */} {/* Progress Steps */}
<div className="mb-8"> <div className="mb-8 bg-white rounded-lg shadow-md p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{[1, 2, 3].map(step => ( {[1, 2, 3].map(step => (
<div key={step} className="flex items-center flex-1"> <div key={step} className={`flex items-center ${step < 3 ? 'flex-1' : ''}`}>
<div className="flex items-center"> <div className="flex items-center">
<div <div
className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${ className={`w-10 h-10 rounded-full flex items-center justify-center font-semibold ${
@ -608,8 +609,9 @@ function NewBookingPageContent() {
<label className="flex items-start cursor-pointer"> <label className="flex items-start cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={termsAccepted}
onChange={(e) => setTermsAccepted(e.target.checked)}
className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" className="mt-1 h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
required
/> />
<span className="ml-3 text-sm text-gray-700"> <span className="ml-3 text-sm text-gray-700">
Je confirme que les informations fournies sont exactes et que j'accepte les{' '} Je confirme que les informations fournies sont exactes et que j'accepte les{' '}

View File

@ -288,9 +288,12 @@ export default function BookingsListPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date Date
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"> <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Devis N° Devis
</th> </th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
N° Booking
</th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
@ -352,11 +355,14 @@ export default function BookingsListPage() {
}) })
: 'N/A'} : 'N/A'}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-left text-sm font-medium">
{booking.type === 'csv' {booking.type === 'csv'
? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}` ? `#${booking.bookingId || booking.id.slice(0, 8).toUpperCase()}`
: booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`} : booking.bookingNumber || `#${booking.id.slice(0, 8).toUpperCase()}`}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium text-brand-navy">
{booking.bookingNumber || '-'}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@ -19,7 +19,6 @@ import {
FileText, FileText,
Search, Search,
BookOpen, BookOpen,
User,
Building2, Building2,
Users, Users,
LogOut, LogOut,
@ -36,7 +35,6 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{ name: 'Documents', href: '/dashboard/documents', icon: FileText }, { name: 'Documents', href: '/dashboard/documents', icon: FileText },
{ name: 'Suivi', href: '/dashboard/track-trace', icon: Search }, { name: 'Suivi', href: '/dashboard/track-trace', icon: Search },
{ name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen }, { name: 'Wiki Maritime', href: '/dashboard/wiki', icon: BookOpen },
{ name: 'Mon Profil', href: '/dashboard/profile', icon: User },
{ name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 }, { name: 'Organisation', href: '/dashboard/settings/organization', icon: Building2 },
// ADMIN and MANAGER only navigation items // ADMIN and MANAGER only navigation items
...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [ ...(user?.role === 'ADMIN' || user?.role === 'MANAGER' ? [
@ -171,19 +169,10 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
{/* Notifications */} {/* Notifications */}
<NotificationDropdown /> <NotificationDropdown />
{/* User Role Badge */} {/* User Initials */}
{user?.role === 'ADMIN' ? ( <Link href="/dashboard/profile" className="w-9 h-9 bg-blue-600 rounded-full flex items-center justify-center text-white text-sm font-semibold hover:bg-blue-700 transition-colors">
<Link {user?.firstName?.[0]}{user?.lastName?.[0]}
href="/dashboard/admin/users" </Link>
className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full hover:bg-blue-200 transition-colors cursor-pointer"
>
{user.role}
</Link>
) : (
<span className="px-3 py-1 text-xs font-medium text-blue-800 bg-blue-100 rounded-full">
{user?.role}
</span>
)}
</div> </div>
</div> </div>

View File

@ -9,6 +9,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { getSubscriptionOverview } from '@/lib/api/subscriptions'; import { getSubscriptionOverview } from '@/lib/api/subscriptions';
import Link from 'next/link';
import { UserPlus } from 'lucide-react';
export default function LicensesTab() { export default function LicensesTab() {
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -110,10 +112,17 @@ export default function LicensesTab() {
{/* Active Licenses */} {/* Active Licenses */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden"> <div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50"> <div className="px-6 py-4 border-b border-gray-200 bg-gray-50 flex items-center justify-between">
<h3 className="text-lg font-medium text-gray-900"> <h3 className="text-lg font-medium text-gray-900">
Licences actives ({activeLicenses.length}) Licences actives ({activeLicenses.length})
</h3> </h3>
<Link
href="/dashboard/settings/users"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors"
>
<UserPlus className="w-4 h-4 mr-2" />
Inviter un utilisateur
</Link>
</div> </div>
{activeLicenses.length === 0 ? ( {activeLicenses.length === 0 ? (
<div className="px-6 py-8 text-center text-gray-500"> <div className="px-6 py-8 text-center text-gray-500">
@ -335,9 +344,6 @@ export default function LicensesTab() {
<li> <li>
Chaque utilisateur actif de votre organisation consomme une licence Chaque utilisateur actif de votre organisation consomme une licence
</li> </li>
<li>
<strong>Les administrateurs (ADMIN) ont des licences illimitées</strong> et ne sont pas comptés dans le quota
</li>
<li> <li>
Les licences sont automatiquement assignées lors de l&apos;ajout d&apos;un Les licences sont automatiquement assignées lors de l&apos;ajout d&apos;un
utilisateur utilisateur