mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Resolve merge conflicts from Train-Scheduling
This commit is contained in:
14
.dockerignore
Normal file
14
.dockerignore
Normal 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
92
.github/workflows/deploy.yml
vendored
Normal 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
4
.gitignore
vendored
@@ -22,6 +22,10 @@ coverage/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
.npmrc
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
# emacs cache files
|
||||
*~
|
||||
|
||||
187
DEPLOYMENT.md
Normal file
187
DEPLOYMENT.md
Normal 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
766
README.md
@@ -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: ETB→DJF=3.25, ETB→USD=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
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
<<<<<<< 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
|
||||
|
||||
```bash
|
||||
@@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice
|
||||
- **One DB per domain** — no cross-database joins.
|
||||
|
||||
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
||||
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
# App
|
||||
NODE_ENV=development
|
||||
# Copy to .env for local/docker compose (not committed).
|
||||
PORT=3001
|
||||
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5433
|
||||
DB_NAME=edr_freight
|
||||
DB_USER=postgres
|
||||
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_SECRET=
|
||||
JWT_ACCESS_TOKEN_SECRET=
|
||||
|
||||
@@ -1,26 +1,37 @@
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
# syntax=docker/dockerfile:1
|
||||
# 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
|
||||
|
||||
FROM base AS deps
|
||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
|
||||
COPY packages ./packages
|
||||
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/edr-freight-api ./apps/edr-freight-api
|
||||
RUN pnpm --filter @edr/freight-api build
|
||||
FROM base AS installer
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
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
|
||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
||||
WORKDIR /app/apps/edr-freight-api
|
||||
FROM base AS builder
|
||||
COPY --from=installer /app/ .
|
||||
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
|
||||
|
||||
COPY --from=deps /app/node_modules ./../../node_modules
|
||||
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
|
||||
COPY --from=build /app/apps/edr-freight-api/dist ./dist
|
||||
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
|
||||
|
||||
WORKDIR /app
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||
USER nestjs
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/api-common": "workspace:*",
|
||||
"@edr/payment-providers": "workspace:*",
|
||||
"@edr/types": "workspace:*",
|
||||
"@nestjs/axios": "^4.0.1",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/event-emitter": "^2.0.4",
|
||||
"@nestjs/mapped-types": "^2.1.1",
|
||||
"@nestjs/microservices": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
@@ -63,7 +65,7 @@
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typeorm": "^1.0.0",
|
||||
"typeorm": "^0.3.30",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"jest": {
|
||||
|
||||
@@ -8,12 +8,13 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
|
||||
|
||||
import appConfig from "./config/app.config";
|
||||
import databaseConfig from "./config/database.config";
|
||||
import telebirrConfig from "./config/telebirr.config";
|
||||
|
||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||
import { FilesModule } from "./modules/files/files.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 { WagonTypesModule } from "./modules/wagon-types/wagon-types.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 { ContainersModule } from './modules/container-management/containers.module';
|
||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
import { RoutesModule } from './modules/routes/routes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [appConfig, databaseConfig],
|
||||
load: [appConfig, databaseConfig, telebirrConfig],
|
||||
}),
|
||||
// EventEmitterModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||
@@ -98,6 +101,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||
WagonsModule,
|
||||
ContainersModule,
|
||||
CargoesModule,
|
||||
RoutesModule,
|
||||
],
|
||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
PositionType,
|
||||
Position,
|
||||
Project,
|
||||
UnitConfiguration,
|
||||
GlobalUnitConfiguration,
|
||||
Unit,
|
||||
EmployeeSignature,
|
||||
@@ -64,7 +63,6 @@ const iamEntities = [
|
||||
PositionType,
|
||||
Position,
|
||||
Project,
|
||||
UnitConfiguration,
|
||||
GlobalUnitConfiguration,
|
||||
Unit,
|
||||
EmployeeSignature,
|
||||
@@ -98,10 +96,8 @@ const iamMigrationsGlob = join(
|
||||
);
|
||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||
|
||||
export default registerAs(
|
||||
"database",
|
||||
(): TypeOrmModuleOptions => {
|
||||
return {
|
||||
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||
return {
|
||||
type: "postgres",
|
||||
host: process.env.DB_HOST ?? "localhost",
|
||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||
@@ -124,5 +120,4 @@ export default registerAs(
|
||||
synchronize: false,
|
||||
logging: process.env.NODE_ENV === "development",
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal file
10
apps/edr-freight-api/src/config/dmoney.config.ts
Normal 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 ?? ""
|
||||
}));
|
||||
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal file
16
apps/edr-freight-api/src/config/telebirr.config.ts
Normal 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",
|
||||
}));
|
||||
@@ -1,14 +1,15 @@
|
||||
// apps/edr-freight-api/src/data-source.ts
|
||||
import 'dotenv/config';
|
||||
import { DataSource } from 'typeorm';
|
||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||
|
||||
export const AppDataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
username: 'postgres',
|
||||
password: '', // Laragon default: empty
|
||||
database: 'edr_freight',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.DB_PORT ?? 5432),
|
||||
username: process.env.DB_USER ?? 'postgres',
|
||||
password: process.env.DB_PASSWORD ?? '',
|
||||
database: process.env.DB_NAME ?? 'edr_freight',
|
||||
schema: 'freight', // default schema for entities without an explicit schema
|
||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||
@@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({
|
||||
});
|
||||
|
||||
// Optional: call ensurePostgresSchemas before initializing
|
||||
// But you can also run it separately.
|
||||
// But you can also run it separately.
|
||||
|
||||
@@ -18,6 +18,7 @@ async function bootstrap() {
|
||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||
// and any other dev port can call the API with cookies + Authorization.
|
||||
// For production, restrict `origin` to known FQDNs.
|
||||
|
||||
app.enableCors({
|
||||
origin: true, // reflect request origin
|
||||
credentials: true,
|
||||
@@ -52,9 +53,12 @@ async function bootstrap() {
|
||||
SwaggerModule.setup("api/docs", app, document);
|
||||
|
||||
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
|
||||
console.log(`[freight-api] listening on http://localhost:${port}`);
|
||||
console.log(`[freight-api] listening on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -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';
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -25,10 +25,10 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
|
||||
key: 'approved_contract',
|
||||
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||
},
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
|
||||
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||
{
|
||||
key: 'operations',
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
|
||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||
},
|
||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||
|
||||
@@ -34,8 +34,8 @@ export function computeNextStep(
|
||||
};
|
||||
case 'APPROVED':
|
||||
return {
|
||||
action: 'GENERATE_CONTRACT',
|
||||
description: 'Generate the contract document',
|
||||
action: 'CUSTOMER_SIGN',
|
||||
description: 'Contract generated; customer must sign',
|
||||
};
|
||||
case 'CONTRACT_READY':
|
||||
return {
|
||||
@@ -49,8 +49,8 @@ export function computeNextStep(
|
||||
};
|
||||
case 'FULLY_EXECUTED':
|
||||
return {
|
||||
action: 'PAY',
|
||||
description: 'Complete in-app payment',
|
||||
action: 'AWAIT_PAYMENT',
|
||||
description: 'Awaiting customer payment',
|
||||
};
|
||||
case 'PAID':
|
||||
return {
|
||||
|
||||
@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
|
||||
import { assertBookingStatus } from './booking-status.util';
|
||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||
import { PaymentService } from '../payment/payment.service';
|
||||
|
||||
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||
|
||||
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||
"action-required",
|
||||
"processing",
|
||||
"success",
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class BookingPaymentService {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly paymentService: PaymentService,
|
||||
) { }
|
||||
|
||||
async pay(
|
||||
bookingId: string,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||
const booking = await this.requireBooking(bookingId);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
||||
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||
|
||||
// const receipt = this.buildMockReceipt(booking);
|
||||
|
||||
// const updated = await this.bookingsRepository.update(bookingId, {
|
||||
// status: 'PAID',
|
||||
// paymentStatus: 'PAID',
|
||||
// } as never);
|
||||
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 existing = await this.paymentService.findBookingById(bookingId);
|
||||
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||
if (existing.clientAction) {
|
||||
const action = existing.clientAction as { type?: string; url?: string };
|
||||
if (action.type === "REDIRECT" && action.url) {
|
||||
return { redirectUrl: action.url };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||
|
||||
@@ -185,6 +185,11 @@ export class BookingTransitionService {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
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 {
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||
status?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...LOCOMOTIVE_TYPES])
|
||||
locomotiveType?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateLocomotiveDto } from './create-locomotive.dto';
|
||||
|
||||
export class UpdateLocomotiveDto extends PartialType(CreateLocomotiveDto) {}
|
||||
@@ -7,10 +7,13 @@ export const LOCOMOTIVE_STATUSES = [
|
||||
'AVAILABLE',
|
||||
'ASSIGNED',
|
||||
'MAINTENANCE',
|
||||
'INACTIVE',
|
||||
'OUT_OF_SERVICE',
|
||||
] as const;
|
||||
|
||||
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||
|
||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||
@Index(['code'])
|
||||
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
||||
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 })
|
||||
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' })
|
||||
status!: LocomotiveStatus;
|
||||
|
||||
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
|
||||
availableFrom?: Date | null;
|
||||
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||
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)
|
||||
trainSets?: TrainSet[];
|
||||
|
||||
@@ -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 { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||
import { LocomotivesService } from './locomotives.service';
|
||||
|
||||
@ApiTags('locomotives')
|
||||
@@ -15,4 +17,28 @@ export class LocomotivesController {
|
||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { 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';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,13 +12,36 @@ export class LocomotivesService {
|
||||
|
||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||
return this.locomotivesRepository.findAll({
|
||||
where: filter.status
|
||||
? { status: filter.status as LocomotiveStatus }
|
||||
: undefined,
|
||||
where: {
|
||||
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
|
||||
...(filter.locomotiveType
|
||||
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||
: {}),
|
||||
},
|
||||
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> {
|
||||
const locomotive = await this.locomotivesRepository.findById(id);
|
||||
|
||||
@@ -26,4 +51,48 @@ export class LocomotivesService {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class InitiateBookingPayment {
|
||||
@IsString()
|
||||
bookingId!: string;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { BaseEntity, Column, CreateDateColumn, Entity, PrimaryGeneratedColumn }
|
||||
type PaymentType = "booking"
|
||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
||||
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' })
|
||||
export class PaymentEntity extends BaseEntity {
|
||||
|
||||
@@ -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 { Public } from "@edr/api-common";
|
||||
// import { randomUUID } from "crypto";
|
||||
import { Response } from "express"
|
||||
|
||||
@Public()
|
||||
@Controller("payments")
|
||||
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")
|
||||
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
// const filled = await this.paymentService.genReceiptHtml(orderId);
|
||||
// return res.send(filled)
|
||||
// }
|
||||
@Post("/bookings/check-payment/:orderId")
|
||||
checkPayment(@Param("orderId") orderId: string) {
|
||||
return this.paymentService.checkStatusAndUpdate(orderId)
|
||||
}
|
||||
|
||||
// @Post("/initiate/booking")
|
||||
// async initiatePayment() {
|
||||
|
||||
// //Only for testing..
|
||||
// const description = "Booking for contact"
|
||||
// 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)
|
||||
@Get("/bookings/telebirr/redirect/:orderId")
|
||||
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
|
||||
if (!payment) {
|
||||
throw new NotFoundException('payment not found')
|
||||
}
|
||||
|
||||
|
||||
@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(`
|
||||
return res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -62,6 +40,5 @@ export class PaymentController {
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
|
||||
import { PaymentService } from "./payment.service";
|
||||
import { HttpModule } from "@nestjs/axios";
|
||||
import { PaymentController } from "./payment.controller";
|
||||
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
|
||||
import { PaymentRepository } from "./payment.repository";
|
||||
import { WebhookController } from "./webhooks/webhook.controller";
|
||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||
import { TelebirrProvider } from "@edr/payment-providers";
|
||||
|
||||
@Module({
|
||||
imports: [HttpModule, ConfigModule],
|
||||
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
||||
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||
controllers: [PaymentController, WebhookController],
|
||||
exports: [PaymentService]
|
||||
})
|
||||
|
||||
@@ -14,6 +14,13 @@ export class PaymentRepository {
|
||||
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> {
|
||||
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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1,111 +1,90 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
||||
import { DataSource, QueryRunner } from "typeorm";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { DataSource } from "typeorm";
|
||||
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 { ClientAction, PaymentPlatform } from "./strategies/payments.types";
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as Handlebars from 'handlebars';
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as Handlebars from "handlebars";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
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"]
|
||||
type CurrencyType = PaymentEntity["currency"]
|
||||
const DEFAULT_CURRENCY = "ETB";
|
||||
|
||||
@Injectable()
|
||||
export class PaymentService {
|
||||
private strategies: Map<PaymentMethod, PaymentStrategy>;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly datasource: DataSource,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
|
||||
this.strategies = new Map([
|
||||
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
|
||||
])
|
||||
}
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
) { }
|
||||
|
||||
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<{
|
||||
refId: string,
|
||||
clientAction: ClientAction,
|
||||
status: PaymentEntity["status"],
|
||||
paidAt?: string,
|
||||
failureCode?: string,
|
||||
failureMessage?: string,
|
||||
}> {
|
||||
async initBookingTelebirr(
|
||||
bookingId: string,
|
||||
platform: PaymentPlatformDto,
|
||||
): Promise<{ redirectUrl: string }> {
|
||||
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
|
||||
// if (!booking) throw new NotFoundException("Booking not found");
|
||||
|
||||
// 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)
|
||||
if (!strategy) {
|
||||
throw new NotFoundException("strategy not found")
|
||||
}
|
||||
|
||||
const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
|
||||
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({
|
||||
const input: ProviderInitiationInput = {
|
||||
merchantOrderId,
|
||||
orderRef: bookingId,
|
||||
amountMinor,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
platform: platform || "web",
|
||||
redirectUrl,
|
||||
amountMinor: amount,
|
||||
currency: currency,
|
||||
merchantOrderId: orderId,
|
||||
platform: payform,
|
||||
};
|
||||
|
||||
const result = await this.telebirrProvider.initiate(input);
|
||||
|
||||
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()
|
||||
await queryRunner.connect()
|
||||
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()
|
||||
return {
|
||||
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
|
||||
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
|
||||
}
|
||||
|
||||
|
||||
async genReceiptHtml(orderId: string) {
|
||||
const payment = await this.paymentRepo.findOneBy({
|
||||
merchantOrderId: orderId,
|
||||
@@ -141,13 +120,9 @@ export class PaymentService {
|
||||
if (!resp) {
|
||||
throw new NotFoundException("order id not found")
|
||||
}
|
||||
const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId)
|
||||
const bizContent = result.rawResponse.biz_content as {
|
||||
order_status: string;
|
||||
};
|
||||
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
|
||||
|
||||
const ordersStatus = bizContent.order_status
|
||||
if (ordersStatus == "PAY_SUCCESS") {
|
||||
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||
await this.datasource.transaction(async (mg) => {
|
||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal file
62
apps/edr-freight-api/src/modules/payment/payments.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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') ?? '';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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')}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,82 +1,53 @@
|
||||
import { Injectable, } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as crypto from "crypto"
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||
import { PaymentRepository } from '../../payment.repository';
|
||||
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()
|
||||
export class TelebirrWebhookService {
|
||||
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||
|
||||
constructor(
|
||||
private readonly datasource: DataSource,
|
||||
private readonly config: ConfigService,
|
||||
private readonly paymentRepo: PaymentRepository,
|
||||
|
||||
private readonly telebirrProvider: TelebirrProvider,
|
||||
) { }
|
||||
|
||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||
// 1. Extract the signature provided by Telebirr
|
||||
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;
|
||||
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
async handle(payload: TelebirrDto): Promise<void> {
|
||||
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
||||
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":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
|
||||
switch (payment.type) {
|
||||
case "booking":
|
||||
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
|
||||
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
||||
break;
|
||||
|
||||
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
|
||||
|
||||
switch (mapped) {
|
||||
case ProviderPaymentStatus.SUCCEEDED:
|
||||
await this.paymentRepo.update(
|
||||
{ id: payment.id },
|
||||
{ status: "success", paidAt: new Date() },
|
||||
);
|
||||
if (payment.type === "booking") {
|
||||
await this.datasource.manager.update(
|
||||
Booking,
|
||||
{ id: payment.refId },
|
||||
{ paymentStatus: "PAID" },
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "FAILED":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
|
||||
case ProviderPaymentStatus.FAILED:
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
|
||||
break;
|
||||
case "CANCELLED":
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
|
||||
case ProviderPaymentStatus.PROCESSING:
|
||||
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
|
||||
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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -22,14 +22,12 @@ export class WebhookController {
|
||||
);
|
||||
|
||||
try {
|
||||
// const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||
// if (!verified) {
|
||||
// throw new Error("not valid")
|
||||
// }
|
||||
// const merchantOrderId = payload.merch_order_id;
|
||||
const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||
if (!verified) {
|
||||
throw new Error("Telebirr webhook signature verification failed")
|
||||
}
|
||||
await this.telebirr.handle(payload);
|
||||
|
||||
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateRouteDto } from './create-route.dto';
|
||||
|
||||
export class UpdateRouteDto extends PartialType(CreateRouteDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal file
44
apps/edr-freight-api/src/modules/routes/routes.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal file
18
apps/edr-freight-api/src/modules/routes/routes.module.ts
Normal 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 {}
|
||||
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal file
13
apps/edr-freight-api/src/modules/routes/routes.repository.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal file
171
apps/edr-freight-api/src/modules/routes/routes.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||
|
||||
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 { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||
|
||||
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
|
||||
@JoinColumn({ name: 'train_set_id' })
|
||||
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' })
|
||||
originStationId!: string;
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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' })
|
||||
@IsUUID()
|
||||
locomotiveId!: string;
|
||||
|
||||
@@ -17,6 +17,7 @@ const locomotive = {
|
||||
id: 'loc-1',
|
||||
code: 'LOC-001',
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
};
|
||||
|
||||
@@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
|
||||
it('creates a schedule transactionally when validation passes', async () => {
|
||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
|
||||
const validation = {
|
||||
valid: true,
|
||||
violations: [],
|
||||
bookings,
|
||||
wagonType: nw5,
|
||||
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 route = {
|
||||
id: 'route-1',
|
||||
name: 'Djibouti to Addis',
|
||||
originYardId: 'yard-origin',
|
||||
destinationYardId: 'yard-destination',
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const lockedLocomotiveRepo = {
|
||||
@@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
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 = {
|
||||
create: jest.fn().mockImplementation((value) => value),
|
||||
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
||||
@@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => {
|
||||
return lockedLocomotiveRepo;
|
||||
case 'TrainSchedule':
|
||||
return trainScheduleRepo;
|
||||
case 'TrainScheduleBooking':
|
||||
return trainScheduleBookingRepo;
|
||||
case 'TrainSetWagon':
|
||||
return trainSetWagonRepo;
|
||||
case 'WagonBookingAllocation':
|
||||
return wagonAllocRepo;
|
||||
case 'TrainSet':
|
||||
return trainSetRepo;
|
||||
default:
|
||||
@@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation 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);
|
||||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||
callback(manager),
|
||||
);
|
||||
|
||||
const result = await service.createContainerTrainSchedule({
|
||||
bookingIds: ['b1'],
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
locomotiveId: 'loc-1',
|
||||
});
|
||||
|
||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||
expect(trainSetWagonRepo.save).toHaveBeenCalled();
|
||||
expect(wagonAllocRepo.save).toHaveBeenCalled();
|
||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
||||
expect(result).toEqual({ id: 'schedule-1' });
|
||||
});
|
||||
|
||||
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 = {
|
||||
getRepository: jest.fn(() => ({
|
||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
||||
})),
|
||||
};
|
||||
|
||||
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation 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>) =>
|
||||
callback(manager),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.createContainerTrainSchedule({
|
||||
bookingIds: ['b1'],
|
||||
routeId: 'route-1',
|
||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||
originStationId: 'yard-origin',
|
||||
destinationStationId: 'yard-destination',
|
||||
locomotiveId: 'loc-1',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ConflictException);
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
|
||||
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.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 { 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 { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
|
||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||
@@ -179,18 +179,12 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||
const validation = await this.validateContainerBookingsForScheduling(dto);
|
||||
|
||||
if (!validation.valid) {
|
||||
throw new BadRequestException({
|
||||
message: "train_schedule_invalid",
|
||||
violations: validation.violations,
|
||||
});
|
||||
}
|
||||
const route = await this.getActiveRoute(dto.routeId);
|
||||
|
||||
const locomotive = await this.selectOrValidateLocomotive(
|
||||
dto.locomotiveId,
|
||||
validation.summary.totalWeightTons,
|
||||
0,
|
||||
0,
|
||||
);
|
||||
|
||||
const createdSchedule = await this.dataSource.transaction(
|
||||
@@ -211,90 +205,24 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
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(
|
||||
const trainSet = await this.buildEmptyTrainSet(
|
||||
manager,
|
||||
lockedLocomotive,
|
||||
validation.wagonType,
|
||||
validation.summary.totalWeightTons,
|
||||
validation.summary.totalLengthMeters,
|
||||
validation.wagonPlan,
|
||||
);
|
||||
|
||||
const schedule = manager.getRepository(TrainSchedule).create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
routeId: route.id,
|
||||
originStationId: route.originYardId,
|
||||
destinationStationId: route.destinationYardId,
|
||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||
status: "SCHEDULED",
|
||||
status: "DRAFT",
|
||||
});
|
||||
|
||||
const savedSchedule = await manager
|
||||
.getRepository(TrainSchedule)
|
||||
.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, {
|
||||
status: "ASSIGNED",
|
||||
});
|
||||
@@ -461,10 +389,14 @@ export class TrainSchedulingService {
|
||||
where: { status: "AVAILABLE" },
|
||||
});
|
||||
const canPull = capableLocomotives.some(
|
||||
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
||||
(locomotive) =>
|
||||
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
|
||||
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
|
||||
);
|
||||
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(
|
||||
locomotiveId: string,
|
||||
totalWeightTons: number,
|
||||
totalLengthMeters: number,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -568,6 +507,21 @@ export class TrainSchedulingService {
|
||||
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(
|
||||
bookings: Booking[],
|
||||
baseWagonPlan: WagonPlanRecord[],
|
||||
@@ -627,6 +581,7 @@ export class TrainSchedulingService {
|
||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
relations: {
|
||||
trainSet: { locomotive: true },
|
||||
route: true,
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: true,
|
||||
@@ -637,6 +592,7 @@ export class TrainSchedulingService {
|
||||
return schedules.map((schedule) => ({
|
||||
id: schedule.id,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
routeName: schedule.route?.name ?? null,
|
||||
origin:
|
||||
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
@@ -668,6 +624,7 @@ export class TrainSchedulingService {
|
||||
.findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
route: true,
|
||||
trainSet: {
|
||||
locomotive: true,
|
||||
wagons: { wagonType: true, allocations: { booking: true } },
|
||||
@@ -687,6 +644,12 @@ export class TrainSchedulingService {
|
||||
return {
|
||||
id: schedule.id,
|
||||
status: schedule.status,
|
||||
route: schedule.route
|
||||
? {
|
||||
id: schedule.route.id,
|
||||
name: schedule.route.name,
|
||||
}
|
||||
: null,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||
originStation: schedule.originStation,
|
||||
@@ -711,6 +674,9 @@ export class TrainSchedulingService {
|
||||
maxPullWeightTons: this.roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||
),
|
||||
maxTrainLengthMeters: this.roundTons(
|
||||
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
|
||||
),
|
||||
}
|
||||
: null,
|
||||
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) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return date.toISOString().slice(0, 10);
|
||||
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
const toNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? value : Number(value);
|
||||
|
||||
const toOptionalNumber = ({ value }: { value: unknown }) =>
|
||||
value === '' || value == null ? undefined : Number(value);
|
||||
|
||||
const toBoolean = ({ value }: { value: unknown }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true') return true;
|
||||
@@ -22,8 +25,12 @@ const toBoolean = ({ 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 [];
|
||||
|
||||
return value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
@@ -31,36 +38,40 @@ const toStringArray = ({ value }: { value: unknown }) => {
|
||||
};
|
||||
|
||||
export class CreateWagonTypeDto {
|
||||
@ApiProperty({ maxLength: 32, example: 'FLAT' })
|
||||
@ApiProperty({ maxLength: 32, example: 'NW5' })
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 100, example: 'Flat wagon' })
|
||||
@ApiProperty({ maxLength: 100, example: 'Flat wagon container' })
|
||||
@IsString()
|
||||
@MaxLength(100)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ example: 60 })
|
||||
@ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 })
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Min(0.001)
|
||||
capacityTons!: number;
|
||||
|
||||
@ApiProperty({ example: 14.2 })
|
||||
@ApiProperty({ description: 'Wagon length in meters', example: 14 })
|
||||
@Transform(toNumber)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Min(0.001)
|
||||
lengthMeters!: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 45 })
|
||||
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
|
||||
@IsOptional()
|
||||
@Transform(toNumber)
|
||||
@Transform(toOptionalNumber)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxWagonsPerTrain?: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] })
|
||||
@ApiPropertyOptional({
|
||||
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||
type: [String],
|
||||
default: [],
|
||||
})
|
||||
@IsOptional()
|
||||
@Transform(toStringArray)
|
||||
@IsArray()
|
||||
|
||||
@@ -11,48 +11,64 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
} 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 { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||
import { WagonTypesService } from './wagon-types.service';
|
||||
import { WagonType } from './entities/wagon-type.entity';
|
||||
|
||||
@ApiTags('Wagon Types')
|
||||
@ApiTags('wagon-types')
|
||||
@Controller('wagon-types')
|
||||
@ApiBearerAuth()
|
||||
export class WagonTypesController {
|
||||
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()
|
||||
@ApiOperation({ summary: 'Get wagon types' })
|
||||
async findAll(@Query() query: Record<string, string | undefined>): Promise<WagonType[]> {
|
||||
return this.wagonTypesService.findAll(query);
|
||||
@RuleEngineView('wagon-types')
|
||||
@ApiOperation({ summary: 'List wagon types' })
|
||||
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')
|
||||
@RuleEngineView('wagon-types')
|
||||
@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);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RuleEngineManage('wagon-types')
|
||||
@ApiOperation({ summary: 'Create a wagon type' })
|
||||
create(@Body() dto: CreateWagonTypeDto) {
|
||||
return this.wagonTypesService.create(dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@RuleEngineManage('wagon-types')
|
||||
@ApiOperation({ summary: 'Update a wagon type' })
|
||||
async update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateWagonTypeDto,
|
||||
): Promise<WagonType> {
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
||||
return this.wagonTypesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RuleEngineManage('wagon-types')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Deactivate a wagon type' })
|
||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
||||
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
||||
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.wagonTypesService.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByCode(code: string): Promise<WagonType | null> {
|
||||
return this.repository.findOne({ where: { code } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,44 +6,47 @@ import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||
import { WagonType } from './entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from './wagon-types.repository';
|
||||
|
||||
type WagonTypeListFilter = {
|
||||
isActive?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class WagonTypesService {
|
||||
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||
|
||||
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
|
||||
const code = dto.code.trim().toUpperCase();
|
||||
const existing = await this.wagonTypesRepository.findAll({ where: { code } });
|
||||
if (existing.length > 0) {
|
||||
throw new ConflictException(`Wagon type code "${code}" already exists`);
|
||||
}
|
||||
|
||||
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';
|
||||
async findAll(filter: WagonTypeListFilter = {}): Promise<{
|
||||
data: WagonType[];
|
||||
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||
}> {
|
||||
const page = filter.page ?? 1;
|
||||
const pageSize = filter.pageSize ?? 500;
|
||||
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
|
||||
query.sortBy ?? '',
|
||||
filter.sortBy ?? '',
|
||||
)
|
||||
? (query.sortBy as keyof WagonType)
|
||||
? (filter.sortBy as keyof WagonType)
|
||||
: 'code';
|
||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||
|
||||
return this.wagonTypesRepository.findAll({
|
||||
where: isActive === undefined ? {} : { isActive },
|
||||
const [data, total] = await this.wagonTypesRepository.findAndCount({
|
||||
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||
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> {
|
||||
@@ -57,22 +60,39 @@ export class WagonTypesService {
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<WagonType> {
|
||||
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
|
||||
|
||||
const wagonType = await this.wagonTypesRepository.findByCode(code);
|
||||
if (!wagonType) {
|
||||
throw new NotFoundException(`Wagon type ${code} not found`);
|
||||
}
|
||||
|
||||
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> {
|
||||
const wagonType = await this.findById(id);
|
||||
const nextCode = dto.code?.trim().toUpperCase();
|
||||
|
||||
if (nextCode && nextCode !== wagonType.code) {
|
||||
const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } });
|
||||
if (existing.length > 0) {
|
||||
const existing = await this.wagonTypesRepository.findByCode(nextCode);
|
||||
if (existing) {
|
||||
throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +101,9 @@ export class WagonTypesService {
|
||||
...dto,
|
||||
...(nextCode ? { code: nextCode } : {}),
|
||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||
maxWagonsPerTrain:
|
||||
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
|
||||
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
@@ -92,6 +115,6 @@ export class WagonTypesService {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
await this.wagonTypesRepository.update(id, { isActive: false });
|
||||
await this.wagonTypesRepository.softDelete(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,13 +178,17 @@ export class DemoBookingsSeeder {
|
||||
{
|
||||
code: "LOC-001",
|
||||
name: "Demo Locomotive 1",
|
||||
locomotiveType: 'ELECTRIC',
|
||||
maxPullWeightTons: 3500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
{
|
||||
code: "LOC-002",
|
||||
name: "Demo Locomotive 2",
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: "AVAILABLE",
|
||||
},
|
||||
],
|
||||
|
||||
@@ -10,6 +10,7 @@ export type FreightPermissionSeed = {
|
||||
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||
'cargo-types',
|
||||
'container-types',
|
||||
'wagon-types',
|
||||
'service-types',
|
||||
'yards',
|
||||
'shipping-lines',
|
||||
@@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
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' },
|
||||
'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' },
|
||||
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' },
|
||||
|
||||
@@ -353,6 +353,8 @@ export class PricingDataSeeder {
|
||||
): Promise<Rate[]> {
|
||||
const effectiveFrom = new Date("2026-01-01");
|
||||
const now = new Date();
|
||||
// await rRepo.createQueryBuilder().delete().execute();
|
||||
|
||||
const rateData = [
|
||||
{
|
||||
rateType: "CONTAINER_IMPORT",
|
||||
|
||||
@@ -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;"]
|
||||
@@ -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;"]
|
||||
@@ -1,6 +1,15 @@
|
||||
@import "tailwindcss";
|
||||
@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,
|
||||
body,
|
||||
#root {
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
"dependencies": {
|
||||
"@edr/types": "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",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui-common": "1.1.2",
|
||||
|
||||
@@ -34,16 +34,18 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import {
|
||||
import TrainsPage from "./pages/trains/TrainsPage";
|
||||
import {
|
||||
CargoesCrudPage,
|
||||
ContainersCrudPage,
|
||||
LocomotivesCrudPage,
|
||||
TrainMasterDataPage,
|
||||
WagonTypesCrudPage,
|
||||
WagonsCrudPage,
|
||||
} from "./pages/fleet/FleetCrudPages";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||
|
||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
@@ -60,17 +62,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Train scheduling",
|
||||
label: "Train Schedules",
|
||||
href: "/dashboard/operations/train-scheduling",
|
||||
icon: <Train />,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Fleet Management",
|
||||
items: [
|
||||
{
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
},
|
||||
{
|
||||
label: "Trains",
|
||||
href: "/dashboard/trains",
|
||||
@@ -225,17 +242,19 @@ const App = () => {
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||
<Route path="routes" element={<RoutesPage />} />
|
||||
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||
<Route path="wagon-types" element={<WagonTypesCrudPage />} />
|
||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||
<Route path="containers" element={<ContainersCrudPage />} />
|
||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||
<Route path="containers" element={<ContainersCrudPage />} />
|
||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
@@ -11,9 +12,7 @@ import {
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
||||
import { Badge, Button } from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
|
||||
@@ -26,23 +25,16 @@ interface ApprovalStepsCardProps {
|
||||
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
||||
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(booking.approvalSteps ?? [])].sort(
|
||||
(a, b) => a.stepOrder - b.stepOrder,
|
||||
),
|
||||
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
||||
[booking.approvalSteps],
|
||||
);
|
||||
|
||||
const nextPending = getNextPendingApprovalStep(steps);
|
||||
const summary = formatApprovalProgress(booking.status, steps);
|
||||
const pendingAction = pendingStep
|
||||
? buildApproveActionForStep(pendingStep)
|
||||
: null;
|
||||
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
||||
|
||||
const openApprove = (step: BookingApprovalStep) => {
|
||||
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 (
|
||||
<>
|
||||
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>
|
||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Approval chain
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{summary.detail ||
|
||||
(nextPending
|
||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||
: steps.length
|
||||
? "All steps complete"
|
||||
: "Accept submission to begin")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
<div className="px-5 py-5">
|
||||
{steps.length === 0 ? (
|
||||
<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">
|
||||
Use{" "}
|
||||
<strong className="font-semibold text-foreground">
|
||||
Accept for approval
|
||||
</strong>{" "}
|
||||
in staff actions to instantiate steps.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
steps={steps}
|
||||
user={user}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={openApprove}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
steps={steps}
|
||||
user={user}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={openApprove}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<BookingConfirmDialog
|
||||
open={confirmOpen}
|
||||
@@ -144,64 +142,76 @@ function StepRow({
|
||||
onApprove: (step: BookingApprovalStep) => void;
|
||||
}) {
|
||||
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||
const statusStyles =
|
||||
const statusColor =
|
||||
step.status === "APPROVED"
|
||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||
? "green"
|
||||
: step.status === "REJECTED"
|
||||
? "bg-red-500/10 text-red-800 dark:text-red-300"
|
||||
? "red"
|
||||
: isNext
|
||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
||||
: "bg-muted/40 text-muted-foreground";
|
||||
? "green"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
||||
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
|
||||
)}
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
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">
|
||||
<span
|
||||
className={cn(
|
||||
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
||||
isNext
|
||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
||||
: "bg-muted/40 text-muted-foreground",
|
||||
)}
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
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}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-foreground">
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{step.requiredRole}
|
||||
</p>
|
||||
</Text>
|
||||
{step.remarks && (
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{step.remarks}
|
||||
</p>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{canApprove && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 shadow-sm"
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
leftSection={<Check size={14} />}
|
||||
disabled={isPending}
|
||||
onClick={() => onApprove(step)}
|
||||
>
|
||||
<Check className="size-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
||||
>
|
||||
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
||||
{step.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
} from "lucide-react";
|
||||
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
|
||||
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||
|
||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||
@@ -16,16 +12,6 @@ import {
|
||||
type BookingActionContext,
|
||||
} from "@/features/bookings/booking-actions.config";
|
||||
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 {
|
||||
row: BookingListRow;
|
||||
@@ -39,7 +25,6 @@ interface BookingActionsMenuProps {
|
||||
export function BookingActionsMenu({
|
||||
row,
|
||||
variant = "table",
|
||||
className,
|
||||
onSuppressRowClick,
|
||||
}: BookingActionsMenuProps) {
|
||||
const navigate = useNavigate();
|
||||
@@ -57,184 +42,179 @@ export function BookingActionsMenu({
|
||||
const goToContract = () =>
|
||||
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];
|
||||
|
||||
if (!hasMenu && variant === "table") {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8 text-muted-foreground hover:text-primary"
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||
aria-label="View booking"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<ChevronRight size={16} />
|
||||
</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 (
|
||||
<>
|
||||
<div
|
||||
data-stop-row-click
|
||||
className={cn(
|
||||
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
|
||||
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
||||
className,
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{variant === "table" && primary && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
||||
disabled={mutations.isPending}
|
||||
onClick={() =>
|
||||
isContractNavAction(primary.id)
|
||||
? goToContract()
|
||||
: flow.openAction(primary)
|
||||
}
|
||||
<Group
|
||||
gap={4}
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
data-stop-row-click
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{variant === "table" && primary && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
visibleFrom="lg"
|
||||
leftSection={<primary.icon size={14} />}
|
||||
disabled={mutations.isPending}
|
||||
onClick={() => handleAction(primary)}
|
||||
>
|
||||
{primary.shortLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<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" />
|
||||
{primary.shortLabel}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{variant === "toolbar" && actions.length > 0 ? (
|
||||
<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">
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Label>
|
||||
<Text size="xs" ff="monospace" c="dimmed">
|
||||
{row.reference}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={action.id}
|
||||
className={cn(
|
||||
"gap-2 cursor-pointer",
|
||||
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
||||
)}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onSuppressRowClick?.();
|
||||
if (isContractNavAction(action.id)) {
|
||||
goToContract();
|
||||
} else {
|
||||
flow.openAction(action);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon className="size-4 opacity-70" />
|
||||
<span>{action.label}</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
{actions.length > 0 && <DropdownMenuSeparator />}
|
||||
<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>
|
||||
</Text>
|
||||
</Menu.Label>
|
||||
{actions.map((action) => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={action.id}
|
||||
color={action.variant === "destructive" ? "red" : undefined}
|
||||
leftSection={<Icon size={15} />}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
{actions.length > 0 && <Menu.Divider />}
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={15} />}
|
||||
onClick={() => {
|
||||
onSuppressRowClick?.();
|
||||
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||
}}
|
||||
>
|
||||
Open full details
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
<BookingConfirmDialog
|
||||
open={flow.dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onSuppressRowClick?.();
|
||||
flow.setDialogOpen(open);
|
||||
}}
|
||||
action={pendingAction}
|
||||
reference={flow.mergedContext.reference}
|
||||
inputValue={flow.inputValue}
|
||||
onInputChange={flow.setInputValue}
|
||||
selectedFile={flow.selectedFile}
|
||||
onFileChange={flow.setSelectedFile}
|
||||
onConfirm={() => {
|
||||
onSuppressRowClick?.();
|
||||
flow.runAction();
|
||||
}}
|
||||
isPending={mutations.isPending || flow.detailLoading}
|
||||
confirmDisabled={flow.confirmDisabled}
|
||||
extra={
|
||||
flow.detailLoading ? (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading approval steps…
|
||||
</p>
|
||||
) : pendingAction?.id === "approve" &&
|
||||
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||
<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">
|
||||
No pending approval step. Refresh the page after staff accept, or
|
||||
reject the booking.
|
||||
</p>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionDialog({
|
||||
flow,
|
||||
pendingAction,
|
||||
onSuppressRowClick,
|
||||
}: {
|
||||
flow: ReturnType<typeof useBookingActionDialog>;
|
||||
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
||||
onSuppressRowClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<BookingConfirmDialog
|
||||
open={flow.dialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onSuppressRowClick?.();
|
||||
flow.setDialogOpen(open);
|
||||
}}
|
||||
action={pendingAction}
|
||||
reference={flow.mergedContext.reference}
|
||||
inputValue={flow.inputValue}
|
||||
onInputChange={flow.setInputValue}
|
||||
selectedFile={flow.selectedFile}
|
||||
onFileChange={flow.setSelectedFile}
|
||||
onConfirm={() => {
|
||||
onSuppressRowClick?.();
|
||||
flow.runAction();
|
||||
}}
|
||||
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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { bookingSurface } from "./booking-ui.styles";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
import { Button } from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
|
||||
@@ -16,10 +15,7 @@ interface BookingActionsToolbarProps {
|
||||
}
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({
|
||||
booking,
|
||||
mutations,
|
||||
}: BookingActionsToolbarProps) {
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
|
||||
@@ -33,50 +29,84 @@ export function BookingActionsToolbar({
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
if (
|
||||
status === "REJECTED" ||
|
||||
status === "CANCELLED" ||
|
||||
status === "COMPLETED"
|
||||
) {
|
||||
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === "CHANGES_REQUESTED") {
|
||||
return (
|
||||
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
||||
{booking.latestChangeRequestNote && (
|
||||
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
|
||||
{booking.latestChangeRequestNote}
|
||||
</p>
|
||||
)}
|
||||
</PanelShell>
|
||||
<SectionCard icon={Zap} title="Awaiting customer">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions until resubmit.
|
||||
</Text>
|
||||
{booking.latestChangeRequestNote && (
|
||||
<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)) {
|
||||
return (
|
||||
<PanelShell
|
||||
title="No staff actions"
|
||||
description="Monitor until the customer or system advances status."
|
||||
muted
|
||||
/>
|
||||
<SectionCard icon={Zap} title="No staff actions">
|
||||
<Text size="sm" c="dimmed">
|
||||
Monitor until the customer or system advances status.
|
||||
</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 (
|
||||
<div className="space-y-4">
|
||||
<PanelShell
|
||||
title="Staff actions"
|
||||
description="Confirm each step before it is applied."
|
||||
>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</PanelShell>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
<Stack gap="sm">
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{status === "CONTRACT_READY" && (
|
||||
<PanelShell title="Documents" description="Download generated contract.">
|
||||
<SectionCard icon={FileText} title="Documents">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
||||
variant="default"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() =>
|
||||
downloadBlob(
|
||||
() => mutations.downloadContract(),
|
||||
@@ -84,38 +114,10 @@ export function BookingActionsToolbar({
|
||||
)
|
||||
}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
Download contract
|
||||
</Button>
|
||||
</PanelShell>
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell
|
||||
<p
|
||||
className={cn(
|
||||
"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}
|
||||
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Textarea,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
interface BookingConfirmDialogProps {
|
||||
open: boolean;
|
||||
@@ -25,7 +24,7 @@ interface BookingConfirmDialogProps {
|
||||
onConfirm: () => void;
|
||||
isPending: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
extra?: React.ReactNode;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
export function BookingConfirmDialog({
|
||||
@@ -45,129 +44,119 @@ export function BookingConfirmDialog({
|
||||
if (!action || !action.confirmTitle) return null;
|
||||
|
||||
const Icon = action.icon;
|
||||
const needsTextInput =
|
||||
action.input === "note" || action.input === "reason";
|
||||
const needsTextInput = action.input === "note" || action.input === "reason";
|
||||
const needsFileInput = action.input === "file";
|
||||
const inputMissing =
|
||||
(needsTextInput && !inputValue.trim()) ||
|
||||
(needsFileInput && !selectedFile);
|
||||
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
|
||||
const isDestructive = action.variant === "destructive";
|
||||
|
||||
const preventClickThrough = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
const accent = isDestructive ? "red" : "green";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="gap-0 overflow-hidden p-0 sm:max-w-md"
|
||||
showCloseButton={false}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
<Modal
|
||||
opened={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
withCloseButton={false}
|
||||
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
|
||||
className={cn(
|
||||
"border-b px-6 py-5",
|
||||
isDestructive
|
||||
? "bg-gradient-to-br from-red-500/10 via-background to-background"
|
||||
: "bg-gradient-to-br from-primary/8 via-background to-background",
|
||||
)}
|
||||
>
|
||||
<DialogHeader className="gap-3 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"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)}
|
||||
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
flexShrink: 0,
|
||||
background: `var(--mantine-color-${accent}-1)`,
|
||||
color: `var(--mantine-color-${accent}-7)`,
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={isDestructive ? "destructive" : "default"}
|
||||
disabled={isPending || inputMissing || confirmDisabled}
|
||||
className="min-w-[7rem] gap-2"
|
||||
onMouseDown={preventClickThrough}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
<Icon size={20} />
|
||||
</Box>
|
||||
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||
<Text fw={700} size="md" style={{ lineHeight: 1.3 }}>
|
||||
{action.confirmTitle}
|
||||
</Text>
|
||||
{reference && (
|
||||
<Text size="xs" c="dimmed" ff="monospace" fw={600}>
|
||||
{reference}
|
||||
</Text>
|
||||
)}
|
||||
{action.shortLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Stack>
|
||||
</Group>
|
||||
{action.confirmDescription && (
|
||||
<Text size="sm" c="dimmed" mt="sm" style={{ lineHeight: 1.5 }}>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,86 +1,93 @@
|
||||
import { Banknote, Receipt } from "lucide-react";
|
||||
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||
|
||||
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 }) {
|
||||
const amount = Number(booking.totalAmount);
|
||||
const modifiers = booking.cargoModifiers ?? [];
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>
|
||||
<Banknote className="size-4" strokeWidth={1.75} />
|
||||
</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">
|
||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
Total amount
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
|
||||
</Text>
|
||||
<Text
|
||||
size="xl"
|
||||
fw={700}
|
||||
c="green.9"
|
||||
mt={4}
|
||||
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||
>
|
||||
{booking.paymentCurrency}{" "}
|
||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Row label="Payment status" value={booking.paymentStatus} />
|
||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||
|
||||
{modifiers.length > 0 && (
|
||||
<>
|
||||
<Separator className="opacity-50" />
|
||||
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<Receipt className="size-3" />
|
||||
Surcharges applied
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
<Divider color="var(--mantine-color-gray-2)" />
|
||||
<Group gap={6}>
|
||||
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
||||
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||
Surcharges applied
|
||||
</Text>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
{modifiers.map((m) => (
|
||||
<li
|
||||
<Group
|
||||
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>
|
||||
<span className="font-mono font-semibold tabular-nums">
|
||||
<Text size="sm" c="dimmed">
|
||||
Modifier
|
||||
</Text>
|
||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||
{Number(m.calculatedAmount).toLocaleString()}
|
||||
</span>
|
||||
</li>
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
</ul>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
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">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span
|
||||
className={
|
||||
mono
|
||||
? "font-mono text-xs font-semibold text-foreground"
|
||||
: "font-medium text-foreground"
|
||||
}
|
||||
>
|
||||
<Group
|
||||
justify="space-between"
|
||||
px="sm"
|
||||
py="xs"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
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}
|
||||
</span>
|
||||
</div>
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Badge } from "@mantine/core";
|
||||
|
||||
export function BookingPriorityBadge({ score }: { score: number }) {
|
||||
if (score >= 1000) {
|
||||
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
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (score >= 500) {
|
||||
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
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
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
|
||||
</span>
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { bookingGlass } from "./booking-ui.styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, Group, Stack, Text, Paper } from "@mantine/core";
|
||||
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
@@ -10,54 +9,100 @@ export interface StatItem {
|
||||
accent?: "default" | "amber" | "emerald" | "rose";
|
||||
}
|
||||
|
||||
const iconAccentStyles = {
|
||||
default: "text-foreground/70",
|
||||
amber: "text-amber-600 dark:text-amber-400",
|
||||
emerald: "text-emerald-600 dark:text-emerald-400",
|
||||
rose: "text-rose-600 dark:text-rose-400",
|
||||
const accentColors = {
|
||||
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
|
||||
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
|
||||
emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
|
||||
rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
|
||||
};
|
||||
|
||||
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const accent = item.accent ?? "default";
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
className={cn(
|
||||
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
|
||||
bookingGlass.card,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{item.label}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
|
||||
{item.value}
|
||||
</p>
|
||||
{item.hint && (
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{item.hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
|
||||
bookingGlass.iconWellGreen,
|
||||
iconAccentStyles[accent],
|
||||
)}
|
||||
>
|
||||
<Icon className="size-[18px]" strokeWidth={1.75} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Paper
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollBehavior: "smooth",
|
||||
}}
|
||||
>
|
||||
<Group
|
||||
gap="lg"
|
||||
style={{
|
||||
minWidth: "min-content",
|
||||
display: "flex",
|
||||
flexWrap: "nowrap",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const accent = item.accent ?? "default";
|
||||
const accentStyle = accentColors[accent];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={item.label}
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
transition: "all 0.2s ease",
|
||||
cursor: "pointer",
|
||||
minWidth: "280px",
|
||||
width: "280px",
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
import { Badge } from "@edr/ui-common";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@mantine/core";
|
||||
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 }) {
|
||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||
label: status,
|
||||
color: "bg-muted text-muted-foreground border-border",
|
||||
color: "gray",
|
||||
};
|
||||
const color = statusColorMap[status] ?? "gray";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
|
||||
style.color,
|
||||
)}
|
||||
color={color}
|
||||
variant="light"
|
||||
size="sm"
|
||||
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}
|
||||
</Badge>
|
||||
|
||||
@@ -8,27 +8,24 @@ import {
|
||||
Wallet,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
|
||||
|
||||
import {
|
||||
BOOKING_LIST_TABS,
|
||||
type BookingStatusTabKey,
|
||||
} from "@/features/bookings/booking-status.config";
|
||||
import { bookingGlass } from "./booking-ui.styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
||||
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
|
||||
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
|
||||
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
|
||||
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
|
||||
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
|
||||
operations: <Train className="size-3.5" strokeWidth={1.75} />,
|
||||
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
|
||||
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
|
||||
all: <LayoutGrid size={18} strokeWidth={1.75} />,
|
||||
intake: <Inbox size={18} strokeWidth={1.75} />,
|
||||
in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
|
||||
approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
|
||||
payment: <Wallet size={18} strokeWidth={1.75} />,
|
||||
operations: <Train size={18} strokeWidth={1.75} />,
|
||||
completed: <CheckCircle size={18} strokeWidth={1.75} />,
|
||||
closed: <XCircle size={18} strokeWidth={1.75} />,
|
||||
};
|
||||
|
||||
const activeTabText = "text-black";
|
||||
|
||||
interface BookingStatusTabsProps {
|
||||
active: BookingStatusTabKey;
|
||||
onChange: (tab: BookingStatusTabKey) => void;
|
||||
@@ -41,66 +38,74 @@ export function BookingStatusTabs({
|
||||
counts,
|
||||
}: BookingStatusTabsProps) {
|
||||
return (
|
||||
<div className={bookingGlass.tabRail}>
|
||||
<div
|
||||
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
role="tablist"
|
||||
aria-label="Booking status filters"
|
||||
>
|
||||
{BOOKING_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
onClick={() => onChange(tab.key)}
|
||||
className={cn(
|
||||
"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",
|
||||
isActive
|
||||
? bookingGlass.activeTab
|
||||
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center justify-between gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm font-medium",
|
||||
isActive ? activeTabText : "text-muted-foreground",
|
||||
)}
|
||||
<Group
|
||||
gap="sm"
|
||||
wrap="nowrap"
|
||||
p="md"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
borderRadius: "12px",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
overflowX: "auto",
|
||||
overflowY: "hidden",
|
||||
WebkitOverflowScrolling: "touch",
|
||||
scrollBehavior: "smooth",
|
||||
scrollbarWidth: "thin",
|
||||
}}
|
||||
>
|
||||
{BOOKING_LIST_TABS.map((tab) => {
|
||||
const isActive = active === tab.key;
|
||||
const count = counts?.[tab.key];
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={tab.key}
|
||||
onClick={() => onChange(tab.key)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: isActive ? "white" : "transparent",
|
||||
border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
|
||||
borderRadius: "10px",
|
||||
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
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
||||
isActive
|
||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
||||
: "border border-transparent bg-muted/30",
|
||||
)}
|
||||
>
|
||||
{TAB_ICONS[tab.key]}
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{tab.label}</span>
|
||||
</span>
|
||||
{count !== undefined && count > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
|
||||
isActive
|
||||
? cn("bg-emerald-500/15", activeTabText)
|
||||
: "bg-muted/50 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{TAB_ICONS[tab.key]}
|
||||
</div>
|
||||
<Text size="sm" fw={600}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Group>
|
||||
{count !== undefined && count > 0 && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant={isActive ? "filled" : "light"}
|
||||
color={isActive ? "green" : "gray"}
|
||||
radius="lg"
|
||||
>
|
||||
{count}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import {
|
||||
Check,
|
||||
CheckCircle2,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Train,
|
||||
Wallet,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getWorkflowStageIndex,
|
||||
WORKFLOW_STAGES,
|
||||
} 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 {
|
||||
status: string;
|
||||
@@ -27,104 +35,94 @@ export function BookingWorkflowStepper({
|
||||
status,
|
||||
title,
|
||||
description,
|
||||
titleColor,
|
||||
}: BookingWorkflowStepperProps) {
|
||||
const currentStage = getWorkflowStageIndex(status);
|
||||
const isTerminal = currentStage < 0;
|
||||
|
||||
return (
|
||||
<div className={bookingSurface.sectionCard}>
|
||||
<div className={bookingSurface.sectionHeader}>
|
||||
<div className={bookingSurface.sectionIcon}>
|
||||
<Train className="size-4" strokeWidth={1.75} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Workflow progress
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Customer submission through completion
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8 px-5 py-6">
|
||||
<div className="relative px-2">
|
||||
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" />
|
||||
<div
|
||||
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
|
||||
style={{
|
||||
width:
|
||||
!isTerminal && currentStage >= 0
|
||||
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
|
||||
: "0%",
|
||||
}}
|
||||
/>
|
||||
<div className="relative flex justify-between">
|
||||
{WORKFLOW_STAGES.map((stage, idx) => {
|
||||
const Icon = STAGE_ICONS[idx] ?? FileText;
|
||||
const isCompleted = !isTerminal && idx < currentStage;
|
||||
const isActive = !isTerminal && idx === currentStage;
|
||||
return (
|
||||
<div
|
||||
key={stage.label}
|
||||
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none"
|
||||
>
|
||||
<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",
|
||||
)}
|
||||
<SectionCard icon={Train} title="Workflow progress">
|
||||
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||
{WORKFLOW_STAGES.map((stage, index) => {
|
||||
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||
const isComplete = !isTerminal && index < currentStage;
|
||||
const isActive = !isTerminal && index === currentStage;
|
||||
const isLast = index === WORKFLOW_STAGES.length - 1;
|
||||
|
||||
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: 34,
|
||||
height: 34,
|
||||
borderRadius: "50%",
|
||||
background: isComplete
|
||||
? BRAND_GREEN
|
||||
: isActive
|
||||
? "white"
|
||||
: "var(--mantine-color-gray-1)",
|
||||
border: isActive
|
||||
? `2px solid ${BRAND_GREEN}`
|
||||
: isComplete
|
||||
? "2px solid transparent"
|
||||
: "2px solid var(--mantine-color-gray-2)",
|
||||
color: isComplete
|
||||
? "white"
|
||||
: isActive
|
||||
? "var(--freight-brand-dark)"
|
||||
: "var(--mantine-color-gray-5)",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle2 className="size-4" />
|
||||
) : (
|
||||
<Icon className="size-4" />
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
|
||||
isActive ? "text-black" : "text-muted-foreground",
|
||||
)}
|
||||
{isComplete ? <Check size={16} strokeWidth={3} /> : <Icon size={15} />}
|
||||
</Box>
|
||||
<Text
|
||||
size="xs"
|
||||
fw={isActive ? 600 : 500}
|
||||
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
|
||||
ta="center"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{stage.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
{!isLast && (
|
||||
<Box
|
||||
style={{
|
||||
flex: 1,
|
||||
height: 2,
|
||||
marginInline: 8,
|
||||
marginBottom: 20,
|
||||
borderRadius: 2,
|
||||
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border px-5 py-4 backdrop-blur-sm",
|
||||
isTerminal
|
||||
? "border-destructive/20 bg-destructive/5"
|
||||
: bookingGlass.activeTab,
|
||||
)}
|
||||
>
|
||||
<h4
|
||||
className={cn(
|
||||
"text-sm font-semibold tracking-tight",
|
||||
isTerminal ? titleColor : "text-black",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</h4>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Paper
|
||||
radius="md"
|
||||
withBorder
|
||||
p="md"
|
||||
style={
|
||||
isTerminal ? detailStyles.statusBannerTerminal : detailStyles.statusBanner
|
||||
}
|
||||
>
|
||||
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
{description}
|
||||
</Text>
|
||||
</Paper>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Alert, Text } from "@mantine/core";
|
||||
|
||||
import type { BookingNextStep } from "@/types/booking";
|
||||
import { bookingGlass } from "./booking-ui.styles";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface NextStepBannerProps {
|
||||
nextStep: BookingNextStep;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
|
||||
export function NextStepBanner({ nextStep }: NextStepBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
|
||||
bookingGlass.activeTab,
|
||||
className,
|
||||
)}
|
||||
role="status"
|
||||
>
|
||||
<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">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="gray"
|
||||
radius="md"
|
||||
icon={<ArrowRight size={16} />}
|
||||
title={
|
||||
<Text size="sm" fw={600}>
|
||||
Next: {nextStep.action.replace(/_/g, " ")}
|
||||
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{nextStep.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{nextStep.description}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
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:
|
||||
"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:
|
||||
"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:
|
||||
"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:
|
||||
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
||||
tableHeader:
|
||||
@@ -38,7 +38,7 @@ export const bookingSurface = {
|
||||
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}`,
|
||||
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",
|
||||
metricTile:
|
||||
"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 = {
|
||||
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;
|
||||
|
||||
export const bookingTable = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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";
|
||||
@@ -86,14 +86,8 @@ export function useBookingActionDialog(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "generateContract":
|
||||
mutations.generateContract.mutate(undefined, { onSuccess });
|
||||
break;
|
||||
case "viewContract":
|
||||
break;
|
||||
case "payBooking":
|
||||
mutations.payBooking.mutate(undefined, { onSuccess });
|
||||
break;
|
||||
case "startTransit":
|
||||
mutations.startTransit.mutate(undefined, { onSuccess });
|
||||
break;
|
||||
|
||||
@@ -9,13 +9,10 @@ import {
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
|
||||
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";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
export interface FreightDashboardHeaderProps {
|
||||
pageMeta: PageMeta;
|
||||
@@ -77,120 +74,146 @@ const FreightDashboardHeader = ({
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
return (
|
||||
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6">
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
|
||||
<header
|
||||
style={{
|
||||
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}
|
||||
</h1>
|
||||
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" truncate>
|
||||
{pageMeta.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{enableThemeToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{enableThemeToggle && (
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
onClick={onToggleTheme}
|
||||
aria-label={
|
||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||
}
|
||||
className={iconButtonClass}
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
}}
|
||||
>
|
||||
{theme === "dark" ? (
|
||||
<Sun className="h-5 w-5" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Change language"
|
||||
className={iconButtonClass}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
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" />
|
||||
</button>
|
||||
<Languages size={18} />
|
||||
</ActionIcon>
|
||||
|
||||
<button type="button" aria-label="Messages" className={iconButtonClass}>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={iconButtonClass}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
||||
</button>
|
||||
<MessageSquare size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
circle
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-3px",
|
||||
right: "-3px",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
|
||||
<div ref={userMenuRef} className="relative ml-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition",
|
||||
isUserMenuOpen
|
||||
? "border-primary/30 bg-primary/5"
|
||||
: "hover:border-primary/20 hover:bg-gray-50",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
||||
{initials}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"hidden h-4 w-4 text-gray-400 transition sm:block",
|
||||
isUserMenuOpen && "rotate-180 text-primary",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size={40}
|
||||
radius="lg"
|
||||
style={{
|
||||
background: "var(--mantine-color-gray-1)",
|
||||
border: "1px solid var(--mantine-color-gray-2)",
|
||||
color: "var(--mantine-color-gray-7)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<Bell size={18} />
|
||||
<Badge
|
||||
size="xs"
|
||||
color="red"
|
||||
circle
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-3px",
|
||||
right: "-3px",
|
||||
}}
|
||||
/>
|
||||
</ActionIcon>
|
||||
|
||||
{isUserMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
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"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
|
||||
<Menu.Target>
|
||||
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
|
||||
<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)" }} />
|
||||
</Group>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item disabled>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>
|
||||
{userName}
|
||||
</p>
|
||||
{userEmail ? (
|
||||
<p className="text-xs text-gray-500">{userEmail}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="#profile"
|
||||
role="menuitem"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Text>
|
||||
{userEmail && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{userEmail}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<User size={14} />}
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<LogOut size={14} />}
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
onLogout?.();
|
||||
}}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
{headerRight}
|
||||
</div>
|
||||
</Group>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user