Resolve merge conflicts from Train-Scheduling

This commit is contained in:
hagiye
2026-06-08 16:51:29 +03:00
563 changed files with 55089 additions and 9929 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
**/node_modules
**/dist
**/.turbo
**/.git
**/.github
**/.vscode
**/.idea
**/.env
**/.env.*
!**/.env.example
**/coverage
**/*.tsbuildinfo
**/*.log
.DS_Store

92
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,92 @@
name: Deploy Stacks
on:
push:
branches:
- main
- dev
- staging
paths:
- "apps/edr-freight-api/**"
- "apps/edr-freight-web/**"
- "apps/edr-passenger-api/**"
- "apps/edr-passenger-web/**"
- "packages/**"
- "infrastructure/docker/Dockerfile.web"
- "infrastructure/nginx/**"
- "docker-compose.yaml"
- "pnpm-lock.yaml"
- "scripts/deploy/**"
- ".github/workflows/deploy.yml"
workflow_dispatch:
concurrency:
group: deploy-${{ github.ref_name }}
cancel-in-progress: true
jobs:
deploy:
name: Deploy ${{ matrix.service }}
runs-on: self-hosted
strategy:
fail-fast: false
matrix:
include:
- project: edr-freight
build_env_file: freight-web.build.env
service: freight-api
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-portal
# - project: edr-freight
# build_env_file: freight-web.build.env
# service: freight-backoffice
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-api
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-portal
- project: edr-passenger
build_env_file: passenger-web.build.env
service: passenger-backoffice
env:
PROJECT: ${{ matrix.project }}
BRANCH: ${{ github.ref_name }}
DEPLOY_USER: tria
BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync environment from server
run: |
chmod +x scripts/deploy/*.sh
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}"
- name: Set compose project name
run: |
set -euo pipefail
branch_slug=$(echo "${BRANCH}" | tr "[:upper:]" "[:lower:]" | sed -E "s/[^a-z0-9]+/-/g; s/^-+//; s/-+$//")
echo "COMPOSE_PROJECT_NAME=${PROJECT}-${branch_slug}" >> "${GITHUB_ENV}"
- name: Configure npm auth for Docker builds
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
run: ./scripts/deploy/create-npmrc.sh
- name: Build ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" build --no-cache "${{ matrix.service }}"
- name: Deploy ${{ matrix.service }}
run: |
set -euo pipefail
docker compose --project-name "${COMPOSE_PROJECT_NAME}" up -d "${{ matrix.service }}"
- name: Remove npm credentials from workspace
if: always()
run: rm -f .npmrc .npmrc_temp

4
.gitignore vendored
View File

@@ -22,6 +22,10 @@ coverage/
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
.npmrc
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
# emacs cache files # emacs cache files
*~ *~

187
DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,187 @@
# Deployment Runbook
This document explains how deployments work for the EDR platform using Docker, GitHub Actions, and self-hosted runners.
## Overview
- Monorepo contains 6 deployable services:
- `freight-api`
- `freight-portal`
- `freight-backoffice`
- `passenger-api`
- `passenger-portal`
- `passenger-backoffice`
- Deployments run through one workflow: `.github/workflows/deploy.yml`
- Each service is built/deployed independently in parallel (matrix jobs).
- Docker Compose project names are branch-aware to avoid environment collisions on the same host.
## Prerequisites
- Docker Engine with Compose plugin on the self-hosted runner.
- GitHub self-hosted runner registered for this repository.
- Repository secret configured:
- `NPM_TOKEN` (for private `@tria-plc/*` package install during Docker build)
- Server-side env files created for each branch/environment.
## Server Environment Files
`sync-env-from-server.sh` reads env files from:
`/home/<DEPLOY_USER>/environment/edr/<branch-slug>/<project>/`
Where:
- `<DEPLOY_USER>` defaults to `tria` (overridable by `DEPLOY_USER`)
- `<branch-slug>` is derived from Git branch (lowercase, non-alphanumeric replaced with `-`)
- `<project>` is `edr-freight` or `edr-passenger`
### Required files per project
For `edr-freight`:
- `freight-api.env`
- `freight-portal.env`
- `freight-backoffice.env`
- optional: `freight-web.build.env`
For `edr-passenger`:
- `passenger-api.env`
- `passenger-portal.env`
- `passenger-backoffice.env`
- optional: `passenger-web.build.env`
### Required env key
Each service env file must contain:
- `PORT=<number>`
The sync script validates this and fails if missing.
### Build env files (optional)
Used for build-time variables (example: Vite API URLs), with `export` syntax:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
export PASSENGER_VITE_API_URL=https://passenger-api.example.com
```
These are injected into `GITHUB_ENV` during workflow execution.
## Docker Compose Port Mapping
`docker-compose.yaml` uses per-service env variables for host/container port mappings:
- `FREIGHT_API_PORT`
- `PASSENGER_API_PORT`
- `FREIGHT_PORTAL_PORT`
- `FREIGHT_BACKOFFICE_PORT`
- `PASSENGER_PORTAL_PORT`
- `PASSENGER_BACKOFFICE_PORT`
`scripts/deploy/sync-env-from-server.sh` extracts `PORT` from each synced `.env` and exports the corresponding `*_PORT` variable to `GITHUB_ENV`.
## GitHub Actions Deployment Flow
Workflow file: `.github/workflows/deploy.yml`
### 1) `prepare` job
- Checks out repository once.
- Creates workspace artifact (`workspace.tgz`) and uploads it.
### 2) `deploy` matrix job (parallel)
For each service:
- Downloads and extracts workspace artifact.
- Syncs that service env file from server path.
- Computes branch slug and sets:
- `COMPOSE_PROJECT_NAME=<project>-<branch-slug>`
- Creates `.npmrc`/`.npmrc_temp` from `NPM_TOKEN`.
- Runs:
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" build <service>`
- `docker compose --project-name "$COMPOSE_PROJECT_NAME" up -d <service>`
- Cleans `.npmrc`/`.npmrc_temp`.
## Branch/Environment Isolation
Compose project name is generated as:
`<project>-<branch-slug>`
Examples:
- `edr-freight-main`
- `edr-freight-staging`
- `edr-passenger-dev`
This prevents container/network/volume name collisions between branches.
## Local Manual Deployment (Optional)
From repo root:
```bash
DOCKER_BUILDKIT=1 docker compose build <service>
docker compose up -d <service>
```
If private packages are required locally, create `.npmrc`:
```bash
cat <<EOF > .npmrc
@tria-plc:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=<YOUR_TOKEN>
always-auth=true
EOF
```
## Passenger API Startup Behavior
Passenger container entrypoint runs on startup:
1. `npm run prisma:generate`
2. `npm run prisma:migrate` (deploy mode)
3. `npm run prisma:seed`
4. starts API process
## Troubleshooting
### Missing env file
Error:
- `Missing env file: ...`
Fix:
- Create the required file in the server env directory for that project/branch slug.
### Missing PORT in env file
Error:
- `Missing required PORT in env file: ...`
Fix:
- Add `PORT=<number>` to that service env file.
### Private package install fails
Check:
- `NPM_TOKEN` exists in repo secrets.
- Workflow created `.npmrc` successfully.
### Prisma seed/migrate failures (passenger)
Check:
- `DATABASE_URL` in `passenger-api.env`
- DB reachability from runner host/container network
- migration history consistency

766
README.md
View File

@@ -1,3 +1,98 @@
# EDR Platform - Ethio-Djibouti Railway Passenger API
Enterprise-grade NestJS REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with TypeScript, PostgreSQL, and Prisma ORM.
## 🚀 Features
### 🆕 NEW: Age-Based Pricing, Verifayda 2.0 & Multi-Currency
#### Age-Based Pricing
- **ADULT** (≥5 years): Pay 100% of base fare
- **CHILD** (<5 years): First child travels FREE, subsequent children pay 100%
- Automatic age calculation from date of birth
- Example: 2 adults + 3 children = 4× base fare (first child free)
#### Verifayda 2.0 Integration
- Real-time Ethiopian national ID verification
- Retrieves passenger data from government database
- National IDs NOT stored (policy compliant)
- Non-Ethiopians use passport (no verification required)
- Booking fails if verification unsuccessful
#### Multi-Currency Support
- **Transaction Currency**: ETB (Ethiopian Birr)
- **Display Currencies**: ETB, DJF (Djiboutian Franc), USD (US Dollar)
- Real-time exchange rate conversion
- Prices shown in user's preferred currency
- Exchange rates: ETBDJF=3.25, ETBUSD=0.018
### Core Modules
- **Authentication & Authorization** - Dual authentication system:
- **Passenger Auth**: JWT-based auth with OTP verification, password reset, account lockout
- **Corporate IAM**: Integration with @tria-plc corporate identity system for back-office operations (agents, supervisors, admins)
- Role-based access control (RBAC) with granular permissions
- **Age-Based Pricing** - Smart passenger categorization:
- **ADULT** (≥5 years): Full fare
- **CHILD** (<5 years): First child free, subsequent children full fare
- Automatic age calculation from date of birth
- **Verifayda 2.0 Integration** - Ethiopian national ID verification:
- Real-time verification via government API
- Retrieves passenger data (name, DOB, nationality)
- National IDs NOT stored (policy compliant)
- Non-Ethiopians use passport (no verification)
- **Multi-Currency Support** - Display prices in multiple currencies:
- **ETB** (Ethiopian Birr) - Transaction currency
- **DJF** (Djiboutian Franc) - Display option
- **USD** (US Dollar) - Display option
- Real-time exchange rate conversion
- **Booking Management** - Complete booking lifecycle:
- **Guest Booking**: Book without login, optional account creation
- **Saved Passengers**: Store passenger details for quick rebooking
- Modification, cancellation, refunds, and fare breakdown
- Multi-segment journey support
- **Payment Integration** - Multi-provider support (Telebirr, CBE Birr, eBirr, Card, Wallet) with webhook handling
- **Seat Management** - Real-time seat inventory:
- Seat holds with 5-minute expiry
- Seat releases and blocking with coach/class management
- Segment-based seat availability (partial journey bookings)
- Auto-assign seats with contiguous algorithm
- CSV import/export for seat configurations
- **Ticketing** - QR code and barcode generation, PDF tickets, gate validation with audit logs
- **Agent Operations** - Counter booking, shift management, commission tracking, and reconciliation
- **Passenger Services** - Profile management, traveler profiles, saved routes, and preferences
- **Loyalty Program** - Points accumulation, tier management (Bronze/Silver/Gold/Platinum), and rewards
- **Wallet System** - Balance management, top-up, transaction ledger
- **Live Tracking** - Real-time trip status, location updates, delay notifications, crowd signals
- **Notifications** - Multi-channel (Email, SMS, Push) with templating engine
- **Support System** - FAQ management, live chat conversations
- **Reports & Analytics** - Revenue reports, occupancy analytics, agent sales tracking
- **Route Management** - Route configuration, stops, fare rules, baggage allowance
### Technical Features
- **Security** - Password hashing (bcrypt), JWT tokens, rate limiting, audit logging
- **Validation** - Request validation with class-validator, DTO transformation
- **Documentation** - Auto-generated Swagger/OpenAPI docs at `/api-docs`
- **Error Handling** - Global exception filters with standardized error responses
- **Database** - PostgreSQL with Prisma ORM, migrations, and comprehensive seeding
- **Scheduling** - Cron jobs for automated tasks (seat release, report generation)
- **Event System** - Event-driven architecture with @nestjs/event-emitter
## 📋 Prerequisites
- **Node.js** >= 20.x
- **pnpm** >= 9.x (`npm install -g pnpm`)
- **PostgreSQL** >= 15.x
- **Git**
## 🛠️ Installation & Setup
### 1. Clone Repository
```bash
git clone <repository-url>
cd edr-platform
```
### 2. Install Dependencies
# EDR Platform # EDR Platform
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library. Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
@@ -185,6 +280,676 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap
pnpm install pnpm install
``` ```
<<<<<<< HEAD
### 3. Environment Configuration
```bash
# Copy environment template
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
# Edit .env file with your configuration
```
#### Required Environment Variables
| Variable | Description | Example |
|----------|-------------|---------|
| `NODE_ENV` | Environment mode | `development` |
| `PORT` | HTTP server port | `4000` |
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` |
| `BACK_OFFICE_URL` | Admin portal CORS origin | `http://localhost:3001` |
| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` |
| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` |
#### Verifayda 2.0 Configuration (Ethiopian National ID Verification)
| Variable | Description | Example |
|----------|-------------|---------|
| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` |
| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` |
| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` |
**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only).
#### Corporate IAM Configuration (Back-office Authentication)
| Variable | Description | Example |
|----------|-------------|---------|
| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` |
| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` |
| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` |
**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only).
#### Optional: Payment Provider Configuration
```bash
# Telebirr Configuration
TELEBIRR_BASE_URL=https://api.telebirr.com
TELEBIRR_MERCHANT_CODE=your-merchant-code
TELEBIRR_APP_SECRET=your-app-secret
# ... see .env.example for complete list
```
### 4. Database Setup
#### Start PostgreSQL
```bash
# Using Docker (recommended)
docker run --name edr-postgres \
-e POSTGRES_USER=edr \
-e POSTGRES_PASSWORD=edr_secret \
-e POSTGRES_DB=edr_passenger \
-p 5432:5432 \
-d postgres:15
# Or use your local PostgreSQL installation
```
#### Generate Prisma Client
```bash
pnpm --filter @edr/passenger-api run prisma:generate
```
#### Run Migrations
```bash
pnpm --filter @edr/passenger-api run prisma:migrate:dev
```
#### Seed Database
```bash
pnpm --filter @edr/passenger-api run prisma:seed
```
**Seed Data Includes:**
- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)
- 1 Route with 21 stops and fare rules
- 2 Train services with 4 trips
- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes)
- 3 User accounts (Admin, Passenger, Agent)
- Fare rules for ADULT and CHILD passenger categories
- Currency exchange rates (ETB, DJF, USD)
- Baggage allowance rules
- Notification templates
- Promotions and FAQ content
- Menu items and station crowd signals
- Fraud detection rules
- Saved passenger profiles for testing
### 5. Start Development Server
```bash
pnpm --filter @edr/passenger-api run dev
```
**API Server:** http://localhost:4000
**Swagger Docs:** http://localhost:4000/api-docs
## 🔑 Default Credentials
After seeding, use these credentials to test the API:
| Role | Email | Password | Description |
|------|-------|----------|-------------|
| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management |
| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet |
| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking |
## 📚 API Documentation
### Swagger UI
Interactive API documentation available at: **http://localhost:4000/api-docs**
### Authentication Methods
The API uses two authentication schemes:
#### 1. JWT Authentication (Passenger-facing)
- **Used for**: Passenger bookings, profile management, wallet, loyalty
- **Header**: `Authorization: Bearer <jwt-token>`
- **Obtain token**: `POST /auth/login` with passenger credentials
- **Swagger Security**: `JWT-auth`
#### 2. IAM Authentication (Back-office)
- **Used for**: Agent operations, fraud detection, reports, admin functions
- **Header**: `Authorization: Bearer <iam-token>`
- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com)
- **Swagger Security**: `IAM-auth`
- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF
### API Endpoints Overview
| Module | Base Path | Auth Type | Description |
|--------|-----------|-----------|-------------|
| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset |
| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles |
| **Search** | `/search` | Public | Trip search, availability, fare quotes |
| **Stations** | `/stations` | Public/JWT | Station directory and information |
| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking |
| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings |
| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds |
| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation |
| **Notifications** | `/notifications` | JWT | In-app notifications, preferences |
| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption |
| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history |
| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation |
| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals |
| **Support** | `/support` | Public/JWT | FAQ, chat conversations |
| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data |
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics |
### Example API Calls
#### 1. Register Passenger
```bash
POST /auth/register
Content-Type: application/json
{
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"password": "SecurePass123"
}
```
#### 2. Login (Passenger)
```bash
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "SecurePass123"
}
# Response includes JWT token
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": { "id": "uuid", "role": "PASSENGER" }
}
```
#### 3. Verify Ethiopian National ID (Verifayda)
```bash
POST /passengers/verify-fayda
Content-Type: application/json
{
"nationalId": "ET123456789"
}
# Response with verified passenger data
{
"verified": true,
"passengerData": {
"fullName": "Abebe Kebede",
"dateOfBirth": "1985-03-15T00:00:00.000Z",
"gender": "Male",
"nationality": "Ethiopian"
}
}
```
#### 4. Universal Passenger Registration (NEW)
```bash
# Guest Ethiopian with Fayda verification
POST /passengers/register
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567",
"deviceId": "device-uuid-123"
}
# Logged-in user with JWT token
POST /passengers/register
Authorization: Bearer <jwt-token>
Content-Type: application/json
{
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"nationalId": "ET123456789",
"phone": "+251911234567"
}
# International passenger (passport)
POST /passengers/register
Content-Type: application/json
{
"passengerName": "John Smith",
"dateOfBirth": "1990-07-20",
"passportNumber": "P1234567",
"passportCountry": "Kenya",
"nationality": "Kenyan",
"phone": "+254712345678",
"email": "john@example.com",
"deviceId": "device-uuid-123"
}
```
#### 5. Get User Profile (NEW)
```bash
GET /auth/profile
Authorization: Bearer <jwt-token>
# Response includes user, passenger, loyalty, and wallet details
{
"id": "uuid",
"email": "user@example.com",
"phone": "+251911234567",
"fullName": "John Doe",
"role": "PASSENGER",
"nationality": "Ethiopian",
"faydaVerified": true,
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
"passenger": {
"id": "uuid",
"loyalty": {
"tier": "SILVER",
"pointsBalance": 1500,
"lifetimePoints": 3000
},
"wallet": {
"balanceMinor": 50000,
"currency": "ETB"
}
}
}
```
#### 6. Search Trips
```bash
POST /search
Content-Type: application/json
{
"originStationId": "uuid",
"destinationStationId": "uuid",
"date": "2026-06-15",
"adultCount": 2,
"childCount": 1
}
```
#### 7. Get Fare Quote
```bash
POST /search/fare-quote
Content-Type: application/json
{
"tripId": "uuid",
"serviceClass": "ECONOMY_REGULAR",
"adultCount": 2,
"childCount": 1,
"displayCurrency": "USD"
}
# Response includes age-based pricing breakdown
{
"baseFareMinor": 35000,
"adultCount": 2,
"adultFareMinor": 70000,
"childCount": 1,
"freeChildrenCount": 1,
"paidChildrenCount": 0,
"childFareMinor": 0,
"totalMinor": 73500,
"currency": "ETB",
"displayCurrency": "USD",
"displayTotalMinor": 1323
}
```
#### 8. Guest Booking (No Login Required)
```bash
POST /bookings/guest
Content-Type: application/json
{
"tripId": "uuid",
"holdId": "uuid",
"serviceClass": "ECONOMY_REGULAR",
"displayCurrency": "ETB",
"passengers": [
{
"seatId": "uuid",
"passengerName": "Abebe Kebede",
"dateOfBirth": "1985-03-15",
"idDocumentType": "NATIONAL_ID",
"idDocumentNumber": "ET123456789"
}
],
"createAccount": false,
"savePassengerDetails": true,
"deviceId": "device-uuid"
}
```
#### 9. Agent Booking (IAM Auth)
```bash
POST /agents/bookings
Authorization: Bearer <iam-token>
Content-Type: application/json
{
"tripId": "uuid",
"seats": [...],
"paymentMethod": "CASH",
"cashReceived": 50000
}
```
## 🏗️ Project Structure
```
apps/edr-passenger-api/
├── prisma/
│ ├── schema.prisma # Database schema (40+ models)
│ ├── seed.ts # Comprehensive seed script
│ └── migrations/ # Database migrations
├── src/
│ ├── common/ # Shared utilities
│ │ ├── filters/ # Exception filters
│ │ ├── interceptors/ # Response interceptors
│ │ ├── pipes/ # Validation pipes
│ │ ├── i18n/ # Internationalization
│ │ ├── jwt.guard.ts # JWT authentication guard (passengers)
│ │ ├── jwt.strategy.ts # Passport JWT strategy
│ │ ├── iam-adapter.ts # Corporate IAM guard (back-office)
│ │ ├── iam.module.ts # IAM module
│ │ ├── roles.guard.ts # RBAC authorization guard
│ │ ├── roles.decorator.ts # Roles decorator
│ │ ├── prisma.service.ts # Prisma client service
│ │ └── prisma.module.ts # Prisma module
│ ├── config/ # Configuration files
│ │ ├── app.config.ts # App configuration
│ │ ├── database.config.ts # Database configuration
│ │ └── telebirr.config.ts # Payment provider config
│ ├── modules/ # Feature modules
│ │ ├── auth/ # Authentication & authorization (JWT)
│ │ ├── agents/ # Agent operations (IAM-protected)
│ │ ├── bookings/ # Booking management (JWT)
│ │ ├── currency/ # Currency conversion service
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
│ │ ├── fraud/ # Fraud detection (IAM-protected)
│ │ ├── live/ # Live tracking (JWT)
│ │ ├── loyalty/ # Loyalty program (JWT)
│ │ ├── notifications/ # Notification system (JWT)
│ │ ├── passengers/ # Passenger management (JWT)
│ │ ├── payments/ # Payment processing (JWT/Webhooks)
│ │ ├── promos/ # Promotions (JWT)
│ │ ├── reports/ # Reports & analytics (IAM-protected)
│ │ ├── schedules/ # Trip schedules (JWT/IAM)
│ │ ├── search/ # Trip search (JWT)
│ │ ├── seats/ # Seat management (JWT/IAM)
│ │ ├── segments/ # Journey segments (JWT)
│ │ ├── stations/ # Station management (JWT)
│ │ ├── support/ # Customer support (JWT)
│ │ ├── tickets/ # Ticketing (JWT/IAM)
│ │ ├── verifayda/ # Verifayda 2.0 integration
│ │ └── wallet/ # Wallet system (JWT)
│ ├── app.module.ts # Root application module
│ └── main.ts # Application entry point
├── test/ # E2E tests
├── .env.example # Environment template
├── Dockerfile # Docker configuration
├── nest-cli.json # NestJS CLI configuration
├── package.json # Dependencies & scripts
├── tsconfig.json # TypeScript configuration
└── tsconfig.build.json # Build configuration
```
## 🗄️ Database Schema
### Key Models (40+ total)
**Core Entities:**
- `User`, `Session`, `Passenger`, `Agent`
- `Station`, `Route`, `RouteStop`, `RouteFareRule`
- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat`
- `Booking`, `BookingSeat`, `Ticket`
- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent`
**Enhanced Features:**
- `OtpCode`, `PasswordResetToken` (Auth)
- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents)
- `BookingModification`, `BookingCancellation` (Booking lifecycle)
- `GateValidationLog` (Ticket validation)
- `BaggageAllowance`, `BaggageBooking` (Baggage)
- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward`
- `WalletAccount`, `WalletLedgerEntry`
- `Notification`, `NotificationTemplate`
- `AuditLog`, `OperationalReport`
- `SeatBlock`, `SeatHold`
- `CurrencyExchangeRate` (Multi-currency)
- `VerifaydaVerification` (National ID verification)
- `SavedPassengerProfile` (Guest booking)
- `SeatClass` (Seat class configuration)
- `JourneySegment` (Multi-segment journeys)
## 🔧 Available Scripts
```bash
# Development
pnpm --filter @edr/passenger-api run dev # Start with hot-reload
# Build
pnpm --filter @edr/passenger-api run build # Compile TypeScript
# Production
pnpm --filter @edr/passenger-api run start # Run compiled code
# Testing
pnpm --filter @edr/passenger-api run test # Unit tests
pnpm --filter @edr/passenger-api run test:e2e # E2E tests
# Code Quality
pnpm --filter @edr/passenger-api run lint # ESLint
pnpm --filter @edr/passenger-api run type-check # TypeScript check
# Database
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev)
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
```
## 🐳 Docker Deployment
All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**.
**Prerequisites**
- Docker with BuildKit enabled
- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images)
- External Postgres for each API (compose does **not** include databases)
- Copy `apps/edr-freight-api/.env.example` `.env` and `apps/edr-passenger-api/.env.example` `.env` with real connection strings
### Build and run (all apps)
```bash
# From monorepo root
DOCKER_BUILDKIT=1 pnpm docker:build
pnpm docker:up
```
Or without pnpm scripts:
```bash
DOCKER_BUILDKIT=1 docker compose build
docker compose up -d
```
| Service | URL (default) |
|---------|----------------|
| Freight API | http://localhost:3001 |
| Passenger API | http://localhost:4000 |
| Freight portal | http://localhost:5173 |
| Freight backoffice | http://localhost:5183 |
| Passenger portal | http://localhost:5174 |
| Passenger backoffice | http://localhost:5184 |
### Build a single service
```bash
docker compose build freight-api
docker compose build passenger-portal
```
Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages.
### `VITE_API_URL` (frontends)
API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.:
```bash
docker compose build freight-portal \
--build-arg VITE_API_URL=https://freight-api.example.com/api
```
### Migrations
- **Freight API:** TypeORM migrations are not run on container startup apply them separately before deploy.
- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance.
For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`.
### GitHub Actions (self-hosted runner)
Two workflows deploy independently on push to `main`, `develop`, or `staging`:
| Workflow | Services | Server env root |
|----------|----------|-----------------|
| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight/<branch>/` |
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
**On the runner**, place env files before the first deploy (example for branch `main`):
```text
/home/user/environmen/edr-freight/main/
freight-api.env
freight-portal.env # optional runtime env for Vite/nginx
freight-backoffice.env
freight-web.build.env # exports FREIGHT_VITE_API_URL=...
/home/user/environmen/edr-passenger/main/
passenger-api.env
passenger-portal.env
passenger-backoffice.env
passenger-web.build.env # exports PASSENGER_VITE_API_URL=...
```
Example `freight-web.build.env`:
```bash
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
```
The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack.
## 🔒 Security Best Practices
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters).
3. **Password Hashing** - Bcrypt with salt rounds (default: 10).
4. **Rate Limiting** - Implement rate limiting for auth endpoints.
5. **CORS** - Configure allowed origins in production.
6. **HTTPS** - Always use HTTPS in production.
7. **Database** - Use connection pooling and prepared statements (Prisma handles this).
8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table.
9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM.
10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service.
11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN).
12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout.
## 📊 Monitoring & Logging
- **Application Logs** - NestJS built-in logger
- **Database Queries** - Prisma query logging (enable in development)
- **Audit Trail** - All user actions logged in `AuditLog` table
- **Error Tracking** - Global exception filters with detailed error responses
## 🧪 Testing
```bash
# Unit tests
pnpm --filter @edr/passenger-api run test
# E2E tests
pnpm --filter @edr/passenger-api run test:e2e
# Test coverage
pnpm --filter @edr/passenger-api run test:cov
```
## 🚀 Production Deployment
### Pre-deployment Checklist
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY)
- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY)
- [ ] Set up currency exchange rate sync (external API)
- [ ] Set NODE_ENV=production
- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL)
- [ ] Set up SSL/TLS certificates
- [ ] Configure database connection pooling
- [ ] Set up monitoring and logging
- [ ] Configure backup strategy
- [ ] Test payment provider integrations
- [ ] Verify IAM token validation endpoint
- [ ] Test Verifayda verification with real national IDs
- [ ] Verify currency conversion accuracy
- [ ] Test age-based pricing calculations
- [ ] Review security settings and audit logs
- [ ] Test both JWT and IAM authentication flows
### Deployment Steps
```bash
# 1. Build application
pnpm --filter @edr/passenger-api run build
# 2. Run migrations
pnpm --filter @edr/passenger-api run prisma:migrate
# 3. Start production server
NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod
```
## 🤝 Contributing
1. Fork the repository
2. Create feature branch (`git checkout -b feature/amazing-feature`)
3. Commit changes (`git commit -m 'Add amazing feature'`)
4. Push to branch (`git push origin feature/amazing-feature`)
5. Open Pull Request
## 📝 License
This project is proprietary and confidential.
## 📧 Support
For technical support or questions:
- Email: support@edr-platform.com
- Documentation: http://localhost:4000/api-docs
---
**Built with ❤️ for Ethio-Djibouti Railway**
=======
### Start local databases ### Start local databases
```bash ```bash
@@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice
- **One DB per domain** no cross-database joins. - **One DB per domain** no cross-database joins.
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions. See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467

View File

@@ -1,14 +1,24 @@
# App # Copy to .env for local/docker compose (not committed).
NODE_ENV=development
PORT=3001 PORT=3001
# Database
DB_HOST=localhost DB_HOST=localhost
DB_PORT=5433 DB_PORT=5433
DB_NAME=edr_freight
DB_USER=postgres DB_USER=postgres
DB_PASSWORD= DB_PASSWORD=
DB_NAME=edr_freight
# Telebirr payment gateway (freight merchant credentials)
TELEBIRR_BASE_URL=
TELEBIRR_WEB_BASE_URL=
TELEBIRR_FABRIC_APP_ID=
TELEBIRR_APP_SECRET=
TELEBIRR_MERCHANT_APP_ID=
TELEBIRR_MERCHANT_CODE=
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
TELEBIRR_RETURN_URL=
TELEBIRR_TIMEOUT_EXPRESS=15m
TELEBIRR_PRIVATE_KEY=
TELEBIRR_PUBLIC_KEY=
TELEBIRR_INSECURE_TLS=false
# JWT (used by @tria-plc/api-common SharedAuthModule) # JWT (used by @tria-plc/api-common SharedAuthModule)
JWT_SECRET= JWT_SECRET=
JWT_ACCESS_TOKEN_SECRET= JWT_ACCESS_TOKEN_SECRET=

View File

@@ -1,26 +1,37 @@
FROM node:20-alpine AS base # syntax=docker/dockerfile:1
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate # Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
FROM node:24.15.0-alpine AS base
RUN apk add --no-cache libc6-compat
RUN corepack enable
WORKDIR /app WORKDIR /app
FROM base AS deps FROM base AS pruner
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./ COPY . .
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/ RUN pnpm dlx turbo prune "@edr/freight-api" --docker
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
FROM deps AS build FROM base AS installer
COPY apps/edr-freight-api ./apps/edr-freight-api COPY --from=pruner /app/out/json/ .
RUN pnpm --filter @edr/freight-api build COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
pnpm install --frozen-lockfile
FROM node:20-alpine AS runtime FROM base AS builder
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate COPY --from=installer /app/ .
WORKDIR /app/apps/edr-freight-api COPY --from=pruner /app/out/full/ .
RUN pnpm turbo build --filter="@edr/freight-api..."
FROM base AS deployer
COPY --from=builder /app/ .
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
FROM node:24.15.0-alpine AS runner
RUN apk add --no-cache libc6-compat
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app
COPY --from=deps /app/node_modules ./../../node_modules RUN addgroup --system --gid 1001 nodejs \
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules && adduser --system --uid 1001 --ingroup nodejs nestjs
COPY --from=build /app/apps/edr-freight-api/dist ./dist COPY --from=deployer --chown=nestjs:nodejs /deploy .
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json USER nestjs
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dist/main.js"] CMD ["node", "dist/main.js"]

View File

@@ -17,11 +17,13 @@
}, },
"dependencies": { "dependencies": {
"@edr/api-common": "workspace:*", "@edr/api-common": "workspace:*",
"@edr/payment-providers": "workspace:*",
"@edr/types": "workspace:*", "@edr/types": "workspace:*",
"@nestjs/axios": "^4.0.1", "@nestjs/axios": "^4.0.1",
"@nestjs/common": "^11.0.0", "@nestjs/common": "^11.0.0",
"@nestjs/config": "^4.0.0", "@nestjs/config": "^4.0.0",
"@nestjs/core": "^11.0.0", "@nestjs/core": "^11.0.0",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/mapped-types": "^2.1.1", "@nestjs/mapped-types": "^2.1.1",
"@nestjs/microservices": "^11.0.0", "@nestjs/microservices": "^11.0.0",
"@nestjs/platform-express": "^11.0.0", "@nestjs/platform-express": "^11.0.0",
@@ -63,7 +65,7 @@
"ts-loader": "^9.5.1", "ts-loader": "^9.5.1",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typeorm": "^1.0.0", "typeorm": "^0.3.30",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"jest": { "jest": {

View File

@@ -8,12 +8,13 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
import appConfig from "./config/app.config"; import appConfig from "./config/app.config";
import databaseConfig from "./config/database.config"; import databaseConfig from "./config/database.config";
import telebirrConfig from "./config/telebirr.config";
import { BookingsModule } from "./modules/bookings/bookings.module"; import { BookingsModule } from "./modules/bookings/bookings.module";
import { FilesModule } from "./modules/files/files.module"; import { FilesModule } from "./modules/files/files.module";
import { ConsignmentsModule } from "./modules/consignments/consignments.module"; import { ConsignmentsModule } from "./modules/consignments/consignments.module";
//import { TrainsModule } from "./modules/trains/trains.module"; // import { TrainsModule } from "./modules/trains/trains.module";
import { LocomotivesModule } from "./modules/locomotives/locomotives.module"; import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module"; import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
import { TrainSetsModule } from "./modules/train-sets/train-sets.module"; import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
@@ -47,13 +48,15 @@ import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module'; import { WagonsModule } from './modules/wagons/wagons.module';
import { ContainersModule } from './modules/container-management/containers.module'; import { ContainersModule } from './modules/container-management/containers.module';
import { CargoesModule } from './modules/cargoes/cargoes.module'; import { CargoesModule } from './modules/cargoes/cargoes.module';
import { RoutesModule } from './modules/routes/routes.module';
@Module({ @Module({
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, isGlobal: true,
load: [appConfig, databaseConfig], load: [appConfig, databaseConfig, telebirrConfig],
}), }),
// EventEmitterModule.forRoot(),
TypeOrmModule.forRootAsync({ TypeOrmModule.forRootAsync({
inject: [ConfigService], inject: [ConfigService],
useFactory: (config: ConfigService): TypeOrmModuleOptions => useFactory: (config: ConfigService): TypeOrmModuleOptions =>
@@ -98,6 +101,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
WagonsModule, WagonsModule,
ContainersModule, ContainersModule,
CargoesModule, CargoesModule,
RoutesModule,
], ],
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder], providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
}) })

View File

@@ -17,7 +17,6 @@ import {
PositionType, PositionType,
Position, Position,
Project, Project,
UnitConfiguration,
GlobalUnitConfiguration, GlobalUnitConfiguration,
Unit, Unit,
EmployeeSignature, EmployeeSignature,
@@ -64,7 +63,6 @@ const iamEntities = [
PositionType, PositionType,
Position, Position,
Project, Project,
UnitConfiguration,
GlobalUnitConfiguration, GlobalUnitConfiguration,
Unit, Unit,
EmployeeSignature, EmployeeSignature,
@@ -98,10 +96,8 @@ const iamMigrationsGlob = join(
); );
const freightMigrationsGlob = join(__dirname, "../migrations/*.js"); const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
export default registerAs( export default registerAs("database", (): TypeOrmModuleOptions => {
"database", return {
(): TypeOrmModuleOptions => {
return {
type: "postgres", type: "postgres",
host: process.env.DB_HOST ?? "localhost", host: process.env.DB_HOST ?? "localhost",
port: parseInt(process.env.DB_PORT ?? "5433", 10), port: parseInt(process.env.DB_PORT ?? "5433", 10),
@@ -124,5 +120,4 @@ export default registerAs(
synchronize: false, synchronize: false,
logging: process.env.NODE_ENV === "development", logging: process.env.NODE_ENV === "development",
}; };
}, });
);

View File

@@ -0,0 +1,10 @@
import { registerAs } from "@nestjs/config";
export default registerAs("dmoney", () => ({
baseUrl: process.env.DMONEY_BASE_URL ?? "",
appId: process.env.DMONEY_APP_ID ?? "",
appSecret: process.env.DMONEY_APP_SECRET ?? "",
publicKey: process.env.DMONEY_PUBLIC_KEY ?? "",
privateKey: process.env.DMONEY_PRIVATE_KEY ?? "",
notifyUrl: process.env.DMONEY_NOTIFY_URL ?? ""
}));

View File

@@ -0,0 +1,16 @@
import { registerAs } from "@nestjs/config";
export default registerAs("telebirr", () => ({
baseUrl: process.env.TELEBIRR_BASE_URL ?? "",
webBaseUrl: process.env.TELEBIRR_WEB_BASE_URL ?? "",
fabricAppId: process.env.TELEBIRR_FABRIC_APP_ID ?? "",
appSecret: process.env.TELEBIRR_APP_SECRET ?? "",
merchantAppId: process.env.TELEBIRR_MERCHANT_APP_ID ?? "",
merchantCode: process.env.TELEBIRR_MERCHANT_CODE ?? "",
notifyUrl: process.env.TELEBIRR_NOTIFY_URL ?? "",
returnUrl: process.env.TELEBIRR_RETURN_URL ?? "",
timeoutExpress: process.env.TELEBIRR_TIMEOUT_EXPRESS ?? "15m",
privateKey: process.env.TELEBIRR_PRIVATE_KEY ?? "",
publicKey: process.env.TELEBIRR_PUBLIC_KEY ?? "",
insecureTls: process.env.TELEBIRR_INSECURE_TLS === "true",
}));

View File

@@ -1,14 +1,15 @@
// apps/edr-freight-api/src/data-source.ts // apps/edr-freight-api/src/data-source.ts
import 'dotenv/config';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed //import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
export const AppDataSource = new DataSource({ export const AppDataSource = new DataSource({
type: 'postgres', type: 'postgres',
host: 'localhost', host: process.env.DB_HOST ?? 'localhost',
port: 5432, port: Number(process.env.DB_PORT ?? 5432),
username: 'postgres', username: process.env.DB_USER ?? 'postgres',
password: '', // Laragon default: empty password: process.env.DB_PASSWORD ?? '',
database: 'edr_freight', database: process.env.DB_NAME ?? 'edr_freight',
schema: 'freight', // default schema for entities without an explicit schema schema: 'freight', // default schema for entities without an explicit schema
entities: [__dirname + '/**/*.entity{.ts,.js}'], entities: [__dirname + '/**/*.entity{.ts,.js}'],
migrations: [__dirname + '/migrations/*{.ts,.js}'], migrations: [__dirname + '/migrations/*{.ts,.js}'],
@@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({
}); });
// Optional: call ensurePostgresSchemas before initializing // Optional: call ensurePostgresSchemas before initializing
// But you can also run it separately. // But you can also run it separately.

View File

@@ -18,6 +18,7 @@ async function bootstrap() {
// freight portal (5173), passenger portal (5174), backoffices (5183/5184) // freight portal (5173), passenger portal (5174), backoffices (5183/5184)
// and any other dev port can call the API with cookies + Authorization. // and any other dev port can call the API with cookies + Authorization.
// For production, restrict `origin` to known FQDNs. // For production, restrict `origin` to known FQDNs.
app.enableCors({ app.enableCors({
origin: true, // reflect request origin origin: true, // reflect request origin
credentials: true, credentials: true,
@@ -52,9 +53,12 @@ async function bootstrap() {
SwaggerModule.setup("api/docs", app, document); SwaggerModule.setup("api/docs", app, document);
const port = parseInt(process.env.PORT ?? "3001", 10); const port = parseInt(process.env.PORT ?? "3001", 10);
await app.listen(port); // await app.listen(port, "0.0.0.0");
await app.listen(
port)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`[freight-api] listening on http://localhost:${port}`); console.log(`[freight-api] listening on port ${port}`);
} }
bootstrap(); bootstrap();

View File

@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRoutesAndExtendLocomotives1750100000000 implements MigrationInterface {
name = 'AddRoutesAndExtendLocomotives1750100000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.locomotives
ADD COLUMN IF NOT EXISTS locomotive_type VARCHAR(20) NOT NULL DEFAULT 'DIESEL',
ADD COLUMN IF NOT EXISTS max_train_length_meters NUMERIC(10,3) NOT NULL DEFAULT 760,
ADD COLUMN IF NOT EXISTS power_kw NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS traction_force_kn NUMERIC(10,3) NULL,
ADD COLUMN IF NOT EXISTS max_speed_kmh NUMERIC(10,3) NULL;
`);
await queryRunner.query(`
UPDATE freight.locomotives
SET status = 'OUT_OF_SERVICE'
WHERE status = 'INACTIVE';
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(120) NOT NULL UNIQUE,
origin_yard_id UUID NOT NULL REFERENCES freight.yards(id),
destination_yard_id UUID NOT NULL REFERENCES freight.yards(id),
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL
);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.route_milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
route_id UUID NOT NULL REFERENCES freight.routes(id) ON DELETE CASCADE,
yard_id UUID NOT NULL REFERENCES freight.yards(id),
sequence_no INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ NULL,
CONSTRAINT uq_route_milestones_route_sequence UNIQUE (route_id, sequence_no)
);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_origin_yard_id
ON freight.routes(origin_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_destination_yard_id
ON freight.routes(destination_yard_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_routes_is_active
ON freight.routes(is_active);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_route_milestones_route_id
ON freight.route_milestones(route_id);
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_route_milestones_yard_id
ON freight.route_milestones(yard_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.route_milestones;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.routes;`);
await queryRunner.query(`
ALTER TABLE freight.locomotives
DROP COLUMN IF EXISTS max_speed_kmh,
DROP COLUMN IF EXISTS traction_force_kn,
DROP COLUMN IF EXISTS power_kw,
DROP COLUMN IF EXISTS max_train_length_meters,
DROP COLUMN IF EXISTS locomotive_type;
`);
await queryRunner.query(`
UPDATE freight.locomotives
SET status = 'INACTIVE'
WHERE status = 'OUT_OF_SERVICE';
`);
}
}

View File

@@ -0,0 +1,44 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddRouteToTrainSchedules1750300000000 implements MigrationInterface {
name = 'AddRouteToTrainSchedules1750300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS route_id UUID NULL;
`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fk_train_schedules_route'
) THEN
ALTER TABLE freight.train_schedules
ADD CONSTRAINT fk_train_schedules_route
FOREIGN KEY (route_id) REFERENCES freight.routes(id);
END IF;
END $$;
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_train_schedules_route_id
ON freight.train_schedules(route_id);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_train_schedules_route_id;`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP CONSTRAINT IF EXISTS fk_train_schedules_route;
`);
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS route_id;
`);
}
}

View File

@@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
key: 'approved_contract', key: 'approved_contract',
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'], statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
}, },
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] }, { key: 'payment', statuses: ['FULLY_EXECUTED'] },
{ {
key: 'operations', key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'], statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
}, },
{ key: 'completed', statuses: ['COMPLETED'] }, { key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] }, { key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -34,8 +34,8 @@ export function computeNextStep(
}; };
case 'APPROVED': case 'APPROVED':
return { return {
action: 'GENERATE_CONTRACT', action: 'CUSTOMER_SIGN',
description: 'Generate the contract document', description: 'Contract generated; customer must sign',
}; };
case 'CONTRACT_READY': case 'CONTRACT_READY':
return { return {
@@ -49,8 +49,8 @@ export function computeNextStep(
}; };
case 'FULLY_EXECUTED': case 'FULLY_EXECUTED':
return { return {
action: 'PAY', action: 'AWAIT_PAYMENT',
description: 'Complete in-app payment', description: 'Awaiting customer payment',
}; };
case 'PAID': case 'PAID':
return { return {

View File

@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util'; import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto'; import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service'; import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { } export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable() @Injectable()
export class BookingPaymentService { export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { } constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
) { }
async pay( async pay(bookingId: string): Promise<{ redirectUrl: string }> {
bookingId: string,
): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId); const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']); assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
// const receipt = this.buildMockReceipt(booking); const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
// const updated = await this.bookingsRepository.update(bookingId, { if (existing.clientAction) {
// status: 'PAID', const action = existing.clientAction as { type?: string; url?: string };
// paymentStatus: 'PAID', if (action.type === "REDIRECT" && action.url) {
// } as never); return { redirectUrl: action.url };
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => { }
return new Promise((resp, _) => { }
resp({
id: booking.id,
type: "booking"
})
});
})
return {
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
} }
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
return {
redirectUrl:
resp.redirectUrl ?? "",
};
} }
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
// const timestamp = Date.now();
// const isEtb = booking.paymentCurrency === 'ETB';
// const prefix = isEtb ? 'TB' : 'CARD';
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
// return {
// success: true,
// provider,
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
// amount: booking.totalAmount,
// currency: booking.paymentCurrency,
// paidAt: new Date().toISOString(),
// };
// }
private async requireBooking(id: string): Promise<Booking> { private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id); const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`); if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -185,6 +185,11 @@ export class BookingTransitionService {
await this.bookingsRepository.update(bookingId, updates as never); await this.bookingsRepository.update(bookingId, updates as never);
} }
if (allDone) {
const generated = await this.contractService.generateContract(bookingId);
return this.bookingsService.findById(generated.id);
}
return this.bookingsService.findById(bookingId); return this.bookingsService.findById(bookingId);
} }

View File

@@ -0,0 +1,59 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsOptional, IsString, MaxLength, Min } from 'class-validator';
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
export class CreateLocomotiveDto {
@ApiProperty({ example: 'LOCO-001' })
@IsString()
@MaxLength(32)
code!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(100)
name?: string;
@ApiProperty({ enum: LOCOMOTIVE_TYPES })
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType!: string;
@ApiProperty({ enum: LOCOMOTIVE_STATUSES })
@IsIn([...LOCOMOTIVE_STATUSES])
status!: string;
@ApiProperty({ example: 3500 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(0)
maxPullWeightTons!: number;
@ApiProperty({ example: 760 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(0)
maxTrainLengthMeters!: number;
@ApiPropertyOptional({ example: 4200 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
powerKw?: number;
@ApiPropertyOptional({ example: 300 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
tractionForceKn?: number;
@ApiPropertyOptional({ example: 120 })
@IsOptional()
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
@IsNumber()
@Min(0)
maxSpeedKmh?: number;
}

View File

@@ -1,11 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger'; import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator'; import { IsIn, IsOptional } from 'class-validator';
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity'; import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
export class FilterLocomotivesDto { export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES }) @ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional() @IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES]) @IsIn([...LOCOMOTIVE_STATUSES])
status?: string; status?: string;
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
@IsOptional()
@IsIn([...LOCOMOTIVE_TYPES])
locomotiveType?: string;
} }

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateLocomotiveDto } from './create-locomotive.dto';
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}

View File

@@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [
'AVAILABLE', 'AVAILABLE',
'ASSIGNED', 'ASSIGNED',
'MAINTENANCE', 'MAINTENANCE',
'INACTIVE', 'OUT_OF_SERVICE',
] as const; ] as const;
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number]; export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
@Entity({ schema: 'freight', name: 'locomotives' }) @Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code']) @Index(['code'])
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true }) @Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
name?: string | null; name?: string | null;
@Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' })
locomotiveType!: LocomotiveType;
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 }) @Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
maxPullWeightTons!: number; maxPullWeightTons!: number;
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
maxTrainLengthMeters!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' }) @Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus; status!: LocomotiveStatus;
@Column({ name: 'available_from', type: 'timestamptz', nullable: true }) @Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
availableFrom?: Date | null; powerKw?: number | null;
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
tractionForceKn?: number | null;
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
maxSpeedKmh?: number | null;
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive) @OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
trainSets?: TrainSet[]; trainSets?: TrainSet[];

View File

@@ -1,7 +1,9 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { LocomotivesService } from './locomotives.service'; import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives') @ApiTags('locomotives')
@@ -15,4 +17,28 @@ export class LocomotivesController {
findAll(@Query() filter: FilterLocomotivesDto) { findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter); return this.locomotivesService.findAll(filter);
} }
@Get(':id')
@ApiOperation({ summary: 'Get a locomotive by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create a locomotive' })
create(@Body() dto: CreateLocomotiveDto) {
return this.locomotivesService.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a locomotive' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
return this.locomotivesService.update(id, dto);
}
@Post(':id/decommission')
@ApiOperation({ summary: 'Decommission a locomotive' })
decommission(@Param('id', ParseUUIDPipe) id: string) {
return this.locomotivesService.decommission(id);
}
} }

View File

@@ -1,7 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository'; import { LocomotivesRepository } from './locomotives.repository';
@Injectable() @Injectable()
@@ -10,13 +12,36 @@ export class LocomotivesService {
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> { findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({ return this.locomotivesRepository.findAll({
where: filter.status where: {
? { status: filter.status as LocomotiveStatus } ...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
: undefined, ...(filter.locomotiveType
? { locomotiveType: filter.locomotiveType as LocomotiveType }
: {}),
},
order: { code: 'ASC' }, order: { code: 'ASC' },
}); });
} }
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
if (existing) {
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
}
return this.locomotivesRepository.create({
code: dto.code,
name: dto.name?.trim() || null,
locomotiveType: dto.locomotiveType as LocomotiveType,
status: dto.status as LocomotiveStatus,
maxPullWeightTons: dto.maxPullWeightTons,
maxTrainLengthMeters: dto.maxTrainLengthMeters,
powerKw: dto.powerKw ?? null,
tractionForceKn: dto.tractionForceKn ?? null,
maxSpeedKmh: dto.maxSpeedKmh ?? null,
});
}
async findById(id: string): Promise<Locomotive> { async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id); const locomotive = await this.locomotivesRepository.findById(id);
@@ -26,4 +51,48 @@ export class LocomotivesService {
return locomotive; return locomotive;
} }
async update(id: string, dto: UpdateLocomotiveDto): Promise<Locomotive> {
const locomotive = await this.findById(id);
if (dto.code && dto.code !== locomotive.code) {
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
if (existing && existing.id !== id) {
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
}
}
const updated = await this.locomotivesRepository.update(id, {
...dto,
locomotiveType:
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
tractionForceKn:
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
maxSpeedKmh:
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
});
if (!updated) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return updated;
}
async decommission(id: string): Promise<Locomotive> {
await this.findById(id);
const updated = await this.locomotivesRepository.update(id, {
status: 'OUT_OF_SERVICE',
});
if (!updated) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return updated;
}
} }

View File

@@ -1,6 +0,0 @@
import { IsString } from "class-validator";
export class InitiateBookingPayment {
@IsString()
bookingId!: string;
}

View File

@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
type PaymentType = "booking" type PaymentType = "booking"
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr" type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
type Currency = "ETB" | "USD" type Currency = "ETB" | "USD"
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded" export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
@Entity({ schema: 'freight', name: 'payments' }) @Entity({ schema: 'freight', name: 'payments' })
export class PaymentEntity extends BaseEntity { export class PaymentEntity extends BaseEntity {

View File

@@ -1,53 +1,31 @@
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common"; import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
import { PaymentService } from "./payment.service"; import { PaymentService } from "./payment.service";
import { Public } from "@edr/api-common"; import { Public } from "@edr/api-common";
// import { randomUUID } from "crypto";
import { Response } from "express" import { Response } from "express"
@Public() @Public()
@Controller("payments") @Controller("payments")
export class PaymentController { export class PaymentController {
constructor(private readonly paymentService: PaymentService,) { } constructor(private readonly paymentService: PaymentService,) { }
@Post("/initiate")
initiate() {
return this.paymentService.initBookingTelebirr("123", "web")
}
// @Get("/receipts/:orderId/html") @Post("/bookings/check-payment/:orderId")
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) { checkPayment(@Param("orderId") orderId: string) {
// const filled = await this.paymentService.genReceiptHtml(orderId); return this.paymentService.checkStatusAndUpdate(orderId)
// return res.send(filled) }
// }
// @Post("/initiate/booking") @Get("/bookings/telebirr/redirect/:orderId")
// async initiatePayment() { async pay(@Param("orderId") orderId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
// //Only for testing.. if (!payment) {
// const description = "Booking for contact" throw new NotFoundException('payment not found')
// const price = 2000
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
// return new Promise((resp, _) => {
// resp({
// id: randomUUID(),
// type: "booking"
// })
// });
// })
// return data
// }
@Post("/bookings/check-payment/:orderId")
checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) {
return this.paymentService.checkStatusAndUpdate(orderId)
} }
return res.send(`
@Get("/telebirr/:refId")
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
if (!payment) {
throw new NotFoundException('payment not found')
}
return res.send(`
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@@ -62,6 +40,5 @@ export class PaymentController {
</body> </body>
</html> </html>
`); `);
} }
} }

View File

@@ -1,5 +1,4 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentService } from "./payment.service"; import { PaymentService } from "./payment.service";
import { HttpModule } from "@nestjs/axios"; import { HttpModule } from "@nestjs/axios";
import { PaymentController } from "./payment.controller"; import { PaymentController } from "./payment.controller";
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
import { PaymentRepository } from "./payment.repository"; import { PaymentRepository } from "./payment.repository";
import { WebhookController } from "./webhooks/webhook.controller"; import { WebhookController } from "./webhooks/webhook.controller";
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service"; import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
import { TelebirrProvider } from "@edr/payment-providers";
@Module({ @Module({
imports: [HttpModule, ConfigModule], imports: [HttpModule, ConfigModule],
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService], providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
controllers: [PaymentController, WebhookController], controllers: [PaymentController, WebhookController],
exports: [PaymentService] exports: [PaymentService]
}) })

View File

@@ -14,6 +14,13 @@ export class PaymentRepository {
return qr.manager.save(payment) return qr.manager.save(payment)
} }
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
const payment = this.paymentRepo.create(data)
return this.paymentRepo.save(payment)
}
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> { findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
return this.paymentRepo.findOneBy(options); return this.paymentRepo.findOneBy(options);
} }
@@ -36,4 +43,20 @@ export class PaymentRepository {
getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) {
return this.paymentRepo
.createQueryBuilder('payment')
.where('payment.method = :method', { method })
.andWhere('payment.merchantOrderId = :orderId', { orderId })
.andWhere('payment.status IN (:...statuses)', {
statuses: ['action-required'],
})
.andWhere('payment.expiresAt > :now', { now: new Date() })
.getOne();
}
} }

View File

@@ -1,111 +1,90 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; import {
import { DataSource, QueryRunner } from "typeorm"; BadRequestException,
Injectable,
InternalServerErrorException,
NotFoundException,
} from "@nestjs/common";
import { DataSource } from "typeorm";
import { PaymentEntity } from "./entities/payment.entity"; import { PaymentEntity } from "./entities/payment.entity";
import { PaymentStrategy } from "./strategies/payment.strategy";
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
import { PaymentRepository } from "./payment.repository"; import { PaymentRepository } from "./payment.repository";
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
import * as crypto from 'crypto';
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import * as Handlebars from 'handlebars'; import * as Handlebars from "handlebars";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
import {
ClientAction,
createMerchantOrderId,
ProviderPaymentStatus,
TelebirrProvider,
} from "@edr/payment-providers";
import { ProviderInitiationInput } from "@edr/types"
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
type PaymentMethod = PaymentEntity["method"] const DEFAULT_CURRENCY = "ETB";
type CurrencyType = PaymentEntity["currency"]
@Injectable() @Injectable()
export class PaymentService { export class PaymentService {
private strategies: Map<PaymentMethod, PaymentStrategy>;
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly datasource: DataSource, private readonly datasource: DataSource,
private readonly paymentRepo: PaymentRepository, private readonly paymentRepo: PaymentRepository,
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) { private readonly telebirrProvider: TelebirrProvider,
this.strategies = new Map([ ) { }
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
])
}
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{ async initBookingTelebirr(
refId: string, bookingId: string,
clientAction: ClientAction, platform: PaymentPlatformDto,
status: PaymentEntity["status"], ): Promise<{ redirectUrl: string }> {
paidAt?: string, // const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
failureCode?: string, // if (!booking) throw new NotFoundException("Booking not found");
failureMessage?: string,
}> {
// const booking = new Booking()
// booking.totalAmount = 20
// booking.id = randomUUID
const amount = 20
const merchantOrderId = createMerchantOrderId();
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
const amountMinor = Math.round(Number(amount) * 100);
const strategy = this.strategies.get(method) const input: ProviderInitiationInput = {
if (!strategy) { merchantOrderId,
throw new NotFoundException("strategy not found") orderRef: bookingId,
} amountMinor,
currency: DEFAULT_CURRENCY,
const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic platform: platform || "web",
let redirectUrl: string;
switch (type) {
case "booking":
const url = this.configService.get<string>("TELEBIRR_SUCCESS_REDIRECT_BASE_URL")
redirectUrl = `${url}/check-status/${orderId}`
break;
}
const paymentResp = await strategy.pay({
redirectUrl, redirectUrl,
amountMinor: amount, };
currency: currency,
merchantOrderId: orderId, const result = await this.telebirrProvider.initiate(input);
platform: payform,
const payment = await this.paymentRepo.create({
amount: amount,
currency: DEFAULT_CURRENCY,
method: "telebirr",
refId: bookingId,
type: "booking",
merchantOrderId,
rawInitiation: result.rawInitiation,
clientAction: result.clientAction as Record<string, unknown>,
expiresAt: result.expiresAt,
reason: `Payment for booking`,
}); });
const queryRunner = this.datasource.createQueryRunner() return {
await queryRunner.connect() redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
await queryRunner.startTransaction()
console.log(paymentResp.expiresAt)
try {
const resp = await cb(queryRunner)
const payment = await this.paymentRepo.createTr(queryRunner, {
amount,
currency,
method,
refId: resp.id,
type: resp.type,
merchantOrderId: orderId,
rawInitiation: paymentResp.rawInitiation,
clientAction: paymentResp.clientAction,
expiresAt: paymentResp.expiresAt,
reason
})
await queryRunner.commitTransaction()
return {
refId: payment.refId,
clientAction: paymentResp.clientAction,
status: payment.status,
paidAt: payment.paidAt?.toISOString(),
failureCode: payment.failerCode ?? undefined,
failureMessage: payment.failureMessage ?? undefined,
}
} catch (err) {
await queryRunner.rollbackTransaction()
throw new Error("payment failed")
} finally {
await queryRunner.release()
} }
} }
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> { async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method) return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
} }
async genReceiptHtml(orderId: string) { async genReceiptHtml(orderId: string) {
const payment = await this.paymentRepo.findOneBy({ const payment = await this.paymentRepo.findOneBy({
merchantOrderId: orderId, merchantOrderId: orderId,
@@ -141,13 +120,9 @@ export class PaymentService {
if (!resp) { if (!resp) {
throw new NotFoundException("order id not found") throw new NotFoundException("order id not found")
} }
const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId) const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
const bizContent = result.rawResponse.biz_content as {
order_status: string;
};
const ordersStatus = bizContent.order_status if (result.status === ProviderPaymentStatus.SUCCEEDED) {
if (ordersStatus == "PAY_SUCCESS") {
await this.datasource.transaction(async (mg) => { await this.datasource.transaction(async (mg) => {
await mg.update(Booking, { id: resp.refId }, { status: "PAID" }) await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" }) await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
@@ -158,5 +133,28 @@ export class PaymentService {
} }
} }
} findBookingById(id: string) {
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
}
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
const clientAction =
intent.clientAction && typeof intent.clientAction === "object"
? (intent.clientAction as unknown as ClientAction)
: undefined;
const statusMap: Record<string, ProviderPaymentStatus> = {
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
"processing": ProviderPaymentStatus.PROCESSING,
"success": ProviderPaymentStatus.SUCCEEDED,
"failed": ProviderPaymentStatus.FAILED,
"canceled": ProviderPaymentStatus.CANCELLED,
"refunded": ProviderPaymentStatus.CANCELLED,
};
return {
intentId: intent.id,
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
clientAction,
merchantOrderId: intent.merchantOrderId ?? undefined,
};
}
}

View File

@@ -0,0 +1,62 @@
import { ProviderPaymentStatus } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString } from "class-validator";
export type PaymentPlatformDto = "web" | "mobile";
export class InitiatePaymentDto {
@ApiProperty({ example: "booking-uuid" })
@IsString()
bookingId!: string;
@ApiProperty({ enum: ["TELEBIRR"], example: "TELEBIRR" })
@IsIn(["TELEBIRR"])
method!: "TELEBIRR";
@ApiPropertyOptional({ enum: ["web", "mobile"], default: "web" })
@IsOptional()
@IsIn(["web", "mobile"])
platform?: PaymentPlatformDto;
}
export class ClientActionDto {
@ApiProperty({ enum: ["REDIRECT", "LAUNCH_APP"] })
type!: "REDIRECT" | "LAUNCH_APP";
@ApiPropertyOptional({ description: "Set when type=REDIRECT (web flow)" })
url?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
appId?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
receiveCode?: string;
@ApiPropertyOptional({ description: "Set when type=LAUNCH_APP (mobile flow)" })
shortCode?: string;
}
export class InitiateResponseDto {
@ApiProperty()
intentId!: string;
@ApiProperty({ enum: ProviderPaymentStatus })
status!: ProviderPaymentStatus;
@ApiPropertyOptional({ type: ClientActionDto })
clientAction?: ClientActionDto;
@ApiPropertyOptional()
merchantOrderId?: string;
}
export class IntentStatusDto extends InitiateResponseDto {
@ApiPropertyOptional()
paidAt?: string;
@ApiPropertyOptional()
failureCode?: string;
@ApiPropertyOptional()
failureMessage?: string;
}

View File

@@ -1,8 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ProviderInitiationInput, ProviderInitiationResult } from "./payments.types";
@Injectable()
export abstract class PaymentStrategy {
abstract pay(data: ProviderInitiationInput): Promise<ProviderInitiationResult>
}

View File

@@ -1,304 +0,0 @@
import { Injectable, Logger } from "@nestjs/common";
import { PaymentStrategy } from "./payment.strategy";
import { ConfigService } from '@nestjs/config';
import { HttpService } from '@nestjs/axios';
import { AxiosError, AxiosRequestConfig } from 'axios';
import { firstValueFrom } from 'rxjs';
import * as https from 'node:https';
import { PaymentEntity } from "../entities/payment.entity";
import { ProviderInitiationInput, ProviderInitiationResult, ProviderStatus } from "./payments.types";
import { CreateOrderRequest, CreateOrderResponse, FabricTokenResponse, QueryOrderResponse } from "./telebirr/telebirr.types";
import { createNonceStr, createTimestamp, signRequestObject, verifyRequestObject } from "./telebirr/telebirr.crypto";
// type PaymentCurrency = PaymentEntity["currency"]
type PaymentIntentStatus = PaymentEntity["status"]
const TELEBIRR_HTTP_TIMEOUT_MS = 10_000;
@Injectable()
export class PaymentTelebirrStrategy implements PaymentStrategy {
async pay(data: ProviderInitiationInput): Promise<any> {
// const refId = randomUUID()
// const orderId = createMerchantOrderId()
const resp = await this.initiate(data)
return resp;
}
// readonly method = PaymentMethodType.TELEBIRR;
private readonly logger = new Logger(PaymentTelebirrStrategy.name);
private readonly httpsAgent: https.Agent;
constructor(
private readonly config: ConfigService,
private readonly http: HttpService,
) {
const insecure = this.config.get<boolean>('telebirr.insecureTls');
if (insecure) {
this.logger.warn('TELEBIRR_INSECURE_TLS=true — TLS verification disabled for Telebirr calls. DEV ONLY.');
}
this.httpsAgent = new https.Agent({
rejectUnauthorized: !insecure,
secureProtocol: 'TLSv1_2_method',
});
}
async initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildCreateOrderRequest(input);
const response = await this.requestCreateOrder(fabricToken, requestBody);
const prepayId = response.biz_content?.prepay_id;
if (!prepayId) {
throw new Error(
`Telebirr createOrder returned no prepay_id: ${JSON.stringify(response)}`,
);
}
const expiresAt = this.computeExpiresAt(requestBody.biz_content.timeout_express);
const platform = input.platform ?? 'web';
const clientAction =
platform === 'mobile'
? {
type: 'LAUNCH_APP' as const,
prepayId,
receiveCode: response.biz_content?.receiveCode,
shortCode: this.merchantCode,
}
: { type: 'REDIRECT' as const, url: this.buildCheckoutUrl(prepayId) };
return {
providerOrderId: prepayId,
clientAction,
expiresAt,
rawInitiation: {
request: this.sanitize(requestBody),
response,
},
};
}
async queryStatus(merchantOrderId: string): Promise<ProviderStatus> {
const fabricToken = await this.applyFabricToken();
const requestBody = this.buildQueryOrderRequest(merchantOrderId);
const response = await this.postJson<QueryOrderResponse>(
`${this.baseUrl}/payment/v1/merchant/queryOrder`,
requestBody,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
const tradeStatus = response.biz_content?.trade_status;
const providerTxnId =
response.biz_content?.trans_id ?? response.biz_content?.payment_order_id;
const mapped = this.mapTradeStatus(tradeStatus);
return {
status: mapped,
providerTxnId,
failureCode:
mapped === "failed" && tradeStatus ? tradeStatus : undefined,
rawResponse: response as Record<string, unknown>,
};
}
mapTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'PAY_SUCCESS':
return "success";
case 'PAY_FAILED':
case 'ORDER_CLOSED':
return "failed";
case 'WAIT_PAY':
return "action-required";
case 'PAYING':
return "processing";
default:
return "processing";
}
}
mapWebhookTradeStatus(tradeStatus: string | undefined): PaymentIntentStatus {
switch (tradeStatus) {
case 'Completed':
return "success";
case 'Failure':
case 'Expired':
return "failed";
case 'Paying':
case 'Pending':
return "processing";
default:
return "processing";
}
}
verifyWebhookSignature(payload: Record<string, unknown>): boolean {
if (!this.publicKey) {
this.logger.error('TELEBIRR_PUBLIC_KEY not configured; rejecting all webhooks');
return false;
}
return verifyRequestObject(payload, this.publicKey);
}
private async applyFabricToken(): Promise<string> {
console.log(this.baseUrl, "base url")
const response = await this.postJson<FabricTokenResponse>(
`${this.baseUrl}/payment/v1/token`,
{ appSecret: this.appSecret },
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
},
);
if (!response?.token) {
throw new Error(`Telebirr token request failed: ${JSON.stringify(response)}`);
}
return response.token;
}
private async requestCreateOrder(
fabricToken: string,
body: CreateOrderRequest,
): Promise<CreateOrderResponse> {
return this.postJson<CreateOrderResponse>(
`${this.baseUrl}/payment/v1/inapp/createOrder`,
body,
{
'Content-Type': 'application/json',
'X-APP-Key': this.fabricAppId,
Authorization: fabricToken,
},
);
}
private buildCreateOrderRequest(input: ProviderInitiationInput): CreateOrderRequest {
// const totalAmount = String(input.amountMinor / 100);
const totalAmount = String(input.amountMinor)
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.preorder' as const,
version: '1.0' as const,
biz_content: {
notify_url: this.notifyUrl,
appid: this.merchantAppId,
redirect_url: input.redirectUrl,
merch_code: this.merchantCode,
merch_order_id: input.merchantOrderId,
trade_type: 'Checkout' as const,
title: `EDR Booking`,
total_amount: totalAmount,
trans_currency: input.currency,
timeout_express: this.timeoutExpress,
},
};
const sign = signRequestObject(req as unknown as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildQueryOrderRequest(merchantOrderId: string): Record<string, unknown> {
const req = {
timestamp: createTimestamp(),
nonce_str: createNonceStr(),
method: 'payment.queryorder',
version: '1.0',
biz_content: {
appid: this.merchantAppId,
merch_code: this.merchantCode,
merch_order_id: merchantOrderId,
},
};
const sign = signRequestObject(req as Record<string, unknown>, this.privateKey);
return { ...req, sign, sign_type: 'SHA256WithRSA' };
}
private buildCheckoutUrl(prepayId: string): string {
const map: Record<string, string> = {
appid: this.merchantAppId,
merch_code: this.merchantCode,
nonce_str: createNonceStr(),
prepay_id: prepayId,
timestamp: createTimestamp(),
};
const sign = signRequestObject(map, this.privateKey);
const rawRequest = [
`appid=${map.appid}`,
`merch_code=${map.merch_code}`,
`nonce_str=${map.nonce_str}`,
`prepay_id=${map.prepay_id}`,
`timestamp=${map.timestamp}`,
'sign_type=SHA256WithRSA',
`sign=${sign}`,
'version=1.0',
'trade_type=Checkout',
].join('&');
return `${this.webBaseUrl}${rawRequest}`;
}
private computeExpiresAt(timeoutExpress: string): Date {
const match = /^(\d+)([smhd])$/.exec(timeoutExpress);
const minutes = match ? this.toMinutes(parseInt(match[1], 10), match[2]) : 15;
return new Date(Date.now() + minutes * 60_000);
}
private toMinutes(n: number, unit: string): number {
switch (unit) {
case 's': return Math.max(1, Math.round(n / 60));
case 'm': return n;
case 'h': return n * 60;
case 'd': return n * 60 * 24;
default: return 15;
}
}
private async postJson<T>(
url: string,
body: unknown,
headers: Record<string, string>,
): Promise<T> {
const config: AxiosRequestConfig = {
headers,
timeout: TELEBIRR_HTTP_TIMEOUT_MS,
httpsAgent: this.httpsAgent,
};
const started = Date.now();
try {
const res = await firstValueFrom(this.http.post<T>(url, body, config));
this.logger.debug(`Telebirr POST ${url} status=${res.status} latency=${Date.now() - started}ms`);
return res.data;
} catch (err) {
if (err instanceof AxiosError) {
this.logger.error(
`Telebirr POST ${url} failed: status=${err.response?.status} body=${JSON.stringify(err.response?.data)} code=${err.code} message=${err.message}`,
);
} else {
this.logger.error(`Telebirr POST ${url} threw: ${err instanceof Error ? err.message : err}`);
}
throw err;
}
}
private sanitize(body: CreateOrderRequest): Record<string, unknown> {
const { sign: _sign, ...rest } = body;
return rest;
}
private get baseUrl(): string { return this.config.get<string>('telebirr.baseUrl') ?? ''; }
private get webBaseUrl(): string { return this.config.get<string>('telebirr.webBaseUrl') ?? ''; }
private get fabricAppId(): string { return this.config.get<string>('telebirr.fabricAppId') ?? ''; }
private get appSecret(): string { return this.config.get<string>('telebirr.appSecret') ?? ''; }
private get merchantAppId(): string { return this.config.get<string>('telebirr.merchantAppId') ?? ''; }
private get merchantCode(): string { return this.config.get<string>('telebirr.merchantCode') ?? ''; }
private get notifyUrl(): string { return this.config.get<string>('telebirr.notifyUrl') ?? ''; }
private get timeoutExpress(): string { return this.config.get<string>('telebirr.timeoutExpress') ?? '15m'; }
private get privateKey(): string { return this.config.get<string>('telebirr.privateKey') ?? ''; }
private get publicKey(): string {
return this.config.get<string>('telebirr.publicKey') ?? '';
}
}

View File

@@ -1,40 +0,0 @@
import { PaymentEntity } from "../entities/payment.entity";
type PaymentIntentStatus = PaymentEntity["status"]
type PaymentMethodType = PaymentEntity["method"]
export type PaymentPlatform = 'web' | 'mobile';
export type ClientAction =
| { type: 'REDIRECT'; url: string }
| { type: 'LAUNCH_APP'; prepayId: string; receiveCode?: string; shortCode: string };
export interface ProviderInitiationInput {
redirectUrl: string;
merchantOrderId: string;
// bookingRef: string;
amountMinor: number;
currency: string;
platform?: PaymentPlatform;
}
export interface ProviderInitiationResult {
providerOrderId: string;
clientAction: ClientAction;
expiresAt: Date;
rawInitiation: Record<string, unknown>;
}
export interface ProviderStatus {
status: PaymentIntentStatus;
providerTxnId?: string;
failureCode?: string;
failureMessage?: string;
rawResponse: Record<string, unknown>;
}
export interface PaymentProvider {
readonly method: PaymentMethodType;
initiate(input: ProviderInitiationInput): Promise<ProviderInitiationResult>;
queryStatus(merchantOrderId: string): Promise<ProviderStatus>;
}

View File

@@ -1,98 +0,0 @@
import * as crypto from 'crypto';
const EXCLUDE_FIELDS = new Set([
'sign',
'sign_type',
'header',
'refund_info',
'openType',
'raw_request',
'biz_content',
]);
const NONCE_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
export function buildCanonicalString(requestObject: Record<string, unknown>): string {
const fieldMap: Record<string, unknown> = {};
for (const key of Object.keys(requestObject)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = requestObject[key];
}
const biz = requestObject['biz_content'];
if (biz && typeof biz === 'object') {
for (const key of Object.keys(biz as Record<string, unknown>)) {
if (EXCLUDE_FIELDS.has(key)) continue;
fieldMap[key] = (biz as Record<string, unknown>)[key];
}
}
return Object.keys(fieldMap)
.sort()
.map((k) => `${k}=${fieldMap[k]}`)
.join('&');
}
export function signRequestObject(
requestObject: Record<string, unknown>,
privateKey: string,
): string {
return signString(buildCanonicalString(requestObject), privateKey);
}
export function verifyRequestObject(
requestObject: Record<string, unknown>,
publicKey: string,
): boolean {
const signature = requestObject['sign'];
if (typeof signature !== 'string' || signature.length === 0) return false;
return verifySignature(buildCanonicalString(requestObject), signature, publicKey);
}
export function signString(text: string, privateKey: string): string {
const signature = crypto.sign('sha256', Buffer.from(text), {
key: privateKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
});
return signature.toString('base64');
}
export function verifySignature(
text: string,
signatureBase64: string,
publicKey: string,
): boolean {
try {
return crypto.verify(
'sha256',
Buffer.from(text),
{
key: publicKey,
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST,
},
Buffer.from(signatureBase64, 'base64'),
);
} catch {
return false;
}
}
export function createTimestamp(): string {
return Math.round(Date.now() / 1000).toString();
}
export function createNonceStr(length = 32): string {
const bytes = crypto.randomBytes(length);
let out = '';
for (let i = 0; i < length; i++) {
out += NONCE_CHARS[bytes[i] % NONCE_CHARS.length];
}
return out;
}
export function createMerchantOrderId(): string {
return `${Date.now()}${crypto.randomBytes(4).toString('hex')}`;
}

View File

@@ -1,69 +0,0 @@
export interface FabricTokenResponse {
token: string;
expires_in?: number | string;
}
export interface CreateOrderBizContent {
notify_url: string;
appid: string;
merch_code: string;
merch_order_id: string;
trade_type: 'Checkout' | 'InApp' | 'MiniApp';
title: string;
total_amount: string;
trans_currency: string;
timeout_express: string;
}
export interface CreateOrderRequest {
timestamp: string;
nonce_str: string;
method: 'payment.preorder';
version: '1.0';
biz_content: CreateOrderBizContent;
sign: string;
sign_type: 'SHA256WithRSA';
}
export interface CreateOrderResponse {
code?: string;
msg?: string;
biz_content?: {
prepay_id?: string;
receiveCode?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}
export type TelebirrTradeStatus =
| 'PAY_SUCCESS'
| 'PAY_FAILED'
| 'WAIT_PAY'
| 'ORDER_CLOSED'
| 'PAYING'
| 'ACCEPTED'
| 'REFUNDING'
| 'REFUND_SUCCESS'
| 'REFUND_FAILED';
export interface QueryOrderResponse {
result?: 'SUCCESS' | 'FAIL';
code?: string;
msg?: string;
nonce_str?: string;
sign?: string;
sign_type?: string;
biz_content?: {
merch_order_id?: string;
order_status?: string;
trade_status?: TelebirrTradeStatus | string;
payment_order_id?: string;
trans_id?: string;
trans_time?: string;
trans_currency?: string;
total_amount?: string;
[key: string]: unknown;
};
[key: string]: unknown;
}

View File

@@ -1,82 +1,53 @@
import { Injectable, } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from "crypto"
import { TelebirrDto } from '../dto/telebirr.dto'; import { TelebirrDto } from '../dto/telebirr.dto';
import { PaymentRepository } from '../../payment.repository'; import { PaymentRepository } from '../../payment.repository';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { Booking } from 'src/modules/bookings/entities/booking.entity'; import { Booking } from '../../../bookings/entities/booking.entity';
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
@Injectable() @Injectable()
export class TelebirrWebhookService { export class TelebirrWebhookService {
// private readonly logger = new Logger(TelebirrWebhookService.name); private readonly logger = new Logger(TelebirrWebhookService.name);
constructor( constructor(
private readonly datasource: DataSource, private readonly datasource: DataSource,
private readonly config: ConfigService,
private readonly paymentRepo: PaymentRepository, private readonly paymentRepo: PaymentRepository,
private readonly telebirrProvider: TelebirrProvider,
) { } ) { }
verifyTelebirrNotification(payload: TelebirrDto) { verifyTelebirrNotification(payload: TelebirrDto) {
// 1. Extract the signature provided by Telebirr return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
const { sign, ...bizContent } = payload;
if (!sign) {
throw new Error("Missing 'sign' field from Telebirr payload");
}
// 2. Sort the remaining keys alphabetically to rebuild the raw string
const sortedKeys = Object.keys(bizContent).sort();
const signString = sortedKeys
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
.join('&');
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
const publicKey = {
key: this.config.get<string>("telebirr.publicKey") ?? "",
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
saltLength: 32 // Telebirr standard salt length
};
// 4. Verify the signature against the sorted string
const isVerified = crypto.verify(
"sha256",
Buffer.from(signString),
publicKey,
Buffer.from(sign, 'base64')
);
return isVerified;
} }
async handle(payload: TelebirrDto): Promise<void> { async handle(payload: TelebirrDto): Promise<void> {
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id }) const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
if (!payment) { if (!payment) {
throw new Error("payment not found") this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
return;
} }
switch (payload.trade_status) {
case "SUCCEEDED": const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
switch (payment.type) { switch (mapped) {
case "booking": case ProviderPaymentStatus.SUCCEEDED:
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", }) await this.paymentRepo.update(
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", }) { id: payment.id },
break; { status: "success", paidAt: new Date() },
);
if (payment.type === "booking") {
await this.datasource.manager.update(
Booking,
{ id: payment.refId },
{ paymentStatus: "PAID" },
);
} }
break; break;
case "FAILED": case ProviderPaymentStatus.FAILED:
await this.paymentRepo.update({ id: payment.id }, { status: "failed" }) await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
break; break;
case "CANCELLED": case ProviderPaymentStatus.PROCESSING:
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" }) await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
break; break;
case "PROCESSING":
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
break;
case "REFUNDED":
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
break;
} }
} }
} }

View File

@@ -22,14 +22,12 @@ export class WebhookController {
); );
try { try {
// const verified = this.telebirr.verifyTelebirrNotification(payload) const verified = this.telebirr.verifyTelebirrNotification(payload)
// if (!verified) { if (!verified) {
// throw new Error("not valid") throw new Error("Telebirr webhook signature verification failed")
// } }
// const merchantOrderId = payload.merch_order_id;
await this.telebirr.handle(payload); await this.telebirr.handle(payload);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : String(err); const message = err instanceof Error ? err.message : String(err);
this.logger.error(`Telebirr webhook handler threw: ${message}`); this.logger.error(`Telebirr webhook handler threw: ${message}`);

View File

@@ -0,0 +1,28 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayMinSize, IsArray, IsBoolean, IsOptional, IsString, IsUUID, MaxLength, ValidateNested } from 'class-validator';
export class CreateRouteMilestoneDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
}
export class CreateRouteDto {
@ApiProperty()
@IsString()
@MaxLength(120)
name!: string;
@ApiProperty({ type: [CreateRouteMilestoneDto] })
@IsArray()
@ArrayMinSize(2)
@ValidateNested({ each: true })
@Type(() => CreateRouteMilestoneDto)
milestones!: CreateRouteMilestoneDto[];
@ApiPropertyOptional()
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,16 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
export class FilterRoutesDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateRouteDto } from './create-route.dto';
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from './route.entity';
@Entity({ schema: 'freight', name: 'route_milestones' })
@Index(['routeId', 'sequenceNo'], { unique: true })
export class RouteMilestone extends BaseEntity {
@Column({ name: 'route_id', type: 'uuid' })
routeId!: string;
@ManyToOne(() => Route, (route) => route.milestones, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'route_id' })
route?: Route;
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
}

View File

@@ -0,0 +1,33 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { RouteMilestone } from './route-milestone.entity';
@Entity({ schema: 'freight', name: 'routes' })
@Index(['name'])
@Index(['isActive'])
export class Route extends BaseEntity {
@Column({ name: 'name', type: 'varchar', length: 120, unique: true })
name!: string;
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;
@Column({ name: 'destination_yard_id', type: 'uuid' })
destinationYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_yard_id' })
destinationYard?: Yard;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[];
}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { RouteMilestone } from './entities/route-milestone.entity';
@Injectable()
export class RouteMilestonesRepository extends BaseRepository<RouteMilestone> {
constructor(@InjectRepository(RouteMilestone) repository: Repository<RouteMilestone>) {
super(repository);
}
}

View File

@@ -0,0 +1,44 @@
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RoutesService } from './routes.service';
@ApiTags('routes')
@ApiBearerAuth()
@Controller('routes')
export class RoutesController {
constructor(private readonly routesService: RoutesService) {}
@Get()
@ApiOperation({ summary: 'List routes' })
findAll(@Query() filter: FilterRoutesDto) {
return this.routesService.findAll(filter);
}
@Get(':id')
@ApiOperation({ summary: 'Get route by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.findById(id);
}
@Post()
@ApiOperation({ summary: 'Create route' })
create(@Body() dto: CreateRouteDto) {
return this.routesService.create(dto);
}
@Patch(':id')
@ApiOperation({ summary: 'Update route' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) {
return this.routesService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Deactivate route' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.routesService.deactivate(id);
}
}

View File

@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity';
import { RouteMilestonesRepository } from './route-milestones.repository';
import { RoutesController } from './routes.controller';
import { RoutesRepository } from './routes.repository';
import { RoutesService } from './routes.service';
@Module({
imports: [TypeOrmModule.forFeature([Route, RouteMilestone, Yard])],
controllers: [RoutesController],
providers: [RoutesRepository, RouteMilestonesRepository, RoutesService],
exports: [RoutesRepository, RouteMilestonesRepository, RoutesService],
})
export class RoutesModule {}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Route } from './entities/route.entity';
@Injectable()
export class RoutesRepository extends BaseRepository<Route> {
constructor(@InjectRepository(Route) repository: Repository<Route>) {
super(repository);
}
}

View File

@@ -0,0 +1,171 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, ILike } from 'typeorm';
import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto';
import { UpdateRouteDto } from './dto/update-route.dto';
import { RouteMilestone } from './entities/route-milestone.entity';
import { Route } from './entities/route.entity';
import { RoutesRepository } from './routes.repository';
@Injectable()
export class RoutesService {
constructor(
private readonly dataSource: DataSource,
private readonly routesRepository: RoutesRepository,
) {}
findAll(filter: FilterRoutesDto): Promise<Route[]> {
return this.routesRepository.findAll({
where: {
...(filter.search ? { name: ILike(`%${filter.search.trim()}%`) } : {}),
...(filter.isActive !== undefined ? { isActive: filter.isActive } : {}),
},
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: {
name: 'ASC',
milestones: { sequenceNo: 'ASC' },
},
});
}
async findById(id: string): Promise<Route> {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id },
relations: {
originYard: true,
destinationYard: true,
milestones: { yard: true },
},
order: { milestones: { sequenceNo: 'ASC' } },
});
if (!route) {
throw new NotFoundException(`Route ${id} not found`);
}
return route;
}
async create(dto: CreateRouteDto): Promise<Route> {
await this.validateRouteName(dto.name);
const validated = await this.validateMilestones(dto.milestones);
const route = await this.dataSource.transaction(async (manager) => {
const savedRoute = await manager.getRepository(Route).save(
manager.getRepository(Route).create({
name: dto.name.trim(),
originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId,
isActive: dto.isActive ?? true,
}),
);
await manager.getRepository(RouteMilestone).save(
validated.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: savedRoute.id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
}),
),
);
return savedRoute;
});
return this.findById(route.id);
}
async update(id: string, dto: UpdateRouteDto): Promise<Route> {
const existing = await this.findById(id);
if (dto.name && dto.name.trim() !== existing.name) {
await this.validateRouteName(dto.name, id);
}
const milestoneInput = dto.milestones
? await this.validateMilestones(dto.milestones)
: null;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(Route).update(id, {
name: dto.name?.trim() ?? existing.name,
originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId: milestoneInput?.destinationYardId ?? existing.destinationYardId,
isActive: dto.isActive ?? existing.isActive,
});
if (milestoneInput) {
await manager.getRepository(RouteMilestone).delete({ routeId: id });
await manager.getRepository(RouteMilestone).save(
milestoneInput.milestones.map((milestone) =>
manager.getRepository(RouteMilestone).create({
routeId: id,
yardId: milestone.yardId,
sequenceNo: milestone.sequenceNo,
}),
),
);
}
});
return this.findById(id);
}
async deactivate(id: string): Promise<Route> {
await this.findById(id);
const updated = await this.routesRepository.update(id, { isActive: false });
if (!updated) {
throw new NotFoundException(`Route ${id} not found`);
}
return this.findById(id);
}
private async validateRouteName(name: string, routeId?: string) {
const trimmedName = name.trim();
const [existing] = await this.routesRepository.findAll({ where: { name: trimmedName } });
if (existing && existing.id !== routeId) {
throw new ConflictException(`Route name ${trimmedName} already exists`);
}
}
private async validateMilestones(milestones: Array<{ yardId: string }>) {
if (milestones.length < 2) {
throw new BadRequestException('A route requires at least two yards');
}
const normalized = milestones.map((milestone, index) => ({
yardId: milestone.yardId,
sequenceNo: index + 1,
}));
const uniqueYardIds = [...new Set(normalized.map((milestone) => milestone.yardId))];
const yards = await this.dataSource.getRepository(Yard).find({ where: uniqueYardIds.map((id) => ({ id })) });
const yardIds = new Set(yards.map((yard) => yard.id));
for (const milestone of normalized) {
if (!yardIds.has(milestone.yardId)) {
throw new BadRequestException(`Yard ${milestone.yardId} does not exist`);
}
}
if (normalized[0].yardId === normalized[normalized.length - 1].yardId) {
throw new BadRequestException('Origin and destination yards must be different');
}
return {
originYardId: normalized[0].yardId,
destinationYardId: normalized[normalized.length - 1].yardId,
milestones: normalized,
};
}
}

View File

@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity'; import { Yard } from '../../rule-engine/entities/yard.entity';
import { Route } from '../../routes/entities/route.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity'; import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity'; import { TrainScheduleBooking } from './train-schedule-booking.entity';
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
@JoinColumn({ name: 'train_set_id' }) @JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet; trainSet?: TrainSet;
@Column({ name: 'route_id', type: 'uuid', nullable: true })
routeId?: string | null;
@ManyToOne(() => Route)
@JoinColumn({ name: 'route_id' })
route?: Route | null;
@Column({ name: 'origin_station_id', type: 'uuid' }) @Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string; originStationId!: string;

View File

@@ -1,9 +1,15 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator'; import { IsDateString, IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto'; export class CreateContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
routeId!: string;
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' }) @ApiProperty({ format: 'uuid' })
@IsUUID() @IsUUID()
locomotiveId!: string; locomotiveId!: string;

View File

@@ -17,6 +17,7 @@ const locomotive = {
id: 'loc-1', id: 'loc-1',
code: 'LOC-001', code: 'LOC-001',
maxPullWeightTons: 3500, maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: 'AVAILABLE', status: 'AVAILABLE',
}; };
@@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => {
}); });
it('creates a schedule transactionally when validation passes', async () => { it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')]; const route = {
const validation = { id: 'route-1',
valid: true, name: 'Djibouti to Addis',
violations: [], originYardId: 'yard-origin',
bookings, destinationYardId: 'yard-destination',
wagonType: nw5, isActive: true,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
}; };
const lockedLocomotiveRepo = { const lockedLocomotiveRepo = {
@@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => {
create: jest.fn().mockImplementation((value) => value), create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }), save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
}; };
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = { const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value), create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }), save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
@@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => {
return lockedLocomotiveRepo; return lockedLocomotiveRepo;
case 'TrainSchedule': case 'TrainSchedule':
return trainScheduleRepo; return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet': case 'TrainSet':
return trainSetRepo; return trainSetRepo;
default: default:
@@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => {
}), }),
}; };
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return { findOne: jest.fn().mockResolvedValue(route) };
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never); jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) => dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager), callback(manager),
); );
const result = await service.createContainerTrainSchedule({ const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'], routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z', scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1', locomotiveId: 'loc-1',
}); });
expect(trainSetRepo.save).toHaveBeenCalled(); expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled(); expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' }); expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' }); expect(result).toEqual({ id: 'schedule-1' });
}); });
it('rejects create when the locked locomotive is no longer available', async () => { it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = { const manager = {
getRepository: jest.fn(() => ({ getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }), findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})), })),
}; };
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never); jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Route') {
return {
findOne: jest.fn().mockResolvedValue({
id: 'route-1',
name: 'Djibouti to Addis',
originYardId: 'yard-origin',
destinationYardId: 'yard-destination',
isActive: true,
}),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) => dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager), callback(manager),
); );
await expect( await expect(
service.createContainerTrainSchedule({ service.createContainerTrainSchedule({
bookingIds: ['b1'], routeId: 'route-1',
scheduleDate: '2026-06-20T08:00:00.000Z', scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1', locomotiveId: 'loc-1',
}), }),
).rejects.toBeInstanceOf(ConflictException); ).rejects.toBeInstanceOf(ConflictException);

View File

@@ -15,9 +15,9 @@ import {
import { LocomotivesRepository } from "../locomotives/locomotives.repository"; import { LocomotivesRepository } from "../locomotives/locomotives.repository";
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity"; import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
import { TrainSet } from "../train-sets/entities/train-set.entity"; import { TrainSet } from "../train-sets/entities/train-set.entity";
import { Route } from "../routes/entities/route.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity"; import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity"; import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { WagonType } from "../wagon-types/entities/wagon-type.entity"; import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository"; import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -179,18 +179,12 @@ export class TrainSchedulingService {
} }
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) { async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto); const route = await this.getActiveRoute(dto.routeId);
if (!validation.valid) {
throw new BadRequestException({
message: "train_schedule_invalid",
violations: validation.violations,
});
}
const locomotive = await this.selectOrValidateLocomotive( const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId, dto.locomotiveId,
validation.summary.totalWeightTons, 0,
0,
); );
const createdSchedule = await this.dataSource.transaction( const createdSchedule = await this.dataSource.transaction(
@@ -211,90 +205,24 @@ export class TrainSchedulingService {
); );
} }
if ( const trainSet = await this.buildEmptyTrainSet(
Number(lockedLocomotive.maxPullWeightTons) <
validation.summary.totalWeightTons
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager
.getRepository(TrainScheduleBooking)
.count({
where: {
bookingId: In(validation.bookings.map((booking) => booking.id)),
},
});
if (existingScheduleCount > 0) {
throw new BadRequestException(
"One or more bookings are already scheduled",
);
}
const trainSet = await this.buildTrainSet(
manager, manager,
lockedLocomotive, lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
); );
const schedule = manager.getRepository(TrainSchedule).create({ const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id, trainSetId: trainSet.id,
originStationId: dto.originStationId, routeId: route.id,
destinationStationId: dto.destinationStationId, originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate), scheduledDepartureDate: new Date(dto.scheduleDate),
status: "SCHEDULED", status: "DRAFT",
}); });
const savedSchedule = await manager const savedSchedule = await manager
.getRepository(TrainSchedule) .getRepository(TrainSchedule)
.save(schedule); .save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager
.getRepository(TrainScheduleBooking)
.save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: "ASC" },
});
const wagonBySequence = new Map(
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
);
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager
.getRepository(WagonBookingAllocation)
.save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, { await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED", status: "ASSIGNED",
}); });
@@ -461,10 +389,14 @@ export class TrainSchedulingService {
where: { status: "AVAILABLE" }, where: { status: "AVAILABLE" },
}); });
const canPull = capableLocomotives.some( const canPull = capableLocomotives.some(
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons, (locomotive) =>
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
); );
if (!canPull) { if (!canPull) {
violations.push("No available locomotive can pull the total weight"); violations.push(
'No available locomotive can support the total train weight and length',
);
} }
} }
@@ -513,6 +445,7 @@ export class TrainSchedulingService {
async selectOrValidateLocomotive( async selectOrValidateLocomotive(
locomotiveId: string, locomotiveId: string,
totalWeightTons: number, totalWeightTons: number,
totalLengthMeters: number,
) { ) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId); const locomotive = await this.locomotivesRepository.findById(locomotiveId);
@@ -532,6 +465,12 @@ export class TrainSchedulingService {
); );
} }
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
);
}
return locomotive; return locomotive;
} }
@@ -568,6 +507,21 @@ export class TrainSchedulingService {
return savedTrainSet; return savedTrainSet;
} }
async buildEmptyTrainSet(
manager: EntityManager,
locomotive: Locomotive,
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons: 0,
totalLengthMeters: 0,
wagonCount: 0,
status: 'DRAFT',
});
return manager.getRepository(TrainSet).save(trainSet);
}
allocateBookingsToWagons( allocateBookingsToWagons(
bookings: Booking[], bookings: Booking[],
baseWagonPlan: WagonPlanRecord[], baseWagonPlan: WagonPlanRecord[],
@@ -627,6 +581,7 @@ export class TrainSchedulingService {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({ const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: { relations: {
trainSet: { locomotive: true }, trainSet: { locomotive: true },
route: true,
originStation: true, originStation: true,
destinationStation: true, destinationStation: true,
scheduleBookings: true, scheduleBookings: true,
@@ -637,6 +592,7 @@ export class TrainSchedulingService {
return schedules.map((schedule) => ({ return schedules.map((schedule) => ({
id: schedule.id, id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate, scheduleDate: schedule.scheduledDepartureDate,
routeName: schedule.route?.name ?? null,
origin: origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null, schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination: destination:
@@ -668,6 +624,7 @@ export class TrainSchedulingService {
.findOne({ .findOne({
where: { id }, where: { id },
relations: { relations: {
route: true,
trainSet: { trainSet: {
locomotive: true, locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } }, wagons: { wagonType: true, allocations: { booking: true } },
@@ -687,6 +644,12 @@ export class TrainSchedulingService {
return { return {
id: schedule.id, id: schedule.id,
status: schedule.status, status: schedule.status,
route: schedule.route
? {
id: schedule.route.id,
name: schedule.route.name,
}
: null,
scheduledDepartureDate: schedule.scheduledDepartureDate, scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate, scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation, originStation: schedule.originStation,
@@ -711,6 +674,9 @@ export class TrainSchedulingService {
maxPullWeightTons: this.roundTons( maxPullWeightTons: this.roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons), Number(schedule.trainSet.locomotive.maxPullWeightTons),
), ),
maxTrainLengthMeters: this.roundTons(
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
),
} }
: null, : null,
wagons: [...(schedule.trainSet.wagons ?? [])] wagons: [...(schedule.trainSet.wagons ?? [])]
@@ -806,6 +772,22 @@ export class TrainSchedulingService {
}); });
} }
private async getActiveRoute(routeId: string) {
const route = await this.dataSource.getRepository(Route).findOne({
where: { id: routeId },
});
if (!route) {
throw new NotFoundException(`Route ${routeId} not found`);
}
if (!route.isActive) {
throw new BadRequestException(`Route ${route.name} is inactive`);
}
return route;
}
private toUtcDateKey(value: Date | string) { private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value); const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);

View File

@@ -14,6 +14,9 @@ import {
const toNumber = ({ value }: { value: unknown }) => const toNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? value : Number(value); value === '' || value == null ? value : Number(value);
const toOptionalNumber = ({ value }: { value: unknown }) =>
value === '' || value == null ? undefined : Number(value);
const toBoolean = ({ value }: { value: unknown }) => { const toBoolean = ({ value }: { value: unknown }) => {
if (typeof value === 'boolean') return value; if (typeof value === 'boolean') return value;
if (value === 'true') return true; if (value === 'true') return true;
@@ -22,8 +25,12 @@ const toBoolean = ({ value }: { value: unknown }) => {
}; };
const toStringArray = ({ value }: { value: unknown }) => { const toStringArray = ({ value }: { value: unknown }) => {
if (Array.isArray(value)) return value; if (Array.isArray(value)) {
return value.map((entry) => String(entry).trim()).filter(Boolean);
}
if (typeof value !== 'string') return []; if (typeof value !== 'string') return [];
return value return value
.split(',') .split(',')
.map((entry) => entry.trim()) .map((entry) => entry.trim())
@@ -31,36 +38,40 @@ const toStringArray = ({ value }: { value: unknown }) => {
}; };
export class CreateWagonTypeDto { export class CreateWagonTypeDto {
@ApiProperty({ maxLength: 32, example: 'FLAT' }) @ApiProperty({ maxLength: 32, example: 'NW5' })
@IsString() @IsString()
@MaxLength(32) @MaxLength(32)
code!: string; code!: string;
@ApiProperty({ maxLength: 100, example: 'Flat wagon' }) @ApiProperty({ maxLength: 100, example: 'Flat wagon container' })
@IsString() @IsString()
@MaxLength(100) @MaxLength(100)
name!: string; name!: string;
@ApiProperty({ example: 60 }) @ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 })
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0.001)
capacityTons!: number; capacityTons!: number;
@ApiProperty({ example: 14.2 }) @ApiProperty({ description: 'Wagon length in meters', example: 14 })
@Transform(toNumber) @Transform(toNumber)
@IsNumber() @IsNumber()
@Min(0) @Min(0.001)
lengthMeters!: number; lengthMeters!: number;
@ApiPropertyOptional({ example: 45 }) @ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
@IsOptional() @IsOptional()
@Transform(toNumber) @Transform(toOptionalNumber)
@IsInt() @IsInt()
@Min(1) @Min(1)
maxWagonsPerTrain?: number; maxWagonsPerTrain?: number;
@ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] }) @ApiPropertyOptional({
description: 'Supported load types, e.g. CONTAINER,BULK',
type: [String],
default: [],
})
@IsOptional() @IsOptional()
@Transform(toStringArray) @Transform(toStringArray)
@IsArray() @IsArray()

View File

@@ -11,48 +11,64 @@ import {
Post, Post,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto'; import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto'; import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
import { WagonTypesService } from './wagon-types.service'; import { WagonTypesService } from './wagon-types.service';
import { WagonType } from './entities/wagon-type.entity';
@ApiTags('Wagon Types') @ApiTags('wagon-types')
@Controller('wagon-types') @Controller('wagon-types')
@ApiBearerAuth()
export class WagonTypesController { export class WagonTypesController {
constructor(private readonly wagonTypesService: WagonTypesService) {} constructor(private readonly wagonTypesService: WagonTypesService) {}
@Post()
@ApiOperation({ summary: 'Create a wagon type' })
async create(@Body() dto: CreateWagonTypeDto): Promise<WagonType> {
return this.wagonTypesService.create(dto);
}
@Get() @Get()
@ApiOperation({ summary: 'Get wagon types' }) @RuleEngineView('wagon-types')
async findAll(@Query() query: Record<string, string | undefined>): Promise<WagonType[]> { @ApiOperation({ summary: 'List wagon types' })
return this.wagonTypesService.findAll(query); findAll(@Query() query: Record<string, string | undefined>) {
return this.wagonTypesService.findAll({
isActive:
query.isActive === 'all'
? undefined
: query.isActive !== undefined
? query.isActive === 'true'
: true,
page: query.page ? parseInt(query.page, 10) : undefined,
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
sortBy: query.sortBy,
sortOrder: query.sortOrder,
});
} }
@Get(':id') @Get(':id')
@RuleEngineView('wagon-types')
@ApiOperation({ summary: 'Get a wagon type by ID' }) @ApiOperation({ summary: 'Get a wagon type by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<WagonType> { findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonTypesService.findById(id); return this.wagonTypesService.findById(id);
} }
@Post()
@RuleEngineManage('wagon-types')
@ApiOperation({ summary: 'Create a wagon type' })
create(@Body() dto: CreateWagonTypeDto) {
return this.wagonTypesService.create(dto);
}
@Patch(':id') @Patch(':id')
@RuleEngineManage('wagon-types')
@ApiOperation({ summary: 'Update a wagon type' }) @ApiOperation({ summary: 'Update a wagon type' })
async update( update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateWagonTypeDto,
): Promise<WagonType> {
return this.wagonTypesService.update(id, dto); return this.wagonTypesService.update(id, dto);
} }
@Delete(':id') @Delete(':id')
@RuleEngineManage('wagon-types')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Deactivate a wagon type' }) @ApiOperation({ summary: 'Soft-delete a wagon type' })
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> { remove(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonTypesService.remove(id); return this.wagonTypesService.remove(id);
} }
} }

View File

@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
) { ) {
super(repository); super(repository);
} }
findByCode(code: string): Promise<WagonType | null> {
return this.repository.findOne({ where: { code } });
}
} }

View File

@@ -6,44 +6,47 @@ import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
import { WagonType } from './entities/wagon-type.entity'; import { WagonType } from './entities/wagon-type.entity';
import { WagonTypesRepository } from './wagon-types.repository'; import { WagonTypesRepository } from './wagon-types.repository';
type WagonTypeListFilter = {
isActive?: boolean;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: string;
};
@Injectable() @Injectable()
export class WagonTypesService { export class WagonTypesService {
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {} constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
async create(dto: CreateWagonTypeDto): Promise<WagonType> { async findAll(filter: WagonTypeListFilter = {}): Promise<{
const code = dto.code.trim().toUpperCase(); data: WagonType[];
const existing = await this.wagonTypesRepository.findAll({ where: { code } }); meta: { total: number; page: number; pageSize: number; totalPages: number };
if (existing.length > 0) { }> {
throw new ConflictException(`Wagon type code "${code}" already exists`); const page = filter.page ?? 1;
} const pageSize = filter.pageSize ?? 500;
return this.wagonTypesRepository.create({
...dto,
code,
name: dto.name.trim(),
supportedLoadTypes: dto.supportedLoadTypes ?? [],
isActive: dto.isActive ?? true,
});
}
async findAll(query: Record<string, string | undefined> = {}): Promise<WagonType[]> {
const isActive =
query.isActive === 'all'
? undefined
: query.isActive === undefined
? true
: query.isActive === 'true';
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes( const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
query.sortBy ?? '', filter.sortBy ?? '',
) )
? (query.sortBy as keyof WagonType) ? (filter.sortBy as keyof WagonType)
: 'code'; : 'code';
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC'; const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
return this.wagonTypesRepository.findAll({ const [data, total] = await this.wagonTypesRepository.findAndCount({
where: isActive === undefined ? {} : { isActive }, where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>, order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>,
skip: (page - 1) * pageSize,
take: pageSize,
}); });
return {
data,
meta: {
total,
page,
pageSize,
totalPages: Math.max(1, Math.ceil(total / pageSize)),
},
};
} }
async findById(id: string): Promise<WagonType> { async findById(id: string): Promise<WagonType> {
@@ -57,22 +60,39 @@ export class WagonTypesService {
} }
async findByCode(code: string): Promise<WagonType> { async findByCode(code: string): Promise<WagonType> {
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } }); const wagonType = await this.wagonTypesRepository.findByCode(code);
if (!wagonType) { if (!wagonType) {
throw new NotFoundException(`Wagon type ${code} not found`); throw new NotFoundException(`Wagon type ${code} not found`);
} }
return wagonType; return wagonType;
} }
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
const code = dto.code.trim().toUpperCase();
const existing = await this.wagonTypesRepository.findByCode(code);
if (existing) {
throw new ConflictException(`Wagon type code "${code}" already exists`);
}
return this.wagonTypesRepository.create({
code,
name: dto.name.trim(),
capacityTons: dto.capacityTons,
lengthMeters: dto.lengthMeters,
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? [],
isActive: dto.isActive ?? true,
});
}
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> { async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
const wagonType = await this.findById(id); const wagonType = await this.findById(id);
const nextCode = dto.code?.trim().toUpperCase(); const nextCode = dto.code?.trim().toUpperCase();
if (nextCode && nextCode !== wagonType.code) { if (nextCode && nextCode !== wagonType.code) {
const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } }); const existing = await this.wagonTypesRepository.findByCode(nextCode);
if (existing.length > 0) { if (existing) {
throw new ConflictException(`Wagon type code "${nextCode}" already exists`); throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
} }
} }
@@ -81,6 +101,9 @@ export class WagonTypesService {
...dto, ...dto,
...(nextCode ? { code: nextCode } : {}), ...(nextCode ? { code: nextCode } : {}),
...(dto.name ? { name: dto.name.trim() } : {}), ...(dto.name ? { name: dto.name.trim() } : {}),
maxWagonsPerTrain:
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
}); });
if (!updated) { if (!updated) {
@@ -92,6 +115,6 @@ export class WagonTypesService {
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await this.findById(id); await this.findById(id);
await this.wagonTypesRepository.update(id, { isActive: false }); await this.wagonTypesRepository.softDelete(id);
} }
} }

View File

@@ -178,13 +178,17 @@ export class DemoBookingsSeeder {
{ {
code: "LOC-001", code: "LOC-001",
name: "Demo Locomotive 1", name: "Demo Locomotive 1",
locomotiveType: 'ELECTRIC',
maxPullWeightTons: 3500, maxPullWeightTons: 3500,
maxTrainLengthMeters: 760,
status: "AVAILABLE", status: "AVAILABLE",
}, },
{ {
code: "LOC-002", code: "LOC-002",
name: "Demo Locomotive 2", name: "Demo Locomotive 2",
locomotiveType: 'DIESEL',
maxPullWeightTons: 2500, maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
status: "AVAILABLE", status: "AVAILABLE",
}, },
], ],

View File

@@ -10,6 +10,7 @@ export type FreightPermissionSeed = {
export const RULE_ENGINE_RESOURCE_SLUGS = [ export const RULE_ENGINE_RESOURCE_SLUGS = [
'cargo-types', 'cargo-types',
'container-types', 'container-types',
'wagon-types',
'service-types', 'service-types',
'yards', 'yards',
'shipping-lines', 'shipping-lines',
@@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = { const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' }, 'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' }, 'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' }, 'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' }, yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' }, 'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },

View File

@@ -353,6 +353,8 @@ export class PricingDataSeeder {
): Promise<Rate[]> { ): Promise<Rate[]> {
const effectiveFrom = new Date("2026-01-01"); const effectiveFrom = new Date("2026-01-01");
const now = new Date(); const now = new Date();
// await rRepo.createQueryBuilder().delete().execute();
const rateData = [ const rateData = [
{ {
rateType: "CONTAINER_IMPORT", rateType: "CONTAINER_IMPORT",

View File

@@ -1,18 +0,0 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-freight-web/backoffice/package.json ./apps/edr-freight-web/backoffice/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-backoffice...
FROM deps AS build
COPY apps/edr-freight-web/backoffice ./apps/edr-freight-web/backoffice
RUN pnpm --filter @edr/freight-backoffice build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/apps/edr-freight-web/backoffice/dist /usr/share/nginx/html
EXPOSE 5183
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,18 +0,0 @@
FROM node:20-alpine AS base
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
WORKDIR /app
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/edr-freight-web/portal/package.json ./apps/edr-freight-web/portal/
COPY packages ./packages
RUN pnpm install --frozen-lockfile --filter @edr/freight-portal...
FROM deps AS build
COPY apps/edr-freight-web/portal ./apps/edr-freight-web/portal
RUN pnpm --filter @edr/freight-portal build
FROM nginx:1.27-alpine AS runtime
COPY --from=build /app/apps/edr-freight-web/portal/dist /usr/share/nginx/html
EXPOSE 5173
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,6 +1,15 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@edr/ui-common/theme.css" layer(theme); @import "@edr/ui-common/theme.css" layer(theme);
:root {
--freight-brand: #15803d;
--freight-brand-dark: #166534;
--freight-brand-light: #22c55e;
--freight-brand-muted: #f0fdf4;
--freight-brand-border: #bbf7d0;
--freight-brand-ring: rgb(21 128 61 / 0.2);
}
html, html,
body, body,
#root { #root {

View File

@@ -14,6 +14,9 @@
"dependencies": { "dependencies": {
"@edr/types": "workspace:*", "@edr/types": "workspace:*",
"@edr/ui-common": "workspace:*", "@edr/ui-common": "workspace:*",
"@mantine/core": "^9.3.0",
"@mantine/hooks": "^9.3.0",
"@tabler/icons-react": "^3.44.0",
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@tanstack/react-query": "^5.100.11", "@tanstack/react-query": "^5.100.11",
"@tria-plc/iamui-common": "1.1.2", "@tria-plc/iamui-common": "1.1.2",

View File

@@ -34,16 +34,18 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
import TrainsPage from "./pages/trains/TrainsPage"; import TrainsPage from "./pages/trains/TrainsPage";
import { import {
CargoesCrudPage, CargoesCrudPage,
ContainersCrudPage, ContainersCrudPage,
LocomotivesCrudPage,
TrainMasterDataPage, TrainMasterDataPage,
WagonTypesCrudPage, WagonTypesCrudPage,
WagonsCrudPage, WagonsCrudPage,
} from "./pages/fleet/FleetCrudPages"; } from "./pages/fleet/FleetCrudPages";
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
import TrainDetailPage from "./pages/trains/TrainDetailPage"; import TrainDetailPage from "./pages/trains/TrainDetailPage";
import RoutesPage from "./pages/fleet/RoutesPage";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{ {
@@ -60,17 +62,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests", href: "/dashboard/booking-requests",
icon: <FileText />, icon: <FileText />,
}, },
...demoItems,
],
},
{
title: "Operations",
items: [
{ {
label: "Train scheduling", label: "Train Schedules",
href: "/dashboard/operations/train-scheduling", href: "/dashboard/operations/train-scheduling",
icon: <Train />, icon: <Train />,
}, },
...demoItems,
], ],
}, },
{ {
title: "Fleet Management", title: "Fleet Management",
items: [ items: [
{
label: "Routes",
href: "/dashboard/routes",
icon: <Network />,
},
{
label: "Locomotives",
href: "/dashboard/locomotives",
icon: <Train />,
},
{ {
label: "Trains", label: "Trains",
href: "/dashboard/trains", href: "/dashboard/trains",
@@ -225,17 +242,19 @@ const App = () => {
<Route path="booking-requests" element={<BookingRequestsPage />} /> <Route path="booking-requests" element={<BookingRequestsPage />} />
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} /> <Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
<Route <Route
path="booking-requests/:id/contract" path="booking-requests/:id/contract"
element={<BookingContractPage />} element={<BookingContractPage />}
/> />
<Route path="operations/train-scheduling" element={<TrainsPage />} /> <Route path="operations/train-scheduling" element={<TrainsPage />} />
<Route path="trains" element={<TrainMasterDataPage />} /> <Route path="trains" element={<TrainMasterDataPage />} />
<Route path="trains/:id" element={<TrainDetailPage />} /> <Route path="trains/:id" element={<TrainDetailPage />} />
<Route path="routes" element={<RoutesPage />} />
<Route path="locomotives" element={<LocomotivesCrudPage />} />
<Route path="wagon-types" element={<WagonTypesCrudPage />} /> <Route path="wagon-types" element={<WagonTypesCrudPage />} />
<Route path="wagons" element={<WagonsCrudPage />} /> <Route path="wagons" element={<WagonsCrudPage />} />
<Route path="containers" element={<ContainersCrudPage />} /> <Route path="containers" element={<ContainersCrudPage />} />
<Route path="cargoes" element={<CargoesCrudPage />} /> <Route path="cargoes" element={<CargoesCrudPage />} />
<Route path="user-management" element={<UserManagementPage />} /> <Route path="user-management" element={<UserManagementPage />} />
<Route path="user-management/users" element={<UsersPage />} /> <Route path="user-management/users" element={<UsersPage />} />

View File

@@ -1,5 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react"; import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth"; import { useAuth } from "@/auth/useAuth";
@@ -11,9 +12,7 @@ import {
} from "@/features/bookings/booking-actions.config"; } from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking"; import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { Badge, Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -26,23 +25,16 @@ interface ApprovalStepsCardProps {
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) { export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth(); const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>( const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
null,
);
const steps = useMemo( const steps = useMemo(
() => () => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
[...(booking.approvalSteps ?? [])].sort(
(a, b) => a.stepOrder - b.stepOrder,
),
[booking.approvalSteps], [booking.approvalSteps],
); );
const nextPending = getNextPendingApprovalStep(steps); const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps); const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
? buildApproveActionForStep(pendingStep)
: null;
const openApprove = (step: BookingApprovalStep) => { const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step); setPendingStep(step);
@@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
); );
}; };
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
return ( return (
<> <>
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}> <SectionCard
<div className={bookingSurface.sectionHeader}> icon={ShieldCheck}
<div className={bookingSurface.sectionIcon}> title="Approval chain"
<ShieldCheck className="size-4" strokeWidth={1.75} /> extra={
</div> <Badge color="green" variant="light" radius="sm">
<div> {steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
<h2 className="text-sm font-semibold text-foreground"> </Badge>
Approval chain }
</h2> >
<p className="text-xs text-muted-foreground"> <Text size="xs" c="dimmed" mb="sm">
{summary.detail || {subtitle}
(nextPending </Text>
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin")}
</p>
</div>
</div>
<div className="px-5 py-5"> {steps.length === 0 ? (
{steps.length === 0 ? ( <Text
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm"> size="sm"
Use{" "} c="dimmed"
<strong className="font-semibold text-foreground"> ta="center"
Accept for approval py="lg"
</strong>{" "} px="md"
in staff actions to instantiate steps. style={{
</p> borderRadius: 8,
) : ( border: "1px dashed var(--mantine-color-gray-3)",
<ul className="space-y-2"> background: "var(--mantine-color-gray-0)",
{steps.map((step) => ( }}
<StepRow >
key={step.id} Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
step={step} </Text>
steps={steps} ) : (
user={user} <Stack gap="xs">
isNext={nextPending?.id === step.id} {steps.map((step) => (
isPending={mutations.approveStep.isPending} <StepRow
onApprove={openApprove} key={step.id}
/> step={step}
))} steps={steps}
</ul> user={user}
)} isNext={nextPending?.id === step.id}
</div> isPending={mutations.approveStep.isPending}
</div> onApprove={openApprove}
/>
))}
</Stack>
)}
</SectionCard>
<BookingConfirmDialog <BookingConfirmDialog
open={confirmOpen} open={confirmOpen}
@@ -144,64 +142,76 @@ function StepRow({
onApprove: (step: BookingApprovalStep) => void; onApprove: (step: BookingApprovalStep) => void;
}) { }) {
const canApprove = canActOnApprovalStep(user, step, steps); const canApprove = canActOnApprovalStep(user, step, steps);
const statusStyles = const statusColor =
step.status === "APPROVED" step.status === "APPROVED"
? "border-emerald-500/25 bg-emerald-500/10 text-black" ? "green"
: step.status === "REJECTED" : step.status === "REJECTED"
? "bg-red-500/10 text-red-800 dark:text-red-300" ? "red"
: isNext : isNext
? "border-emerald-500/25 bg-emerald-500/10 text-black" ? "green"
: "bg-muted/40 text-muted-foreground"; : "gray";
return ( return (
<li <Group
className={cn( justify="space-between"
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm", wrap="nowrap"
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60", gap="sm"
)} px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-0)" : "white",
}}
> >
<div className="flex min-w-0 items-center gap-3"> <Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<span <Box
className={cn( style={{
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold", display: "flex",
isNext alignItems: "center",
? cn(bookingGlass.iconWellGreen, "text-black") justifyContent: "center",
: "bg-muted/40 text-muted-foreground", width: 28,
)} height: 28,
borderRadius: 8,
flexShrink: 0,
fontSize: 12,
fontWeight: 700,
background: "var(--mantine-color-gray-1)",
color: isNext ? "var(--mantine-color-gray-7)" : "var(--mantine-color-gray-6)",
}}
> >
{step.stepOrder} {step.stepOrder}
</span> </Box>
<div className="min-w-0"> <Box style={{ minWidth: 0 }}>
<p className="text-sm font-semibold text-foreground"> <Text size="sm" fw={600}>
{step.requiredRole} {step.requiredRole}
</p> </Text>
{step.remarks && ( {step.remarks && (
<p className="truncate text-xs text-muted-foreground"> <Text size="xs" c="dimmed" truncate>
{step.remarks} {step.remarks}
</p> </Text>
)} )}
</div> </Box>
</div> </Group>
<div className="flex shrink-0 items-center gap-2"> <Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{canApprove && ( {canApprove && (
<Button <Button
type="button" size="compact-sm"
size="sm" color="green"
className="h-8 gap-1.5 shadow-sm" leftSection={<Check size={14} />}
disabled={isPending} disabled={isPending}
onClick={() => onApprove(step)} onClick={() => onApprove(step)}
> >
<Check className="size-3.5" />
Approve Approve
</Button> </Button>
)} )}
<Badge <Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
variant="outline"
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
>
{step.status} {step.status}
</Badge> </Badge>
</div> </Group>
</li> </Group>
); );
} }

View File

@@ -1,10 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
ChevronRight, import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
ExternalLink,
Loader2,
MoreHorizontal,
} from "lucide-react";
import { BookingConfirmDialog } from "./BookingConfirmDialog"; import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog"; import { useBookingActionDialog } from "./useBookingActionDialog";
@@ -16,16 +12,6 @@ import {
type BookingActionContext, type BookingActionContext,
} from "@/features/bookings/booking-actions.config"; } from "@/features/bookings/booking-actions.config";
import type { BookingListRow } from "@/types/booking"; import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@edr/ui-common";
interface BookingActionsMenuProps { interface BookingActionsMenuProps {
row: BookingListRow; row: BookingListRow;
@@ -39,7 +25,6 @@ interface BookingActionsMenuProps {
export function BookingActionsMenu({ export function BookingActionsMenu({
row, row,
variant = "table", variant = "table",
className,
onSuppressRowClick, onSuppressRowClick,
}: BookingActionsMenuProps) { }: BookingActionsMenuProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -57,184 +42,179 @@ export function BookingActionsMenu({
const goToContract = () => const goToContract = () =>
navigate(`/dashboard/booking-requests/${row.id}/contract`); navigate(`/dashboard/booking-requests/${row.id}/contract`);
const hasMenu = listRowHasActions(row, user); const handleAction = (action: (typeof actions)[number]) => {
onSuppressRowClick?.();
if (isContractNavAction(action.id)) {
goToContract();
} else {
flow.openAction(action);
}
};
const hasMenu = listRowHasActions(row, user);
const primary = actions.find((a) => a.primary) ?? actions[0]; const primary = actions.find((a) => a.primary) ?? actions[0];
if (!hasMenu && variant === "table") { if (!hasMenu && variant === "table") {
return ( return (
<Button <ActionIcon
variant="ghost" variant="subtle"
size="icon" color="gray"
className="size-8 text-muted-foreground hover:text-primary"
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)} onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
aria-label="View booking" aria-label="View booking"
> >
<ChevronRight className="size-4" /> <ChevronRight size={16} />
</Button> </ActionIcon>
);
}
// Toolbar: lay every action out as a button row.
if (variant === "toolbar" && actions.length > 0) {
return (
<>
<Group gap="sm" w="100%">
{actions.map((action) => {
const Icon = action.icon;
const destructive = action.variant === "destructive";
return (
<Button
key={action.id}
size="sm"
variant={action.primary && !destructive ? "filled" : "default"}
color={destructive ? "red" : action.primary ? "green" : "gray"}
leftSection={<Icon size={16} />}
disabled={mutations.isPending}
onClick={() => handleAction(action)}
>
{action.label}
</Button>
);
})}
</Group>
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
</>
); );
} }
return ( return (
<> <Group
<div gap={4}
data-stop-row-click justify="flex-end"
className={cn( wrap="nowrap"
"flex w-full min-h-[2.5rem] items-center justify-end gap-1", data-stop-row-click
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100", onClick={(e) => e.stopPropagation()}
className, onKeyDown={(e) => e.stopPropagation()}
)} >
onClick={(e) => e.stopPropagation()} {variant === "table" && primary && (
onKeyDown={(e) => e.stopPropagation()} <Button
> size="compact-sm"
{variant === "table" && primary && ( color="green"
<Button visibleFrom="lg"
size="sm" leftSection={<primary.icon size={14} />}
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex" disabled={mutations.isPending}
disabled={mutations.isPending} onClick={() => handleAction(primary)}
onClick={() => >
isContractNavAction(primary.id) {primary.shortLabel}
? goToContract() </Button>
: flow.openAction(primary) )}
}
<Menu position="bottom-end" width={220} withinPortal>
<Menu.Target>
<ActionIcon
variant={variant === "table" ? "subtle" : "default"}
color="gray"
loading={mutations.isPending}
aria-label="Booking actions"
> >
<primary.icon className="size-3.5" /> <MoreHorizontal size={16} />
{primary.shortLabel} </ActionIcon>
</Button> </Menu.Target>
)} <Menu.Dropdown>
<Menu.Label>
{variant === "toolbar" && actions.length > 0 ? ( <Text size="xs" ff="monospace" c="dimmed">
<div className="flex w-full flex-wrap gap-2">
{actions.map((action) => {
const Icon = action.icon;
return (
<Button
key={action.id}
size="sm"
variant={
action.variant === "destructive"
? "outline"
: action.primary
? "default"
: "outline"
}
className={cn(
"gap-2 shadow-sm",
action.variant === "destructive" &&
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
)}
disabled={mutations.isPending}
onClick={() =>
isContractNavAction(action.id)
? goToContract()
: flow.openAction(action)
}
>
<Icon className="size-4" />
{action.label}
</Button>
);
})}
</div>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={variant === "table" ? "ghost" : "outline"}
size={variant === "table" ? "icon" : "sm"}
className={cn(
variant === "table" ? "size-8" : "gap-2",
"shrink-0",
)}
disabled={mutations.isPending}
aria-label="Booking actions"
>
{mutations.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<MoreHorizontal className="size-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
{row.reference} {row.reference}
</DropdownMenuLabel> </Text>
<DropdownMenuSeparator /> </Menu.Label>
{actions.map((action) => { {actions.map((action) => {
const Icon = action.icon; const Icon = action.icon;
return ( return (
<DropdownMenuItem <Menu.Item
key={action.id} key={action.id}
className={cn( color={action.variant === "destructive" ? "red" : undefined}
"gap-2 cursor-pointer", leftSection={<Icon size={15} />}
action.variant === "destructive" && "text-red-700 focus:text-red-700", onClick={() => handleAction(action)}
)} >
onSelect={(event) => { {action.label}
event.preventDefault(); </Menu.Item>
onSuppressRowClick?.(); );
if (isContractNavAction(action.id)) { })}
goToContract(); {actions.length > 0 && <Menu.Divider />}
} else { <Menu.Item
flow.openAction(action); leftSection={<ExternalLink size={15} />}
} onClick={() => {
}} onSuppressRowClick?.();
> navigate(`/dashboard/booking-requests/${row.id}`);
<Icon className="size-4 opacity-70" /> }}
<span>{action.label}</span> >
</DropdownMenuItem> Open full details
); </Menu.Item>
})} </Menu.Dropdown>
{actions.length > 0 && <DropdownMenuSeparator />} </Menu>
<DropdownMenuItem
className="gap-2 cursor-pointer"
onSelect={(event) => {
event.preventDefault();
onSuppressRowClick?.();
navigate(`/dashboard/booking-requests/${row.id}`);
}}
>
<ExternalLink className="size-4 opacity-70" />
Open full details
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<BookingConfirmDialog <ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
open={flow.dialogOpen} </Group>
onOpenChange={(open) => { );
if (!open) onSuppressRowClick?.(); }
flow.setDialogOpen(open);
}} function ActionDialog({
action={pendingAction} flow,
reference={flow.mergedContext.reference} pendingAction,
inputValue={flow.inputValue} onSuppressRowClick,
onInputChange={flow.setInputValue} }: {
selectedFile={flow.selectedFile} flow: ReturnType<typeof useBookingActionDialog>;
onFileChange={flow.setSelectedFile} pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
onConfirm={() => { onSuppressRowClick?: () => void;
onSuppressRowClick?.(); }) {
flow.runAction(); return (
}} <BookingConfirmDialog
isPending={mutations.isPending || flow.detailLoading} open={flow.dialogOpen}
confirmDisabled={flow.confirmDisabled} onOpenChange={(open) => {
extra={ if (!open) onSuppressRowClick?.();
flow.detailLoading ? ( flow.setDialogOpen(open);
<p className="flex items-center gap-2 text-sm text-muted-foreground"> }}
<Loader2 className="size-4 animate-spin" /> action={pendingAction}
Loading approval steps reference={flow.mergedContext.reference}
</p> inputValue={flow.inputValue}
) : pendingAction?.id === "approve" && onInputChange={flow.setInputValue}
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? ( selectedFile={flow.selectedFile}
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200"> onFileChange={flow.setSelectedFile}
No pending approval step. Refresh the page after staff accept, or onConfirm={() => {
reject the booking. onSuppressRowClick?.();
</p> flow.runAction();
) : null }}
} isPending={flow.mutations.isPending || flow.detailLoading}
/> confirmDisabled={flow.confirmDisabled}
</> extra={
flow.detailLoading ? (
<Text size="sm" c="dimmed">
Loading approval steps
</Text>
) : pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<Text
size="sm"
c="orange.9"
p="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-orange-2)",
background: "var(--mantine-color-orange-0)",
}}
>
No pending approval step. Refresh the page after staff accept, or reject the
booking.
</Text>
) : null
}
/>
); );
} }

View File

@@ -1,12 +1,11 @@
import { Download, Zap } from "lucide-react"; import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu"; import { BookingActionsMenu } from "./BookingActionsMenu";
import { bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import type { useBookingMutations } from "@/hooks/bookings/useBookings"; import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import { Button } from "@edr/ui-common";
import { cn } from "@/lib/utils";
type Mutations = ReturnType<typeof useBookingMutations>; type Mutations = ReturnType<typeof useBookingMutations>;
@@ -16,10 +15,7 @@ interface BookingActionsToolbarProps {
} }
/** Detail-page actions: primary toolbar + downloads. */ /** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
booking,
mutations,
}: BookingActionsToolbarProps) {
const row = toBookingListRow(booking); const row = toBookingListRow(booking);
const { status } = booking; const { status } = booking;
@@ -33,50 +29,84 @@ export function BookingActionsToolbar({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
if ( if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
status === "REJECTED" ||
status === "CANCELLED" ||
status === "COMPLETED"
) {
return null; return null;
} }
if (status === "CHANGES_REQUESTED") { if (status === "CHANGES_REQUESTED") {
return ( return (
<PanelShell title="Awaiting customer" description="No staff actions until resubmit."> <SectionCard icon={Zap} title="Awaiting customer">
{booking.latestChangeRequestNote && ( <Stack gap="sm">
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm"> <Text size="sm" c="dimmed">
{booking.latestChangeRequestNote} No staff actions until resubmit.
</p> </Text>
)} {booking.latestChangeRequestNote && (
</PanelShell> <Text
size="sm"
p="sm"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
lineHeight: 1.5,
}}
>
{booking.latestChangeRequestNote}
</Text>
)}
</Stack>
</SectionCard>
); );
} }
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) { if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
return ( return (
<PanelShell <SectionCard icon={Zap} title="No staff actions">
title="No staff actions" <Text size="sm" c="dimmed">
description="Monitor until the customer or system advances status." Monitor until the customer or system advances status.
muted </Text>
/> </SectionCard>
);
}
if (
["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes(
status,
)
) {
return (
<Stack gap="lg">
<SectionCard icon={Clock} title="Awaiting customer payment">
<Stack gap="sm">
<Text size="sm" c="dimmed">
Payment is completed by the customer. The booking status updates
automatically once payment is confirmed, then moves to Operations.
</Text>
{status === "FULLY_EXECUTED" && (
<BookingActionsMenu row={row} variant="toolbar" />
)}
</Stack>
</SectionCard>
</Stack>
); );
} }
return ( return (
<div className="space-y-4"> <Stack gap="lg">
<PanelShell <SectionCard icon={Zap} title="Staff actions">
title="Staff actions" <Stack gap="sm">
description="Confirm each step before it is applied." <Text size="xs" c="dimmed">
> Confirm each step before it is applied.
<BookingActionsMenu row={row} variant="toolbar" /> </Text>
</PanelShell> <BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
{status === "CONTRACT_READY" && ( {status === "CONTRACT_READY" && (
<PanelShell title="Documents" description="Download generated contract."> <SectionCard icon={FileText} title="Documents">
<Button <Button
variant="outline" variant="default"
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80" leftSection={<Download size={16} />}
onClick={() => onClick={() =>
downloadBlob( downloadBlob(
() => mutations.downloadContract(), () => mutations.downloadContract(),
@@ -84,38 +114,10 @@ export function BookingActionsToolbar({
) )
} }
> >
<Download className="size-4" />
Download contract Download contract
</Button> </Button>
</PanelShell> </SectionCard>
)} )}
</div> </Stack>
);
}
function PanelShell({
title,
description,
children,
muted,
}: {
title: string;
description: string;
children: React.ReactNode;
muted?: boolean;
}) {
return (
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
<div className={bookingSurface.sectionHeader}>
<div className={bookingSurface.sectionIcon}>
<Zap className="size-4" strokeWidth={1.75} />
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
</div>
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
</div>
); );
} }

View File

@@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell
<p <p
className={cn( className={cn(
"text-sm font-semibold", "text-sm font-semibold",
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground", summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
)} )}
> >
{summary.label} {summary.label}

View File

@@ -1,17 +1,16 @@
import { Loader2 } from "lucide-react"; import type { ReactNode } from "react";
import {
Modal,
Group,
Stack,
Text,
Box,
Button,
Textarea,
FileInput,
} from "@mantine/core";
import type { BookingActionDef } from "@/features/bookings/booking-actions.config"; import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
import { cn } from "@/lib/utils";
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Textarea,
} from "@edr/ui-common";
interface BookingConfirmDialogProps { interface BookingConfirmDialogProps {
open: boolean; open: boolean;
@@ -25,7 +24,7 @@ interface BookingConfirmDialogProps {
onConfirm: () => void; onConfirm: () => void;
isPending: boolean; isPending: boolean;
confirmDisabled?: boolean; confirmDisabled?: boolean;
extra?: React.ReactNode; extra?: ReactNode;
} }
export function BookingConfirmDialog({ export function BookingConfirmDialog({
@@ -45,129 +44,119 @@ export function BookingConfirmDialog({
if (!action || !action.confirmTitle) return null; if (!action || !action.confirmTitle) return null;
const Icon = action.icon; const Icon = action.icon;
const needsTextInput = const needsTextInput = action.input === "note" || action.input === "reason";
action.input === "note" || action.input === "reason";
const needsFileInput = action.input === "file"; const needsFileInput = action.input === "file";
const inputMissing = const inputMissing =
(needsTextInput && !inputValue.trim()) || (needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
(needsFileInput && !selectedFile);
const isDestructive = action.variant === "destructive"; const isDestructive = action.variant === "destructive";
const accent = isDestructive ? "red" : "green";
const preventClickThrough = (event: React.MouseEvent) => {
event.preventDefault();
};
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Modal
<DialogContent opened={open}
className="gap-0 overflow-hidden p-0 sm:max-w-md" onClose={() => onOpenChange(false)}
showCloseButton={false} withCloseButton={false}
onCloseAutoFocus={(event) => event.preventDefault()} centered
radius="md"
size="md"
padding={0}
title={null}
>
{/* Header */}
<Box
px="lg"
py="md"
style={{
background: `var(--mantine-color-${accent}-0)`,
borderBottom: `1px solid var(--mantine-color-${accent}-1)`,
}}
> >
<div <Group align="flex-start" gap="sm" wrap="nowrap">
className={cn( <Box
"border-b px-6 py-5", style={{
isDestructive display: "flex",
? "bg-gradient-to-br from-red-500/10 via-background to-background" alignItems: "center",
: "bg-gradient-to-br from-primary/8 via-background to-background", justifyContent: "center",
)} width: 44,
> height: 44,
<DialogHeader className="gap-3 text-left"> borderRadius: 12,
<div className="flex items-start gap-3"> flexShrink: 0,
<div background: `var(--mantine-color-${accent}-1)`,
className={cn( color: `var(--mantine-color-${accent}-7)`,
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm", }}
isDestructive
? "bg-red-500/15 text-red-700 dark:text-red-300"
: "bg-primary/15 text-primary",
)}
>
<Icon className="size-5" />
</div>
<div className="min-w-0 space-y-1 pt-0.5">
<DialogTitle className="text-base leading-snug">
{action.confirmTitle}
</DialogTitle>
{reference && (
<p className="font-mono text-xs font-semibold text-muted-foreground">
{reference}
</p>
)}
</div>
</div>
<DialogDescription className="text-left text-sm leading-relaxed">
{action.confirmDescription}
</DialogDescription>
</DialogHeader>
</div>
<div className="space-y-4 px-6 py-5">
{needsTextInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel}
<span className="text-red-600"> *</span>
</label>
<Textarea
value={inputValue}
onChange={(e) => onInputChange(e.target.value)}
placeholder={action.inputPlaceholder}
rows={4}
className="min-h-[100px] resize-y"
/>
</div>
)}
{needsFileInput && (
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{action.inputLabel ?? "Bank slip file"}
<span className="text-red-600"> *</span>
</label>
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg"
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
onChange={(e) =>
onFileChange?.(e.target.files?.[0] ?? null)
}
/>
{selectedFile && (
<p className="text-xs text-muted-foreground">
Selected: {selectedFile.name}
</p>
)}
</div>
)}
{extra}
</div>
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
disabled={isPending}
onMouseDown={preventClickThrough}
onClick={() => onOpenChange(false)}
> >
Cancel <Icon size={20} />
</Button> </Box>
<Button <Stack gap={2} style={{ minWidth: 0 }}>
type="button" <Text fw={700} size="md" style={{ lineHeight: 1.3 }}>
variant={isDestructive ? "destructive" : "default"} {action.confirmTitle}
disabled={isPending || inputMissing || confirmDisabled} </Text>
className="min-w-[7rem] gap-2" {reference && (
onMouseDown={preventClickThrough} <Text size="xs" c="dimmed" ff="monospace" fw={600}>
onClick={onConfirm} {reference}
> </Text>
{isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Icon className="size-4" />
)} )}
{action.shortLabel} </Stack>
</Button> </Group>
</DialogFooter> {action.confirmDescription && (
</DialogContent> <Text size="sm" c="dimmed" mt="sm" style={{ lineHeight: 1.5 }}>
</Dialog> {action.confirmDescription}
</Text>
)}
</Box>
{/* Body */}
<Stack gap="md" px="lg" py="lg">
{needsTextInput && (
<Textarea
label={action.inputLabel}
withAsterisk
value={inputValue}
onChange={(e) => onInputChange(e.currentTarget.value)}
placeholder={action.inputPlaceholder}
minRows={4}
autosize
/>
)}
{needsFileInput && (
<FileInput
label={action.inputLabel ?? "Bank slip file"}
withAsterisk
placeholder="Select a file"
accept=".pdf,.png,.jpg,.jpeg"
value={selectedFile}
onChange={(file) => onFileChange?.(file)}
clearable
/>
)}
{extra}
</Stack>
{/* Footer */}
<Group
justify="flex-end"
gap="sm"
px="lg"
py="md"
style={{
borderTop: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
>
<Button variant="default" disabled={isPending} onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
color={accent}
loading={isPending}
disabled={inputMissing || confirmDisabled}
leftSection={<Icon size={16} />}
onClick={onConfirm}
miw={112}
>
{action.shortLabel}
</Button>
</Group>
</Modal>
); );
} }

View File

@@ -1,86 +1,93 @@
import { Banknote, Receipt } from "lucide-react"; import { Banknote, Receipt } from "lucide-react";
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
import type { BookingDetail } from "@/types/booking"; import type { BookingDetail } from "@/types/booking";
import { Separator } from "@edr/ui-common";
import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const amount = Number(booking.totalAmount); const amount = Number(booking.totalAmount);
const modifiers = booking.cargoModifiers ?? []; const modifiers = booking.cargoModifiers ?? [];
return ( return (
<div className={bookingSurface.sectionCard}> <SectionCard icon={Banknote} title="Pricing & payment">
<div className={bookingSurface.sectionHeader}> <Stack gap="md">
<div className={bookingSurface.sectionIcon}> <Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Banknote className="size-4" strokeWidth={1.75} /> <Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
</div>
<div>
<h2 className="text-sm font-semibold text-foreground">
Pricing & payment
</h2>
<p className="text-xs text-muted-foreground">Commercial terms</p>
</div>
</div>
<div className="space-y-4 px-5 py-5">
<div className={bookingSurface.valueCard}>
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Total amount Total amount
</p> </Text>
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground"> <Text
size="xl"
fw={700}
c="green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{booking.paymentCurrency}{" "} {booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })} {amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</p> </Text>
</div> </Paper>
<Row label="Payment status" value={booking.paymentStatus} /> <Row label="Payment status" value={booking.paymentStatus} />
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />} {booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
{modifiers.length > 0 && ( {modifiers.length > 0 && (
<> <>
<Separator className="opacity-50" /> <Divider color="var(--mantine-color-gray-2)" />
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground"> <Group gap={6}>
<Receipt className="size-3" /> <Receipt size={13} color="var(--mantine-color-gray-5)" />
Surcharges applied <Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
</p> Surcharges applied
<ul className="space-y-2"> </Text>
</Group>
<Stack gap="xs">
{modifiers.map((m) => ( {modifiers.map((m) => (
<li <Group
key={m.id} key={m.id}
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm" justify="space-between"
px="sm"
py={6}
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
background: "var(--mantine-color-gray-0)",
}}
> >
<span className="text-muted-foreground">Modifier</span> <Text size="sm" c="dimmed">
<span className="font-mono font-semibold tabular-nums"> Modifier
</Text>
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
{Number(m.calculatedAmount).toLocaleString()} {Number(m.calculatedAmount).toLocaleString()}
</span> </Text>
</li> </Group>
))} ))}
</ul> </Stack>
</> </>
)} )}
</div> </Stack>
</div> </SectionCard>
); );
} }
function Row({ function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
label,
value,
mono,
}: {
label: string;
value: string;
mono?: boolean;
}) {
return ( return (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm"> <Group
<span className="text-muted-foreground">{label}</span> justify="space-between"
<span px="sm"
className={ py="xs"
mono style={{
? "font-mono text-xs font-semibold text-foreground" borderRadius: 8,
: "font-medium text-foreground" border: "1px solid var(--mantine-color-gray-2)",
} background: "var(--mantine-color-gray-0)",
> }}
>
<Text size="sm" c="dimmed">
{label}
</Text>
<Text size="sm" fw={600} ff={mono ? "monospace" : undefined}>
{value} {value}
</span> </Text>
</div> </Group>
); );
} }

View File

@@ -1,21 +1,23 @@
import { Badge } from "@mantine/core";
export function BookingPriorityBadge({ score }: { score: number }) { export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) { if (score >= 1000) {
return ( return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700"> <Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
Urgent Urgent
</span> </Badge>
); );
} }
if (score >= 500) { if (score >= 500) {
return ( return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700"> <Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
High High
</span> </Badge>
); );
} }
return ( return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600"> <Badge color="gray" variant="light" size="sm" radius="lg" tt="uppercase">
Normal Normal
</span> </Badge>
); );
} }

View File

@@ -1,6 +1,5 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { bookingGlass } from "./booking-ui.styles"; import { Card, Group, Stack, Text, Paper } from "@mantine/core";
import { cn } from "@/lib/utils";
export interface StatItem { export interface StatItem {
label: string; label: string;
@@ -10,54 +9,100 @@ export interface StatItem {
accent?: "default" | "amber" | "emerald" | "rose"; accent?: "default" | "amber" | "emerald" | "rose";
} }
const iconAccentStyles = { const accentColors = {
default: "text-foreground/70", default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
amber: "text-amber-600 dark:text-amber-400", amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
emerald: "text-emerald-600 dark:text-emerald-400", emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
rose: "text-rose-600 dark:text-rose-400", rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
}; };
export function BookingStatGrid({ items }: { items: StatItem[] }) { export function BookingStatGrid({ items }: { items: StatItem[] }) {
return ( return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"> <Paper
{items.map((item) => { p="md"
const Icon = item.icon; radius="lg"
const accent = item.accent ?? "default"; withBorder
return ( style={{
<div background: "white",
key={item.label} border: "1px solid var(--mantine-color-gray-2)",
className={cn( overflowX: "auto",
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md", overflowY: "hidden",
bookingGlass.card, WebkitOverflowScrolling: "touch",
)} scrollBehavior: "smooth",
> }}
<div className="flex items-start justify-between gap-3"> >
<div className="min-w-0 flex-1"> <Group
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground"> gap="lg"
{item.label} style={{
</p> minWidth: "min-content",
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground"> display: "flex",
{item.value} flexWrap: "nowrap",
</p> }}
{item.hint && ( >
<p className="mt-1 text-xs leading-relaxed text-muted-foreground"> {items.map((item) => {
{item.hint} const Icon = item.icon;
</p> const accent = item.accent ?? "default";
)} const accentStyle = accentColors[accent];
</div>
<div return (
className={cn( <Card
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]", key={item.label}
bookingGlass.iconWellGreen, p="lg"
iconAccentStyles[accent], radius="lg"
)} withBorder
> style={{
<Icon className="size-[18px]" strokeWidth={1.75} /> background: "white",
</div> border: "1px solid var(--mantine-color-gray-2)",
</div> transition: "all 0.2s ease",
</div> cursor: "pointer",
); minWidth: "280px",
})} width: "280px",
</div> flexShrink: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)";
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "none";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group justify="space-between" align="flex-start">
<Stack gap="xs" style={{ flex: 1 }}>
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
{item.label}
</Text>
<Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
{item.value}
</Text>
{item.hint && (
<Text size="xs" c="dimmed">
{item.hint}
</Text>
)}
</Stack>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "44px",
height: "44px",
borderRadius: "10px",
background: accentStyle.bg,
color: accentStyle.color,
flexShrink: 0,
transition: "transform 0.2s ease",
}}
>
<Icon size={22} strokeWidth={1.75} />
</div>
</Group>
</Card>
);
})}
</Group>
</Paper>
); );
} }

View File

@@ -1,19 +1,50 @@
import { Badge } from "@edr/ui-common"; import { Badge } from "@mantine/core";
import { cn } from "@/lib/utils";
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
const statusColorMap: Record<string, string> = {
DRAFT: "gray",
SUBMITTED: "yellow",
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED_PENDING_SIGNATURE: "cyan",
APPROVED: "green",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
PNR_GENERATED: "violet",
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
PAID: "green",
IN_TRANSIT: "cyan",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",
PENDING_CONSOLIDATION: "yellow",
CONSOLIDATED: "indigo",
};
export function BookingStatusBadge({ status }: { status: string }) { export function BookingStatusBadge({ status }: { status: string }) {
const style = BOOKING_STATUS_STYLES[status] ?? { const style = BOOKING_STATUS_STYLES[status] ?? {
label: status, label: status,
color: "bg-muted text-muted-foreground border-border", color: "gray",
}; };
const color = statusColorMap[status] ?? "gray";
return ( return (
<Badge <Badge
variant="outline" color={color}
className={cn( variant="light"
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider", size="sm"
style.color, radius="md"
)} tt="uppercase"
fw={600}
title={style.label}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
maxWidth: "100%",
whiteSpace: "nowrap",
}}
> >
{style.label} {style.label}
</Badge> </Badge>

View File

@@ -8,27 +8,24 @@ import {
Wallet, Wallet,
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
import { import {
BOOKING_LIST_TABS, BOOKING_LIST_TABS,
type BookingStatusTabKey, type BookingStatusTabKey,
} from "@/features/bookings/booking-status.config"; } from "@/features/bookings/booking-status.config";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = { const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />, all: <LayoutGrid size={18} strokeWidth={1.75} />,
intake: <Inbox className="size-3.5" strokeWidth={1.75} />, intake: <Inbox size={18} strokeWidth={1.75} />,
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />, in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />, approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
payment: <Wallet className="size-3.5" strokeWidth={1.75} />, payment: <Wallet size={18} strokeWidth={1.75} />,
operations: <Train className="size-3.5" strokeWidth={1.75} />, operations: <Train size={18} strokeWidth={1.75} />,
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />, completed: <CheckCircle size={18} strokeWidth={1.75} />,
closed: <XCircle className="size-3.5" strokeWidth={1.75} />, closed: <XCircle size={18} strokeWidth={1.75} />,
}; };
const activeTabText = "text-black";
interface BookingStatusTabsProps { interface BookingStatusTabsProps {
active: BookingStatusTabKey; active: BookingStatusTabKey;
onChange: (tab: BookingStatusTabKey) => void; onChange: (tab: BookingStatusTabKey) => void;
@@ -41,66 +38,74 @@ export function BookingStatusTabs({
counts, counts,
}: BookingStatusTabsProps) { }: BookingStatusTabsProps) {
return ( return (
<div className={bookingGlass.tabRail}> <Group
<div gap="sm"
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden" wrap="nowrap"
role="tablist" p="md"
aria-label="Booking status filters" style={{
> background: "var(--mantine-color-gray-0)",
{BOOKING_LIST_TABS.map((tab) => { borderRadius: "12px",
const isActive = active === tab.key; border: "1px solid var(--mantine-color-gray-2)",
const count = counts?.[tab.key]; overflowX: "auto",
return ( overflowY: "hidden",
<button WebkitOverflowScrolling: "touch",
key={tab.key} scrollBehavior: "smooth",
type="button" scrollbarWidth: "thin",
role="tab" }}
aria-selected={isActive} >
onClick={() => onChange(tab.key)} {BOOKING_LIST_TABS.map((tab) => {
className={cn( const isActive = active === tab.key;
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200", const count = counts?.[tab.key];
isActive return (
? bookingGlass.activeTab <UnstyledButton
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground", key={tab.key}
)} onClick={() => onChange(tab.key)}
> style={{
<span className="flex w-full items-center justify-between gap-2"> flexShrink: 0,
<span background: isActive ? "white" : "transparent",
className={cn( border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
"flex items-center gap-2 text-sm font-medium", borderRadius: "10px",
isActive ? activeTabText : "text-muted-foreground", padding: "10px 16px",
)} transition: "all 0.2s ease",
cursor: "pointer",
boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none",
}}
>
<Group gap="sm" justify="space-between" wrap="nowrap">
<Group gap={8}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "8px",
background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)",
color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)",
}}
> >
<span {TAB_ICONS[tab.key]}
className={cn( </div>
"flex size-7 shrink-0 items-center justify-center rounded-md", <Text size="sm" fw={600}>
isActive {tab.label}
? cn(bookingGlass.iconWellGreen, "text-black") </Text>
: "border border-transparent bg-muted/30", </Group>
)} {count !== undefined && count > 0 && (
> <Badge
{TAB_ICONS[tab.key]} size="sm"
</span> variant={isActive ? "filled" : "light"}
<span className="whitespace-nowrap">{tab.label}</span> color={isActive ? "green" : "gray"}
</span> radius="lg"
{count !== undefined && count > 0 && ( >
<span {count}
className={cn( </Badge>
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums", )}
isActive </Group>
? cn("bg-emerald-500/15", activeTabText) </UnstyledButton>
: "bg-muted/50 text-muted-foreground", );
)} })}
> </Group>
{count}
</span>
)}
</span>
</button>
);
})}
</div>
</div>
); );
} }

View File

@@ -1,20 +1,28 @@
import { import {
Check, Check,
CheckCircle2,
FileSignature, FileSignature,
FileText, FileText,
Train, Train,
Wallet, Wallet,
type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
import { cn } from "@/lib/utils";
import { import {
getWorkflowStageIndex, getWorkflowStageIndex,
WORKFLOW_STAGES, WORKFLOW_STAGES,
} from "@/features/bookings/booking-status.config"; } from "@/features/bookings/booking-status.config";
import { bookingGlass, bookingSurface } from "./booking-ui.styles"; import { SectionCard } from "./detail/SectionCard";
import { BRAND_GREEN, detailStyles } from "./detail/booking-detail.styles";
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check]; const STAGE_ICONS: LucideIcon[] = [
FileText,
FileSignature,
FileSignature,
Wallet,
Train,
Check,
];
interface BookingWorkflowStepperProps { interface BookingWorkflowStepperProps {
status: string; status: string;
@@ -27,104 +35,94 @@ export function BookingWorkflowStepper({
status, status,
title, title,
description, description,
titleColor,
}: BookingWorkflowStepperProps) { }: BookingWorkflowStepperProps) {
const currentStage = getWorkflowStageIndex(status); const currentStage = getWorkflowStageIndex(status);
const isTerminal = currentStage < 0; const isTerminal = currentStage < 0;
return ( return (
<div className={bookingSurface.sectionCard}> <SectionCard icon={Train} title="Workflow progress">
<div className={bookingSurface.sectionHeader}> <Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
<div className={bookingSurface.sectionIcon}> {WORKFLOW_STAGES.map((stage, index) => {
<Train className="size-4" strokeWidth={1.75} /> const Icon = STAGE_ICONS[index] ?? FileText;
</div> const isComplete = !isTerminal && index < currentStage;
<div> const isActive = !isTerminal && index === currentStage;
<h2 className="text-sm font-semibold text-foreground"> const isLast = index === WORKFLOW_STAGES.length - 1;
Workflow progress
</h2> return (
<p className="text-xs text-muted-foreground"> <Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
Customer submission through completion <Group gap={0} wrap="nowrap" align="center">
</p> <Stack gap={6} align="center" style={{ flexShrink: 0 }}>
</div> <Box
</div> style={{
<div className="space-y-8 px-5 py-6"> display: "flex",
<div className="relative px-2"> alignItems: "center",
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" /> justifyContent: "center",
<div width: 34,
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out" height: 34,
style={{ borderRadius: "50%",
width: background: isComplete
!isTerminal && currentStage >= 0 ? BRAND_GREEN
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)` : isActive
: "0%", ? "white"
}} : "var(--mantine-color-gray-1)",
/> border: isActive
<div className="relative flex justify-between"> ? `2px solid ${BRAND_GREEN}`
{WORKFLOW_STAGES.map((stage, idx) => { : isComplete
const Icon = STAGE_ICONS[idx] ?? FileText; ? "2px solid transparent"
const isCompleted = !isTerminal && idx < currentStage; : "2px solid var(--mantine-color-gray-2)",
const isActive = !isTerminal && idx === currentStage; color: isComplete
return ( ? "white"
<div : isActive
key={stage.label} ? "var(--freight-brand-dark)"
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none" : "var(--mantine-color-gray-5)",
> transition: "all 0.2s ease",
<div }}
className={cn(
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
isCompleted &&
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
isActive &&
cn(
bookingGlass.activeTab,
"scale-105 border-emerald-500/30 text-black shadow-sm",
),
!isCompleted &&
!isActive &&
"border-border/60 text-muted-foreground",
)}
> >
{isCompleted ? ( {isComplete ? <Check size={16} strokeWidth={3} /> : <Icon size={15} />}
<CheckCircle2 className="size-4" /> </Box>
) : ( <Text
<Icon className="size-4" /> size="xs"
)} fw={isActive ? 600 : 500}
</div> c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
<span ta="center"
className={cn( style={{ whiteSpace: "nowrap" }}
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
isActive ? "text-black" : "text-muted-foreground",
)}
> >
{stage.label} {stage.label}
</span> </Text>
</div> </Stack>
); {!isLast && (
})} <Box
</div> style={{
</div> flex: 1,
height: 2,
marginInline: 8,
marginBottom: 20,
borderRadius: 2,
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
<div <Paper
className={cn( radius="md"
"rounded-xl border px-5 py-4 backdrop-blur-sm", withBorder
isTerminal p="md"
? "border-destructive/20 bg-destructive/5" style={
: bookingGlass.activeTab, isTerminal ? detailStyles.statusBannerTerminal : detailStyles.statusBanner
)} }
> >
<h4 <Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
className={cn( {title}
"text-sm font-semibold tracking-tight", </Text>
isTerminal ? titleColor : "text-black", <Text size="sm" c="dimmed" mt={4}>
)} {description}
> </Text>
{title} </Paper>
</h4> </SectionCard>
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
{description}
</p>
</div>
</div>
</div>
); );
} }

View File

@@ -1,32 +1,29 @@
import { ArrowRight } from "lucide-react"; import { ArrowRight } from "lucide-react";
import { Alert, Text } from "@mantine/core";
import type { BookingNextStep } from "@/types/booking"; import type { BookingNextStep } from "@/types/booking";
import { bookingGlass } from "./booking-ui.styles";
import { cn } from "@/lib/utils";
interface NextStepBannerProps { interface NextStepBannerProps {
nextStep: BookingNextStep; nextStep: BookingNextStep;
className?: string;
} }
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) { export function NextStepBanner({ nextStep }: NextStepBannerProps) {
return ( return (
<div <Alert
className={cn( variant="light"
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm", color="gray"
bookingGlass.activeTab, radius="md"
className, icon={<ArrowRight size={16} />}
)} title={
role="status" <Text size="sm" fw={600}>
>
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
<div className="min-w-0 space-y-0.5">
<p className="font-semibold text-black">
Next: {nextStep.action.replace(/_/g, " ")} Next: {nextStep.action.replace(/_/g, " ")}
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""} {nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
</p> </Text>
<p className="text-muted-foreground">{nextStep.description}</p> }
</div> >
</div> <Text size="sm" c="dimmed">
{nextStep.description}
</Text>
</Alert>
); );
} }

View File

@@ -1,4 +1,4 @@
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */ /** Shared surfaces for booking list & detail — frosted glass, brand accents. */
export const bookingGlass = { export const bookingGlass = {
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60", card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
@@ -10,9 +10,9 @@ export const bookingGlass = {
iconWellHero: iconWellHero:
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45", "border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
activeTab: activeTab:
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]", "border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] shadow-sm backdrop-blur-md ring-1 ring-[color:var(--freight-brand-ring)]",
iconWellGreen: iconWellGreen:
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400", "border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] shadow-sm backdrop-blur-sm",
tabRail: tabRail:
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5", "rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
tableHeader: tableHeader:
@@ -38,7 +38,7 @@ export const bookingSurface = {
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`, sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`, sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
valueCard: valueCard:
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]", "rounded-xl border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] p-4 shadow-sm backdrop-blur-md",
stickySidebar: "lg:sticky lg:top-6 lg:self-start", stickySidebar: "lg:sticky lg:top-6 lg:self-start",
metricTile: metricTile:
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm", "rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
@@ -48,7 +48,7 @@ export const bookingSurface = {
export const bookingInput = { export const bookingInput = {
search: search:
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs", "h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-[color:var(--freight-brand)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--freight-brand-ring)] sm:max-w-xs",
} as const; } as const;
export const bookingTable = { export const bookingTable = {

View File

@@ -0,0 +1,68 @@
import { CheckCircle, Clock, XCircle } from "lucide-react";
import { Group, Text, Badge, Timeline } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import {
approvalStatusColor,
formatDateTime,
type BookingApprovalStepView,
} from "./booking-detail.styles";
export interface BookingApprovalCardProps {
steps: BookingApprovalStepView[];
approvedCount: number;
}
/** Vertical timeline of the booking's approval chain. */
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
return (
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
extra={
<Badge color="green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="green">
{steps.map((step) => (
<Timeline.Item
key={step.id}
color={approvalStatusColor(step.status)}
bullet={
step.status === "APPROVED" ? (
<CheckCircle size={14} />
) : step.status === "REJECTED" ? (
<XCircle size={14} />
) : (
<Clock size={14} />
)
}
title={
<Group gap="sm">
<Text fw={600} size="sm">
{step.requiredRole.replace(/_/g, " ")}
</Text>
<Badge
color={approvalStatusColor(step.status)}
size="xs"
radius="sm"
variant="light"
>
{step.status}
</Badge>
</Group>
}
>
{step.actionedAt && (
<Text size="xs" c="dimmed">
{formatDateTime(step.actionedAt)}
</Text>
)}
</Timeline.Item>
))}
</Timeline>
</SectionCard>
);
}

View File

@@ -0,0 +1,63 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingCargoCardProps {
booking: BookingDetail;
}
/** Cargo specs + container manifest table. */
export function BookingCargoCard({ booking }: BookingCargoCardProps) {
const containers = booking.bookingContainers ?? [];
return (
<SectionCard icon={Package} title="Cargo specifications">
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
/>
<MetricTile label="Total VGM" value={`${booking.cargoTotalWeightVgm} tons`} />
<MetricTile
label="Hazardous"
value={booking.isHazardous ? "Yes" : "No"}
highlight={booking.isHazardous}
/>
</SimpleGrid>
{containers.length > 0 && (
<>
<Divider my="lg" color="var(--mantine-color-gray-2)" />
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="sm" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / unit</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((c) => (
<Table.Tr key={c.id}>
<Table.Td>
<Text fw={600} size="sm">
{c.containerType?.label ?? c.containerType?.code ?? c.containerTypeId}
</Text>
</Table.Td>
<Table.Td>{c.quantity}</Table.Td>
<Table.Td>{c.vgmPerUnitTons} t</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</>
)}
</SectionCard>
);
}

View File

@@ -0,0 +1,60 @@
import { Boxes } from "lucide-react";
import { Text, Badge, Box, Table } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import type { BookingContainerView } from "./booking-detail.styles";
export interface BookingContainersCardProps {
containers: BookingContainerView[];
}
export function BookingContainersCard({ containers }: BookingContainersCardProps) {
return (
<SectionCard
icon={Boxes}
title="Containers & Cargo"
extra={
<Badge color="gray" variant="light" radius="sm">
{containers.length} line{containers.length === 1 ? "" : "s"}
</Badge>
}
>
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="md" horizontalSpacing="md" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Container Type</Table.Th>
<Table.Th>Qty</Table.Th>
<Table.Th>VGM / Unit</Table.Th>
<Table.Th>Total VGM</Table.Th>
<Table.Th>Size</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{containers.map((container) => (
<Table.Tr key={container.id}>
<Table.Td>
<Text fw={600} size="sm">
{container.containerType?.label}
</Text>
</Table.Td>
<Table.Td>{container.quantity}</Table.Td>
<Table.Td>{container.vgmPerUnitTons} t</Table.Td>
<Table.Td>
<Text fw={600} c="green.7" size="sm">
{(container.quantity * container.vgmPerUnitTons).toFixed(2)} t
</Text>
</Table.Td>
<Table.Td>
<Badge color="gray" variant="light" radius="sm">
{container.containerType?.sizeFt}FT
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
</SectionCard>
);
}

View File

@@ -0,0 +1,27 @@
import { Anchor } from "lucide-react";
import { Code } from "@mantine/core";
import { SectionCard } from "./SectionCard";
export interface BookingContractSummaryCardProps {
summary: string;
}
/** Generated contract terms, shown verbatim. */
export function BookingContractSummaryCard({ summary }: BookingContractSummaryCardProps) {
return (
<SectionCard icon={Anchor} title="Contract summary">
<Code
block
style={{
maxHeight: 256,
overflow: "auto",
whiteSpace: "pre-wrap",
background: "var(--mantine-color-gray-0)",
}}
>
{summary}
</Code>
</SectionCard>
);
}

View File

@@ -0,0 +1,76 @@
import { Building2, Calendar, CheckCircle, Boxes, Truck } from "lucide-react";
import { Paper, Group, Stack, Title, Text, Divider, SimpleGrid } from "@mantine/core";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { detailStyles, formatDate, type BookingDetailView } from "./booking-detail.styles";
export interface BookingDetailHeaderProps {
booking: BookingDetailView;
approvedCount: number;
totalSteps: number;
}
export function BookingDetailHeader({
booking,
approvedCount,
totalSteps,
}: BookingDetailHeaderProps) {
const kpis = [
{ icon: Truck, label: "Trade Direction", value: booking.tradeDirection },
{ icon: Calendar, label: "Scheduled", value: formatDate(booking.scheduledDate) },
{ icon: Boxes, label: "Freight Type", value: booking.freightType },
{
icon: CheckCircle,
label: "Approvals",
value: `${approvedCount} / ${totalSteps} complete`,
},
];
return (
<Paper radius="md" withBorder mt="sm" mb="lg" p="xl" style={detailStyles.card}>
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={6}>
<Group gap="sm" align="center">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
</Group>
<Group gap="xs">
<Building2 size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{booking.company?.companyName}
</Text>
<Text size="sm" c="dimmed">
</Text>
<Text size="sm" c="dimmed">
Created {formatDate(booking.createdAt)}
</Text>
</Group>
</Stack>
<BookingPriorityBadge score={booking.priorityScore} />
</Group>
<Divider my="lg" color="var(--mantine-color-gray-2)" />
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="xl">
{kpis.map((kpi) => (
<Group key={kpi.label} gap="sm" wrap="nowrap" align="center">
<kpi.icon size={18} color="var(--mantine-color-gray-5)" />
<Stack gap={2}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{kpi.label}
</Text>
<Text size="sm" fw={600}>
{kpi.value}
</Text>
</Stack>
</Group>
))}
</SimpleGrid>
</Paper>
);
}

View File

@@ -0,0 +1,37 @@
import { ArrowLeft, Download, CheckCircle } from "lucide-react";
import { Group, Button } from "@mantine/core";
export interface BookingDetailToolbarProps {
onBack: () => void;
onExport?: () => void;
onAction?: () => void;
}
/** Top action bar for the booking detail page. */
export function BookingDetailToolbar({
onBack,
onExport,
onAction,
}: BookingDetailToolbarProps) {
return (
<Group justify="space-between" mb="md">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={18} />}
onClick={onBack}
fw={600}
>
Back
</Button>
<Group gap="sm">
<Button variant="default" leftSection={<Download size={16} />} onClick={onExport}>
Export
</Button>
<Button color="green" leftSection={<CheckCircle size={16} />} onClick={onAction}>
Take Action
</Button>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,69 @@
import { FileText, Download } from "lucide-react";
import { Group, Stack, Text, Badge, ThemeIcon, ActionIcon } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { detailStyles, type BookingFileView } from "./booking-detail.styles";
export interface BookingDocumentsCardProps {
files: BookingFileView[];
onDownload?: (file: BookingFileView) => void;
}
/** List of attached documents with per-file download actions. */
export function BookingDocumentsCard({ files, onDownload }: BookingDocumentsCardProps) {
return (
<SectionCard
icon={FileText}
title="Documents"
extra={
<Badge color="gray" variant="light" radius="sm">
{files.length}
</Badge>
}
>
{files.length === 0 ? (
<Text size="sm" c="dimmed">
No documents attached.
</Text>
) : (
<Stack gap="xs">
{files.map((file) => (
<Group
key={file.id}
justify="space-between"
wrap="nowrap"
p="xs"
style={detailStyles.fileRow}
onMouseEnter={(e) => {
e.currentTarget.style.background = "var(--mantine-color-gray-0)";
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "transparent";
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon size={32} radius="md" variant="light" color="red">
<FileText size={16} />
</ThemeIcon>
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
</Group>
<ActionIcon
variant="subtle"
color="gray"
radius="md"
onClick={() => onDownload?.(file)}
aria-label={`Download ${file.name}`}
>
<Download size={16} />
</ActionIcon>
</Group>
))}
</Stack>
)}
</SectionCard>
);
}

View File

@@ -0,0 +1,57 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Hash, Package, Ship, Weight, Clock } from "lucide-react";
import { Group, Stack, Text, Divider } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { formatDate, type BookingDetailView } from "./booking-detail.styles";
interface FactRowProps {
icon: LucideIcon;
label: string;
value: ReactNode;
}
function FactRow({ icon: Icon, label, value }: FactRowProps) {
return (
<Group justify="space-between" wrap="nowrap" py={6}>
<Group gap="xs" wrap="nowrap">
<Icon size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Text size="sm" fw={600} ta="right">
{value}
</Text>
</Group>
);
}
export interface BookingFactsCardProps {
booking: BookingDetailView;
}
/** Key/value summary of the booking's reference data. */
export function BookingFactsCard({ booking }: BookingFactsCardProps) {
const facts: FactRowProps[] = [
{ icon: Hash, label: "PNR Code", value: booking.pnrCode || "—" },
{ icon: Package, label: "Cargo Type", value: booking.cargoType?.label ?? "—" },
{ icon: Ship, label: "Shipping Line", value: booking.shippingLine?.label ?? "—" },
{ icon: Weight, label: "VGM Weight", value: `${booking.cargoTotalWeightVgm} tons` },
{ icon: Clock, label: "Last Updated", value: formatDate(booking.updatedAt) },
];
return (
<SectionCard icon={Hash} title="Booking Details">
<Stack gap={0}>
{facts.map((fact, index) => (
<div key={fact.label}>
{index > 0 && <Divider />}
<FactRow {...fact} />
</div>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,100 @@
import { Check } from "lucide-react";
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
import {
WORKFLOW_STAGES,
getWorkflowStageIndex,
} from "@/features/bookings/booking-status.config";
import { detailStyles, BRAND_GREEN } from "./booking-detail.styles";
export interface BookingLifecycleStepperProps {
status: string;
}
/** Horizontal lifecycle tracker showing how far the booking has progressed. */
export function BookingLifecycleStepper({ status }: BookingLifecycleStepperProps) {
const currentStage = getWorkflowStageIndex(status);
return (
<Paper radius="md" withBorder p="xl" mb="lg" style={detailStyles.card}>
<Group gap={0} wrap="nowrap" align="flex-start">
{WORKFLOW_STAGES.map((stage, index) => {
const isComplete = currentStage >= 0 && index < currentStage;
const isActive = index === currentStage;
const isLast = index === WORKFLOW_STAGES.length - 1;
const circleBg = isComplete
? BRAND_GREEN
: isActive
? "white"
: "var(--mantine-color-gray-1)";
const circleBorder = isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-2)";
return (
<Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "32px",
height: "32px",
borderRadius: "50%",
background: circleBg,
border: circleBorder,
color: isComplete
? "white"
: isActive
? "var(--freight-brand-dark)"
: "var(--mantine-color-gray-5)",
transition: "all 0.2s ease",
}}
>
{isComplete ? (
<Check size={16} strokeWidth={3} />
) : (
<Text size="xs" fw={700}>
{index + 1}
</Text>
)}
</Box>
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{stage.label}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: "2px",
marginInline: "8px",
marginBottom: "20px",
borderRadius: "2px",
background: isComplete
? BRAND_GREEN
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,31 @@
import { Truck } from "lucide-react";
import { SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingMileServicesCardProps {
booking: BookingDetail;
}
/** First / last mile addresses. Renders nothing when neither is present. */
export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) {
if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) {
return null;
}
return (
<SectionCard icon={Truck} title="Mile services">
<SimpleGrid cols={{ base: 1, md: 2 }} spacing="sm">
{booking.firstMilePickupAddress && (
<MetricTile label="First mile pickup" value={booking.firstMilePickupAddress} />
)}
{booking.lastMileDeliveryAddress && (
<MetricTile label="Last mile delivery" value={booking.lastMileDeliveryAddress} />
)}
</SimpleGrid>
</SectionCard>
);
}

View File

@@ -0,0 +1,43 @@
import { Paper, Stack, Group, Text, Title, Badge } from "@mantine/core";
import { detailStyles } from "./booking-detail.styles";
export interface BookingPaymentCardProps {
totalAmount: number;
currency: string;
paymentStatus: string;
}
/** Key-figure card: total amount + payment status. Flat, lightly tinted. */
export function BookingPaymentCard({
totalAmount,
currency,
paymentStatus,
}: BookingPaymentCardProps) {
return (
<Paper radius="md" withBorder p="xl" style={detailStyles.highlightCard}>
<Stack gap={6}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Total Amount
</Text>
<Group align="flex-end" gap="xs">
<Title order={1} fw={700} c="green.9" style={{ letterSpacing: "-1px" }}>
{totalAmount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Title>
<Text fw={600} c="green.7" mb={6}>
{currency}
</Text>
</Group>
<Badge
color={paymentStatus === "PAID" ? "green" : "yellow"}
variant="light"
radius="sm"
mt="xs"
w="fit-content"
>
{paymentStatus}
</Badge>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,111 @@
import { Building2, Calendar, Clock, RefreshCw, ArrowLeft } from "lucide-react";
import { Paper, Group, Stack, Title, Text, Button, Box } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
import { detailStyles, formatDate } from "./booking-detail.styles";
export interface BookingRequestHeroProps {
booking: BookingDetail;
customerLabel: string;
onBack: () => void;
onRefresh: () => void;
isFetching?: boolean;
}
/** Top hero for the request detail page: identity, status, next step, total value. */
export function BookingRequestHero({
booking,
customerLabel,
onBack,
onRefresh,
isFetching,
}: BookingRequestHeroProps) {
const amount = Number(booking.totalAmount);
return (
<Paper radius="md" withBorder p="xl" style={detailStyles.card}>
<Button
variant="subtle"
color="gray"
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
onClick={onBack}
mb="md"
ml={-8}
fw={600}
>
Back to list
</Button>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Stack gap="sm" style={{ flex: 1, minWidth: 0 }}>
<Text size="xs" c="dimmed" fw={600} tt="uppercase" lts="0.06em">
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
</Group>
{booking.nextStep && (
<Box maw={520}>
<NextStepBanner nextStep={booking.nextStep} />
</Box>
)}
<Group gap="lg" mt={4}>
<Group gap={6} wrap="nowrap">
<Building2 size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
{customerLabel}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Calendar size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
Scheduled {booking.scheduledDate}
</Text>
</Group>
<Group gap={6} wrap="nowrap">
<Clock size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" c="dimmed">
Created {formatDate(booking.createdAt)}
</Text>
</Group>
</Group>
</Stack>
<Stack gap="sm" align="flex-end">
<Paper radius="md" withBorder p="md" miw={200} style={detailStyles.highlightCard}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em" ta="right">
Total value
</Text>
<Text size="xl" fw={700} c="green.9" ta="right" mt={4} style={{ fontVariantNumeric: "tabular-nums" }}>
{booking.paymentCurrency}{" "}
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
</Text>
<Text size="xs" c="dimmed" ta="right" mt={2}>
{booking.paymentStatus}
</Text>
</Paper>
<Button
variant="default"
size="sm"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={onRefresh}
>
Refresh
</Button>
</Stack>
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,49 @@
import { FileText, MessageSquare } from "lucide-react";
import { Group, Stack, Text, Badge, ThemeIcon } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { formatDateTime, type BookingReviewNoteView } from "./booking-detail.styles";
export interface BookingReviewNotesCardProps {
notes: BookingReviewNoteView[];
}
/** Chronological list of reviewer / compliance notes. */
export function BookingReviewNotesCard({ notes }: BookingReviewNotesCardProps) {
if (notes.length === 0) {
return (
<SectionCard icon={MessageSquare} title="Review Notes">
<Text size="sm" c="dimmed">
No review notes have been added yet.
</Text>
</SectionCard>
);
}
return (
<SectionCard icon={MessageSquare} title="Review Notes">
<Stack gap="md">
{notes.map((note) => (
<Group key={note.id} align="flex-start" gap="md" wrap="nowrap">
<ThemeIcon size={34} radius="xl" variant="light" color="green">
<FileText size={16} />
</ThemeIcon>
<Stack gap={2} style={{ flex: 1 }}>
<Group justify="space-between">
<Badge color="green" variant="light" size="sm" radius="sm">
{note.type}
</Badge>
<Text size="xs" c="dimmed">
{formatDateTime(note.createdAt)}
</Text>
</Group>
<Text size="sm" style={{ lineHeight: 1.5 }}>
{note.note}
</Text>
</Stack>
</Group>
))}
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,69 @@
import { MapPin } from "lucide-react";
import { Group, Stack, Text, Box } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import { detailStyles, type BookingDetailView } from "./booking-detail.styles";
export interface BookingRouteCardProps {
booking: BookingDetailView;
}
export function BookingRouteCard({ booking }: BookingRouteCardProps) {
return (
<SectionCard icon={MapPin} title="Shipment Route">
<Group justify="space-between" align="center" wrap="nowrap" gap="xl">
{/* Origin */}
<Stack gap={2} style={{ flex: 1 }}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Origin
</Text>
<Text fw={600}>{booking.originYard?.label}</Text>
<Text size="xs" c="dimmed">
{booking.originYard?.code}
</Text>
</Stack>
{/* Connector */}
<Box style={{ flex: 1.4 }}>
<Group gap={6} wrap="nowrap" align="center">
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
background: "var(--freight-brand)",
flexShrink: 0,
}}
/>
<Box style={detailStyles.routeLine} />
<Box
style={{
width: 8,
height: 8,
borderRadius: "50%",
border: "2px solid var(--mantine-color-gray-4)",
flexShrink: 0,
}}
/>
</Group>
<Text size="xs" c="dimmed" ta="center" mt={6}>
{booking.shippingLine?.label} · {booking.serviceType?.label}
</Text>
</Box>
{/* Destination */}
<Stack gap={2} style={{ flex: 1 }} align="flex-end">
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
Destination
</Text>
<Text fw={600} ta="right">
{booking.destinationYard?.label}
</Text>
<Text size="xs" c="dimmed">
{booking.destinationYard?.code}
</Text>
</Stack>
</Group>
</SectionCard>
);
}

View File

@@ -0,0 +1,101 @@
import { Train, MapPin, ArrowRight } from "lucide-react";
import { Group, Stack, Text, Badge, Box, SimpleGrid } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
export interface BookingRouteServiceCardProps {
booking: BookingDetail;
originLabel: string;
destinationLabel: string;
}
function Endpoint({
label,
station,
align = "left",
}: {
label: string;
station: string;
align?: "left" | "right";
}) {
return (
<Stack gap={2} style={{ flex: 1, minWidth: 0 }} align={align === "right" ? "flex-end" : "flex-start"}>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{label}
</Text>
<Group gap={6} wrap="nowrap">
<MapPin size={15} color="var(--freight-brand)" />
<Text fw={600} truncate>
{station}
</Text>
</Group>
</Stack>
);
}
export function BookingRouteServiceCard({
booking,
originLabel,
destinationLabel,
}: BookingRouteServiceCardProps) {
const serviceLabel =
booking.serviceType?.label ?? booking.serviceType?.code ?? "Rail service";
const metrics = [
{ label: "Trade direction", value: booking.tradeDirection },
{ label: "Freight type", value: booking.freightType },
{ label: "Equipment return", value: booking.equipmentReturn ?? "—" },
...(booking.shippingLine
? [
{
label: "Shipping line",
value:
booking.shippingLine.label ??
booking.shippingLine.name ??
booking.shippingLine.code ??
"—",
},
]
: []),
];
return (
<SectionCard icon={Train} title="Route & service">
<Group justify="space-between" align="center" wrap="nowrap" gap="md">
<Endpoint label="Origin" station={originLabel} />
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 36,
height: 36,
borderRadius: "50%",
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-3)",
}}
>
<Train size={18} color="var(--mantine-color-gray-7)" />
</Box>
<Badge variant="light" color="gray" size="sm" radius="sm" tt="uppercase">
{serviceLabel}
</Badge>
</Stack>
<Group gap={6} wrap="nowrap" style={{ flex: 1, justifyContent: "flex-end" }}>
<ArrowRight size={16} color="var(--mantine-color-gray-4)" style={{ flexShrink: 0 }} />
<Endpoint label="Destination" station={destinationLabel} align="right" />
</Group>
</Group>
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="sm" mt="lg">
{metrics.map((m) => (
<MetricTile key={m.label} label={m.label} value={m.value} />
))}
</SimpleGrid>
</SectionCard>
);
}

View File

@@ -0,0 +1,32 @@
import { Paper, Text } from "@mantine/core";
export interface MetricTileProps {
label: string;
value: string;
highlight?: boolean;
}
/** Small flat label/value tile used across the detail sections. */
export function MetricTile({ label, value, highlight }: MetricTileProps) {
return (
<Paper
radius="md"
withBorder
px="md"
py="sm"
style={{
background: highlight ? "var(--mantine-color-yellow-0)" : "var(--mantine-color-gray-0)",
borderColor: highlight
? "var(--mantine-color-yellow-3)"
: "var(--mantine-color-gray-2)",
}}
>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{label}
</Text>
<Text size="sm" fw={600} mt={4} style={{ lineHeight: 1.4 }}>
{value}
</Text>
</Paper>
);
}

View File

@@ -0,0 +1,32 @@
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Paper, Group, Text, Box } from "@mantine/core";
import { detailStyles } from "./booking-detail.styles";
export interface SectionCardProps {
icon: LucideIcon;
title: string;
extra?: ReactNode;
children: ReactNode;
}
/** Consistent flat card with a minimal icon + title header used by every detail section. */
export function SectionCard({ icon: Icon, title, extra, children }: SectionCardProps) {
return (
<Paper radius="md" withBorder style={detailStyles.card}>
<Group justify="space-between" px="xl" py="md" style={detailStyles.cardHeader}>
<Group gap="sm">
<Icon size={16} color="var(--mantine-color-gray-6)" />
<Text fw={600} size="sm" c="dark">
{title}
</Text>
</Group>
{extra}
</Group>
<Box px="xl" py="lg">
{children}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,154 @@
import type { CSSProperties } from "react";
import { FREIGHT_BRAND } from "@/theme/freight-brand";
/** Single brand accent. Minimal design uses solid green sparingly, no gradients. */
export const BRAND_GREEN = FREIGHT_BRAND;
/** Centralised style tokens for the booking detail page + cards. */
export const detailStyles = {
page: {
background: "var(--mantine-color-gray-0)",
minHeight: "100vh",
} satisfies CSSProperties,
/** Flat white card — thin border, no shadow. */
card: {
background: "white",
borderColor: "var(--mantine-color-gray-2)",
} satisfies CSSProperties,
cardHeader: {
borderBottom: "1px solid var(--mantine-color-gray-2)",
} satisfies CSSProperties,
/** Subtle key-figure card (e.g. payment) — neutral tint, still flat. */
highlightCard: {
background: "var(--mantine-color-gray-0)",
borderColor: "var(--mantine-color-gray-2)",
} satisfies CSSProperties,
/** Workflow status description banner — neutral default. */
statusBanner: {
background: "var(--mantine-color-gray-0)",
borderColor: "var(--mantine-color-gray-2)",
} satisfies CSSProperties,
/** Workflow status banner for terminal (rejected/cancelled) states. */
statusBannerTerminal: {
background: "var(--mantine-color-red-0)",
borderColor: "var(--mantine-color-red-2)",
} satisfies CSSProperties,
routeLine: {
flex: 1,
height: "1px",
background: "var(--mantine-color-gray-3)",
} satisfies CSSProperties,
fileRow: {
borderRadius: "8px",
border: "1px solid var(--mantine-color-gray-2)",
transition: "background 0.12s ease, border-color 0.12s ease",
} satisfies CSSProperties,
} as const;
/** Map an approval-step status to a Mantine colour. */
export function approvalStatusColor(status: string): string {
switch (status) {
case "APPROVED":
return "green";
case "PENDING":
return "yellow";
case "REJECTED":
return "red";
default:
return "gray";
}
}
export function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
export function formatDateTime(iso: string): string {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
// ---- View model -----------------------------------------------------------
export interface BookingNamedRefView {
id: string;
label?: string;
code?: string;
companyName?: string;
name?: string;
}
export interface BookingContainerView {
id: string;
quantity: number;
vgmPerUnitTons: number;
containerType?: {
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}
export interface BookingApprovalStepView {
id: string;
stepOrder: number;
requiredRole: string;
status: string;
actionedAt?: string | null;
}
export interface BookingReviewNoteView {
id: string;
note: string;
type: string;
createdAt: string;
}
export interface BookingFileView {
id: string;
name: string;
mimeType?: string;
}
export interface BookingDetailView {
id: string;
reference: string;
status: string;
scheduledDate: string;
totalAmount: number;
paymentCurrency: string;
paymentStatus: string;
tradeDirection: string;
freightType: string;
priorityScore: number;
cargoTotalWeightVgm: number;
pnrCode?: string | null;
createdAt: string;
updatedAt: string;
company?: BookingNamedRefView;
originYard?: BookingNamedRefView;
destinationYard?: BookingNamedRefView;
serviceType?: BookingNamedRefView;
cargoType?: BookingNamedRefView;
shippingLine?: BookingNamedRefView;
bookingContainers?: BookingContainerView[];
approvalSteps?: BookingApprovalStepView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
}

View File

@@ -0,0 +1,18 @@
export * from "./booking-detail.styles";
export * from "./SectionCard";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";
export * from "./BookingDetailHeader";
export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";
export * from "./BookingFactsCard";
export * from "./BookingDocumentsCard";
export * from "./BookingRequestHero";
export * from "./BookingRouteServiceCard";
export * from "./BookingMileServicesCard";
export * from "./BookingCargoCard";
export * from "./BookingContractSummaryCard";

View File

@@ -86,14 +86,8 @@ export function useBookingActionDialog(
); );
break; break;
} }
case "generateContract":
mutations.generateContract.mutate(undefined, { onSuccess });
break;
case "viewContract": case "viewContract":
break; break;
case "payBooking":
mutations.payBooking.mutate(undefined, { onSuccess });
break;
case "startTransit": case "startTransit":
mutations.startTransit.mutate(undefined, { onSuccess }); mutations.startTransit.mutate(undefined, { onSuccess });
break; break;

View File

@@ -9,13 +9,10 @@ import {
Sun, Sun,
User, User,
} from "lucide-react"; } from "lucide-react";
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
import { cn } from "@/lib/utils";
import type { PageMeta } from "./types"; import type { PageMeta } from "./types";
import { freightBrand } from "@/theme/freight-brand";
const iconButtonClass =
"relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition hover:border-primary/30 hover:bg-gray-50 hover:text-gray-900";
export interface FreightDashboardHeaderProps { export interface FreightDashboardHeaderProps {
pageMeta: PageMeta; pageMeta: PageMeta;
@@ -77,120 +74,146 @@ const FreightDashboardHeader = ({
}, [isUserMenuOpen]); }, [isUserMenuOpen]);
return ( return (
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6"> <header
<div className="min-w-0"> style={{
<h1 className="truncate text-xl font-bold tracking-tight text-foreground"> display: "flex",
height: "80px",
alignItems: "center",
justifyContent: "space-between",
gap: "16px",
padding: "0 24px",
// borderBottom: `3px solid ${freightBrand.primary}`,
}}
>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
{pageMeta.title} {pageMeta.title}
</h1> </Text>
<p className="mt-0.5 truncate text-sm text-secondary-foreground"> <Text size="sm" c="dimmed" truncate>
{pageMeta.subtitle} {pageMeta.subtitle}
</p> </Text>
</div> </Stack>
<div className="flex shrink-0 items-center gap-2"> <Group gap="sm" wrap="nowrap">
{enableThemeToggle ? ( {enableThemeToggle && (
<button <ActionIcon
type="button" variant="default"
size={40}
radius="lg"
onClick={onToggleTheme} onClick={onToggleTheme}
aria-label={ style={{
theme === "dark" ? "Switch to light mode" : "Switch to dark mode" background: "var(--mantine-color-gray-1)",
} border: "1px solid var(--mantine-color-gray-2)",
className={iconButtonClass} color: "var(--mantine-color-gray-7)",
}}
> >
{theme === "dark" ? ( {theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
<Sun className="h-5 w-5" /> </ActionIcon>
) : ( )}
<Moon className="h-5 w-5" />
)}
</button>
) : null}
<button <ActionIcon
type="button" variant="default"
aria-label="Change language" size={40}
className={iconButtonClass} radius="lg"
style={{
background: "var(--mantine-color-gray-1)",
border: "1px solid var(--mantine-color-gray-2)",
color: "var(--mantine-color-gray-7)",
}}
> >
<Languages className="h-5 w-5" /> <Languages size={18} />
</button> </ActionIcon>
<button type="button" aria-label="Messages" className={iconButtonClass}> <ActionIcon
<MessageSquare className="h-5 w-5" /> variant="default"
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" /> size={40}
</button> radius="lg"
style={{
<button background: "var(--mantine-color-gray-1)",
type="button" border: "1px solid var(--mantine-color-gray-2)",
aria-label="Notifications" color: "var(--mantine-color-gray-7)",
className={iconButtonClass} position: "relative",
}}
> >
<Bell className="h-5 w-5" /> <MessageSquare size={18} />
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" /> <Badge
</button> size="xs"
color="red"
circle
style={{
position: "absolute",
top: "-3px",
right: "-3px",
}}
/>
</ActionIcon>
<div ref={userMenuRef} className="relative ml-1"> <ActionIcon
<button variant="default"
type="button" size={40}
aria-haspopup="menu" radius="lg"
aria-expanded={isUserMenuOpen} style={{
onClick={() => setIsUserMenuOpen((open) => !open)} background: "var(--mantine-color-gray-1)",
className={cn( border: "1px solid var(--mantine-color-gray-2)",
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition", color: "var(--mantine-color-gray-7)",
isUserMenuOpen position: "relative",
? "border-primary/30 bg-primary/5" }}
: "hover:border-primary/20 hover:bg-gray-50", >
)} <Bell size={18} />
> <Badge
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"> size="xs"
{initials} color="red"
</div> circle
<ChevronDown style={{
className={cn( position: "absolute",
"hidden h-4 w-4 text-gray-400 transition sm:block", top: "-3px",
isUserMenuOpen && "rotate-180 text-primary", right: "-3px",
)} }}
/> />
</button> </ActionIcon>
{isUserMenuOpen ? ( <Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
<div <Menu.Target>
role="menu" <Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg" <Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} />
> <ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
<div className="border-b border-gray-100 px-4 py-3"> </Group>
<p className="text-sm font-semibold text-gray-900"> </Menu.Target>
<Menu.Dropdown>
<Menu.Item disabled>
<Stack gap={0}>
<Text size="sm" fw={600}>
{userName} {userName}
</p> </Text>
{userEmail ? ( {userEmail && (
<p className="text-xs text-gray-500">{userEmail}</p> <Text size="xs" c="dimmed">
) : null} {userEmail}
</div> </Text>
<a )}
href="#profile" </Stack>
role="menuitem" </Menu.Item>
onClick={() => setIsUserMenuOpen(false)} <Menu.Divider />
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50" <Menu.Item
> leftSection={<User size={14} />}
<User className="h-4 w-4" /> onClick={() => setIsUserMenuOpen(false)}
Profile >
</a> Profile
<button </Menu.Item>
type="button" <Menu.Item
role="menuitem" leftSection={<LogOut size={14} />}
onClick={() => { color="red"
setIsUserMenuOpen(false); onClick={() => {
onLogout?.(); setIsUserMenuOpen(false);
}} onLogout?.();
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50" }}
> >
<LogOut className="h-4 w-4" /> Logout
Logout </Menu.Item>
</button> </Menu.Dropdown>
</div> </Menu>
) : null}
</div>
{headerRight} {headerRight}
</div> </Group>
</header> </header>
); );
}; };

Some files were not shown because too many files have changed in this diff Show More