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
|
.DS_Store
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.npmrc
|
||||||
|
branch_structure.json
|
||||||
|
temp_auto_push.bat
|
||||||
|
temp_interactive_push.bat
|
||||||
|
|
||||||
# emacs cache files
|
# emacs cache files
|
||||||
*~
|
*~
|
||||||
|
|||||||
187
DEPLOYMENT.md
Normal file
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
|
# EDR Platform
|
||||||
|
|
||||||
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
|
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
|
||||||
@@ -185,6 +280,676 @@ Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamap
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
<<<<<<< HEAD
|
||||||
|
### 3. Environment Configuration
|
||||||
|
```bash
|
||||||
|
# Copy environment template
|
||||||
|
cp apps/edr-passenger-api/.env.example apps/edr-passenger-api/.env
|
||||||
|
|
||||||
|
# Edit .env file with your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Required Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `NODE_ENV` | Environment mode | `development` |
|
||||||
|
| `PORT` | HTTP server port | `4000` |
|
||||||
|
| `DATABASE_URL` | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/edr_passenger` |
|
||||||
|
| `JWT_SECRET` | JWT signing secret (change in production) | `your-secret-key` |
|
||||||
|
| `JWT_EXPIRES_IN` | JWT token expiry | `7d` |
|
||||||
|
| `PORTAL_URL` | Web app CORS origin | `http://localhost:3000` |
|
||||||
|
| `BACK_OFFICE_URL` | Admin portal CORS origin | `http://localhost:3001` |
|
||||||
|
| `SENDGRID_API_KEY` | SendGrid API key (optional) | `SG.xxx` |
|
||||||
|
| `SENDGRID_FROM_EMAIL` | Email sender address | `noreply@edr-platform.com` |
|
||||||
|
|
||||||
|
#### Verifayda 2.0 Configuration (Ethiopian National ID Verification)
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `VERIFAYDA_ENABLED` | Enable Verifayda integration | `true` or `false` |
|
||||||
|
| `VERIFAYDA_API_URL` | Verifayda API endpoint | `https://api.verifayda.gov.et/v2` |
|
||||||
|
| `VERIFAYDA_API_KEY` | API key for Verifayda service | `your-verifayda-api-key` |
|
||||||
|
|
||||||
|
**Note:** When `VERIFAYDA_ENABLED=false`, verification is skipped (development mode only).
|
||||||
|
|
||||||
|
#### Corporate IAM Configuration (Back-office Authentication)
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `IAM_ENABLED` | Enable corporate IAM integration | `true` or `false` |
|
||||||
|
| `IAM_API_URL` | Corporate IAM API endpoint | `https://iam.tria-plc.com/api` |
|
||||||
|
| `IAM_API_KEY` | API key for IAM service | `your-iam-api-key` |
|
||||||
|
|
||||||
|
**Note:** When `IAM_ENABLED=false`, IAM-protected routes allow access without validation (development mode only).
|
||||||
|
|
||||||
|
#### Optional: Payment Provider Configuration
|
||||||
|
```bash
|
||||||
|
# Telebirr Configuration
|
||||||
|
TELEBIRR_BASE_URL=https://api.telebirr.com
|
||||||
|
TELEBIRR_MERCHANT_CODE=your-merchant-code
|
||||||
|
TELEBIRR_APP_SECRET=your-app-secret
|
||||||
|
# ... see .env.example for complete list
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Database Setup
|
||||||
|
|
||||||
|
#### Start PostgreSQL
|
||||||
|
```bash
|
||||||
|
# Using Docker (recommended)
|
||||||
|
docker run --name edr-postgres \
|
||||||
|
-e POSTGRES_USER=edr \
|
||||||
|
-e POSTGRES_PASSWORD=edr_secret \
|
||||||
|
-e POSTGRES_DB=edr_passenger \
|
||||||
|
-p 5432:5432 \
|
||||||
|
-d postgres:15
|
||||||
|
|
||||||
|
# Or use your local PostgreSQL installation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Generate Prisma Client
|
||||||
|
```bash
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:generate
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Run Migrations
|
||||||
|
```bash
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:migrate:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Seed Database
|
||||||
|
```bash
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:seed
|
||||||
|
```
|
||||||
|
|
||||||
|
**Seed Data Includes:**
|
||||||
|
- 21 Stations (Complete Ethiopian-Djibouti Railway with country codes)
|
||||||
|
- 1 Route with 21 stops and fare rules
|
||||||
|
- 2 Train services with 4 trips
|
||||||
|
- 360 seats across 12 coaches (Economy Regular, Economy Bed, VIP Bed classes)
|
||||||
|
- 3 User accounts (Admin, Passenger, Agent)
|
||||||
|
- Fare rules for ADULT and CHILD passenger categories
|
||||||
|
- Currency exchange rates (ETB, DJF, USD)
|
||||||
|
- Baggage allowance rules
|
||||||
|
- Notification templates
|
||||||
|
- Promotions and FAQ content
|
||||||
|
- Menu items and station crowd signals
|
||||||
|
- Fraud detection rules
|
||||||
|
- Saved passenger profiles for testing
|
||||||
|
|
||||||
|
### 5. Start Development Server
|
||||||
|
```bash
|
||||||
|
pnpm --filter @edr/passenger-api run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**API Server:** http://localhost:4000
|
||||||
|
**Swagger Docs:** http://localhost:4000/api-docs
|
||||||
|
|
||||||
|
## 🔑 Default Credentials
|
||||||
|
|
||||||
|
After seeding, use these credentials to test the API:
|
||||||
|
|
||||||
|
| Role | Email | Password | Description |
|
||||||
|
|------|-------|----------|-------------|
|
||||||
|
| **Admin** | `admin@edr-platform.com` | `admin123` | Full system access, reports, agent management |
|
||||||
|
| **Passenger** | `kelemu@email.com` | `password123` | Regular user with loyalty (Silver) and wallet |
|
||||||
|
| **Agent** | `agent@edr-platform.com` | `agent123` | Counter booking agent with commission tracking |
|
||||||
|
|
||||||
|
## 📚 API Documentation
|
||||||
|
|
||||||
|
### Swagger UI
|
||||||
|
Interactive API documentation available at: **http://localhost:4000/api-docs**
|
||||||
|
|
||||||
|
### Authentication Methods
|
||||||
|
|
||||||
|
The API uses two authentication schemes:
|
||||||
|
|
||||||
|
#### 1. JWT Authentication (Passenger-facing)
|
||||||
|
- **Used for**: Passenger bookings, profile management, wallet, loyalty
|
||||||
|
- **Header**: `Authorization: Bearer <jwt-token>`
|
||||||
|
- **Obtain token**: `POST /auth/login` with passenger credentials
|
||||||
|
- **Swagger Security**: `JWT-auth`
|
||||||
|
|
||||||
|
#### 2. IAM Authentication (Back-office)
|
||||||
|
- **Used for**: Agent operations, fraud detection, reports, admin functions
|
||||||
|
- **Header**: `Authorization: Bearer <iam-token>`
|
||||||
|
- **Obtain token**: From corporate IAM system (https://iam.tria-plc.com)
|
||||||
|
- **Swagger Security**: `IAM-auth`
|
||||||
|
- **Roles**: AGENT, SUPERVISOR, ADMIN, STAFF
|
||||||
|
|
||||||
|
### API Endpoints Overview
|
||||||
|
|
||||||
|
| Module | Base Path | Auth Type | Description |
|
||||||
|
|--------|-----------|-----------|-------------|
|
||||||
|
| **Auth** | `/auth` | Public/JWT | Register, login, OTP verification, password reset |
|
||||||
|
| **Passengers** | `/passengers` | Public/JWT | Verifayda verification, international registration, profiles |
|
||||||
|
| **Search** | `/search` | Public | Trip search, availability, fare quotes |
|
||||||
|
| **Stations** | `/stations` | Public/JWT | Station directory and information |
|
||||||
|
| **Seats** | `/seats` | JWT/IAM | Seat maps, holds, releases, blocking |
|
||||||
|
| **Bookings** | `/bookings` | Public/JWT | Guest booking, create, modify, cancel bookings |
|
||||||
|
| **Payments** | `/payments` | JWT/Public | Payment initiation, webhooks, refunds |
|
||||||
|
| **Tickets** | `/tickets` | JWT/IAM | Ticket generation, QR/barcode, validation |
|
||||||
|
| **Notifications** | `/notifications` | JWT | In-app notifications, preferences |
|
||||||
|
| **Loyalty** | `/loyalty` | JWT | Points, tiers, rewards redemption |
|
||||||
|
| **Wallet** | `/wallet` | JWT | Balance, top-up, transaction history |
|
||||||
|
| **Promotions** | `/promos` | Public/JWT | Active promotions, promo code validation |
|
||||||
|
| **Live Tracking** | `/live` | Public/JWT | Real-time trip status, crowd signals |
|
||||||
|
| **Support** | `/support` | Public/JWT | FAQ, chat conversations |
|
||||||
|
| **Dashboard** | `/dashboard` | JWT | Home screen aggregated data |
|
||||||
|
| **Routes** | `/routes` | JWT/IAM | Reusable route templates with ordered stops |
|
||||||
|
| **Schedules** | `/schedules` | JWT/IAM | Trip schedules, fare rules, status updates |
|
||||||
|
| **Fleet** | `/fleet` | JWT/IAM | Train services, coaches, seat configurations |
|
||||||
|
| **Seat Classes** | `/seat-classes` | Public/JWT/IAM | Seat class management and configuration |
|
||||||
|
| **Segment Seats** | `/segments/seats` | Public/JWT | Segment-based seat availability and booking |
|
||||||
|
| **Agents** | `/agents` | IAM | Agent booking, shifts, commissions, reconciliation |
|
||||||
|
| **Fraud Detection** | `/fraud` | IAM | Fraud alerts, rules management, user blocking |
|
||||||
|
| **Reports** | `/reports` | IAM | Revenue, occupancy, agent sales analytics |
|
||||||
|
|
||||||
|
### Example API Calls
|
||||||
|
|
||||||
|
#### 1. Register Passenger
|
||||||
|
```bash
|
||||||
|
POST /auth/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"phone": "+251911234567",
|
||||||
|
"fullName": "John Doe",
|
||||||
|
"password": "SecurePass123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Login (Passenger)
|
||||||
|
```bash
|
||||||
|
POST /auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"password": "SecurePass123"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response includes JWT token
|
||||||
|
{
|
||||||
|
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"user": { "id": "uuid", "role": "PASSENGER" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Verify Ethiopian National ID (Verifayda)
|
||||||
|
```bash
|
||||||
|
POST /passengers/verify-fayda
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"nationalId": "ET123456789"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response with verified passenger data
|
||||||
|
{
|
||||||
|
"verified": true,
|
||||||
|
"passengerData": {
|
||||||
|
"fullName": "Abebe Kebede",
|
||||||
|
"dateOfBirth": "1985-03-15T00:00:00.000Z",
|
||||||
|
"gender": "Male",
|
||||||
|
"nationality": "Ethiopian"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Universal Passenger Registration (NEW)
|
||||||
|
```bash
|
||||||
|
# Guest Ethiopian with Fayda verification
|
||||||
|
POST /passengers/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"passengerName": "Abebe Kebede",
|
||||||
|
"dateOfBirth": "1985-03-15",
|
||||||
|
"nationalId": "ET123456789",
|
||||||
|
"phone": "+251911234567",
|
||||||
|
"deviceId": "device-uuid-123"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logged-in user with JWT token
|
||||||
|
POST /passengers/register
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"passengerName": "Abebe Kebede",
|
||||||
|
"dateOfBirth": "1985-03-15",
|
||||||
|
"nationalId": "ET123456789",
|
||||||
|
"phone": "+251911234567"
|
||||||
|
}
|
||||||
|
|
||||||
|
# International passenger (passport)
|
||||||
|
POST /passengers/register
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"passengerName": "John Smith",
|
||||||
|
"dateOfBirth": "1990-07-20",
|
||||||
|
"passportNumber": "P1234567",
|
||||||
|
"passportCountry": "Kenya",
|
||||||
|
"nationality": "Kenyan",
|
||||||
|
"phone": "+254712345678",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"deviceId": "device-uuid-123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Get User Profile (NEW)
|
||||||
|
```bash
|
||||||
|
GET /auth/profile
|
||||||
|
Authorization: Bearer <jwt-token>
|
||||||
|
|
||||||
|
# Response includes user, passenger, loyalty, and wallet details
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"phone": "+251911234567",
|
||||||
|
"fullName": "John Doe",
|
||||||
|
"role": "PASSENGER",
|
||||||
|
"nationality": "Ethiopian",
|
||||||
|
"faydaVerified": true,
|
||||||
|
"faydaVerifiedAt": "2024-01-15T10:30:00.000Z",
|
||||||
|
"passenger": {
|
||||||
|
"id": "uuid",
|
||||||
|
"loyalty": {
|
||||||
|
"tier": "SILVER",
|
||||||
|
"pointsBalance": 1500,
|
||||||
|
"lifetimePoints": 3000
|
||||||
|
},
|
||||||
|
"wallet": {
|
||||||
|
"balanceMinor": 50000,
|
||||||
|
"currency": "ETB"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Search Trips
|
||||||
|
```bash
|
||||||
|
POST /search
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"originStationId": "uuid",
|
||||||
|
"destinationStationId": "uuid",
|
||||||
|
"date": "2026-06-15",
|
||||||
|
"adultCount": 2,
|
||||||
|
"childCount": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. Get Fare Quote
|
||||||
|
```bash
|
||||||
|
POST /search/fare-quote
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"tripId": "uuid",
|
||||||
|
"serviceClass": "ECONOMY_REGULAR",
|
||||||
|
"adultCount": 2,
|
||||||
|
"childCount": 1,
|
||||||
|
"displayCurrency": "USD"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Response includes age-based pricing breakdown
|
||||||
|
{
|
||||||
|
"baseFareMinor": 35000,
|
||||||
|
"adultCount": 2,
|
||||||
|
"adultFareMinor": 70000,
|
||||||
|
"childCount": 1,
|
||||||
|
"freeChildrenCount": 1,
|
||||||
|
"paidChildrenCount": 0,
|
||||||
|
"childFareMinor": 0,
|
||||||
|
"totalMinor": 73500,
|
||||||
|
"currency": "ETB",
|
||||||
|
"displayCurrency": "USD",
|
||||||
|
"displayTotalMinor": 1323
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 8. Guest Booking (No Login Required)
|
||||||
|
```bash
|
||||||
|
POST /bookings/guest
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"tripId": "uuid",
|
||||||
|
"holdId": "uuid",
|
||||||
|
"serviceClass": "ECONOMY_REGULAR",
|
||||||
|
"displayCurrency": "ETB",
|
||||||
|
"passengers": [
|
||||||
|
{
|
||||||
|
"seatId": "uuid",
|
||||||
|
"passengerName": "Abebe Kebede",
|
||||||
|
"dateOfBirth": "1985-03-15",
|
||||||
|
"idDocumentType": "NATIONAL_ID",
|
||||||
|
"idDocumentNumber": "ET123456789"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"createAccount": false,
|
||||||
|
"savePassengerDetails": true,
|
||||||
|
"deviceId": "device-uuid"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 9. Agent Booking (IAM Auth)
|
||||||
|
```bash
|
||||||
|
POST /agents/bookings
|
||||||
|
Authorization: Bearer <iam-token>
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"tripId": "uuid",
|
||||||
|
"seats": [...],
|
||||||
|
"paymentMethod": "CASH",
|
||||||
|
"cashReceived": 50000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/edr-passenger-api/
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma # Database schema (40+ models)
|
||||||
|
│ ├── seed.ts # Comprehensive seed script
|
||||||
|
│ └── migrations/ # Database migrations
|
||||||
|
├── src/
|
||||||
|
│ ├── common/ # Shared utilities
|
||||||
|
│ │ ├── filters/ # Exception filters
|
||||||
|
│ │ ├── interceptors/ # Response interceptors
|
||||||
|
│ │ ├── pipes/ # Validation pipes
|
||||||
|
│ │ ├── i18n/ # Internationalization
|
||||||
|
│ │ ├── jwt.guard.ts # JWT authentication guard (passengers)
|
||||||
|
│ │ ├── jwt.strategy.ts # Passport JWT strategy
|
||||||
|
│ │ ├── iam-adapter.ts # Corporate IAM guard (back-office)
|
||||||
|
│ │ ├── iam.module.ts # IAM module
|
||||||
|
│ │ ├── roles.guard.ts # RBAC authorization guard
|
||||||
|
│ │ ├── roles.decorator.ts # Roles decorator
|
||||||
|
│ │ ├── prisma.service.ts # Prisma client service
|
||||||
|
│ │ └── prisma.module.ts # Prisma module
|
||||||
|
│ ├── config/ # Configuration files
|
||||||
|
│ │ ├── app.config.ts # App configuration
|
||||||
|
│ │ ├── database.config.ts # Database configuration
|
||||||
|
│ │ └── telebirr.config.ts # Payment provider config
|
||||||
|
│ ├── modules/ # Feature modules
|
||||||
|
│ │ ├── auth/ # Authentication & authorization (JWT)
|
||||||
|
│ │ ├── agents/ # Agent operations (IAM-protected)
|
||||||
|
│ │ ├── bookings/ # Booking management (JWT)
|
||||||
|
│ │ ├── currency/ # Currency conversion service
|
||||||
|
│ │ ├── dashboard/ # Dashboard aggregations (JWT)
|
||||||
|
│ │ ├── fleet/ # Train fleet management (JWT/IAM)
|
||||||
|
│ │ ├── fraud/ # Fraud detection (IAM-protected)
|
||||||
|
│ │ ├── live/ # Live tracking (JWT)
|
||||||
|
│ │ ├── loyalty/ # Loyalty program (JWT)
|
||||||
|
│ │ ├── notifications/ # Notification system (JWT)
|
||||||
|
│ │ ├── passengers/ # Passenger management (JWT)
|
||||||
|
│ │ ├── payments/ # Payment processing (JWT/Webhooks)
|
||||||
|
│ │ ├── promos/ # Promotions (JWT)
|
||||||
|
│ │ ├── reports/ # Reports & analytics (IAM-protected)
|
||||||
|
│ │ ├── schedules/ # Trip schedules (JWT/IAM)
|
||||||
|
│ │ ├── search/ # Trip search (JWT)
|
||||||
|
│ │ ├── seats/ # Seat management (JWT/IAM)
|
||||||
|
│ │ ├── segments/ # Journey segments (JWT)
|
||||||
|
│ │ ├── stations/ # Station management (JWT)
|
||||||
|
│ │ ├── support/ # Customer support (JWT)
|
||||||
|
│ │ ├── tickets/ # Ticketing (JWT/IAM)
|
||||||
|
│ │ ├── verifayda/ # Verifayda 2.0 integration
|
||||||
|
│ │ └── wallet/ # Wallet system (JWT)
|
||||||
|
│ ├── app.module.ts # Root application module
|
||||||
|
│ └── main.ts # Application entry point
|
||||||
|
├── test/ # E2E tests
|
||||||
|
├── .env.example # Environment template
|
||||||
|
├── Dockerfile # Docker configuration
|
||||||
|
├── nest-cli.json # NestJS CLI configuration
|
||||||
|
├── package.json # Dependencies & scripts
|
||||||
|
├── tsconfig.json # TypeScript configuration
|
||||||
|
└── tsconfig.build.json # Build configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Database Schema
|
||||||
|
|
||||||
|
### Key Models (40+ total)
|
||||||
|
|
||||||
|
**Core Entities:**
|
||||||
|
- `User`, `Session`, `Passenger`, `Agent`
|
||||||
|
- `Station`, `Route`, `RouteStop`, `RouteFareRule`
|
||||||
|
- `TrainService`, `Trip`, `TripStopTime`, `Coach`, `Seat`
|
||||||
|
- `Booking`, `BookingSeat`, `Ticket`
|
||||||
|
- `PaymentIntent`, `PaymentRefund`, `PaymentWebhookEvent`
|
||||||
|
|
||||||
|
**Enhanced Features:**
|
||||||
|
- `OtpCode`, `PasswordResetToken` (Auth)
|
||||||
|
- `AgentBooking`, `AgentShift`, `AgentCommission` (Agents)
|
||||||
|
- `BookingModification`, `BookingCancellation` (Booking lifecycle)
|
||||||
|
- `GateValidationLog` (Ticket validation)
|
||||||
|
- `BaggageAllowance`, `BaggageBooking` (Baggage)
|
||||||
|
- `LoyaltyAccount`, `LoyaltyLedgerEntry`, `LoyaltyReward`
|
||||||
|
- `WalletAccount`, `WalletLedgerEntry`
|
||||||
|
- `Notification`, `NotificationTemplate`
|
||||||
|
- `AuditLog`, `OperationalReport`
|
||||||
|
- `SeatBlock`, `SeatHold`
|
||||||
|
- `CurrencyExchangeRate` (Multi-currency)
|
||||||
|
- `VerifaydaVerification` (National ID verification)
|
||||||
|
- `SavedPassengerProfile` (Guest booking)
|
||||||
|
- `SeatClass` (Seat class configuration)
|
||||||
|
- `JourneySegment` (Multi-segment journeys)
|
||||||
|
|
||||||
|
## 🔧 Available Scripts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
pnpm --filter @edr/passenger-api run dev # Start with hot-reload
|
||||||
|
|
||||||
|
# Build
|
||||||
|
pnpm --filter @edr/passenger-api run build # Compile TypeScript
|
||||||
|
|
||||||
|
# Production
|
||||||
|
pnpm --filter @edr/passenger-api run start # Run compiled code
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pnpm --filter @edr/passenger-api run test # Unit tests
|
||||||
|
pnpm --filter @edr/passenger-api run test:e2e # E2E tests
|
||||||
|
|
||||||
|
# Code Quality
|
||||||
|
pnpm --filter @edr/passenger-api run lint # ESLint
|
||||||
|
pnpm --filter @edr/passenger-api run type-check # TypeScript check
|
||||||
|
|
||||||
|
# Database
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:generate # Generate Prisma client
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:migrate:dev # Run migrations (local dev)
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:seed # Seed database
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Docker Deployment
|
||||||
|
|
||||||
|
All six apps build from Dockerfiles: each API has its own (`apps/edr-freight-api/Dockerfile`, `apps/edr-passenger-api/Dockerfile`); Vite frontends share `infrastructure/docker/Dockerfile.web` and are served with **nginx**. APIs run on **Node 22**.
|
||||||
|
|
||||||
|
**Prerequisites**
|
||||||
|
|
||||||
|
- Docker with BuildKit enabled
|
||||||
|
- A local [`.npmrc`](.gitignore) with GitHub Packages auth for `@tria-plc/*` (required for **freight** API and web images)
|
||||||
|
- External Postgres for each API (compose does **not** include databases)
|
||||||
|
- Copy `apps/edr-freight-api/.env.example` → `.env` and `apps/edr-passenger-api/.env.example` → `.env` with real connection strings
|
||||||
|
|
||||||
|
### Build and run (all apps)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From monorepo root
|
||||||
|
DOCKER_BUILDKIT=1 pnpm docker:build
|
||||||
|
pnpm docker:up
|
||||||
|
```
|
||||||
|
|
||||||
|
Or without pnpm scripts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DOCKER_BUILDKIT=1 docker compose build
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
| Service | URL (default) |
|
||||||
|
|---------|----------------|
|
||||||
|
| Freight API | http://localhost:3001 |
|
||||||
|
| Passenger API | http://localhost:4000 |
|
||||||
|
| Freight portal | http://localhost:5173 |
|
||||||
|
| Freight backoffice | http://localhost:5183 |
|
||||||
|
| Passenger portal | http://localhost:5174 |
|
||||||
|
| Passenger backoffice | http://localhost:5184 |
|
||||||
|
|
||||||
|
### Build a single service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build freight-api
|
||||||
|
docker compose build passenger-portal
|
||||||
|
```
|
||||||
|
|
||||||
|
Freight images mount `.npmrc` as a BuildKit secret during `pnpm install`. Passenger web images do not require private packages.
|
||||||
|
|
||||||
|
### `VITE_API_URL` (frontends)
|
||||||
|
|
||||||
|
API URLs are **baked in at image build time** (`import.meta.env.VITE_API_URL`). Defaults in [`docker-compose.yaml`](docker-compose.yaml) use `http://localhost:3001/api` (freight) and `http://localhost:4000` (passenger) for local smoke tests. Override build args for production, e.g.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build freight-portal \
|
||||||
|
--build-arg VITE_API_URL=https://freight-api.example.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migrations
|
||||||
|
|
||||||
|
- **Freight API:** TypeORM migrations are not run on container startup — apply them separately before deploy.
|
||||||
|
- **Passenger API:** On each container start, the entrypoint runs `npm run prisma:migrate` and `npm run prisma:seed` (same `package.json` scripts as `pnpm run`) before starting the server. Ensure `DATABASE_URL` in `.env` points at a reachable Postgres instance.
|
||||||
|
|
||||||
|
For local development, use `pnpm --filter @edr/passenger-api run prisma:migrate:dev` instead of `prisma:migrate`.
|
||||||
|
|
||||||
|
### GitHub Actions (self-hosted runner)
|
||||||
|
|
||||||
|
Two workflows deploy independently on push to `main`, `develop`, or `staging`:
|
||||||
|
|
||||||
|
| Workflow | Services | Server env root |
|
||||||
|
|----------|----------|-----------------|
|
||||||
|
| [`.github/workflows/deploy-freight.yml`](.github/workflows/deploy-freight.yml) | freight-api, freight-portal, freight-backoffice | `/home/user/environmen/edr-freight/<branch>/` |
|
||||||
|
| [`.github/workflows/deploy-passenger.yml`](.github/workflows/deploy-passenger.yml) | passenger-api, passenger-portal, passenger-backoffice | `/home/user/environmen/edr-passenger/<branch>/` |
|
||||||
|
|
||||||
|
**On the runner**, place env files before the first deploy (example for branch `main`):
|
||||||
|
|
||||||
|
```text
|
||||||
|
/home/user/environmen/edr-freight/main/
|
||||||
|
freight-api.env
|
||||||
|
freight-portal.env # optional runtime env for Vite/nginx
|
||||||
|
freight-backoffice.env
|
||||||
|
freight-web.build.env # exports FREIGHT_VITE_API_URL=...
|
||||||
|
|
||||||
|
/home/user/environmen/edr-passenger/main/
|
||||||
|
passenger-api.env
|
||||||
|
passenger-portal.env
|
||||||
|
passenger-backoffice.env
|
||||||
|
passenger-web.build.env # exports PASSENGER_VITE_API_URL=...
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `freight-web.build.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export FREIGHT_VITE_API_URL=https://freight-api.example.com/api
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow copies `*.env` into each app directory, creates `.npmrc` from the `NPM_TOKEN` repository secret, then runs `docker compose build` and `docker compose up -d` for that stack.
|
||||||
|
|
||||||
|
## 🔒 Security Best Practices
|
||||||
|
|
||||||
|
1. **Environment Variables** - Never commit `.env` files. Use secrets management in production.
|
||||||
|
2. **JWT Secret** - Use strong, randomly generated secrets (min 32 characters).
|
||||||
|
3. **Password Hashing** - Bcrypt with salt rounds (default: 10).
|
||||||
|
4. **Rate Limiting** - Implement rate limiting for auth endpoints.
|
||||||
|
5. **CORS** - Configure allowed origins in production.
|
||||||
|
6. **HTTPS** - Always use HTTPS in production.
|
||||||
|
7. **Database** - Use connection pooling and prepared statements (Prisma handles this).
|
||||||
|
8. **Audit Logging** - All sensitive operations are logged in `AuditLog` table.
|
||||||
|
9. **Dual Authentication** - Passenger routes use JWT, back-office routes use corporate IAM.
|
||||||
|
10. **IAM Integration** - Corporate IAM validates tokens against centralized identity service.
|
||||||
|
11. **Role-Based Access** - Granular permissions enforced via IAM roles (AGENT, SUPERVISOR, ADMIN).
|
||||||
|
12. **Token Validation** - IAM tokens validated in real-time with 5-second timeout.
|
||||||
|
|
||||||
|
## 📊 Monitoring & Logging
|
||||||
|
|
||||||
|
- **Application Logs** - NestJS built-in logger
|
||||||
|
- **Database Queries** - Prisma query logging (enable in development)
|
||||||
|
- **Audit Trail** - All user actions logged in `AuditLog` table
|
||||||
|
- **Error Tracking** - Global exception filters with detailed error responses
|
||||||
|
|
||||||
|
## 🧪 Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Unit tests
|
||||||
|
pnpm --filter @edr/passenger-api run test
|
||||||
|
|
||||||
|
# E2E tests
|
||||||
|
pnpm --filter @edr/passenger-api run test:e2e
|
||||||
|
|
||||||
|
# Test coverage
|
||||||
|
pnpm --filter @edr/passenger-api run test:cov
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Production Deployment
|
||||||
|
|
||||||
|
### Pre-deployment Checklist
|
||||||
|
- [ ] Update environment variables (JWT_SECRET, DATABASE_URL, etc.)
|
||||||
|
- [ ] Configure IAM integration (IAM_ENABLED=true, IAM_API_URL, IAM_API_KEY)
|
||||||
|
- [ ] Configure Verifayda integration (VERIFAYDA_ENABLED=true, VERIFAYDA_API_KEY)
|
||||||
|
- [ ] Set up currency exchange rate sync (external API)
|
||||||
|
- [ ] Set NODE_ENV=production
|
||||||
|
- [ ] Configure CORS origins (PORTAL_URL, BACK_OFFICE_URL)
|
||||||
|
- [ ] Set up SSL/TLS certificates
|
||||||
|
- [ ] Configure database connection pooling
|
||||||
|
- [ ] Set up monitoring and logging
|
||||||
|
- [ ] Configure backup strategy
|
||||||
|
- [ ] Test payment provider integrations
|
||||||
|
- [ ] Verify IAM token validation endpoint
|
||||||
|
- [ ] Test Verifayda verification with real national IDs
|
||||||
|
- [ ] Verify currency conversion accuracy
|
||||||
|
- [ ] Test age-based pricing calculations
|
||||||
|
- [ ] Review security settings and audit logs
|
||||||
|
- [ ] Test both JWT and IAM authentication flows
|
||||||
|
|
||||||
|
### Deployment Steps
|
||||||
|
```bash
|
||||||
|
# 1. Build application
|
||||||
|
pnpm --filter @edr/passenger-api run build
|
||||||
|
|
||||||
|
# 2. Run migrations
|
||||||
|
pnpm --filter @edr/passenger-api run prisma:migrate
|
||||||
|
|
||||||
|
# 3. Start production server
|
||||||
|
NODE_ENV=production pnpm --filter @edr/passenger-api run start:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🤝 Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create feature branch (`git checkout -b feature/amazing-feature`)
|
||||||
|
3. Commit changes (`git commit -m 'Add amazing feature'`)
|
||||||
|
4. Push to branch (`git push origin feature/amazing-feature`)
|
||||||
|
5. Open Pull Request
|
||||||
|
|
||||||
|
## 📝 License
|
||||||
|
|
||||||
|
This project is proprietary and confidential.
|
||||||
|
|
||||||
|
## 📧 Support
|
||||||
|
|
||||||
|
For technical support or questions:
|
||||||
|
- Email: support@edr-platform.com
|
||||||
|
- Documentation: http://localhost:4000/api-docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ for Ethio-Djibouti Railway**
|
||||||
|
=======
|
||||||
### Start local databases
|
### Start local databases
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -257,3 +1022,4 @@ pnpm dev:passenger # passenger API + portal + backoffice
|
|||||||
- **One DB per domain** — no cross-database joins.
|
- **One DB per domain** — no cross-database joins.
|
||||||
|
|
||||||
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.
|
||||||
|
>>>>>>> b9cfce70fe17b5066ae5320cfcc595bf3c253467
|
||||||
|
|||||||
@@ -1,14 +1,24 @@
|
|||||||
# App
|
# Copy to .env for local/docker compose (not committed).
|
||||||
NODE_ENV=development
|
|
||||||
PORT=3001
|
PORT=3001
|
||||||
|
|
||||||
# Database
|
|
||||||
DB_HOST=localhost
|
DB_HOST=localhost
|
||||||
DB_PORT=5433
|
DB_PORT=5433
|
||||||
DB_NAME=edr_freight
|
|
||||||
DB_USER=postgres
|
DB_USER=postgres
|
||||||
DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
DB_NAME=edr_freight
|
||||||
|
|
||||||
|
# Telebirr payment gateway (freight merchant credentials)
|
||||||
|
TELEBIRR_BASE_URL=
|
||||||
|
TELEBIRR_WEB_BASE_URL=
|
||||||
|
TELEBIRR_FABRIC_APP_ID=
|
||||||
|
TELEBIRR_APP_SECRET=
|
||||||
|
TELEBIRR_MERCHANT_APP_ID=
|
||||||
|
TELEBIRR_MERCHANT_CODE=
|
||||||
|
TELEBIRR_NOTIFY_URL=https://freight-api.edr.et/payments/webhooks/telebirr
|
||||||
|
TELEBIRR_RETURN_URL=
|
||||||
|
TELEBIRR_TIMEOUT_EXPRESS=15m
|
||||||
|
TELEBIRR_PRIVATE_KEY=
|
||||||
|
TELEBIRR_PUBLIC_KEY=
|
||||||
|
TELEBIRR_INSECURE_TLS=false
|
||||||
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
# JWT (used by @tria-plc/api-common SharedAuthModule)
|
||||||
JWT_SECRET=
|
JWT_SECRET=
|
||||||
JWT_ACCESS_TOKEN_SECRET=
|
JWT_ACCESS_TOKEN_SECRET=
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
FROM node:20-alpine AS base
|
# syntax=docker/dockerfile:1
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
# Build from monorepo root: docker build -f apps/edr-freight-api/Dockerfile .
|
||||||
|
|
||||||
|
FROM node:24.15.0-alpine AS base
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
RUN corepack enable
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS deps
|
FROM base AS pruner
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY . .
|
||||||
COPY apps/edr-freight-api/package.json ./apps/edr-freight-api/
|
RUN pnpm dlx turbo prune "@edr/freight-api" --docker
|
||||||
COPY packages ./packages
|
|
||||||
RUN pnpm install --frozen-lockfile --filter @edr/freight-api...
|
|
||||||
|
|
||||||
FROM deps AS build
|
FROM base AS installer
|
||||||
COPY apps/edr-freight-api ./apps/edr-freight-api
|
COPY --from=pruner /app/out/json/ .
|
||||||
RUN pnpm --filter @edr/freight-api build
|
COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||||
|
RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
FROM node:20-alpine AS runtime
|
FROM base AS builder
|
||||||
RUN corepack enable && corepack prepare pnpm@9.12.0 --activate
|
COPY --from=installer /app/ .
|
||||||
WORKDIR /app/apps/edr-freight-api
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
RUN pnpm turbo build --filter="@edr/freight-api..."
|
||||||
|
|
||||||
|
FROM base AS deployer
|
||||||
|
COPY --from=builder /app/ .
|
||||||
|
RUN pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy
|
||||||
|
|
||||||
|
FROM node:24.15.0-alpine AS runner
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./../../node_modules
|
RUN addgroup --system --gid 1001 nodejs \
|
||||||
COPY --from=deps /app/apps/edr-freight-api/node_modules ./node_modules
|
&& adduser --system --uid 1001 --ingroup nodejs nestjs
|
||||||
COPY --from=build /app/apps/edr-freight-api/dist ./dist
|
COPY --from=deployer --chown=nestjs:nodejs /deploy .
|
||||||
COPY --from=build /app/apps/edr-freight-api/package.json ./package.json
|
USER nestjs
|
||||||
|
|
||||||
EXPOSE 3001
|
EXPOSE 3001
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/api-common": "workspace:*",
|
"@edr/api-common": "workspace:*",
|
||||||
|
"@edr/payment-providers": "workspace:*",
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.0.0",
|
"@nestjs/common": "^11.0.0",
|
||||||
"@nestjs/config": "^4.0.0",
|
"@nestjs/config": "^4.0.0",
|
||||||
"@nestjs/core": "^11.0.0",
|
"@nestjs/core": "^11.0.0",
|
||||||
|
"@nestjs/event-emitter": "^2.0.4",
|
||||||
"@nestjs/mapped-types": "^2.1.1",
|
"@nestjs/mapped-types": "^2.1.1",
|
||||||
"@nestjs/microservices": "^11.0.0",
|
"@nestjs/microservices": "^11.0.0",
|
||||||
"@nestjs/platform-express": "^11.0.0",
|
"@nestjs/platform-express": "^11.0.0",
|
||||||
@@ -63,7 +65,7 @@
|
|||||||
"ts-loader": "^9.5.1",
|
"ts-loader": "^9.5.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"tsconfig-paths": "^4.2.0",
|
"tsconfig-paths": "^4.2.0",
|
||||||
"typeorm": "^1.0.0",
|
"typeorm": "^0.3.30",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.
|
|||||||
|
|
||||||
import appConfig from "./config/app.config";
|
import appConfig from "./config/app.config";
|
||||||
import databaseConfig from "./config/database.config";
|
import databaseConfig from "./config/database.config";
|
||||||
|
import telebirrConfig from "./config/telebirr.config";
|
||||||
|
|
||||||
import { BookingsModule } from "./modules/bookings/bookings.module";
|
import { BookingsModule } from "./modules/bookings/bookings.module";
|
||||||
import { FilesModule } from "./modules/files/files.module";
|
import { FilesModule } from "./modules/files/files.module";
|
||||||
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
import { ConsignmentsModule } from "./modules/consignments/consignments.module";
|
||||||
|
|
||||||
//import { TrainsModule } from "./modules/trains/trains.module";
|
// import { TrainsModule } from "./modules/trains/trains.module";
|
||||||
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
import { LocomotivesModule } from "./modules/locomotives/locomotives.module";
|
||||||
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
import { WagonTypesModule } from "./modules/wagon-types/wagon-types.module";
|
||||||
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
import { TrainSetsModule } from "./modules/train-sets/train-sets.module";
|
||||||
@@ -47,13 +48,15 @@ import { TrainsModule } from "./modules/trains/trains.module";
|
|||||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||||
import { ContainersModule } from './modules/container-management/containers.module';
|
import { ContainersModule } from './modules/container-management/containers.module';
|
||||||
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
import { CargoesModule } from './modules/cargoes/cargoes.module';
|
||||||
|
import { RoutesModule } from './modules/routes/routes.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
load: [appConfig, databaseConfig],
|
load: [appConfig, databaseConfig, telebirrConfig],
|
||||||
}),
|
}),
|
||||||
|
// EventEmitterModule.forRoot(),
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
inject: [ConfigService],
|
inject: [ConfigService],
|
||||||
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
useFactory: (config: ConfigService): TypeOrmModuleOptions =>
|
||||||
@@ -98,6 +101,7 @@ import { CargoesModule } from './modules/cargoes/cargoes.module';
|
|||||||
WagonsModule,
|
WagonsModule,
|
||||||
ContainersModule,
|
ContainersModule,
|
||||||
CargoesModule,
|
CargoesModule,
|
||||||
|
RoutesModule,
|
||||||
],
|
],
|
||||||
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
providers: [EdrOrgSeeder, DemoUsersSeeder,FreightStaffUsersSeeder, DemoBookingsSeeder, PricingDataSeeder, FileUploadSettingsSeeder],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
PositionType,
|
PositionType,
|
||||||
Position,
|
Position,
|
||||||
Project,
|
Project,
|
||||||
UnitConfiguration,
|
|
||||||
GlobalUnitConfiguration,
|
GlobalUnitConfiguration,
|
||||||
Unit,
|
Unit,
|
||||||
EmployeeSignature,
|
EmployeeSignature,
|
||||||
@@ -64,7 +63,6 @@ const iamEntities = [
|
|||||||
PositionType,
|
PositionType,
|
||||||
Position,
|
Position,
|
||||||
Project,
|
Project,
|
||||||
UnitConfiguration,
|
|
||||||
GlobalUnitConfiguration,
|
GlobalUnitConfiguration,
|
||||||
Unit,
|
Unit,
|
||||||
EmployeeSignature,
|
EmployeeSignature,
|
||||||
@@ -98,10 +96,8 @@ const iamMigrationsGlob = join(
|
|||||||
);
|
);
|
||||||
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
const freightMigrationsGlob = join(__dirname, "../migrations/*.js");
|
||||||
|
|
||||||
export default registerAs(
|
export default registerAs("database", (): TypeOrmModuleOptions => {
|
||||||
"database",
|
return {
|
||||||
(): TypeOrmModuleOptions => {
|
|
||||||
return {
|
|
||||||
type: "postgres",
|
type: "postgres",
|
||||||
host: process.env.DB_HOST ?? "localhost",
|
host: process.env.DB_HOST ?? "localhost",
|
||||||
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
port: parseInt(process.env.DB_PORT ?? "5433", 10),
|
||||||
@@ -124,5 +120,4 @@ export default registerAs(
|
|||||||
synchronize: false,
|
synchronize: false,
|
||||||
logging: process.env.NODE_ENV === "development",
|
logging: process.env.NODE_ENV === "development",
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|||||||
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
|
// apps/edr-freight-api/src/data-source.ts
|
||||||
|
import 'dotenv/config';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
//import { ensurePostgresSchemas } from './utils/ensure-postgres-schemas'; // adjust path if needed
|
||||||
|
|
||||||
export const AppDataSource = new DataSource({
|
export const AppDataSource = new DataSource({
|
||||||
type: 'postgres',
|
type: 'postgres',
|
||||||
host: 'localhost',
|
host: process.env.DB_HOST ?? 'localhost',
|
||||||
port: 5432,
|
port: Number(process.env.DB_PORT ?? 5432),
|
||||||
username: 'postgres',
|
username: process.env.DB_USER ?? 'postgres',
|
||||||
password: '', // Laragon default: empty
|
password: process.env.DB_PASSWORD ?? '',
|
||||||
database: 'edr_freight',
|
database: process.env.DB_NAME ?? 'edr_freight',
|
||||||
schema: 'freight', // default schema for entities without an explicit schema
|
schema: 'freight', // default schema for entities without an explicit schema
|
||||||
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
entities: [__dirname + '/**/*.entity{.ts,.js}'],
|
||||||
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
migrations: [__dirname + '/migrations/*{.ts,.js}'],
|
||||||
@@ -17,4 +18,4 @@ export const AppDataSource = new DataSource({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Optional: call ensurePostgresSchemas before initializing
|
// Optional: call ensurePostgresSchemas before initializing
|
||||||
// But you can also run it separately.
|
// But you can also run it separately.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async function bootstrap() {
|
|||||||
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
// freight portal (5173), passenger portal (5174), backoffices (5183/5184)
|
||||||
// and any other dev port can call the API with cookies + Authorization.
|
// and any other dev port can call the API with cookies + Authorization.
|
||||||
// For production, restrict `origin` to known FQDNs.
|
// For production, restrict `origin` to known FQDNs.
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: true, // reflect request origin
|
origin: true, // reflect request origin
|
||||||
credentials: true,
|
credentials: true,
|
||||||
@@ -52,9 +53,12 @@ async function bootstrap() {
|
|||||||
SwaggerModule.setup("api/docs", app, document);
|
SwaggerModule.setup("api/docs", app, document);
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||||
await app.listen(port);
|
// await app.listen(port, "0.0.0.0");
|
||||||
|
await app.listen(
|
||||||
|
|
||||||
|
port)
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`[freight-api] listening on http://localhost:${port}`);
|
console.log(`[freight-api] listening on port ${port}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
@@ -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',
|
key: 'approved_contract',
|
||||||
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
|
||||||
},
|
},
|
||||||
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
|
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
|
||||||
{
|
{
|
||||||
key: 'operations',
|
key: 'operations',
|
||||||
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
|
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
|
||||||
},
|
},
|
||||||
{ key: 'completed', statuses: ['COMPLETED'] },
|
{ key: 'completed', statuses: ['COMPLETED'] },
|
||||||
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export function computeNextStep(
|
|||||||
};
|
};
|
||||||
case 'APPROVED':
|
case 'APPROVED':
|
||||||
return {
|
return {
|
||||||
action: 'GENERATE_CONTRACT',
|
action: 'CUSTOMER_SIGN',
|
||||||
description: 'Generate the contract document',
|
description: 'Contract generated; customer must sign',
|
||||||
};
|
};
|
||||||
case 'CONTRACT_READY':
|
case 'CONTRACT_READY':
|
||||||
return {
|
return {
|
||||||
@@ -49,8 +49,8 @@ export function computeNextStep(
|
|||||||
};
|
};
|
||||||
case 'FULLY_EXECUTED':
|
case 'FULLY_EXECUTED':
|
||||||
return {
|
return {
|
||||||
action: 'PAY',
|
action: 'AWAIT_PAYMENT',
|
||||||
description: 'Complete in-app payment',
|
description: 'Awaiting customer payment',
|
||||||
};
|
};
|
||||||
case 'PAID':
|
case 'PAID':
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -4,56 +4,44 @@ import { Booking } from './entities/booking.entity';
|
|||||||
import { assertBookingStatus } from './booking-status.util';
|
import { assertBookingStatus } from './booking-status.util';
|
||||||
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
|
||||||
import { PaymentService } from '../payment/payment.service';
|
import { PaymentService } from '../payment/payment.service';
|
||||||
|
import { PaymentStatus } from '../payment/entities/payment.entity';
|
||||||
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
|
||||||
|
|
||||||
|
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
|
||||||
|
"action-required",
|
||||||
|
"processing",
|
||||||
|
"success",
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingPaymentService {
|
export class BookingPaymentService {
|
||||||
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
|
constructor(
|
||||||
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
|
private readonly paymentService: PaymentService,
|
||||||
|
) { }
|
||||||
|
|
||||||
async pay(
|
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
|
||||||
bookingId: string,
|
|
||||||
): Promise<{ redirectUrl: string }> {
|
|
||||||
const booking = await this.requireBooking(bookingId);
|
const booking = await this.requireBooking(bookingId);
|
||||||
assertBookingStatus(booking, ['FULLY_EXECUTED']);
|
assertBookingStatus(booking, ['FULLY_EXECUTED', '']);
|
||||||
|
|
||||||
// const receipt = this.buildMockReceipt(booking);
|
const existing = await this.paymentService.findBookingById(bookingId);
|
||||||
|
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
|
||||||
// const updated = await this.bookingsRepository.update(bookingId, {
|
if (existing.clientAction) {
|
||||||
// status: 'PAID',
|
const action = existing.clientAction as { type?: string; url?: string };
|
||||||
// paymentStatus: 'PAID',
|
if (action.type === "REDIRECT" && action.url) {
|
||||||
// } as never);
|
return { redirectUrl: action.url };
|
||||||
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
|
}
|
||||||
return new Promise((resp, _) => {
|
}
|
||||||
resp({
|
|
||||||
id: booking.id,
|
|
||||||
type: "booking"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resp = await this.paymentService.initBookingTelebirr(bookingId, "web");
|
||||||
|
|
||||||
|
return {
|
||||||
|
redirectUrl:
|
||||||
|
resp.redirectUrl ?? "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
|
|
||||||
// const timestamp = Date.now();
|
|
||||||
// const isEtb = booking.paymentCurrency === 'ETB';
|
|
||||||
// const prefix = isEtb ? 'TB' : 'CARD';
|
|
||||||
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
|
|
||||||
|
|
||||||
// return {
|
|
||||||
// success: true,
|
|
||||||
// provider,
|
|
||||||
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
|
|
||||||
// amount: booking.totalAmount,
|
|
||||||
// currency: booking.paymentCurrency,
|
|
||||||
// paidAt: new Date().toISOString(),
|
|
||||||
// };
|
|
||||||
// }
|
|
||||||
|
|
||||||
private async requireBooking(id: string): Promise<Booking> {
|
private async requireBooking(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findById(id);
|
const booking = await this.bookingsRepository.findById(id);
|
||||||
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
|
||||||
|
|||||||
@@ -185,6 +185,11 @@ export class BookingTransitionService {
|
|||||||
await this.bookingsRepository.update(bookingId, updates as never);
|
await this.bookingsRepository.update(bookingId, updates as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (allDone) {
|
||||||
|
const generated = await this.contractService.generateContract(bookingId);
|
||||||
|
return this.bookingsService.findById(generated.id);
|
||||||
|
}
|
||||||
|
|
||||||
return this.bookingsService.findById(bookingId);
|
return this.bookingsService.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
|
import { LOCOMOTIVE_STATUSES, LOCOMOTIVE_TYPES } from '../entities/locomotive.entity';
|
||||||
|
|
||||||
export class FilterLocomotivesDto {
|
export class FilterLocomotivesDto {
|
||||||
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn([...LOCOMOTIVE_STATUSES])
|
@IsIn([...LOCOMOTIVE_STATUSES])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ enum: LOCOMOTIVE_TYPES })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn([...LOCOMOTIVE_TYPES])
|
||||||
|
locomotiveType?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
'AVAILABLE',
|
||||||
'ASSIGNED',
|
'ASSIGNED',
|
||||||
'MAINTENANCE',
|
'MAINTENANCE',
|
||||||
'INACTIVE',
|
'OUT_OF_SERVICE',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const LOCOMOTIVE_TYPES = ['DIESEL', 'ELECTRIC'] as const;
|
||||||
|
|
||||||
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
|
||||||
|
export type LocomotiveType = (typeof LOCOMOTIVE_TYPES)[number];
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'locomotives' })
|
@Entity({ schema: 'freight', name: 'locomotives' })
|
||||||
@Index(['code'])
|
@Index(['code'])
|
||||||
@@ -22,14 +25,26 @@ export class Locomotive extends BaseEntity {
|
|||||||
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
|
||||||
|
@Column({ name: 'locomotive_type', type: 'varchar', length: 20, default: 'DIESEL' })
|
||||||
|
locomotiveType!: LocomotiveType;
|
||||||
|
|
||||||
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
maxPullWeightTons!: number;
|
maxPullWeightTons!: number;
|
||||||
|
|
||||||
|
@Column({ name: 'max_train_length_meters', type: 'numeric', precision: 10, scale: 3, default: 760 })
|
||||||
|
maxTrainLengthMeters!: number;
|
||||||
|
|
||||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
|
||||||
status!: LocomotiveStatus;
|
status!: LocomotiveStatus;
|
||||||
|
|
||||||
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
|
@Column({ name: 'power_kw', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
availableFrom?: Date | null;
|
powerKw?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'traction_force_kn', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
tractionForceKn?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'max_speed_kmh', type: 'numeric', precision: 10, scale: 3, nullable: true })
|
||||||
|
maxSpeedKmh?: number | null;
|
||||||
|
|
||||||
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
|
||||||
trainSets?: TrainSet[];
|
trainSets?: TrainSet[];
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Controller, Get, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
|
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||||
import { LocomotivesService } from './locomotives.service';
|
import { LocomotivesService } from './locomotives.service';
|
||||||
|
|
||||||
@ApiTags('locomotives')
|
@ApiTags('locomotives')
|
||||||
@@ -15,4 +17,28 @@ export class LocomotivesController {
|
|||||||
findAll(@Query() filter: FilterLocomotivesDto) {
|
findAll(@Query() filter: FilterLocomotivesDto) {
|
||||||
return this.locomotivesService.findAll(filter);
|
return this.locomotivesService.findAll(filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOperation({ summary: 'Get a locomotive by ID' })
|
||||||
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.locomotivesService.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@ApiOperation({ summary: 'Create a locomotive' })
|
||||||
|
create(@Body() dto: CreateLocomotiveDto) {
|
||||||
|
return this.locomotivesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
@ApiOperation({ summary: 'Update a locomotive' })
|
||||||
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) {
|
||||||
|
return this.locomotivesService.update(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/decommission')
|
||||||
|
@ApiOperation({ summary: 'Decommission a locomotive' })
|
||||||
|
decommission(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.locomotivesService.decommission(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { CreateLocomotiveDto } from './dto/create-locomotive.dto';
|
||||||
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
|
||||||
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
|
import { UpdateLocomotiveDto } from './dto/update-locomotive.dto';
|
||||||
|
import { Locomotive, type LocomotiveStatus, type LocomotiveType } from './entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from './locomotives.repository';
|
import { LocomotivesRepository } from './locomotives.repository';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -10,13 +12,36 @@ export class LocomotivesService {
|
|||||||
|
|
||||||
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
|
||||||
return this.locomotivesRepository.findAll({
|
return this.locomotivesRepository.findAll({
|
||||||
where: filter.status
|
where: {
|
||||||
? { status: filter.status as LocomotiveStatus }
|
...(filter.status ? { status: filter.status as LocomotiveStatus } : {}),
|
||||||
: undefined,
|
...(filter.locomotiveType
|
||||||
|
? { locomotiveType: filter.locomotiveType as LocomotiveType }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
order: { code: 'ASC' },
|
order: { code: 'ASC' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||||
|
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.locomotivesRepository.create({
|
||||||
|
code: dto.code,
|
||||||
|
name: dto.name?.trim() || null,
|
||||||
|
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||||
|
status: dto.status as LocomotiveStatus,
|
||||||
|
maxPullWeightTons: dto.maxPullWeightTons,
|
||||||
|
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||||
|
powerKw: dto.powerKw ?? null,
|
||||||
|
tractionForceKn: dto.tractionForceKn ?? null,
|
||||||
|
maxSpeedKmh: dto.maxSpeedKmh ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<Locomotive> {
|
async findById(id: string): Promise<Locomotive> {
|
||||||
const locomotive = await this.locomotivesRepository.findById(id);
|
const locomotive = await this.locomotivesRepository.findById(id);
|
||||||
|
|
||||||
@@ -26,4 +51,48 @@ export class LocomotivesService {
|
|||||||
|
|
||||||
return locomotive;
|
return locomotive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdateLocomotiveDto): Promise<Locomotive> {
|
||||||
|
const locomotive = await this.findById(id);
|
||||||
|
|
||||||
|
if (dto.code && dto.code !== locomotive.code) {
|
||||||
|
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||||
|
if (existing && existing.id !== id) {
|
||||||
|
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.locomotivesRepository.update(id, {
|
||||||
|
...dto,
|
||||||
|
locomotiveType:
|
||||||
|
dto.locomotiveType === undefined ? locomotive.locomotiveType : dto.locomotiveType as LocomotiveType,
|
||||||
|
status: dto.status === undefined ? locomotive.status : dto.status as LocomotiveStatus,
|
||||||
|
name: dto.name === undefined ? locomotive.name : dto.name?.trim() || null,
|
||||||
|
powerKw: dto.powerKw === undefined ? locomotive.powerKw : dto.powerKw ?? null,
|
||||||
|
tractionForceKn:
|
||||||
|
dto.tractionForceKn === undefined ? locomotive.tractionForceKn : dto.tractionForceKn ?? null,
|
||||||
|
maxSpeedKmh:
|
||||||
|
dto.maxSpeedKmh === undefined ? locomotive.maxSpeedKmh : dto.maxSpeedKmh ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decommission(id: string): Promise<Locomotive> {
|
||||||
|
await this.findById(id);
|
||||||
|
|
||||||
|
const updated = await this.locomotivesRepository.update(id, {
|
||||||
|
status: 'OUT_OF_SERVICE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new NotFoundException(`Locomotive ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 PaymentType = "booking"
|
||||||
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
type PaymentMethod = "telebirr" | "cbe-birr" | "ebirr"
|
||||||
type Currency = "ETB" | "USD"
|
type Currency = "ETB" | "USD"
|
||||||
type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
export type PaymentStatus = "action-required" | "processing" | "success" | "failed" | "canceled" | "refunded"
|
||||||
|
|
||||||
@Entity({ schema: 'freight', name: 'payments' })
|
@Entity({ schema: 'freight', name: 'payments' })
|
||||||
export class PaymentEntity extends BaseEntity {
|
export class PaymentEntity extends BaseEntity {
|
||||||
|
|||||||
@@ -1,53 +1,31 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, ParseUUIDPipe, Post, Res } from "@nestjs/common";
|
import { Controller, Get, NotFoundException, Param, Post, Res } from "@nestjs/common";
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { Public } from "@edr/api-common";
|
import { Public } from "@edr/api-common";
|
||||||
// import { randomUUID } from "crypto";
|
|
||||||
import { Response } from "express"
|
import { Response } from "express"
|
||||||
|
|
||||||
@Public()
|
@Public()
|
||||||
@Controller("payments")
|
@Controller("payments")
|
||||||
export class PaymentController {
|
export class PaymentController {
|
||||||
constructor(private readonly paymentService: PaymentService,) { }
|
constructor(private readonly paymentService: PaymentService,) { }
|
||||||
|
|
||||||
|
@Post("/initiate")
|
||||||
|
initiate() {
|
||||||
|
return this.paymentService.initBookingTelebirr("123", "web")
|
||||||
|
}
|
||||||
|
|
||||||
// @Get("/receipts/:orderId/html")
|
@Post("/bookings/check-payment/:orderId")
|
||||||
// async genReceipt(@Param("orderId") orderId: string, @Res() res: Response) {
|
checkPayment(@Param("orderId") orderId: string) {
|
||||||
// const filled = await this.paymentService.genReceiptHtml(orderId);
|
return this.paymentService.checkStatusAndUpdate(orderId)
|
||||||
// return res.send(filled)
|
}
|
||||||
// }
|
|
||||||
|
|
||||||
// @Post("/initiate/booking")
|
@Get("/bookings/telebirr/redirect/:orderId")
|
||||||
// async initiatePayment() {
|
async pay(@Param("orderId") orderId: string, @Res() res: Response) {
|
||||||
|
const payment = await this.paymentService.getActivePaymentByOrderIdAndMethod(orderId, "telebirr")
|
||||||
// //Only for testing..
|
if (!payment) {
|
||||||
// const description = "Booking for contact"
|
throw new NotFoundException('payment not found')
|
||||||
// const price = 2000
|
|
||||||
// const data = await this.paymentService.pay(price, "ETB", "telebirr", description, "booking", (_) => {
|
|
||||||
// return new Promise((resp, _) => {
|
|
||||||
// resp({
|
|
||||||
// id: randomUUID(),
|
|
||||||
// type: "booking"
|
|
||||||
// })
|
|
||||||
// });
|
|
||||||
// })
|
|
||||||
|
|
||||||
// return data
|
|
||||||
// }
|
|
||||||
|
|
||||||
@Post("/bookings/check-payment/:orderId")
|
|
||||||
checkPayment(@Param("orderId", ParseUUIDPipe) orderId: string) {
|
|
||||||
return this.paymentService.checkStatusAndUpdate(orderId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return res.send(`
|
||||||
@Get("/telebirr/:refId")
|
|
||||||
async pay(@Param("refId", ParseUUIDPipe) refId: string, @Res() res: Response) {
|
|
||||||
const payment = await this.paymentService.getActivePaymentByRefIdAndMethod(refId, "telebirr")
|
|
||||||
if (!payment) {
|
|
||||||
throw new NotFoundException('payment not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.send(`
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
@@ -62,6 +40,5 @@ export class PaymentController {
|
|||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
|
|
||||||
import { PaymentService } from "./payment.service";
|
import { PaymentService } from "./payment.service";
|
||||||
import { HttpModule } from "@nestjs/axios";
|
import { HttpModule } from "@nestjs/axios";
|
||||||
import { PaymentController } from "./payment.controller";
|
import { PaymentController } from "./payment.controller";
|
||||||
@@ -7,10 +6,11 @@ import { ConfigModule } from "@nestjs/config";
|
|||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { WebhookController } from "./webhooks/webhook.controller";
|
import { WebhookController } from "./webhooks/webhook.controller";
|
||||||
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
import { TelebirrWebhookService } from "./webhooks/providers/telebirr.service";
|
||||||
|
import { TelebirrProvider } from "@edr/payment-providers";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HttpModule, ConfigModule],
|
imports: [HttpModule, ConfigModule],
|
||||||
providers: [PaymentRepository, PaymentTelebirrStrategy, PaymentService, TelebirrWebhookService],
|
providers: [PaymentRepository, PaymentService, TelebirrWebhookService, TelebirrProvider],
|
||||||
controllers: [PaymentController, WebhookController],
|
controllers: [PaymentController, WebhookController],
|
||||||
exports: [PaymentService]
|
exports: [PaymentService]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export class PaymentRepository {
|
|||||||
return qr.manager.save(payment)
|
return qr.manager.save(payment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(data: Pick<PaymentEntity, "amount" | "method" | "currency" | "type" | "refId" | "merchantOrderId" | "rawInitiation" | "clientAction" | "expiresAt" | "reason">): Promise<PaymentEntity> {
|
||||||
|
const payment = this.paymentRepo.create(data)
|
||||||
|
return this.paymentRepo.save(payment)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
|
findOneBy(options: FindOptionsWhere<PaymentEntity> | FindOptionsWhere<PaymentEntity>[]): Promise<PaymentEntity | null> {
|
||||||
return this.paymentRepo.findOneBy(options);
|
return this.paymentRepo.findOneBy(options);
|
||||||
}
|
}
|
||||||
@@ -36,4 +43,20 @@ export class PaymentRepository {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]) {
|
||||||
|
return this.paymentRepo
|
||||||
|
.createQueryBuilder('payment')
|
||||||
|
.where('payment.method = :method', { method })
|
||||||
|
.andWhere('payment.merchantOrderId = :orderId', { orderId })
|
||||||
|
.andWhere('payment.status IN (:...statuses)', {
|
||||||
|
statuses: ['action-required'],
|
||||||
|
})
|
||||||
|
.andWhere('payment.expiresAt > :now', { now: new Date() })
|
||||||
|
.getOne();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,111 +1,90 @@
|
|||||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
import { DataSource, QueryRunner } from "typeorm";
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
InternalServerErrorException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { DataSource } from "typeorm";
|
||||||
import { PaymentEntity } from "./entities/payment.entity";
|
import { PaymentEntity } from "./entities/payment.entity";
|
||||||
import { PaymentStrategy } from "./strategies/payment.strategy";
|
|
||||||
import { PaymentTelebirrStrategy } from "./strategies/payment.telebirr.strategy";
|
|
||||||
import { PaymentRepository } from "./payment.repository";
|
import { PaymentRepository } from "./payment.repository";
|
||||||
import { ClientAction, PaymentPlatform } from "./strategies/payments.types";
|
|
||||||
import * as crypto from 'crypto';
|
|
||||||
|
|
||||||
import * as fs from 'fs';
|
import * as fs from "fs";
|
||||||
import * as path from 'path';
|
import * as path from "path";
|
||||||
import * as Handlebars from 'handlebars';
|
import * as Handlebars from "handlebars";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ClientAction,
|
||||||
|
createMerchantOrderId,
|
||||||
|
ProviderPaymentStatus,
|
||||||
|
TelebirrProvider,
|
||||||
|
} from "@edr/payment-providers";
|
||||||
|
import { ProviderInitiationInput } from "@edr/types"
|
||||||
|
import { InitiateResponseDto, PaymentPlatformDto } from "./payments.dto";
|
||||||
|
|
||||||
type PaymentMethod = PaymentEntity["method"]
|
const DEFAULT_CURRENCY = "ETB";
|
||||||
type CurrencyType = PaymentEntity["currency"]
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaymentService {
|
export class PaymentService {
|
||||||
private strategies: Map<PaymentMethod, PaymentStrategy>;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly datasource: DataSource,
|
private readonly datasource: DataSource,
|
||||||
private readonly paymentRepo: PaymentRepository,
|
private readonly paymentRepo: PaymentRepository,
|
||||||
private readonly telebirrPaymentStategy: PaymentTelebirrStrategy) {
|
private readonly telebirrProvider: TelebirrProvider,
|
||||||
this.strategies = new Map([
|
) { }
|
||||||
["telebirr", this.telebirrPaymentStategy as PaymentStrategy]
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
async pay(amount: number, currency: CurrencyType, method: PaymentMethod, reason: string, type: PaymentEntity["type"], cb: (qr: QueryRunner) => Promise<{ id: string, type: PaymentEntity["type"] }>, payform: PaymentPlatform = "web"): Promise<{
|
async initBookingTelebirr(
|
||||||
refId: string,
|
bookingId: string,
|
||||||
clientAction: ClientAction,
|
platform: PaymentPlatformDto,
|
||||||
status: PaymentEntity["status"],
|
): Promise<{ redirectUrl: string }> {
|
||||||
paidAt?: string,
|
// const booking = await this.datasource.getRepository(Booking).findOneBy({ id: bookingId });
|
||||||
failureCode?: string,
|
// if (!booking) throw new NotFoundException("Booking not found");
|
||||||
failureMessage?: string,
|
|
||||||
}> {
|
|
||||||
|
|
||||||
|
// const booking = new Booking()
|
||||||
|
// booking.totalAmount = 20
|
||||||
|
// booking.id = randomUUID
|
||||||
|
const amount = 20
|
||||||
|
const merchantOrderId = createMerchantOrderId();
|
||||||
|
const redirectBase = this.configService.get<string>("TELEBIRR_SUCCESS_BOOKING_REDIRECT_BASE_URL");
|
||||||
|
const redirectUrl = `${redirectBase}/${merchantOrderId}`;
|
||||||
|
const amountMinor = Math.round(Number(amount) * 100);
|
||||||
|
|
||||||
const strategy = this.strategies.get(method)
|
const input: ProviderInitiationInput = {
|
||||||
if (!strategy) {
|
merchantOrderId,
|
||||||
throw new NotFoundException("strategy not found")
|
orderRef: bookingId,
|
||||||
}
|
amountMinor,
|
||||||
|
currency: DEFAULT_CURRENCY,
|
||||||
const orderId = `${Date.now()}${crypto.randomBytes(4).toString('hex')}` //todo: make it dynamic
|
platform: platform || "web",
|
||||||
let redirectUrl: string;
|
|
||||||
switch (type) {
|
|
||||||
case "booking":
|
|
||||||
const url = this.configService.get<string>("TELEBIRR_SUCCESS_REDIRECT_BASE_URL")
|
|
||||||
redirectUrl = `${url}/check-status/${orderId}`
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentResp = await strategy.pay({
|
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
amountMinor: amount,
|
};
|
||||||
currency: currency,
|
|
||||||
merchantOrderId: orderId,
|
const result = await this.telebirrProvider.initiate(input);
|
||||||
platform: payform,
|
|
||||||
|
const payment = await this.paymentRepo.create({
|
||||||
|
amount: amount,
|
||||||
|
currency: DEFAULT_CURRENCY,
|
||||||
|
method: "telebirr",
|
||||||
|
refId: bookingId,
|
||||||
|
type: "booking",
|
||||||
|
merchantOrderId,
|
||||||
|
rawInitiation: result.rawInitiation,
|
||||||
|
clientAction: result.clientAction as Record<string, unknown>,
|
||||||
|
expiresAt: result.expiresAt,
|
||||||
|
reason: `Payment for booking`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const queryRunner = this.datasource.createQueryRunner()
|
return {
|
||||||
await queryRunner.connect()
|
redirectUrl: `${this.configService.get<string>("TELEBIRR_REDIRECT_BASE_URL")}/${payment.merchantOrderId}`
|
||||||
await queryRunner.startTransaction()
|
|
||||||
|
|
||||||
console.log(paymentResp.expiresAt)
|
|
||||||
try {
|
|
||||||
const resp = await cb(queryRunner)
|
|
||||||
const payment = await this.paymentRepo.createTr(queryRunner, {
|
|
||||||
amount,
|
|
||||||
currency,
|
|
||||||
method,
|
|
||||||
refId: resp.id,
|
|
||||||
type: resp.type,
|
|
||||||
merchantOrderId: orderId,
|
|
||||||
rawInitiation: paymentResp.rawInitiation,
|
|
||||||
clientAction: paymentResp.clientAction,
|
|
||||||
expiresAt: paymentResp.expiresAt,
|
|
||||||
reason
|
|
||||||
|
|
||||||
})
|
|
||||||
await queryRunner.commitTransaction()
|
|
||||||
return {
|
|
||||||
refId: payment.refId,
|
|
||||||
clientAction: paymentResp.clientAction,
|
|
||||||
status: payment.status,
|
|
||||||
paidAt: payment.paidAt?.toISOString(),
|
|
||||||
failureCode: payment.failerCode ?? undefined,
|
|
||||||
failureMessage: payment.failureMessage ?? undefined,
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
await queryRunner.rollbackTransaction()
|
|
||||||
throw new Error("payment failed")
|
|
||||||
} finally {
|
|
||||||
await queryRunner.release()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async getActivePaymentByRefIdAndMethod(refId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
async getActivePaymentByOrderIdAndMethod(orderId: string, method: PaymentEntity["method"]): Promise<PaymentEntity | null> {
|
||||||
return this.paymentRepo.getActivePaymentByRefIdAndMethod(refId, method)
|
return this.paymentRepo.getActivePaymentByOrderIdAndMethod(orderId, method)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async genReceiptHtml(orderId: string) {
|
async genReceiptHtml(orderId: string) {
|
||||||
const payment = await this.paymentRepo.findOneBy({
|
const payment = await this.paymentRepo.findOneBy({
|
||||||
merchantOrderId: orderId,
|
merchantOrderId: orderId,
|
||||||
@@ -141,13 +120,9 @@ export class PaymentService {
|
|||||||
if (!resp) {
|
if (!resp) {
|
||||||
throw new NotFoundException("order id not found")
|
throw new NotFoundException("order id not found")
|
||||||
}
|
}
|
||||||
const result = await this.telebirrPaymentStategy.queryStatus(resp.merchantOrderId)
|
const result = await this.telebirrProvider.queryStatus(resp.merchantOrderId)
|
||||||
const bizContent = result.rawResponse.biz_content as {
|
|
||||||
order_status: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ordersStatus = bizContent.order_status
|
if (result.status === ProviderPaymentStatus.SUCCEEDED) {
|
||||||
if (ordersStatus == "PAY_SUCCESS") {
|
|
||||||
await this.datasource.transaction(async (mg) => {
|
await this.datasource.transaction(async (mg) => {
|
||||||
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
await mg.update(Booking, { id: resp.refId }, { status: "PAID" })
|
||||||
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
await mg.update(PaymentEntity, { id: resp.id }, { status: "success" })
|
||||||
@@ -158,5 +133,28 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
findBookingById(id: string) {
|
||||||
|
return this.paymentRepo.findOneBy({ refId: id, type: "booking" })
|
||||||
|
}
|
||||||
|
|
||||||
|
formatIntentResponse(intent: PaymentEntity): InitiateResponseDto {
|
||||||
|
const clientAction =
|
||||||
|
intent.clientAction && typeof intent.clientAction === "object"
|
||||||
|
? (intent.clientAction as unknown as ClientAction)
|
||||||
|
: undefined;
|
||||||
|
const statusMap: Record<string, ProviderPaymentStatus> = {
|
||||||
|
"action-required": ProviderPaymentStatus.REQUIRES_ACTION,
|
||||||
|
"processing": ProviderPaymentStatus.PROCESSING,
|
||||||
|
"success": ProviderPaymentStatus.SUCCEEDED,
|
||||||
|
"failed": ProviderPaymentStatus.FAILED,
|
||||||
|
"canceled": ProviderPaymentStatus.CANCELLED,
|
||||||
|
"refunded": ProviderPaymentStatus.CANCELLED,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
intentId: intent.id,
|
||||||
|
status: statusMap[intent.status] ?? ProviderPaymentStatus.PROCESSING,
|
||||||
|
clientAction,
|
||||||
|
merchantOrderId: intent.merchantOrderId ?? undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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 { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import * as crypto from "crypto"
|
|
||||||
import { TelebirrDto } from '../dto/telebirr.dto';
|
import { TelebirrDto } from '../dto/telebirr.dto';
|
||||||
import { PaymentRepository } from '../../payment.repository';
|
import { PaymentRepository } from '../../payment.repository';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
import { Booking } from 'src/modules/bookings/entities/booking.entity';
|
import { Booking } from '../../../bookings/entities/booking.entity';
|
||||||
|
import { TelebirrProvider, ProviderPaymentStatus } from '@edr/payment-providers';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TelebirrWebhookService {
|
export class TelebirrWebhookService {
|
||||||
// private readonly logger = new Logger(TelebirrWebhookService.name);
|
private readonly logger = new Logger(TelebirrWebhookService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly datasource: DataSource,
|
private readonly datasource: DataSource,
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly paymentRepo: PaymentRepository,
|
private readonly paymentRepo: PaymentRepository,
|
||||||
|
private readonly telebirrProvider: TelebirrProvider,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
verifyTelebirrNotification(payload: TelebirrDto) {
|
verifyTelebirrNotification(payload: TelebirrDto) {
|
||||||
// 1. Extract the signature provided by Telebirr
|
return this.telebirrProvider.verifyWebhookSignature(payload as unknown as Record<string, unknown>);
|
||||||
const { sign, ...bizContent } = payload;
|
|
||||||
|
|
||||||
if (!sign) {
|
|
||||||
throw new Error("Missing 'sign' field from Telebirr payload");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Sort the remaining keys alphabetically to rebuild the raw string
|
|
||||||
const sortedKeys = Object.keys(bizContent).sort();
|
|
||||||
const signString = sortedKeys
|
|
||||||
.map(key => `${key}=${typeof bizContent[key] === 'object' ? JSON.stringify(bizContent[key]) : bizContent[key]}`)
|
|
||||||
.join('&');
|
|
||||||
|
|
||||||
// 3. Convert Telebirr's public key into an object specifying RSA-PSS padding
|
|
||||||
const publicKey = {
|
|
||||||
key: this.config.get<string>("telebirr.publicKey") ?? "",
|
|
||||||
padding: crypto.constants.RSA_PKCS1_PSS_PADDING,
|
|
||||||
saltLength: 32 // Telebirr standard salt length
|
|
||||||
};
|
|
||||||
|
|
||||||
// 4. Verify the signature against the sorted string
|
|
||||||
const isVerified = crypto.verify(
|
|
||||||
"sha256",
|
|
||||||
Buffer.from(signString),
|
|
||||||
publicKey,
|
|
||||||
Buffer.from(sign, 'base64')
|
|
||||||
);
|
|
||||||
|
|
||||||
return isVerified;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async handle(payload: TelebirrDto): Promise<void> {
|
async handle(payload: TelebirrDto): Promise<void> {
|
||||||
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
const payment = await this.paymentRepo.findOneBy({ merchantOrderId: payload.merch_order_id })
|
||||||
if (!payment) {
|
if (!payment) {
|
||||||
throw new Error("payment not found")
|
this.logger.warn(`Webhook received for unknown merchantOrderId: ${payload.merch_order_id}`);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
switch (payload.trade_status) {
|
|
||||||
case "SUCCEEDED":
|
const mapped = this.telebirrProvider.mapWebhookTradeStatus(payload.trade_status);
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "success", paidAt: new Date() })
|
|
||||||
switch (payment.type) {
|
switch (mapped) {
|
||||||
case "booking":
|
case ProviderPaymentStatus.SUCCEEDED:
|
||||||
await this.datasource.manager.update(Booking, { id: payment.refId }, { paymentStatus: "PAID", })
|
await this.paymentRepo.update(
|
||||||
// await this.bookingRepo.update(payment.refId, { paymentStatus: "PAID", })
|
{ id: payment.id },
|
||||||
break;
|
{ status: "success", paidAt: new Date() },
|
||||||
|
);
|
||||||
|
if (payment.type === "booking") {
|
||||||
|
await this.datasource.manager.update(
|
||||||
|
Booking,
|
||||||
|
{ id: payment.refId },
|
||||||
|
{ paymentStatus: "PAID" },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "FAILED":
|
case ProviderPaymentStatus.FAILED:
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "failed" })
|
await this.paymentRepo.update({ id: payment.id }, { status: "failed" });
|
||||||
break;
|
break;
|
||||||
case "CANCELLED":
|
case ProviderPaymentStatus.PROCESSING:
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "canceled" })
|
await this.paymentRepo.update({ id: payment.id }, { status: "processing" });
|
||||||
break;
|
break;
|
||||||
case "PROCESSING":
|
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "processing" })
|
|
||||||
break;
|
|
||||||
case "REFUNDED":
|
|
||||||
await this.paymentRepo.update({ id: payment.id }, { status: "refunded" })
|
|
||||||
break;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -22,14 +22,12 @@ export class WebhookController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// const verified = this.telebirr.verifyTelebirrNotification(payload)
|
const verified = this.telebirr.verifyTelebirrNotification(payload)
|
||||||
// if (!verified) {
|
if (!verified) {
|
||||||
// throw new Error("not valid")
|
throw new Error("Telebirr webhook signature verification failed")
|
||||||
// }
|
}
|
||||||
// const merchantOrderId = payload.merch_order_id;
|
|
||||||
await this.telebirr.handle(payload);
|
await this.telebirr.handle(payload);
|
||||||
|
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
this.logger.error(`Telebirr webhook handler threw: ${message}`);
|
||||||
|
|||||||
@@ -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 { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
|
||||||
|
|
||||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
|
import { Route } from '../../routes/entities/route.entity';
|
||||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||||
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
import { TrainScheduleBooking } from './train-schedule-booking.entity';
|
||||||
|
|
||||||
@@ -26,6 +27,13 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@JoinColumn({ name: 'train_set_id' })
|
@JoinColumn({ name: 'train_set_id' })
|
||||||
trainSet?: TrainSet;
|
trainSet?: TrainSet;
|
||||||
|
|
||||||
|
@Column({ name: 'route_id', type: 'uuid', nullable: true })
|
||||||
|
routeId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => Route)
|
||||||
|
@JoinColumn({ name: 'route_id' })
|
||||||
|
route?: Route | null;
|
||||||
|
|
||||||
@Column({ name: 'origin_station_id', type: 'uuid' })
|
@Column({ name: 'origin_station_id', type: 'uuid' })
|
||||||
originStationId!: string;
|
originStationId!: string;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsUUID } from 'class-validator';
|
import { IsDateString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
|
export class CreateContainerTrainScheduleDto {
|
||||||
|
@ApiProperty({ format: 'uuid' })
|
||||||
|
@IsUUID()
|
||||||
|
routeId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
|
||||||
|
@IsDateString()
|
||||||
|
scheduleDate!: string;
|
||||||
|
|
||||||
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
locomotiveId!: string;
|
locomotiveId!: string;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const locomotive = {
|
|||||||
id: 'loc-1',
|
id: 'loc-1',
|
||||||
code: 'LOC-001',
|
code: 'LOC-001',
|
||||||
maxPullWeightTons: 3500,
|
maxPullWeightTons: 3500,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
status: 'AVAILABLE',
|
status: 'AVAILABLE',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,47 +203,12 @@ describe('TrainSchedulingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('creates a schedule transactionally when validation passes', async () => {
|
it('creates a schedule transactionally when validation passes', async () => {
|
||||||
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
|
const route = {
|
||||||
const validation = {
|
id: 'route-1',
|
||||||
valid: true,
|
name: 'Djibouti to Addis',
|
||||||
violations: [],
|
originYardId: 'yard-origin',
|
||||||
bookings,
|
destinationYardId: 'yard-destination',
|
||||||
wagonType: nw5,
|
isActive: true,
|
||||||
summary: {
|
|
||||||
totalBookings: 1,
|
|
||||||
totalWeightTons: 140,
|
|
||||||
wagonType: 'NW5',
|
|
||||||
wagonsNeeded: 2,
|
|
||||||
totalLengthMeters: 28,
|
|
||||||
},
|
|
||||||
wagonPlan: [
|
|
||||||
{
|
|
||||||
sequenceNo: 1,
|
|
||||||
capacityTons: 70,
|
|
||||||
lengthMeters: 14,
|
|
||||||
assignedWeightTons: 70,
|
|
||||||
allocations: [
|
|
||||||
{
|
|
||||||
bookingId: 'b1',
|
|
||||||
bookingReference: 'BKG-CONT-001',
|
|
||||||
allocatedWeightTons: 70,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequenceNo: 2,
|
|
||||||
capacityTons: 70,
|
|
||||||
lengthMeters: 14,
|
|
||||||
assignedWeightTons: 70,
|
|
||||||
allocations: [
|
|
||||||
{
|
|
||||||
bookingId: 'b1',
|
|
||||||
bookingReference: 'BKG-CONT-001',
|
|
||||||
allocatedWeightTons: 70,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const lockedLocomotiveRepo = {
|
const lockedLocomotiveRepo = {
|
||||||
@@ -253,23 +219,6 @@ describe('TrainSchedulingService', () => {
|
|||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
|
||||||
};
|
};
|
||||||
const trainScheduleBookingRepo = {
|
|
||||||
count: jest.fn().mockResolvedValue(0),
|
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
|
||||||
save: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const trainSetWagonRepo = {
|
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
|
||||||
save: jest.fn().mockResolvedValue(undefined),
|
|
||||||
find: jest.fn().mockResolvedValue([
|
|
||||||
{ id: 'wagon-1', sequenceNo: 1 },
|
|
||||||
{ id: 'wagon-2', sequenceNo: 2 },
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
const wagonAllocRepo = {
|
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
|
||||||
save: jest.fn().mockResolvedValue(undefined),
|
|
||||||
};
|
|
||||||
const trainSetRepo = {
|
const trainSetRepo = {
|
||||||
create: jest.fn().mockImplementation((value) => value),
|
create: jest.fn().mockImplementation((value) => value),
|
||||||
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
|
||||||
@@ -281,12 +230,6 @@ describe('TrainSchedulingService', () => {
|
|||||||
return lockedLocomotiveRepo;
|
return lockedLocomotiveRepo;
|
||||||
case 'TrainSchedule':
|
case 'TrainSchedule':
|
||||||
return trainScheduleRepo;
|
return trainScheduleRepo;
|
||||||
case 'TrainScheduleBooking':
|
|
||||||
return trainScheduleBookingRepo;
|
|
||||||
case 'TrainSetWagon':
|
|
||||||
return trainSetWagonRepo;
|
|
||||||
case 'WagonBookingAllocation':
|
|
||||||
return wagonAllocRepo;
|
|
||||||
case 'TrainSet':
|
case 'TrainSet':
|
||||||
return trainSetRepo;
|
return trainSetRepo;
|
||||||
default:
|
default:
|
||||||
@@ -295,70 +238,60 @@ describe('TrainSchedulingService', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
|
|
||||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
|
if (entity?.name === 'Route') {
|
||||||
|
return { findOne: jest.fn().mockResolvedValue(route) };
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||||
|
});
|
||||||
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
|
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
|
||||||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||||
callback(manager),
|
callback(manager),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.createContainerTrainSchedule({
|
const result = await service.createContainerTrainSchedule({
|
||||||
bookingIds: ['b1'],
|
routeId: 'route-1',
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
originStationId: 'yard-origin',
|
|
||||||
destinationStationId: 'yard-destination',
|
|
||||||
locomotiveId: 'loc-1',
|
locomotiveId: 'loc-1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(trainSetRepo.save).toHaveBeenCalled();
|
expect(trainSetRepo.save).toHaveBeenCalled();
|
||||||
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
expect(trainScheduleRepo.save).toHaveBeenCalled();
|
||||||
expect(trainSetWagonRepo.save).toHaveBeenCalled();
|
|
||||||
expect(wagonAllocRepo.save).toHaveBeenCalled();
|
|
||||||
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
|
||||||
expect(result).toEqual({ id: 'schedule-1' });
|
expect(result).toEqual({ id: 'schedule-1' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects create when the locked locomotive is no longer available', async () => {
|
it('rejects create when the locked locomotive is no longer available', async () => {
|
||||||
const validation = {
|
|
||||||
valid: true,
|
|
||||||
violations: [],
|
|
||||||
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
|
|
||||||
wagonType: nw5,
|
|
||||||
summary: {
|
|
||||||
totalBookings: 1,
|
|
||||||
totalWeightTons: 70,
|
|
||||||
wagonType: 'NW5',
|
|
||||||
wagonsNeeded: 1,
|
|
||||||
totalLengthMeters: 14,
|
|
||||||
},
|
|
||||||
wagonPlan: [
|
|
||||||
{
|
|
||||||
sequenceNo: 1,
|
|
||||||
capacityTons: 70,
|
|
||||||
lengthMeters: 14,
|
|
||||||
assignedWeightTons: 70,
|
|
||||||
allocations: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const manager = {
|
const manager = {
|
||||||
getRepository: jest.fn(() => ({
|
getRepository: jest.fn(() => ({
|
||||||
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
|
|
||||||
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
|
||||||
|
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
|
||||||
|
if (entity?.name === 'Route') {
|
||||||
|
return {
|
||||||
|
findOne: jest.fn().mockResolvedValue({
|
||||||
|
id: 'route-1',
|
||||||
|
name: 'Djibouti to Addis',
|
||||||
|
originYardId: 'yard-origin',
|
||||||
|
destinationYardId: 'yard-destination',
|
||||||
|
isActive: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`Unexpected repository ${entity?.name}`);
|
||||||
|
});
|
||||||
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
|
||||||
callback(manager),
|
callback(manager),
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.createContainerTrainSchedule({
|
service.createContainerTrainSchedule({
|
||||||
bookingIds: ['b1'],
|
routeId: 'route-1',
|
||||||
scheduleDate: '2026-06-20T08:00:00.000Z',
|
scheduleDate: '2026-06-20T08:00:00.000Z',
|
||||||
originStationId: 'yard-origin',
|
|
||||||
destinationStationId: 'yard-destination',
|
|
||||||
locomotiveId: 'loc-1',
|
locomotiveId: 'loc-1',
|
||||||
}),
|
}),
|
||||||
).rejects.toBeInstanceOf(ConflictException);
|
).rejects.toBeInstanceOf(ConflictException);
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import {
|
|||||||
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
|
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
|
||||||
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
|
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
|
||||||
import { TrainSet } from "../train-sets/entities/train-set.entity";
|
import { TrainSet } from "../train-sets/entities/train-set.entity";
|
||||||
|
import { Route } from "../routes/entities/route.entity";
|
||||||
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
|
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
|
||||||
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
||||||
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
|
|
||||||
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
|
||||||
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
|
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
|
||||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||||
@@ -179,18 +179,12 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
|
||||||
const validation = await this.validateContainerBookingsForScheduling(dto);
|
const route = await this.getActiveRoute(dto.routeId);
|
||||||
|
|
||||||
if (!validation.valid) {
|
|
||||||
throw new BadRequestException({
|
|
||||||
message: "train_schedule_invalid",
|
|
||||||
violations: validation.violations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const locomotive = await this.selectOrValidateLocomotive(
|
const locomotive = await this.selectOrValidateLocomotive(
|
||||||
dto.locomotiveId,
|
dto.locomotiveId,
|
||||||
validation.summary.totalWeightTons,
|
0,
|
||||||
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const createdSchedule = await this.dataSource.transaction(
|
const createdSchedule = await this.dataSource.transaction(
|
||||||
@@ -211,90 +205,24 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const trainSet = await this.buildEmptyTrainSet(
|
||||||
Number(lockedLocomotive.maxPullWeightTons) <
|
|
||||||
validation.summary.totalWeightTons
|
|
||||||
) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingScheduleCount = await manager
|
|
||||||
.getRepository(TrainScheduleBooking)
|
|
||||||
.count({
|
|
||||||
where: {
|
|
||||||
bookingId: In(validation.bookings.map((booking) => booking.id)),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingScheduleCount > 0) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
"One or more bookings are already scheduled",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const trainSet = await this.buildTrainSet(
|
|
||||||
manager,
|
manager,
|
||||||
lockedLocomotive,
|
lockedLocomotive,
|
||||||
validation.wagonType,
|
|
||||||
validation.summary.totalWeightTons,
|
|
||||||
validation.summary.totalLengthMeters,
|
|
||||||
validation.wagonPlan,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const schedule = manager.getRepository(TrainSchedule).create({
|
const schedule = manager.getRepository(TrainSchedule).create({
|
||||||
trainSetId: trainSet.id,
|
trainSetId: trainSet.id,
|
||||||
originStationId: dto.originStationId,
|
routeId: route.id,
|
||||||
destinationStationId: dto.destinationStationId,
|
originStationId: route.originYardId,
|
||||||
|
destinationStationId: route.destinationYardId,
|
||||||
scheduledDepartureDate: new Date(dto.scheduleDate),
|
scheduledDepartureDate: new Date(dto.scheduleDate),
|
||||||
status: "SCHEDULED",
|
status: "DRAFT",
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedSchedule = await manager
|
const savedSchedule = await manager
|
||||||
.getRepository(TrainSchedule)
|
.getRepository(TrainSchedule)
|
||||||
.save(schedule);
|
.save(schedule);
|
||||||
|
|
||||||
const scheduleBookings = validation.bookings.map((booking) =>
|
|
||||||
manager.getRepository(TrainScheduleBooking).create({
|
|
||||||
trainScheduleId: savedSchedule.id,
|
|
||||||
bookingId: booking.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await manager
|
|
||||||
.getRepository(TrainScheduleBooking)
|
|
||||||
.save(scheduleBookings);
|
|
||||||
|
|
||||||
const savedWagons = await manager.getRepository(TrainSetWagon).find({
|
|
||||||
where: { trainSetId: trainSet.id },
|
|
||||||
order: { sequenceNo: "ASC" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const wagonBySequence = new Map(
|
|
||||||
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
|
|
||||||
);
|
|
||||||
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
|
|
||||||
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
|
|
||||||
|
|
||||||
if (!wagon) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wagonPlan.allocations.map((allocation) =>
|
|
||||||
manager.getRepository(WagonBookingAllocation).create({
|
|
||||||
trainSetWagonId: wagon.id,
|
|
||||||
bookingId: allocation.bookingId,
|
|
||||||
allocatedWeightTons: allocation.allocatedWeightTons,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
await manager
|
|
||||||
.getRepository(WagonBookingAllocation)
|
|
||||||
.save(allocationRows);
|
|
||||||
|
|
||||||
await locomotiveRepository.update(lockedLocomotive.id, {
|
await locomotiveRepository.update(lockedLocomotive.id, {
|
||||||
status: "ASSIGNED",
|
status: "ASSIGNED",
|
||||||
});
|
});
|
||||||
@@ -461,10 +389,14 @@ export class TrainSchedulingService {
|
|||||||
where: { status: "AVAILABLE" },
|
where: { status: "AVAILABLE" },
|
||||||
});
|
});
|
||||||
const canPull = capableLocomotives.some(
|
const canPull = capableLocomotives.some(
|
||||||
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
|
(locomotive) =>
|
||||||
|
Number(locomotive.maxPullWeightTons) >= totalWeightTons &&
|
||||||
|
Number(locomotive.maxTrainLengthMeters) >= totalLengthMeters,
|
||||||
);
|
);
|
||||||
if (!canPull) {
|
if (!canPull) {
|
||||||
violations.push("No available locomotive can pull the total weight");
|
violations.push(
|
||||||
|
'No available locomotive can support the total train weight and length',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +445,7 @@ export class TrainSchedulingService {
|
|||||||
async selectOrValidateLocomotive(
|
async selectOrValidateLocomotive(
|
||||||
locomotiveId: string,
|
locomotiveId: string,
|
||||||
totalWeightTons: number,
|
totalWeightTons: number,
|
||||||
|
totalLengthMeters: number,
|
||||||
) {
|
) {
|
||||||
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
|
||||||
|
|
||||||
@@ -532,6 +465,12 @@ export class TrainSchedulingService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Number(locomotive.maxTrainLengthMeters) < totalLengthMeters) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Locomotive ${locomotive.code} cannot support ${totalLengthMeters}m`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return locomotive;
|
return locomotive;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +507,21 @@ export class TrainSchedulingService {
|
|||||||
return savedTrainSet;
|
return savedTrainSet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async buildEmptyTrainSet(
|
||||||
|
manager: EntityManager,
|
||||||
|
locomotive: Locomotive,
|
||||||
|
) {
|
||||||
|
const trainSet = manager.getRepository(TrainSet).create({
|
||||||
|
locomotiveId: locomotive.id,
|
||||||
|
totalWeightTons: 0,
|
||||||
|
totalLengthMeters: 0,
|
||||||
|
wagonCount: 0,
|
||||||
|
status: 'DRAFT',
|
||||||
|
});
|
||||||
|
|
||||||
|
return manager.getRepository(TrainSet).save(trainSet);
|
||||||
|
}
|
||||||
|
|
||||||
allocateBookingsToWagons(
|
allocateBookingsToWagons(
|
||||||
bookings: Booking[],
|
bookings: Booking[],
|
||||||
baseWagonPlan: WagonPlanRecord[],
|
baseWagonPlan: WagonPlanRecord[],
|
||||||
@@ -627,6 +581,7 @@ export class TrainSchedulingService {
|
|||||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||||
relations: {
|
relations: {
|
||||||
trainSet: { locomotive: true },
|
trainSet: { locomotive: true },
|
||||||
|
route: true,
|
||||||
originStation: true,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: true,
|
scheduleBookings: true,
|
||||||
@@ -637,6 +592,7 @@ export class TrainSchedulingService {
|
|||||||
return schedules.map((schedule) => ({
|
return schedules.map((schedule) => ({
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
scheduleDate: schedule.scheduledDepartureDate,
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
|
routeName: schedule.route?.name ?? null,
|
||||||
origin:
|
origin:
|
||||||
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination:
|
destination:
|
||||||
@@ -668,6 +624,7 @@ export class TrainSchedulingService {
|
|||||||
.findOne({
|
.findOne({
|
||||||
where: { id },
|
where: { id },
|
||||||
relations: {
|
relations: {
|
||||||
|
route: true,
|
||||||
trainSet: {
|
trainSet: {
|
||||||
locomotive: true,
|
locomotive: true,
|
||||||
wagons: { wagonType: true, allocations: { booking: true } },
|
wagons: { wagonType: true, allocations: { booking: true } },
|
||||||
@@ -687,6 +644,12 @@ export class TrainSchedulingService {
|
|||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
|
route: schedule.route
|
||||||
|
? {
|
||||||
|
id: schedule.route.id,
|
||||||
|
name: schedule.route.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
||||||
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
||||||
originStation: schedule.originStation,
|
originStation: schedule.originStation,
|
||||||
@@ -711,6 +674,9 @@ export class TrainSchedulingService {
|
|||||||
maxPullWeightTons: this.roundTons(
|
maxPullWeightTons: this.roundTons(
|
||||||
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
Number(schedule.trainSet.locomotive.maxPullWeightTons),
|
||||||
),
|
),
|
||||||
|
maxTrainLengthMeters: this.roundTons(
|
||||||
|
Number(schedule.trainSet.locomotive.maxTrainLengthMeters),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
wagons: [...(schedule.trainSet.wagons ?? [])]
|
wagons: [...(schedule.trainSet.wagons ?? [])]
|
||||||
@@ -806,6 +772,22 @@ export class TrainSchedulingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getActiveRoute(routeId: string) {
|
||||||
|
const route = await this.dataSource.getRepository(Route).findOne({
|
||||||
|
where: { id: routeId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
throw new NotFoundException(`Route ${routeId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!route.isActive) {
|
||||||
|
throw new BadRequestException(`Route ${route.name} is inactive`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
private toUtcDateKey(value: Date | string) {
|
private toUtcDateKey(value: Date | string) {
|
||||||
const date = value instanceof Date ? value : new Date(value);
|
const date = value instanceof Date ? value : new Date(value);
|
||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
const toNumber = ({ value }: { value: unknown }) =>
|
const toNumber = ({ value }: { value: unknown }) =>
|
||||||
value === '' || value == null ? value : Number(value);
|
value === '' || value == null ? value : Number(value);
|
||||||
|
|
||||||
|
const toOptionalNumber = ({ value }: { value: unknown }) =>
|
||||||
|
value === '' || value == null ? undefined : Number(value);
|
||||||
|
|
||||||
const toBoolean = ({ value }: { value: unknown }) => {
|
const toBoolean = ({ value }: { value: unknown }) => {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === 'true') return true;
|
if (value === 'true') return true;
|
||||||
@@ -22,8 +25,12 @@ const toBoolean = ({ value }: { value: unknown }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toStringArray = ({ value }: { value: unknown }) => {
|
const toStringArray = ({ value }: { value: unknown }) => {
|
||||||
if (Array.isArray(value)) return value;
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((entry) => String(entry).trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof value !== 'string') return [];
|
if (typeof value !== 'string') return [];
|
||||||
|
|
||||||
return value
|
return value
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
@@ -31,36 +38,40 @@ const toStringArray = ({ value }: { value: unknown }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class CreateWagonTypeDto {
|
export class CreateWagonTypeDto {
|
||||||
@ApiProperty({ maxLength: 32, example: 'FLAT' })
|
@ApiProperty({ maxLength: 32, example: 'NW5' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(32)
|
@MaxLength(32)
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
@ApiProperty({ maxLength: 100, example: 'Flat wagon' })
|
@ApiProperty({ maxLength: 100, example: 'Flat wagon container' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
name!: string;
|
name!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 60 })
|
@ApiProperty({ description: 'Maximum payload capacity in metric tons', example: 70 })
|
||||||
@Transform(toNumber)
|
@Transform(toNumber)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0.001)
|
||||||
capacityTons!: number;
|
capacityTons!: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 14.2 })
|
@ApiProperty({ description: 'Wagon length in meters', example: 14 })
|
||||||
@Transform(toNumber)
|
@Transform(toNumber)
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@Min(0)
|
@Min(0.001)
|
||||||
lengthMeters!: number;
|
lengthMeters!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 45 })
|
@ApiPropertyOptional({ description: 'Maximum wagons of this type per train', example: 53 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(toNumber)
|
@Transform(toOptionalNumber)
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(1)
|
@Min(1)
|
||||||
maxWagonsPerTrain?: number;
|
maxWagonsPerTrain?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: [String], example: ['container', 'break-bulk'] })
|
@ApiPropertyOptional({
|
||||||
|
description: 'Supported load types, e.g. CONTAINER,BULK',
|
||||||
|
type: [String],
|
||||||
|
default: [],
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(toStringArray)
|
@Transform(toStringArray)
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -11,48 +11,64 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { RuleEngineManage, RuleEngineView } from '../../common/rule-engine-guards';
|
||||||
|
|
||||||
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
import { CreateWagonTypeDto } from './dto/create-wagon-type.dto';
|
||||||
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
||||||
import { WagonTypesService } from './wagon-types.service';
|
import { WagonTypesService } from './wagon-types.service';
|
||||||
import { WagonType } from './entities/wagon-type.entity';
|
|
||||||
|
|
||||||
@ApiTags('Wagon Types')
|
@ApiTags('wagon-types')
|
||||||
@Controller('wagon-types')
|
@Controller('wagon-types')
|
||||||
|
@ApiBearerAuth()
|
||||||
export class WagonTypesController {
|
export class WagonTypesController {
|
||||||
constructor(private readonly wagonTypesService: WagonTypesService) {}
|
constructor(private readonly wagonTypesService: WagonTypesService) {}
|
||||||
|
|
||||||
@Post()
|
|
||||||
@ApiOperation({ summary: 'Create a wagon type' })
|
|
||||||
async create(@Body() dto: CreateWagonTypeDto): Promise<WagonType> {
|
|
||||||
return this.wagonTypesService.create(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get wagon types' })
|
@RuleEngineView('wagon-types')
|
||||||
async findAll(@Query() query: Record<string, string | undefined>): Promise<WagonType[]> {
|
@ApiOperation({ summary: 'List wagon types' })
|
||||||
return this.wagonTypesService.findAll(query);
|
findAll(@Query() query: Record<string, string | undefined>) {
|
||||||
|
return this.wagonTypesService.findAll({
|
||||||
|
isActive:
|
||||||
|
query.isActive === 'all'
|
||||||
|
? undefined
|
||||||
|
: query.isActive !== undefined
|
||||||
|
? query.isActive === 'true'
|
||||||
|
: true,
|
||||||
|
page: query.page ? parseInt(query.page, 10) : undefined,
|
||||||
|
pageSize: query.pageSize ? parseInt(query.pageSize, 10) : undefined,
|
||||||
|
sortBy: query.sortBy,
|
||||||
|
sortOrder: query.sortOrder,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@RuleEngineView('wagon-types')
|
||||||
@ApiOperation({ summary: 'Get a wagon type by ID' })
|
@ApiOperation({ summary: 'Get a wagon type by ID' })
|
||||||
async findOne(@Param('id', ParseUUIDPipe) id: string): Promise<WagonType> {
|
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonTypesService.findById(id);
|
return this.wagonTypesService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RuleEngineManage('wagon-types')
|
||||||
|
@ApiOperation({ summary: 'Create a wagon type' })
|
||||||
|
create(@Body() dto: CreateWagonTypeDto) {
|
||||||
|
return this.wagonTypesService.create(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
|
@RuleEngineManage('wagon-types')
|
||||||
@ApiOperation({ summary: 'Update a wagon type' })
|
@ApiOperation({ summary: 'Update a wagon type' })
|
||||||
async update(
|
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonTypeDto) {
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() dto: UpdateWagonTypeDto,
|
|
||||||
): Promise<WagonType> {
|
|
||||||
return this.wagonTypesService.update(id, dto);
|
return this.wagonTypesService.update(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
|
@RuleEngineManage('wagon-types')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
@ApiOperation({ summary: 'Deactivate a wagon type' })
|
@ApiOperation({ summary: 'Soft-delete a wagon type' })
|
||||||
async remove(@Param('id', ParseUUIDPipe) id: string): Promise<void> {
|
remove(@Param('id', ParseUUIDPipe) id: string) {
|
||||||
return this.wagonTypesService.remove(id);
|
return this.wagonTypesService.remove(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,8 @@ export class WagonTypesRepository extends BaseRepository<WagonType> {
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findByCode(code: string): Promise<WagonType | null> {
|
||||||
|
return this.repository.findOne({ where: { code } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,44 +6,47 @@ import { UpdateWagonTypeDto } from './dto/update-wagon-type.dto';
|
|||||||
import { WagonType } from './entities/wagon-type.entity';
|
import { WagonType } from './entities/wagon-type.entity';
|
||||||
import { WagonTypesRepository } from './wagon-types.repository';
|
import { WagonTypesRepository } from './wagon-types.repository';
|
||||||
|
|
||||||
|
type WagonTypeListFilter = {
|
||||||
|
isActive?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WagonTypesService {
|
export class WagonTypesService {
|
||||||
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
constructor(private readonly wagonTypesRepository: WagonTypesRepository) {}
|
||||||
|
|
||||||
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
|
async findAll(filter: WagonTypeListFilter = {}): Promise<{
|
||||||
const code = dto.code.trim().toUpperCase();
|
data: WagonType[];
|
||||||
const existing = await this.wagonTypesRepository.findAll({ where: { code } });
|
meta: { total: number; page: number; pageSize: number; totalPages: number };
|
||||||
if (existing.length > 0) {
|
}> {
|
||||||
throw new ConflictException(`Wagon type code "${code}" already exists`);
|
const page = filter.page ?? 1;
|
||||||
}
|
const pageSize = filter.pageSize ?? 500;
|
||||||
|
|
||||||
return this.wagonTypesRepository.create({
|
|
||||||
...dto,
|
|
||||||
code,
|
|
||||||
name: dto.name.trim(),
|
|
||||||
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
|
||||||
isActive: dto.isActive ?? true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async findAll(query: Record<string, string | undefined> = {}): Promise<WagonType[]> {
|
|
||||||
const isActive =
|
|
||||||
query.isActive === 'all'
|
|
||||||
? undefined
|
|
||||||
: query.isActive === undefined
|
|
||||||
? true
|
|
||||||
: query.isActive === 'true';
|
|
||||||
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
|
const sortBy = ['code', 'name', 'capacityTons', 'lengthMeters', 'isActive'].includes(
|
||||||
query.sortBy ?? '',
|
filter.sortBy ?? '',
|
||||||
)
|
)
|
||||||
? (query.sortBy as keyof WagonType)
|
? (filter.sortBy as keyof WagonType)
|
||||||
: 'code';
|
: 'code';
|
||||||
const sortOrder = query.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
const sortOrder = filter.sortOrder?.toUpperCase() === 'DESC' ? 'DESC' : 'ASC';
|
||||||
|
|
||||||
return this.wagonTypesRepository.findAll({
|
const [data, total] = await this.wagonTypesRepository.findAndCount({
|
||||||
where: isActive === undefined ? {} : { isActive },
|
where: filter.isActive === undefined ? {} : { isActive: filter.isActive },
|
||||||
order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>,
|
order: { [sortBy]: sortOrder } as FindOptionsOrder<WagonType>,
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findById(id: string): Promise<WagonType> {
|
async findById(id: string): Promise<WagonType> {
|
||||||
@@ -57,22 +60,39 @@ export class WagonTypesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findByCode(code: string): Promise<WagonType> {
|
async findByCode(code: string): Promise<WagonType> {
|
||||||
const [wagonType] = await this.wagonTypesRepository.findAll({ where: { code } });
|
const wagonType = await this.wagonTypesRepository.findByCode(code);
|
||||||
|
|
||||||
if (!wagonType) {
|
if (!wagonType) {
|
||||||
throw new NotFoundException(`Wagon type ${code} not found`);
|
throw new NotFoundException(`Wagon type ${code} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return wagonType;
|
return wagonType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateWagonTypeDto): Promise<WagonType> {
|
||||||
|
const code = dto.code.trim().toUpperCase();
|
||||||
|
const existing = await this.wagonTypesRepository.findByCode(code);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`Wagon type code "${code}" already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.wagonTypesRepository.create({
|
||||||
|
code,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
capacityTons: dto.capacityTons,
|
||||||
|
lengthMeters: dto.lengthMeters,
|
||||||
|
maxWagonsPerTrain: dto.maxWagonsPerTrain ?? null,
|
||||||
|
supportedLoadTypes: dto.supportedLoadTypes ?? [],
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
|
async update(id: string, dto: UpdateWagonTypeDto): Promise<WagonType> {
|
||||||
const wagonType = await this.findById(id);
|
const wagonType = await this.findById(id);
|
||||||
const nextCode = dto.code?.trim().toUpperCase();
|
const nextCode = dto.code?.trim().toUpperCase();
|
||||||
|
|
||||||
if (nextCode && nextCode !== wagonType.code) {
|
if (nextCode && nextCode !== wagonType.code) {
|
||||||
const existing = await this.wagonTypesRepository.findAll({ where: { code: nextCode } });
|
const existing = await this.wagonTypesRepository.findByCode(nextCode);
|
||||||
if (existing.length > 0) {
|
if (existing) {
|
||||||
throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
|
throw new ConflictException(`Wagon type code "${nextCode}" already exists`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,6 +101,9 @@ export class WagonTypesService {
|
|||||||
...dto,
|
...dto,
|
||||||
...(nextCode ? { code: nextCode } : {}),
|
...(nextCode ? { code: nextCode } : {}),
|
||||||
...(dto.name ? { name: dto.name.trim() } : {}),
|
...(dto.name ? { name: dto.name.trim() } : {}),
|
||||||
|
maxWagonsPerTrain:
|
||||||
|
dto.maxWagonsPerTrain === undefined ? undefined : dto.maxWagonsPerTrain ?? null,
|
||||||
|
supportedLoadTypes: dto.supportedLoadTypes ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!updated) {
|
if (!updated) {
|
||||||
@@ -92,6 +115,6 @@ export class WagonTypesService {
|
|||||||
|
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
await this.wagonTypesRepository.update(id, { isActive: false });
|
await this.wagonTypesRepository.softDelete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,13 +178,17 @@ export class DemoBookingsSeeder {
|
|||||||
{
|
{
|
||||||
code: "LOC-001",
|
code: "LOC-001",
|
||||||
name: "Demo Locomotive 1",
|
name: "Demo Locomotive 1",
|
||||||
|
locomotiveType: 'ELECTRIC',
|
||||||
maxPullWeightTons: 3500,
|
maxPullWeightTons: 3500,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
status: "AVAILABLE",
|
status: "AVAILABLE",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
code: "LOC-002",
|
code: "LOC-002",
|
||||||
name: "Demo Locomotive 2",
|
name: "Demo Locomotive 2",
|
||||||
|
locomotiveType: 'DIESEL',
|
||||||
maxPullWeightTons: 2500,
|
maxPullWeightTons: 2500,
|
||||||
|
maxTrainLengthMeters: 760,
|
||||||
status: "AVAILABLE",
|
status: "AVAILABLE",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type FreightPermissionSeed = {
|
|||||||
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
export const RULE_ENGINE_RESOURCE_SLUGS = [
|
||||||
'cargo-types',
|
'cargo-types',
|
||||||
'container-types',
|
'container-types',
|
||||||
|
'wagon-types',
|
||||||
'service-types',
|
'service-types',
|
||||||
'yards',
|
'yards',
|
||||||
'shipping-lines',
|
'shipping-lines',
|
||||||
@@ -56,6 +57,7 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
const RULE_ENGINE_PERMISSION_IDS: Record<RuleEngineResourceSlug, { view: string; manage: string }> = {
|
||||||
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
'cargo-types': { view: 'b2000001-0001-4000-8000-000000000001', manage: 'b2000001-0001-4000-8000-000000000002' },
|
||||||
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
'container-types': { view: 'b2000001-0001-4000-8000-000000000003', manage: 'b2000001-0001-4000-8000-000000000004' },
|
||||||
|
'wagon-types': { view: 'b2000001-0001-4000-8000-000000000015', manage: 'b2000001-0001-4000-8000-000000000016' },
|
||||||
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
'service-types': { view: 'b2000001-0001-4000-8000-000000000005', manage: 'b2000001-0001-4000-8000-000000000006' },
|
||||||
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
yards: { view: 'b2000001-0001-4000-8000-000000000007', manage: 'b2000001-0001-4000-8000-000000000008' },
|
||||||
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
'shipping-lines': { view: 'b2000001-0001-4000-8000-000000000009', manage: 'b2000001-0001-4000-8000-00000000000a' },
|
||||||
|
|||||||
@@ -353,6 +353,8 @@ export class PricingDataSeeder {
|
|||||||
): Promise<Rate[]> {
|
): Promise<Rate[]> {
|
||||||
const effectiveFrom = new Date("2026-01-01");
|
const effectiveFrom = new Date("2026-01-01");
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// await rRepo.createQueryBuilder().delete().execute();
|
||||||
|
|
||||||
const rateData = [
|
const rateData = [
|
||||||
{
|
{
|
||||||
rateType: "CONTAINER_IMPORT",
|
rateType: "CONTAINER_IMPORT",
|
||||||
|
|||||||
@@ -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 "tailwindcss";
|
||||||
@import "@edr/ui-common/theme.css" layer(theme);
|
@import "@edr/ui-common/theme.css" layer(theme);
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--freight-brand: #15803d;
|
||||||
|
--freight-brand-dark: #166534;
|
||||||
|
--freight-brand-light: #22c55e;
|
||||||
|
--freight-brand-muted: #f0fdf4;
|
||||||
|
--freight-brand-border: #bbf7d0;
|
||||||
|
--freight-brand-ring: rgb(21 128 61 / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@edr/types": "workspace:*",
|
"@edr/types": "workspace:*",
|
||||||
"@edr/ui-common": "workspace:*",
|
"@edr/ui-common": "workspace:*",
|
||||||
|
"@mantine/core": "^9.3.0",
|
||||||
|
"@mantine/hooks": "^9.3.0",
|
||||||
|
"@tabler/icons-react": "^3.44.0",
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
"@tanstack/react-query": "^5.100.11",
|
"@tanstack/react-query": "^5.100.11",
|
||||||
"@tria-plc/iamui-common": "1.1.2",
|
"@tria-plc/iamui-common": "1.1.2",
|
||||||
|
|||||||
@@ -34,16 +34,18 @@ import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage
|
|||||||
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage";
|
||||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import {
|
import {
|
||||||
CargoesCrudPage,
|
CargoesCrudPage,
|
||||||
ContainersCrudPage,
|
ContainersCrudPage,
|
||||||
|
LocomotivesCrudPage,
|
||||||
TrainMasterDataPage,
|
TrainMasterDataPage,
|
||||||
WagonTypesCrudPage,
|
WagonTypesCrudPage,
|
||||||
WagonsCrudPage,
|
WagonsCrudPage,
|
||||||
} from "./pages/fleet/FleetCrudPages";
|
} from "./pages/fleet/FleetCrudPages";
|
||||||
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources";
|
||||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||||
|
import RoutesPage from "./pages/fleet/RoutesPage";
|
||||||
|
|
||||||
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||||
{
|
{
|
||||||
@@ -60,17 +62,32 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
|||||||
href: "/dashboard/booking-requests",
|
href: "/dashboard/booking-requests",
|
||||||
icon: <FileText />,
|
icon: <FileText />,
|
||||||
},
|
},
|
||||||
|
...demoItems,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Operations",
|
||||||
|
items: [
|
||||||
{
|
{
|
||||||
label: "Train scheduling",
|
label: "Train Schedules",
|
||||||
href: "/dashboard/operations/train-scheduling",
|
href: "/dashboard/operations/train-scheduling",
|
||||||
icon: <Train />,
|
icon: <Train />,
|
||||||
},
|
},
|
||||||
...demoItems,
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Fleet Management",
|
title: "Fleet Management",
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
label: "Routes",
|
||||||
|
href: "/dashboard/routes",
|
||||||
|
icon: <Network />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Locomotives",
|
||||||
|
href: "/dashboard/locomotives",
|
||||||
|
icon: <Train />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Trains",
|
label: "Trains",
|
||||||
href: "/dashboard/trains",
|
href: "/dashboard/trains",
|
||||||
@@ -225,17 +242,19 @@ const App = () => {
|
|||||||
|
|
||||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="booking-requests/:id/contract"
|
path="booking-requests/:id/contract"
|
||||||
element={<BookingContractPage />}
|
element={<BookingContractPage />}
|
||||||
/>
|
/>
|
||||||
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
<Route path="operations/train-scheduling" element={<TrainsPage />} />
|
||||||
<Route path="trains" element={<TrainMasterDataPage />} />
|
<Route path="trains" element={<TrainMasterDataPage />} />
|
||||||
<Route path="trains/:id" element={<TrainDetailPage />} />
|
<Route path="trains/:id" element={<TrainDetailPage />} />
|
||||||
|
<Route path="routes" element={<RoutesPage />} />
|
||||||
|
<Route path="locomotives" element={<LocomotivesCrudPage />} />
|
||||||
<Route path="wagon-types" element={<WagonTypesCrudPage />} />
|
<Route path="wagon-types" element={<WagonTypesCrudPage />} />
|
||||||
<Route path="wagons" element={<WagonsCrudPage />} />
|
<Route path="wagons" element={<WagonsCrudPage />} />
|
||||||
<Route path="containers" element={<ContainersCrudPage />} />
|
<Route path="containers" element={<ContainersCrudPage />} />
|
||||||
<Route path="cargoes" element={<CargoesCrudPage />} />
|
<Route path="cargoes" element={<CargoesCrudPage />} />
|
||||||
|
|
||||||
<Route path="user-management" element={<UserManagementPage />} />
|
<Route path="user-management" element={<UserManagementPage />} />
|
||||||
<Route path="user-management/users" element={<UsersPage />} />
|
<Route path="user-management/users" element={<UsersPage />} />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Check, ShieldCheck } from "lucide-react";
|
import { Check, ShieldCheck } from "lucide-react";
|
||||||
|
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -11,9 +12,7 @@ import {
|
|||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
|
||||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { Badge, Button } from "@edr/ui-common";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
@@ -26,23 +25,16 @@ interface ApprovalStepsCardProps {
|
|||||||
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(
|
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const steps = useMemo(
|
const steps = useMemo(
|
||||||
() =>
|
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
|
||||||
[...(booking.approvalSteps ?? [])].sort(
|
|
||||||
(a, b) => a.stepOrder - b.stepOrder,
|
|
||||||
),
|
|
||||||
[booking.approvalSteps],
|
[booking.approvalSteps],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nextPending = getNextPendingApprovalStep(steps);
|
const nextPending = getNextPendingApprovalStep(steps);
|
||||||
const summary = formatApprovalProgress(booking.status, steps);
|
const summary = formatApprovalProgress(booking.status, steps);
|
||||||
const pendingAction = pendingStep
|
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
|
||||||
? buildApproveActionForStep(pendingStep)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const openApprove = (step: BookingApprovalStep) => {
|
const openApprove = (step: BookingApprovalStep) => {
|
||||||
setPendingStep(step);
|
setPendingStep(step);
|
||||||
@@ -62,54 +54,60 @@ export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const subtitle =
|
||||||
|
summary.detail ||
|
||||||
|
(nextPending
|
||||||
|
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
||||||
|
: steps.length
|
||||||
|
? "All steps complete"
|
||||||
|
: "Accept submission to begin");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className={cn(bookingSurface.sectionCard, bookingGlass.activeTab)}>
|
<SectionCard
|
||||||
<div className={bookingSurface.sectionHeader}>
|
icon={ShieldCheck}
|
||||||
<div className={bookingSurface.sectionIcon}>
|
title="Approval chain"
|
||||||
<ShieldCheck className="size-4" strokeWidth={1.75} />
|
extra={
|
||||||
</div>
|
<Badge color="green" variant="light" radius="sm">
|
||||||
<div>
|
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
</Badge>
|
||||||
Approval chain
|
}
|
||||||
</h2>
|
>
|
||||||
<p className="text-xs text-muted-foreground">
|
<Text size="xs" c="dimmed" mb="sm">
|
||||||
{summary.detail ||
|
{subtitle}
|
||||||
(nextPending
|
</Text>
|
||||||
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
|
|
||||||
: steps.length
|
|
||||||
? "All steps complete"
|
|
||||||
: "Accept submission to begin")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-5 py-5">
|
{steps.length === 0 ? (
|
||||||
{steps.length === 0 ? (
|
<Text
|
||||||
<p className="rounded-lg border border-dashed border-border/60 bg-muted/10 px-4 py-6 text-center text-sm text-muted-foreground backdrop-blur-sm">
|
size="sm"
|
||||||
Use{" "}
|
c="dimmed"
|
||||||
<strong className="font-semibold text-foreground">
|
ta="center"
|
||||||
Accept for approval
|
py="lg"
|
||||||
</strong>{" "}
|
px="md"
|
||||||
in staff actions to instantiate steps.
|
style={{
|
||||||
</p>
|
borderRadius: 8,
|
||||||
) : (
|
border: "1px dashed var(--mantine-color-gray-3)",
|
||||||
<ul className="space-y-2">
|
background: "var(--mantine-color-gray-0)",
|
||||||
{steps.map((step) => (
|
}}
|
||||||
<StepRow
|
>
|
||||||
key={step.id}
|
Use <strong>Accept for approval</strong> in staff actions to instantiate steps.
|
||||||
step={step}
|
</Text>
|
||||||
steps={steps}
|
) : (
|
||||||
user={user}
|
<Stack gap="xs">
|
||||||
isNext={nextPending?.id === step.id}
|
{steps.map((step) => (
|
||||||
isPending={mutations.approveStep.isPending}
|
<StepRow
|
||||||
onApprove={openApprove}
|
key={step.id}
|
||||||
/>
|
step={step}
|
||||||
))}
|
steps={steps}
|
||||||
</ul>
|
user={user}
|
||||||
)}
|
isNext={nextPending?.id === step.id}
|
||||||
</div>
|
isPending={mutations.approveStep.isPending}
|
||||||
</div>
|
onApprove={openApprove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
<BookingConfirmDialog
|
<BookingConfirmDialog
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
@@ -144,64 +142,76 @@ function StepRow({
|
|||||||
onApprove: (step: BookingApprovalStep) => void;
|
onApprove: (step: BookingApprovalStep) => void;
|
||||||
}) {
|
}) {
|
||||||
const canApprove = canActOnApprovalStep(user, step, steps);
|
const canApprove = canActOnApprovalStep(user, step, steps);
|
||||||
const statusStyles =
|
const statusColor =
|
||||||
step.status === "APPROVED"
|
step.status === "APPROVED"
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
? "green"
|
||||||
: step.status === "REJECTED"
|
: step.status === "REJECTED"
|
||||||
? "bg-red-500/10 text-red-800 dark:text-red-300"
|
? "red"
|
||||||
: isNext
|
: isNext
|
||||||
? "border-emerald-500/25 bg-emerald-500/10 text-black"
|
? "green"
|
||||||
: "bg-muted/40 text-muted-foreground";
|
: "gray";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li
|
<Group
|
||||||
className={cn(
|
justify="space-between"
|
||||||
"flex items-center justify-between gap-3 rounded-lg border px-4 py-3 transition-colors backdrop-blur-sm",
|
wrap="nowrap"
|
||||||
isNext ? bookingGlass.activeTab : "border-border/50 bg-card/60",
|
gap="sm"
|
||||||
)}
|
px="sm"
|
||||||
|
py="xs"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
borderLeft: isNext
|
||||||
|
? "3px solid var(--freight-brand)"
|
||||||
|
: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: isNext ? "var(--mantine-color-gray-0)" : "white",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex min-w-0 items-center gap-3">
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<span
|
<Box
|
||||||
className={cn(
|
style={{
|
||||||
"flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-bold",
|
display: "flex",
|
||||||
isNext
|
alignItems: "center",
|
||||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
justifyContent: "center",
|
||||||
: "bg-muted/40 text-muted-foreground",
|
width: 28,
|
||||||
)}
|
height: 28,
|
||||||
|
borderRadius: 8,
|
||||||
|
flexShrink: 0,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 700,
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
color: isNext ? "var(--mantine-color-gray-7)" : "var(--mantine-color-gray-6)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{step.stepOrder}
|
{step.stepOrder}
|
||||||
</span>
|
</Box>
|
||||||
<div className="min-w-0">
|
<Box style={{ minWidth: 0 }}>
|
||||||
<p className="text-sm font-semibold text-foreground">
|
<Text size="sm" fw={600}>
|
||||||
{step.requiredRole}
|
{step.requiredRole}
|
||||||
</p>
|
</Text>
|
||||||
{step.remarks && (
|
{step.remarks && (
|
||||||
<p className="truncate text-xs text-muted-foreground">
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{step.remarks}
|
{step.remarks}
|
||||||
</p>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Group>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||||
{canApprove && (
|
{canApprove && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
size="compact-sm"
|
||||||
size="sm"
|
color="green"
|
||||||
className="h-8 gap-1.5 shadow-sm"
|
leftSection={<Check size={14} />}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
onClick={() => onApprove(step)}
|
onClick={() => onApprove(step)}
|
||||||
>
|
>
|
||||||
<Check className="size-3.5" />
|
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Badge
|
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
|
||||||
variant="outline"
|
|
||||||
className={cn("shrink-0 border text-[9px] uppercase", statusStyles)}
|
|
||||||
>
|
|
||||||
{step.status}
|
{step.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</Group>
|
||||||
</li>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import { ChevronRight, ExternalLink, MoreHorizontal } from "lucide-react";
|
||||||
ChevronRight,
|
import { Button, Menu, ActionIcon, Group, Text } from "@mantine/core";
|
||||||
ExternalLink,
|
|
||||||
Loader2,
|
|
||||||
MoreHorizontal,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
import { BookingConfirmDialog } from "./BookingConfirmDialog";
|
||||||
import { useBookingActionDialog } from "./useBookingActionDialog";
|
import { useBookingActionDialog } from "./useBookingActionDialog";
|
||||||
@@ -16,16 +12,6 @@ import {
|
|||||||
type BookingActionContext,
|
type BookingActionContext,
|
||||||
} from "@/features/bookings/booking-actions.config";
|
} from "@/features/bookings/booking-actions.config";
|
||||||
import type { BookingListRow } from "@/types/booking";
|
import type { BookingListRow } from "@/types/booking";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
interface BookingActionsMenuProps {
|
interface BookingActionsMenuProps {
|
||||||
row: BookingListRow;
|
row: BookingListRow;
|
||||||
@@ -39,7 +25,6 @@ interface BookingActionsMenuProps {
|
|||||||
export function BookingActionsMenu({
|
export function BookingActionsMenu({
|
||||||
row,
|
row,
|
||||||
variant = "table",
|
variant = "table",
|
||||||
className,
|
|
||||||
onSuppressRowClick,
|
onSuppressRowClick,
|
||||||
}: BookingActionsMenuProps) {
|
}: BookingActionsMenuProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -57,184 +42,179 @@ export function BookingActionsMenu({
|
|||||||
const goToContract = () =>
|
const goToContract = () =>
|
||||||
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
navigate(`/dashboard/booking-requests/${row.id}/contract`);
|
||||||
|
|
||||||
const hasMenu = listRowHasActions(row, user);
|
const handleAction = (action: (typeof actions)[number]) => {
|
||||||
|
onSuppressRowClick?.();
|
||||||
|
if (isContractNavAction(action.id)) {
|
||||||
|
goToContract();
|
||||||
|
} else {
|
||||||
|
flow.openAction(action);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMenu = listRowHasActions(row, user);
|
||||||
const primary = actions.find((a) => a.primary) ?? actions[0];
|
const primary = actions.find((a) => a.primary) ?? actions[0];
|
||||||
|
|
||||||
if (!hasMenu && variant === "table") {
|
if (!hasMenu && variant === "table") {
|
||||||
return (
|
return (
|
||||||
<Button
|
<ActionIcon
|
||||||
variant="ghost"
|
variant="subtle"
|
||||||
size="icon"
|
color="gray"
|
||||||
className="size-8 text-muted-foreground hover:text-primary"
|
|
||||||
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
onClick={() => navigate(`/dashboard/booking-requests/${row.id}`)}
|
||||||
aria-label="View booking"
|
aria-label="View booking"
|
||||||
>
|
>
|
||||||
<ChevronRight className="size-4" />
|
<ChevronRight size={16} />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toolbar: lay every action out as a button row.
|
||||||
|
if (variant === "toolbar" && actions.length > 0) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group gap="sm" w="100%">
|
||||||
|
{actions.map((action) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
const destructive = action.variant === "destructive";
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={action.id}
|
||||||
|
size="sm"
|
||||||
|
variant={action.primary && !destructive ? "filled" : "default"}
|
||||||
|
color={destructive ? "red" : action.primary ? "green" : "gray"}
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
disabled={mutations.isPending}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Group
|
||||||
<div
|
gap={4}
|
||||||
data-stop-row-click
|
justify="flex-end"
|
||||||
className={cn(
|
wrap="nowrap"
|
||||||
"flex w-full min-h-[2.5rem] items-center justify-end gap-1",
|
data-stop-row-click
|
||||||
variant === "table" && "opacity-80 transition-opacity group-hover/tr:opacity-100",
|
onClick={(e) => e.stopPropagation()}
|
||||||
className,
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
)}
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
{variant === "table" && primary && (
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
<Button
|
||||||
>
|
size="compact-sm"
|
||||||
{variant === "table" && primary && (
|
color="green"
|
||||||
<Button
|
visibleFrom="lg"
|
||||||
size="sm"
|
leftSection={<primary.icon size={14} />}
|
||||||
className="hidden h-8 gap-1.5 px-2.5 shadow-sm lg:inline-flex"
|
disabled={mutations.isPending}
|
||||||
disabled={mutations.isPending}
|
onClick={() => handleAction(primary)}
|
||||||
onClick={() =>
|
>
|
||||||
isContractNavAction(primary.id)
|
{primary.shortLabel}
|
||||||
? goToContract()
|
</Button>
|
||||||
: flow.openAction(primary)
|
)}
|
||||||
}
|
|
||||||
|
<Menu position="bottom-end" width={220} withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon
|
||||||
|
variant={variant === "table" ? "subtle" : "default"}
|
||||||
|
color="gray"
|
||||||
|
loading={mutations.isPending}
|
||||||
|
aria-label="Booking actions"
|
||||||
>
|
>
|
||||||
<primary.icon className="size-3.5" />
|
<MoreHorizontal size={16} />
|
||||||
{primary.shortLabel}
|
</ActionIcon>
|
||||||
</Button>
|
</Menu.Target>
|
||||||
)}
|
<Menu.Dropdown>
|
||||||
|
<Menu.Label>
|
||||||
{variant === "toolbar" && actions.length > 0 ? (
|
<Text size="xs" ff="monospace" c="dimmed">
|
||||||
<div className="flex w-full flex-wrap gap-2">
|
|
||||||
{actions.map((action) => {
|
|
||||||
const Icon = action.icon;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
size="sm"
|
|
||||||
variant={
|
|
||||||
action.variant === "destructive"
|
|
||||||
? "outline"
|
|
||||||
: action.primary
|
|
||||||
? "default"
|
|
||||||
: "outline"
|
|
||||||
}
|
|
||||||
className={cn(
|
|
||||||
"gap-2 shadow-sm",
|
|
||||||
action.variant === "destructive" &&
|
|
||||||
"border-red-200 text-red-700 hover:bg-red-50 dark:hover:bg-red-950/30",
|
|
||||||
)}
|
|
||||||
disabled={mutations.isPending}
|
|
||||||
onClick={() =>
|
|
||||||
isContractNavAction(action.id)
|
|
||||||
? goToContract()
|
|
||||||
: flow.openAction(action)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
{action.label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={variant === "table" ? "ghost" : "outline"}
|
|
||||||
size={variant === "table" ? "icon" : "sm"}
|
|
||||||
className={cn(
|
|
||||||
variant === "table" ? "size-8" : "gap-2",
|
|
||||||
"shrink-0",
|
|
||||||
)}
|
|
||||||
disabled={mutations.isPending}
|
|
||||||
aria-label="Booking actions"
|
|
||||||
>
|
|
||||||
{mutations.isPending ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<MoreHorizontal className="size-4" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-56">
|
|
||||||
<DropdownMenuLabel className="font-mono text-xs text-muted-foreground">
|
|
||||||
{row.reference}
|
{row.reference}
|
||||||
</DropdownMenuLabel>
|
</Text>
|
||||||
<DropdownMenuSeparator />
|
</Menu.Label>
|
||||||
{actions.map((action) => {
|
{actions.map((action) => {
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<Menu.Item
|
||||||
key={action.id}
|
key={action.id}
|
||||||
className={cn(
|
color={action.variant === "destructive" ? "red" : undefined}
|
||||||
"gap-2 cursor-pointer",
|
leftSection={<Icon size={15} />}
|
||||||
action.variant === "destructive" && "text-red-700 focus:text-red-700",
|
onClick={() => handleAction(action)}
|
||||||
)}
|
>
|
||||||
onSelect={(event) => {
|
{action.label}
|
||||||
event.preventDefault();
|
</Menu.Item>
|
||||||
onSuppressRowClick?.();
|
);
|
||||||
if (isContractNavAction(action.id)) {
|
})}
|
||||||
goToContract();
|
{actions.length > 0 && <Menu.Divider />}
|
||||||
} else {
|
<Menu.Item
|
||||||
flow.openAction(action);
|
leftSection={<ExternalLink size={15} />}
|
||||||
}
|
onClick={() => {
|
||||||
}}
|
onSuppressRowClick?.();
|
||||||
>
|
navigate(`/dashboard/booking-requests/${row.id}`);
|
||||||
<Icon className="size-4 opacity-70" />
|
}}
|
||||||
<span>{action.label}</span>
|
>
|
||||||
</DropdownMenuItem>
|
Open full details
|
||||||
);
|
</Menu.Item>
|
||||||
})}
|
</Menu.Dropdown>
|
||||||
{actions.length > 0 && <DropdownMenuSeparator />}
|
</Menu>
|
||||||
<DropdownMenuItem
|
|
||||||
className="gap-2 cursor-pointer"
|
|
||||||
onSelect={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onSuppressRowClick?.();
|
|
||||||
navigate(`/dashboard/booking-requests/${row.id}`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ExternalLink className="size-4 opacity-70" />
|
|
||||||
Open full details
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BookingConfirmDialog
|
<ActionDialog flow={flow} pendingAction={pendingAction} onSuppressRowClick={onSuppressRowClick} />
|
||||||
open={flow.dialogOpen}
|
</Group>
|
||||||
onOpenChange={(open) => {
|
);
|
||||||
if (!open) onSuppressRowClick?.();
|
}
|
||||||
flow.setDialogOpen(open);
|
|
||||||
}}
|
function ActionDialog({
|
||||||
action={pendingAction}
|
flow,
|
||||||
reference={flow.mergedContext.reference}
|
pendingAction,
|
||||||
inputValue={flow.inputValue}
|
onSuppressRowClick,
|
||||||
onInputChange={flow.setInputValue}
|
}: {
|
||||||
selectedFile={flow.selectedFile}
|
flow: ReturnType<typeof useBookingActionDialog>;
|
||||||
onFileChange={flow.setSelectedFile}
|
pendingAction: ReturnType<typeof useBookingActionDialog>["pendingAction"];
|
||||||
onConfirm={() => {
|
onSuppressRowClick?: () => void;
|
||||||
onSuppressRowClick?.();
|
}) {
|
||||||
flow.runAction();
|
return (
|
||||||
}}
|
<BookingConfirmDialog
|
||||||
isPending={mutations.isPending || flow.detailLoading}
|
open={flow.dialogOpen}
|
||||||
confirmDisabled={flow.confirmDisabled}
|
onOpenChange={(open) => {
|
||||||
extra={
|
if (!open) onSuppressRowClick?.();
|
||||||
flow.detailLoading ? (
|
flow.setDialogOpen(open);
|
||||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
}}
|
||||||
<Loader2 className="size-4 animate-spin" />
|
action={pendingAction}
|
||||||
Loading approval steps…
|
reference={flow.mergedContext.reference}
|
||||||
</p>
|
inputValue={flow.inputValue}
|
||||||
) : pendingAction?.id === "approve" &&
|
onInputChange={flow.setInputValue}
|
||||||
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
selectedFile={flow.selectedFile}
|
||||||
<p className="rounded-lg border border-amber-200/80 bg-amber-50/50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-200">
|
onFileChange={flow.setSelectedFile}
|
||||||
No pending approval step. Refresh the page after staff accept, or
|
onConfirm={() => {
|
||||||
reject the booking.
|
onSuppressRowClick?.();
|
||||||
</p>
|
flow.runAction();
|
||||||
) : null
|
}}
|
||||||
}
|
isPending={flow.mutations.isPending || flow.detailLoading}
|
||||||
/>
|
confirmDisabled={flow.confirmDisabled}
|
||||||
</>
|
extra={
|
||||||
|
flow.detailLoading ? (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Loading approval steps…
|
||||||
|
</Text>
|
||||||
|
) : pendingAction?.id === "approve" &&
|
||||||
|
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
c="orange.9"
|
||||||
|
p="xs"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-orange-2)",
|
||||||
|
background: "var(--mantine-color-orange-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No pending approval step. Refresh the page after staff accept, or reject the
|
||||||
|
booking.
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Download, Zap } from "lucide-react";
|
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||||
|
import { Stack, Text, Button } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||||
import { bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||||
import { Button } from "@edr/ui-common";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||||
|
|
||||||
@@ -16,10 +15,7 @@ interface BookingActionsToolbarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Detail-page actions: primary toolbar + downloads. */
|
/** Detail-page actions: primary toolbar + downloads. */
|
||||||
export function BookingActionsToolbar({
|
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||||
booking,
|
|
||||||
mutations,
|
|
||||||
}: BookingActionsToolbarProps) {
|
|
||||||
const row = toBookingListRow(booking);
|
const row = toBookingListRow(booking);
|
||||||
const { status } = booking;
|
const { status } = booking;
|
||||||
|
|
||||||
@@ -33,50 +29,84 @@ export function BookingActionsToolbar({
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (status === "REJECTED" || status === "CANCELLED" || status === "COMPLETED") {
|
||||||
status === "REJECTED" ||
|
|
||||||
status === "CANCELLED" ||
|
|
||||||
status === "COMPLETED"
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "CHANGES_REQUESTED") {
|
if (status === "CHANGES_REQUESTED") {
|
||||||
return (
|
return (
|
||||||
<PanelShell title="Awaiting customer" description="No staff actions until resubmit.">
|
<SectionCard icon={Zap} title="Awaiting customer">
|
||||||
{booking.latestChangeRequestNote && (
|
<Stack gap="sm">
|
||||||
<p className="rounded-lg border border-border/50 bg-muted/15 p-3 text-sm leading-relaxed backdrop-blur-sm">
|
<Text size="sm" c="dimmed">
|
||||||
{booking.latestChangeRequestNote}
|
No staff actions until resubmit.
|
||||||
</p>
|
</Text>
|
||||||
)}
|
{booking.latestChangeRequestNote && (
|
||||||
</PanelShell>
|
<Text
|
||||||
|
size="sm"
|
||||||
|
p="sm"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{booking.latestChangeRequestNote}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
|
if (["DRAFT", "PENDING_CONSOLIDATION", "CONSOLIDATED"].includes(status)) {
|
||||||
return (
|
return (
|
||||||
<PanelShell
|
<SectionCard icon={Zap} title="No staff actions">
|
||||||
title="No staff actions"
|
<Text size="sm" c="dimmed">
|
||||||
description="Monitor until the customer or system advances status."
|
Monitor until the customer or system advances status.
|
||||||
muted
|
</Text>
|
||||||
/>
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
["FULLY_EXECUTED", "PNR_GENERATED", "PAYMENT_VERIFICATION_IN_PROGRESS"].includes(
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Stack gap="lg">
|
||||||
|
<SectionCard icon={Clock} title="Awaiting customer payment">
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Payment is completed by the customer. The booking status updates
|
||||||
|
automatically once payment is confirmed, then moves to Operations.
|
||||||
|
</Text>
|
||||||
|
{status === "FULLY_EXECUTED" && (
|
||||||
|
<BookingActionsMenu row={row} variant="toolbar" />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Stack gap="lg">
|
||||||
<PanelShell
|
<SectionCard icon={Zap} title="Staff actions">
|
||||||
title="Staff actions"
|
<Stack gap="sm">
|
||||||
description="Confirm each step before it is applied."
|
<Text size="xs" c="dimmed">
|
||||||
>
|
Confirm each step before it is applied.
|
||||||
<BookingActionsMenu row={row} variant="toolbar" />
|
</Text>
|
||||||
</PanelShell>
|
<BookingActionsMenu row={row} variant="toolbar" />
|
||||||
|
</Stack>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
{status === "CONTRACT_READY" && (
|
{status === "CONTRACT_READY" && (
|
||||||
<PanelShell title="Documents" description="Download generated contract.">
|
<SectionCard icon={FileText} title="Documents">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="default"
|
||||||
className="gap-2 border-border/60 bg-background/60 backdrop-blur-sm hover:bg-background/80"
|
leftSection={<Download size={16} />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
downloadBlob(
|
downloadBlob(
|
||||||
() => mutations.downloadContract(),
|
() => mutations.downloadContract(),
|
||||||
@@ -84,38 +114,10 @@ export function BookingActionsToolbar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Download className="size-4" />
|
|
||||||
Download contract
|
Download contract
|
||||||
</Button>
|
</Button>
|
||||||
</PanelShell>
|
</SectionCard>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PanelShell({
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
children,
|
|
||||||
muted,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
muted?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className={cn(bookingSurface.sectionCard, !muted && "ring-0")}>
|
|
||||||
<div className={bookingSurface.sectionHeader}>
|
|
||||||
<div className={bookingSurface.sectionIcon}>
|
|
||||||
<Zap className="size-4" strokeWidth={1.75} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">{description}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3 px-5 py-5">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCell
|
|||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm font-semibold",
|
"text-sm font-semibold",
|
||||||
summary.complete ? "text-emerald-700 dark:text-emerald-400" : "text-foreground",
|
summary.complete ? "text-[color:var(--freight-brand)]" : "text-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{summary.label}
|
{summary.label}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { Loader2 } from "lucide-react";
|
import type { ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Textarea,
|
||||||
|
FileInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
|
import type { BookingActionDef } from "@/features/bookings/booking-actions.config";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Textarea,
|
|
||||||
} from "@edr/ui-common";
|
|
||||||
|
|
||||||
interface BookingConfirmDialogProps {
|
interface BookingConfirmDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,7 +24,7 @@ interface BookingConfirmDialogProps {
|
|||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
confirmDisabled?: boolean;
|
confirmDisabled?: boolean;
|
||||||
extra?: React.ReactNode;
|
extra?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BookingConfirmDialog({
|
export function BookingConfirmDialog({
|
||||||
@@ -45,129 +44,119 @@ export function BookingConfirmDialog({
|
|||||||
if (!action || !action.confirmTitle) return null;
|
if (!action || !action.confirmTitle) return null;
|
||||||
|
|
||||||
const Icon = action.icon;
|
const Icon = action.icon;
|
||||||
const needsTextInput =
|
const needsTextInput = action.input === "note" || action.input === "reason";
|
||||||
action.input === "note" || action.input === "reason";
|
|
||||||
const needsFileInput = action.input === "file";
|
const needsFileInput = action.input === "file";
|
||||||
const inputMissing =
|
const inputMissing =
|
||||||
(needsTextInput && !inputValue.trim()) ||
|
(needsTextInput && !inputValue.trim()) || (needsFileInput && !selectedFile);
|
||||||
(needsFileInput && !selectedFile);
|
|
||||||
const isDestructive = action.variant === "destructive";
|
const isDestructive = action.variant === "destructive";
|
||||||
|
const accent = isDestructive ? "red" : "green";
|
||||||
const preventClickThrough = (event: React.MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Modal
|
||||||
<DialogContent
|
opened={open}
|
||||||
className="gap-0 overflow-hidden p-0 sm:max-w-md"
|
onClose={() => onOpenChange(false)}
|
||||||
showCloseButton={false}
|
withCloseButton={false}
|
||||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
centered
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
padding={0}
|
||||||
|
title={null}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<Box
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
background: `var(--mantine-color-${accent}-0)`,
|
||||||
|
borderBottom: `1px solid var(--mantine-color-${accent}-1)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<Group align="flex-start" gap="sm" wrap="nowrap">
|
||||||
className={cn(
|
<Box
|
||||||
"border-b px-6 py-5",
|
style={{
|
||||||
isDestructive
|
display: "flex",
|
||||||
? "bg-gradient-to-br from-red-500/10 via-background to-background"
|
alignItems: "center",
|
||||||
: "bg-gradient-to-br from-primary/8 via-background to-background",
|
justifyContent: "center",
|
||||||
)}
|
width: 44,
|
||||||
>
|
height: 44,
|
||||||
<DialogHeader className="gap-3 text-left">
|
borderRadius: 12,
|
||||||
<div className="flex items-start gap-3">
|
flexShrink: 0,
|
||||||
<div
|
background: `var(--mantine-color-${accent}-1)`,
|
||||||
className={cn(
|
color: `var(--mantine-color-${accent}-7)`,
|
||||||
"flex size-11 shrink-0 items-center justify-center rounded-xl shadow-sm",
|
}}
|
||||||
isDestructive
|
|
||||||
? "bg-red-500/15 text-red-700 dark:text-red-300"
|
|
||||||
: "bg-primary/15 text-primary",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 space-y-1 pt-0.5">
|
|
||||||
<DialogTitle className="text-base leading-snug">
|
|
||||||
{action.confirmTitle}
|
|
||||||
</DialogTitle>
|
|
||||||
{reference && (
|
|
||||||
<p className="font-mono text-xs font-semibold text-muted-foreground">
|
|
||||||
{reference}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogDescription className="text-left text-sm leading-relaxed">
|
|
||||||
{action.confirmDescription}
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 px-6 py-5">
|
|
||||||
{needsTextInput && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{action.inputLabel}
|
|
||||||
<span className="text-red-600"> *</span>
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => onInputChange(e.target.value)}
|
|
||||||
placeholder={action.inputPlaceholder}
|
|
||||||
rows={4}
|
|
||||||
className="min-h-[100px] resize-y"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{needsFileInput && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
{action.inputLabel ?? "Bank slip file"}
|
|
||||||
<span className="text-red-600"> *</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.png,.jpg,.jpeg"
|
|
||||||
className="block w-full text-sm text-muted-foreground file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-2 file:text-xs file:font-semibold file:text-primary-foreground"
|
|
||||||
onChange={(e) =>
|
|
||||||
onFileChange?.(e.target.files?.[0] ?? null)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{selectedFile && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Selected: {selectedFile.name}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{extra}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="gap-2 border-t bg-muted/20 px-6 py-4 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isPending}
|
|
||||||
onMouseDown={preventClickThrough}
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
>
|
||||||
Cancel
|
<Icon size={20} />
|
||||||
</Button>
|
</Box>
|
||||||
<Button
|
<Stack gap={2} style={{ minWidth: 0 }}>
|
||||||
type="button"
|
<Text fw={700} size="md" style={{ lineHeight: 1.3 }}>
|
||||||
variant={isDestructive ? "destructive" : "default"}
|
{action.confirmTitle}
|
||||||
disabled={isPending || inputMissing || confirmDisabled}
|
</Text>
|
||||||
className="min-w-[7rem] gap-2"
|
{reference && (
|
||||||
onMouseDown={preventClickThrough}
|
<Text size="xs" c="dimmed" ff="monospace" fw={600}>
|
||||||
onClick={onConfirm}
|
{reference}
|
||||||
>
|
</Text>
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Icon className="size-4" />
|
|
||||||
)}
|
)}
|
||||||
{action.shortLabel}
|
</Stack>
|
||||||
</Button>
|
</Group>
|
||||||
</DialogFooter>
|
{action.confirmDescription && (
|
||||||
</DialogContent>
|
<Text size="sm" c="dimmed" mt="sm" style={{ lineHeight: 1.5 }}>
|
||||||
</Dialog>
|
{action.confirmDescription}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<Stack gap="md" px="lg" py="lg">
|
||||||
|
{needsTextInput && (
|
||||||
|
<Textarea
|
||||||
|
label={action.inputLabel}
|
||||||
|
withAsterisk
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => onInputChange(e.currentTarget.value)}
|
||||||
|
placeholder={action.inputPlaceholder}
|
||||||
|
minRows={4}
|
||||||
|
autosize
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{needsFileInput && (
|
||||||
|
<FileInput
|
||||||
|
label={action.inputLabel ?? "Bank slip file"}
|
||||||
|
withAsterisk
|
||||||
|
placeholder="Select a file"
|
||||||
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
|
value={selectedFile}
|
||||||
|
onChange={(file) => onFileChange?.(file)}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{extra}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<Group
|
||||||
|
justify="flex-end"
|
||||||
|
gap="sm"
|
||||||
|
px="lg"
|
||||||
|
py="md"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button variant="default" disabled={isPending} onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color={accent}
|
||||||
|
loading={isPending}
|
||||||
|
disabled={inputMissing || confirmDisabled}
|
||||||
|
leftSection={<Icon size={16} />}
|
||||||
|
onClick={onConfirm}
|
||||||
|
miw={112}
|
||||||
|
>
|
||||||
|
{action.shortLabel}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,93 @@
|
|||||||
import { Banknote, Receipt } from "lucide-react";
|
import { Banknote, Receipt } from "lucide-react";
|
||||||
|
import { Paper, Stack, Group, Text, Divider } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import { Separator } from "@edr/ui-common";
|
|
||||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
|
import { detailStyles } from "./detail/booking-detail.styles";
|
||||||
|
|
||||||
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||||
const amount = Number(booking.totalAmount);
|
const amount = Number(booking.totalAmount);
|
||||||
const modifiers = booking.cargoModifiers ?? [];
|
const modifiers = booking.cargoModifiers ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<Stack gap="md">
|
||||||
<div className={bookingSurface.sectionIcon}>
|
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
|
||||||
<Banknote className="size-4" strokeWidth={1.75} />
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
|
||||||
Pricing & payment
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-muted-foreground">Commercial terms</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4 px-5 py-5">
|
|
||||||
<div className={bookingSurface.valueCard}>
|
|
||||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
Total amount
|
Total amount
|
||||||
</p>
|
</Text>
|
||||||
<p className="mt-1 font-mono text-2xl font-semibold tabular-nums tracking-tight text-foreground">
|
<Text
|
||||||
|
size="xl"
|
||||||
|
fw={700}
|
||||||
|
c="green.9"
|
||||||
|
mt={4}
|
||||||
|
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
|
||||||
|
>
|
||||||
{booking.paymentCurrency}{" "}
|
{booking.paymentCurrency}{" "}
|
||||||
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
{amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Paper>
|
||||||
|
|
||||||
<Row label="Payment status" value={booking.paymentStatus} />
|
<Row label="Payment status" value={booking.paymentStatus} />
|
||||||
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
{booking.pnrCode && <Row label="PNR code" value={booking.pnrCode} mono />}
|
||||||
|
|
||||||
{modifiers.length > 0 && (
|
{modifiers.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Separator className="opacity-50" />
|
<Divider color="var(--mantine-color-gray-2)" />
|
||||||
<p className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
<Group gap={6}>
|
||||||
<Receipt className="size-3" />
|
<Receipt size={13} color="var(--mantine-color-gray-5)" />
|
||||||
Surcharges applied
|
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
|
||||||
</p>
|
Surcharges applied
|
||||||
<ul className="space-y-2">
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Stack gap="xs">
|
||||||
{modifiers.map((m) => (
|
{modifiers.map((m) => (
|
||||||
<li
|
<Group
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex justify-between rounded-lg border border-border/50 bg-muted/15 px-3 py-2 text-sm backdrop-blur-sm"
|
justify="space-between"
|
||||||
|
px="sm"
|
||||||
|
py={6}
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
background: "var(--mantine-color-gray-0)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">Modifier</span>
|
<Text size="sm" c="dimmed">
|
||||||
<span className="font-mono font-semibold tabular-nums">
|
Modifier
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{Number(m.calculatedAmount).toLocaleString()}
|
{Number(m.calculatedAmount).toLocaleString()}
|
||||||
</span>
|
</Text>
|
||||||
</li>
|
</Group>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Stack>
|
||||||
</div>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({
|
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||||
label,
|
|
||||||
value,
|
|
||||||
mono,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
mono?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/40 bg-muted/10 px-3 py-2.5 text-sm backdrop-blur-sm">
|
<Group
|
||||||
<span className="text-muted-foreground">{label}</span>
|
justify="space-between"
|
||||||
<span
|
px="sm"
|
||||||
className={
|
py="xs"
|
||||||
mono
|
style={{
|
||||||
? "font-mono text-xs font-semibold text-foreground"
|
borderRadius: 8,
|
||||||
: "font-medium text-foreground"
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
}
|
background: "var(--mantine-color-gray-0)",
|
||||||
>
|
}}
|
||||||
|
>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" fw={600} ff={mono ? "monospace" : undefined}>
|
||||||
{value}
|
{value}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
|
import { Badge } from "@mantine/core";
|
||||||
|
|
||||||
export function BookingPriorityBadge({ score }: { score: number }) {
|
export function BookingPriorityBadge({ score }: { score: number }) {
|
||||||
if (score >= 1000) {
|
if (score >= 1000) {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
|
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
|
||||||
Urgent
|
Urgent
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (score >= 500) {
|
if (score >= 500) {
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
|
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
|
||||||
High
|
High
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
|
<Badge color="gray" variant="light" size="sm" radius="lg" tt="uppercase">
|
||||||
Normal
|
Normal
|
||||||
</span>
|
</Badge>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { bookingGlass } from "./booking-ui.styles";
|
import { Card, Group, Stack, Text, Paper } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export interface StatItem {
|
export interface StatItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -10,54 +9,100 @@ export interface StatItem {
|
|||||||
accent?: "default" | "amber" | "emerald" | "rose";
|
accent?: "default" | "amber" | "emerald" | "rose";
|
||||||
}
|
}
|
||||||
|
|
||||||
const iconAccentStyles = {
|
const accentColors = {
|
||||||
default: "text-foreground/70",
|
default: { bg: "var(--mantine-color-gray-1)", color: "var(--mantine-color-gray-6)" },
|
||||||
amber: "text-amber-600 dark:text-amber-400",
|
amber: { bg: "var(--mantine-color-yellow-1)", color: "var(--mantine-color-yellow-6)" },
|
||||||
emerald: "text-emerald-600 dark:text-emerald-400",
|
emerald: { bg: "var(--freight-brand-muted)", color: "var(--freight-brand)" },
|
||||||
rose: "text-rose-600 dark:text-rose-400",
|
rose: { bg: "var(--mantine-color-red-1)", color: "var(--mantine-color-red-6)" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
export function BookingStatGrid({ items }: { items: StatItem[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
<Paper
|
||||||
{items.map((item) => {
|
p="md"
|
||||||
const Icon = item.icon;
|
radius="lg"
|
||||||
const accent = item.accent ?? "default";
|
withBorder
|
||||||
return (
|
style={{
|
||||||
<div
|
background: "white",
|
||||||
key={item.label}
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
className={cn(
|
overflowX: "auto",
|
||||||
"group relative overflow-hidden rounded-xl p-5 transition-all duration-200 hover:shadow-md",
|
overflowY: "hidden",
|
||||||
bookingGlass.card,
|
WebkitOverflowScrolling: "touch",
|
||||||
)}
|
scrollBehavior: "smooth",
|
||||||
>
|
}}
|
||||||
<div className="flex items-start justify-between gap-3">
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<Group
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
|
gap="lg"
|
||||||
{item.label}
|
style={{
|
||||||
</p>
|
minWidth: "min-content",
|
||||||
<p className="mt-2 text-3xl font-semibold tabular-nums tracking-tight text-foreground">
|
display: "flex",
|
||||||
{item.value}
|
flexWrap: "nowrap",
|
||||||
</p>
|
}}
|
||||||
{item.hint && (
|
>
|
||||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
{items.map((item) => {
|
||||||
{item.hint}
|
const Icon = item.icon;
|
||||||
</p>
|
const accent = item.accent ?? "default";
|
||||||
)}
|
const accentStyle = accentColors[accent];
|
||||||
</div>
|
|
||||||
<div
|
return (
|
||||||
className={cn(
|
<Card
|
||||||
"flex size-10 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 group-hover:scale-[1.02]",
|
key={item.label}
|
||||||
bookingGlass.iconWellGreen,
|
p="lg"
|
||||||
iconAccentStyles[accent],
|
radius="lg"
|
||||||
)}
|
withBorder
|
||||||
>
|
style={{
|
||||||
<Icon className="size-[18px]" strokeWidth={1.75} />
|
background: "white",
|
||||||
</div>
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
</div>
|
transition: "all 0.2s ease",
|
||||||
</div>
|
cursor: "pointer",
|
||||||
);
|
minWidth: "280px",
|
||||||
})}
|
width: "280px",
|
||||||
</div>
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "0 4px 12px rgba(34, 197, 94, 0.12)";
|
||||||
|
e.currentTarget.style.borderColor = "var(--freight-brand-border)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.boxShadow = "none";
|
||||||
|
e.currentTarget.style.borderColor = "var(--mantine-color-gray-2)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="flex-start">
|
||||||
|
<Stack gap="xs" style={{ flex: 1 }}>
|
||||||
|
<Text size="xs" fw={600} c="dimmed" tt="uppercase">
|
||||||
|
{item.label}
|
||||||
|
</Text>
|
||||||
|
<Text size="32px" fw={700} style={{ lineHeight: 1, letterSpacing: "-0.02em" }}>
|
||||||
|
{item.value}
|
||||||
|
</Text>
|
||||||
|
{item.hint && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{item.hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "44px",
|
||||||
|
height: "44px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: accentStyle.bg,
|
||||||
|
color: accentStyle.color,
|
||||||
|
flexShrink: 0,
|
||||||
|
transition: "transform 0.2s ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={22} strokeWidth={1.75} />
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,50 @@
|
|||||||
import { Badge } from "@edr/ui-common";
|
import { Badge } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config";
|
||||||
|
|
||||||
|
const statusColorMap: Record<string, string> = {
|
||||||
|
DRAFT: "gray",
|
||||||
|
SUBMITTED: "yellow",
|
||||||
|
CHANGES_REQUESTED: "orange",
|
||||||
|
PENDING_APPROVAL: "yellow",
|
||||||
|
APPROVED_PENDING_SIGNATURE: "cyan",
|
||||||
|
APPROVED: "green",
|
||||||
|
CONTRACT_READY: "indigo",
|
||||||
|
SIGNED_CUSTOMER: "cyan",
|
||||||
|
FULLY_EXECUTED: "indigo",
|
||||||
|
PNR_GENERATED: "violet",
|
||||||
|
PAYMENT_VERIFICATION_IN_PROGRESS: "yellow",
|
||||||
|
PAID: "green",
|
||||||
|
IN_TRANSIT: "cyan",
|
||||||
|
COMPLETED: "indigo",
|
||||||
|
REJECTED: "red",
|
||||||
|
CANCELLED: "red",
|
||||||
|
PENDING_CONSOLIDATION: "yellow",
|
||||||
|
CONSOLIDATED: "indigo",
|
||||||
|
};
|
||||||
|
|
||||||
export function BookingStatusBadge({ status }: { status: string }) {
|
export function BookingStatusBadge({ status }: { status: string }) {
|
||||||
const style = BOOKING_STATUS_STYLES[status] ?? {
|
const style = BOOKING_STATUS_STYLES[status] ?? {
|
||||||
label: status,
|
label: status,
|
||||||
color: "bg-muted text-muted-foreground border-border",
|
color: "gray",
|
||||||
};
|
};
|
||||||
|
const color = statusColorMap[status] ?? "gray";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
color={color}
|
||||||
className={cn(
|
variant="light"
|
||||||
"px-2 py-0.5 text-[9px] font-bold uppercase tracking-wider",
|
size="sm"
|
||||||
style.color,
|
radius="md"
|
||||||
)}
|
tt="uppercase"
|
||||||
|
fw={600}
|
||||||
|
title={style.label}
|
||||||
|
style={{
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
letterSpacing: "0.05em",
|
||||||
|
display: "inline-flex",
|
||||||
|
maxWidth: "100%",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{style.label}
|
{style.label}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -8,27 +8,24 @@ import {
|
|||||||
Wallet,
|
Wallet,
|
||||||
XCircle,
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Group, Badge, UnstyledButton, Text } from "@mantine/core";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BOOKING_LIST_TABS,
|
BOOKING_LIST_TABS,
|
||||||
type BookingStatusTabKey,
|
type BookingStatusTabKey,
|
||||||
} from "@/features/bookings/booking-status.config";
|
} from "@/features/bookings/booking-status.config";
|
||||||
import { bookingGlass } from "./booking-ui.styles";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
const TAB_ICONS: Record<BookingStatusTabKey, React.ReactNode> = {
|
||||||
all: <LayoutGrid className="size-3.5" strokeWidth={1.75} />,
|
all: <LayoutGrid size={18} strokeWidth={1.75} />,
|
||||||
intake: <Inbox className="size-3.5" strokeWidth={1.75} />,
|
intake: <Inbox size={18} strokeWidth={1.75} />,
|
||||||
in_approval: <ClipboardCheck className="size-3.5" strokeWidth={1.75} />,
|
in_approval: <ClipboardCheck size={18} strokeWidth={1.75} />,
|
||||||
approved_contract: <FileSignature className="size-3.5" strokeWidth={1.75} />,
|
approved_contract: <FileSignature size={18} strokeWidth={1.75} />,
|
||||||
payment: <Wallet className="size-3.5" strokeWidth={1.75} />,
|
payment: <Wallet size={18} strokeWidth={1.75} />,
|
||||||
operations: <Train className="size-3.5" strokeWidth={1.75} />,
|
operations: <Train size={18} strokeWidth={1.75} />,
|
||||||
completed: <CheckCircle className="size-3.5" strokeWidth={1.75} />,
|
completed: <CheckCircle size={18} strokeWidth={1.75} />,
|
||||||
closed: <XCircle className="size-3.5" strokeWidth={1.75} />,
|
closed: <XCircle size={18} strokeWidth={1.75} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const activeTabText = "text-black";
|
|
||||||
|
|
||||||
interface BookingStatusTabsProps {
|
interface BookingStatusTabsProps {
|
||||||
active: BookingStatusTabKey;
|
active: BookingStatusTabKey;
|
||||||
onChange: (tab: BookingStatusTabKey) => void;
|
onChange: (tab: BookingStatusTabKey) => void;
|
||||||
@@ -41,66 +38,74 @@ export function BookingStatusTabs({
|
|||||||
counts,
|
counts,
|
||||||
}: BookingStatusTabsProps) {
|
}: BookingStatusTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={bookingGlass.tabRail}>
|
<Group
|
||||||
<div
|
gap="sm"
|
||||||
className="flex flex-nowrap gap-1.5 overflow-x-auto [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
wrap="nowrap"
|
||||||
role="tablist"
|
p="md"
|
||||||
aria-label="Booking status filters"
|
style={{
|
||||||
>
|
background: "var(--mantine-color-gray-0)",
|
||||||
{BOOKING_LIST_TABS.map((tab) => {
|
borderRadius: "12px",
|
||||||
const isActive = active === tab.key;
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
const count = counts?.[tab.key];
|
overflowX: "auto",
|
||||||
return (
|
overflowY: "hidden",
|
||||||
<button
|
WebkitOverflowScrolling: "touch",
|
||||||
key={tab.key}
|
scrollBehavior: "smooth",
|
||||||
type="button"
|
scrollbarWidth: "thin",
|
||||||
role="tab"
|
}}
|
||||||
aria-selected={isActive}
|
>
|
||||||
onClick={() => onChange(tab.key)}
|
{BOOKING_LIST_TABS.map((tab) => {
|
||||||
className={cn(
|
const isActive = active === tab.key;
|
||||||
"flex min-w-[8rem] shrink-0 flex-col items-start gap-0.5 rounded-lg px-3 py-2.5 text-left transition-all duration-200",
|
const count = counts?.[tab.key];
|
||||||
isActive
|
return (
|
||||||
? bookingGlass.activeTab
|
<UnstyledButton
|
||||||
: "text-muted-foreground hover:bg-emerald-500/5 hover:text-foreground",
|
key={tab.key}
|
||||||
)}
|
onClick={() => onChange(tab.key)}
|
||||||
>
|
style={{
|
||||||
<span className="flex w-full items-center justify-between gap-2">
|
flexShrink: 0,
|
||||||
<span
|
background: isActive ? "white" : "transparent",
|
||||||
className={cn(
|
border: isActive ? "1px solid var(--freight-brand-border)" : "1px solid var(--mantine-color-gray-2)",
|
||||||
"flex items-center gap-2 text-sm font-medium",
|
borderRadius: "10px",
|
||||||
isActive ? activeTabText : "text-muted-foreground",
|
padding: "10px 16px",
|
||||||
)}
|
transition: "all 0.2s ease",
|
||||||
|
cursor: "pointer",
|
||||||
|
boxShadow: isActive ? "0 2px 8px rgb(21 128 61 / 0.12)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Group gap="sm" justify="space-between" wrap="nowrap">
|
||||||
|
<Group gap={8}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: "32px",
|
||||||
|
height: "32px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: isActive ? "var(--freight-brand-muted)" : "var(--mantine-color-gray-1)",
|
||||||
|
color: isActive ? "var(--freight-brand-dark)" : "var(--mantine-color-gray-6)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<span
|
{TAB_ICONS[tab.key]}
|
||||||
className={cn(
|
</div>
|
||||||
"flex size-7 shrink-0 items-center justify-center rounded-md",
|
<Text size="sm" fw={600}>
|
||||||
isActive
|
{tab.label}
|
||||||
? cn(bookingGlass.iconWellGreen, "text-black")
|
</Text>
|
||||||
: "border border-transparent bg-muted/30",
|
</Group>
|
||||||
)}
|
{count !== undefined && count > 0 && (
|
||||||
>
|
<Badge
|
||||||
{TAB_ICONS[tab.key]}
|
size="sm"
|
||||||
</span>
|
variant={isActive ? "filled" : "light"}
|
||||||
<span className="whitespace-nowrap">{tab.label}</span>
|
color={isActive ? "green" : "gray"}
|
||||||
</span>
|
radius="lg"
|
||||||
{count !== undefined && count > 0 && (
|
>
|
||||||
<span
|
{count}
|
||||||
className={cn(
|
</Badge>
|
||||||
"rounded-full px-2 py-0.5 text-[10px] font-semibold tabular-nums",
|
)}
|
||||||
isActive
|
</Group>
|
||||||
? cn("bg-emerald-500/15", activeTabText)
|
</UnstyledButton>
|
||||||
: "bg-muted/50 text-muted-foreground",
|
);
|
||||||
)}
|
})}
|
||||||
>
|
</Group>
|
||||||
{count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,28 @@
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
|
||||||
FileSignature,
|
FileSignature,
|
||||||
FileText,
|
FileText,
|
||||||
Train,
|
Train,
|
||||||
Wallet,
|
Wallet,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
import {
|
||||||
getWorkflowStageIndex,
|
getWorkflowStageIndex,
|
||||||
WORKFLOW_STAGES,
|
WORKFLOW_STAGES,
|
||||||
} from "@/features/bookings/booking-status.config";
|
} from "@/features/bookings/booking-status.config";
|
||||||
import { bookingGlass, bookingSurface } from "./booking-ui.styles";
|
import { SectionCard } from "./detail/SectionCard";
|
||||||
|
import { BRAND_GREEN, detailStyles } from "./detail/booking-detail.styles";
|
||||||
|
|
||||||
const STAGE_ICONS = [FileText, FileSignature, FileSignature, Wallet, Train, Check];
|
const STAGE_ICONS: LucideIcon[] = [
|
||||||
|
FileText,
|
||||||
|
FileSignature,
|
||||||
|
FileSignature,
|
||||||
|
Wallet,
|
||||||
|
Train,
|
||||||
|
Check,
|
||||||
|
];
|
||||||
|
|
||||||
interface BookingWorkflowStepperProps {
|
interface BookingWorkflowStepperProps {
|
||||||
status: string;
|
status: string;
|
||||||
@@ -27,104 +35,94 @@ export function BookingWorkflowStepper({
|
|||||||
status,
|
status,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
titleColor,
|
|
||||||
}: BookingWorkflowStepperProps) {
|
}: BookingWorkflowStepperProps) {
|
||||||
const currentStage = getWorkflowStageIndex(status);
|
const currentStage = getWorkflowStageIndex(status);
|
||||||
const isTerminal = currentStage < 0;
|
const isTerminal = currentStage < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={bookingSurface.sectionCard}>
|
<SectionCard icon={Train} title="Workflow progress">
|
||||||
<div className={bookingSurface.sectionHeader}>
|
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
|
||||||
<div className={bookingSurface.sectionIcon}>
|
{WORKFLOW_STAGES.map((stage, index) => {
|
||||||
<Train className="size-4" strokeWidth={1.75} />
|
const Icon = STAGE_ICONS[index] ?? FileText;
|
||||||
</div>
|
const isComplete = !isTerminal && index < currentStage;
|
||||||
<div>
|
const isActive = !isTerminal && index === currentStage;
|
||||||
<h2 className="text-sm font-semibold text-foreground">
|
const isLast = index === WORKFLOW_STAGES.length - 1;
|
||||||
Workflow progress
|
|
||||||
</h2>
|
return (
|
||||||
<p className="text-xs text-muted-foreground">
|
<Box key={stage.label} style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}>
|
||||||
Customer submission through completion
|
<Group gap={0} wrap="nowrap" align="center">
|
||||||
</p>
|
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||||
</div>
|
<Box
|
||||||
</div>
|
style={{
|
||||||
<div className="space-y-8 px-5 py-6">
|
display: "flex",
|
||||||
<div className="relative px-2">
|
alignItems: "center",
|
||||||
<div className="absolute left-4 right-4 top-5 h-px bg-border/60" />
|
justifyContent: "center",
|
||||||
<div
|
width: 34,
|
||||||
className="absolute left-4 top-5 h-px bg-emerald-500/40 transition-all duration-700 ease-out"
|
height: 34,
|
||||||
style={{
|
borderRadius: "50%",
|
||||||
width:
|
background: isComplete
|
||||||
!isTerminal && currentStage >= 0
|
? BRAND_GREEN
|
||||||
? `calc(${(currentStage / (WORKFLOW_STAGES.length - 1)) * 100}% - 2rem)`
|
: isActive
|
||||||
: "0%",
|
? "white"
|
||||||
}}
|
: "var(--mantine-color-gray-1)",
|
||||||
/>
|
border: isActive
|
||||||
<div className="relative flex justify-between">
|
? `2px solid ${BRAND_GREEN}`
|
||||||
{WORKFLOW_STAGES.map((stage, idx) => {
|
: isComplete
|
||||||
const Icon = STAGE_ICONS[idx] ?? FileText;
|
? "2px solid transparent"
|
||||||
const isCompleted = !isTerminal && idx < currentStage;
|
: "2px solid var(--mantine-color-gray-2)",
|
||||||
const isActive = !isTerminal && idx === currentStage;
|
color: isComplete
|
||||||
return (
|
? "white"
|
||||||
<div
|
: isActive
|
||||||
key={stage.label}
|
? "var(--freight-brand-dark)"
|
||||||
className="flex max-w-[4.5rem] flex-col items-center gap-2.5 sm:max-w-none"
|
: "var(--mantine-color-gray-5)",
|
||||||
>
|
transition: "all 0.2s ease",
|
||||||
<div
|
}}
|
||||||
className={cn(
|
|
||||||
"flex size-10 items-center justify-center rounded-full border-2 bg-card/80 backdrop-blur-sm transition-all duration-300",
|
|
||||||
isCompleted &&
|
|
||||||
cn(bookingGlass.iconWellGreen, "border-emerald-500/30 text-black"),
|
|
||||||
isActive &&
|
|
||||||
cn(
|
|
||||||
bookingGlass.activeTab,
|
|
||||||
"scale-105 border-emerald-500/30 text-black shadow-sm",
|
|
||||||
),
|
|
||||||
!isCompleted &&
|
|
||||||
!isActive &&
|
|
||||||
"border-border/60 text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{isCompleted ? (
|
{isComplete ? <Check size={16} strokeWidth={3} /> : <Icon size={15} />}
|
||||||
<CheckCircle2 className="size-4" />
|
</Box>
|
||||||
) : (
|
<Text
|
||||||
<Icon className="size-4" />
|
size="xs"
|
||||||
)}
|
fw={isActive ? 600 : 500}
|
||||||
</div>
|
c={isActive ? "green.7" : isComplete ? "dark" : "dimmed"}
|
||||||
<span
|
ta="center"
|
||||||
className={cn(
|
style={{ whiteSpace: "nowrap" }}
|
||||||
"text-center text-[10px] font-semibold uppercase leading-tight tracking-wide",
|
|
||||||
isActive ? "text-black" : "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{stage.label}
|
{stage.label}
|
||||||
</span>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
);
|
{!isLast && (
|
||||||
})}
|
<Box
|
||||||
</div>
|
style={{
|
||||||
</div>
|
flex: 1,
|
||||||
|
height: 2,
|
||||||
|
marginInline: 8,
|
||||||
|
marginBottom: 20,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: isComplete ? BRAND_GREEN : "var(--mantine-color-gray-2)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Group>
|
||||||
|
|
||||||
<div
|
<Paper
|
||||||
className={cn(
|
radius="md"
|
||||||
"rounded-xl border px-5 py-4 backdrop-blur-sm",
|
withBorder
|
||||||
isTerminal
|
p="md"
|
||||||
? "border-destructive/20 bg-destructive/5"
|
style={
|
||||||
: bookingGlass.activeTab,
|
isTerminal ? detailStyles.statusBannerTerminal : detailStyles.statusBanner
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
<h4
|
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
|
||||||
className={cn(
|
{title}
|
||||||
"text-sm font-semibold tracking-tight",
|
</Text>
|
||||||
isTerminal ? titleColor : "text-black",
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
)}
|
{description}
|
||||||
>
|
</Text>
|
||||||
{title}
|
</Paper>
|
||||||
</h4>
|
</SectionCard>
|
||||||
<p className="mt-1.5 text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { Alert, Text } from "@mantine/core";
|
||||||
|
|
||||||
import type { BookingNextStep } from "@/types/booking";
|
import type { BookingNextStep } from "@/types/booking";
|
||||||
import { bookingGlass } from "./booking-ui.styles";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface NextStepBannerProps {
|
interface NextStepBannerProps {
|
||||||
nextStep: BookingNextStep;
|
nextStep: BookingNextStep;
|
||||||
className?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NextStepBanner({ nextStep, className }: NextStepBannerProps) {
|
export function NextStepBanner({ nextStep }: NextStepBannerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Alert
|
||||||
className={cn(
|
variant="light"
|
||||||
"flex items-start gap-3 rounded-xl px-4 py-3 text-sm",
|
color="gray"
|
||||||
bookingGlass.activeTab,
|
radius="md"
|
||||||
className,
|
icon={<ArrowRight size={16} />}
|
||||||
)}
|
title={
|
||||||
role="status"
|
<Text size="sm" fw={600}>
|
||||||
>
|
|
||||||
<ArrowRight className="mt-0.5 size-4 shrink-0 text-black" aria-hidden />
|
|
||||||
<div className="min-w-0 space-y-0.5">
|
|
||||||
<p className="font-semibold text-black">
|
|
||||||
Next: {nextStep.action.replace(/_/g, " ")}
|
Next: {nextStep.action.replace(/_/g, " ")}
|
||||||
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
|
{nextStep.requiredRole ? ` (${nextStep.requiredRole})` : ""}
|
||||||
</p>
|
</Text>
|
||||||
<p className="text-muted-foreground">{nextStep.description}</p>
|
}
|
||||||
</div>
|
>
|
||||||
</div>
|
<Text size="sm" c="dimmed">
|
||||||
|
{nextStep.description}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/** Shared surfaces for booking list & detail — frosted glass, neutral accents. */
|
/** Shared surfaces for booking list & detail — frosted glass, brand accents. */
|
||||||
|
|
||||||
export const bookingGlass = {
|
export const bookingGlass = {
|
||||||
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
card: "border border-border/50 bg-card/75 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-card/60",
|
||||||
@@ -10,9 +10,9 @@ export const bookingGlass = {
|
|||||||
iconWellHero:
|
iconWellHero:
|
||||||
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
|
"border border-border/50 bg-background/60 text-foreground shadow-sm ring-1 ring-border/30 backdrop-blur-md supports-[backdrop-filter]:bg-background/45",
|
||||||
activeTab:
|
activeTab:
|
||||||
"border border-emerald-500/20 bg-emerald-500/10 shadow-sm backdrop-blur-md ring-1 ring-emerald-500/10 supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] shadow-sm backdrop-blur-md ring-1 ring-[color:var(--freight-brand-ring)]",
|
||||||
iconWellGreen:
|
iconWellGreen:
|
||||||
"border border-emerald-500/20 bg-emerald-500/15 text-emerald-700 shadow-sm backdrop-blur-sm supports-[backdrop-filter]:bg-emerald-500/10 dark:text-emerald-400",
|
"border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] shadow-sm backdrop-blur-sm",
|
||||||
tabRail:
|
tabRail:
|
||||||
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
"rounded-xl border border-border/60 bg-muted/10 p-2 backdrop-blur-sm supports-[backdrop-filter]:bg-muted/5",
|
||||||
tableHeader:
|
tableHeader:
|
||||||
@@ -38,7 +38,7 @@ export const bookingSurface = {
|
|||||||
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
|
sectionIcon: `flex size-9 shrink-0 items-center justify-center rounded-lg ${bookingGlass.iconWellGreen}`,
|
||||||
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
sectionIconLg: `flex size-11 shrink-0 items-center justify-center rounded-xl ${bookingGlass.iconWellGreen}`,
|
||||||
valueCard:
|
valueCard:
|
||||||
"rounded-xl border border-emerald-500/20 bg-emerald-500/10 p-4 shadow-sm backdrop-blur-md supports-[backdrop-filter]:bg-emerald-500/[0.08]",
|
"rounded-xl border border-[color:var(--freight-brand-border)] bg-[color:var(--freight-brand-muted)] p-4 shadow-sm backdrop-blur-md",
|
||||||
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
stickySidebar: "lg:sticky lg:top-6 lg:self-start",
|
||||||
metricTile:
|
metricTile:
|
||||||
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
|
"rounded-lg border border-border/50 bg-background/70 px-4 py-3 shadow-xs backdrop-blur-sm",
|
||||||
@@ -48,7 +48,7 @@ export const bookingSurface = {
|
|||||||
|
|
||||||
export const bookingInput = {
|
export const bookingInput = {
|
||||||
search:
|
search:
|
||||||
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-ring/60 focus-visible:ring-[3px] focus-visible:ring-ring/20 sm:max-w-xs",
|
"h-10 w-full rounded-lg border border-border/60 bg-background/80 pl-10 text-sm shadow-xs backdrop-blur-sm transition-[box-shadow,border-color] placeholder:text-muted-foreground focus-visible:border-[color:var(--freight-brand)] focus-visible:ring-[3px] focus-visible:ring-[color:var(--freight-brand-ring)] sm:max-w-xs",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const bookingTable = {
|
export const bookingTable = {
|
||||||
|
|||||||
@@ -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;
|
break;
|
||||||
}
|
}
|
||||||
case "generateContract":
|
|
||||||
mutations.generateContract.mutate(undefined, { onSuccess });
|
|
||||||
break;
|
|
||||||
case "viewContract":
|
case "viewContract":
|
||||||
break;
|
break;
|
||||||
case "payBooking":
|
|
||||||
mutations.payBooking.mutate(undefined, { onSuccess });
|
|
||||||
break;
|
|
||||||
case "startTransit":
|
case "startTransit":
|
||||||
mutations.startTransit.mutate(undefined, { onSuccess });
|
mutations.startTransit.mutate(undefined, { onSuccess });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -9,13 +9,10 @@ import {
|
|||||||
Sun,
|
Sun,
|
||||||
User,
|
User,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Group, Stack, Text, Avatar, Menu, ActionIcon, Badge, Box } from "@mantine/core";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
import type { PageMeta } from "./types";
|
import type { PageMeta } from "./types";
|
||||||
|
import { freightBrand } from "@/theme/freight-brand";
|
||||||
const iconButtonClass =
|
|
||||||
"relative inline-flex h-10 w-10 items-center justify-center rounded-xl border border-gray-200 bg-white text-gray-600 shadow-sm transition hover:border-primary/30 hover:bg-gray-50 hover:text-gray-900";
|
|
||||||
|
|
||||||
export interface FreightDashboardHeaderProps {
|
export interface FreightDashboardHeaderProps {
|
||||||
pageMeta: PageMeta;
|
pageMeta: PageMeta;
|
||||||
@@ -77,120 +74,146 @@ const FreightDashboardHeader = ({
|
|||||||
}, [isUserMenuOpen]);
|
}, [isUserMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-20 shrink-0 items-center justify-between gap-4 px-6">
|
<header
|
||||||
<div className="min-w-0">
|
style={{
|
||||||
<h1 className="truncate text-xl font-bold tracking-tight text-foreground">
|
display: "flex",
|
||||||
|
height: "80px",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "16px",
|
||||||
|
padding: "0 24px",
|
||||||
|
// borderBottom: `3px solid ${freightBrand.primary}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Text size="lg" fw={700} truncate style={{ color: freightBrand.primaryDark }}>
|
||||||
{pageMeta.title}
|
{pageMeta.title}
|
||||||
</h1>
|
</Text>
|
||||||
<p className="mt-0.5 truncate text-sm text-secondary-foreground">
|
<Text size="sm" c="dimmed" truncate>
|
||||||
{pageMeta.subtitle}
|
{pageMeta.subtitle}
|
||||||
</p>
|
</Text>
|
||||||
</div>
|
</Stack>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<Group gap="sm" wrap="nowrap">
|
||||||
{enableThemeToggle ? (
|
{enableThemeToggle && (
|
||||||
<button
|
<ActionIcon
|
||||||
type="button"
|
variant="default"
|
||||||
|
size={40}
|
||||||
|
radius="lg"
|
||||||
onClick={onToggleTheme}
|
onClick={onToggleTheme}
|
||||||
aria-label={
|
style={{
|
||||||
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
background: "var(--mantine-color-gray-1)",
|
||||||
}
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
className={iconButtonClass}
|
color: "var(--mantine-color-gray-7)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{theme === "dark" ? (
|
{theme === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||||
<Sun className="h-5 w-5" />
|
</ActionIcon>
|
||||||
) : (
|
)}
|
||||||
<Moon className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<button
|
<ActionIcon
|
||||||
type="button"
|
variant="default"
|
||||||
aria-label="Change language"
|
size={40}
|
||||||
className={iconButtonClass}
|
radius="lg"
|
||||||
|
style={{
|
||||||
|
background: "var(--mantine-color-gray-1)",
|
||||||
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
color: "var(--mantine-color-gray-7)",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Languages className="h-5 w-5" />
|
<Languages size={18} />
|
||||||
</button>
|
</ActionIcon>
|
||||||
|
|
||||||
<button type="button" aria-label="Messages" className={iconButtonClass}>
|
<ActionIcon
|
||||||
<MessageSquare className="h-5 w-5" />
|
variant="default"
|
||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
size={40}
|
||||||
</button>
|
radius="lg"
|
||||||
|
style={{
|
||||||
<button
|
background: "var(--mantine-color-gray-1)",
|
||||||
type="button"
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
aria-label="Notifications"
|
color: "var(--mantine-color-gray-7)",
|
||||||
className={iconButtonClass}
|
position: "relative",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Bell className="h-5 w-5" />
|
<MessageSquare size={18} />
|
||||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white" />
|
<Badge
|
||||||
</button>
|
size="xs"
|
||||||
|
color="red"
|
||||||
|
circle
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-3px",
|
||||||
|
right: "-3px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
<div ref={userMenuRef} className="relative ml-1">
|
<ActionIcon
|
||||||
<button
|
variant="default"
|
||||||
type="button"
|
size={40}
|
||||||
aria-haspopup="menu"
|
radius="lg"
|
||||||
aria-expanded={isUserMenuOpen}
|
style={{
|
||||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
background: "var(--mantine-color-gray-1)",
|
||||||
className={cn(
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
"flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition",
|
color: "var(--mantine-color-gray-7)",
|
||||||
isUserMenuOpen
|
position: "relative",
|
||||||
? "border-primary/30 bg-primary/5"
|
}}
|
||||||
: "hover:border-primary/20 hover:bg-gray-50",
|
>
|
||||||
)}
|
<Bell size={18} />
|
||||||
>
|
<Badge
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground">
|
size="xs"
|
||||||
{initials}
|
color="red"
|
||||||
</div>
|
circle
|
||||||
<ChevronDown
|
style={{
|
||||||
className={cn(
|
position: "absolute",
|
||||||
"hidden h-4 w-4 text-gray-400 transition sm:block",
|
top: "-3px",
|
||||||
isUserMenuOpen && "rotate-180 text-primary",
|
right: "-3px",
|
||||||
)}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</ActionIcon>
|
||||||
|
|
||||||
{isUserMenuOpen ? (
|
<Menu position="bottom-end" shadow="md" opened={isUserMenuOpen} onOpen={() => setIsUserMenuOpen(true)} onClose={() => setIsUserMenuOpen(false)}>
|
||||||
<div
|
<Menu.Target>
|
||||||
role="menu"
|
<Group gap="sm" p="xs" style={{ cursor: "pointer", borderRadius: "12px" }}>
|
||||||
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-gray-200 bg-white py-1 shadow-lg"
|
<Avatar name={initials} color="green" size="md" styles={{ root: { background: freightBrand.primary } }} />
|
||||||
>
|
<ChevronDown size={16} style={{ transition: "transform 0.2s", transform: isUserMenuOpen ? "rotate(180deg)" : "rotate(0deg)" }} />
|
||||||
<div className="border-b border-gray-100 px-4 py-3">
|
</Group>
|
||||||
<p className="text-sm font-semibold text-gray-900">
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item disabled>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
{userName}
|
{userName}
|
||||||
</p>
|
</Text>
|
||||||
{userEmail ? (
|
{userEmail && (
|
||||||
<p className="text-xs text-gray-500">{userEmail}</p>
|
<Text size="xs" c="dimmed">
|
||||||
) : null}
|
{userEmail}
|
||||||
</div>
|
</Text>
|
||||||
<a
|
)}
|
||||||
href="#profile"
|
</Stack>
|
||||||
role="menuitem"
|
</Menu.Item>
|
||||||
onClick={() => setIsUserMenuOpen(false)}
|
<Menu.Divider />
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 transition hover:bg-gray-50"
|
<Menu.Item
|
||||||
>
|
leftSection={<User size={14} />}
|
||||||
<User className="h-4 w-4" />
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
Profile
|
>
|
||||||
</a>
|
Profile
|
||||||
<button
|
</Menu.Item>
|
||||||
type="button"
|
<Menu.Item
|
||||||
role="menuitem"
|
leftSection={<LogOut size={14} />}
|
||||||
onClick={() => {
|
color="red"
|
||||||
setIsUserMenuOpen(false);
|
onClick={() => {
|
||||||
onLogout?.();
|
setIsUserMenuOpen(false);
|
||||||
}}
|
onLogout?.();
|
||||||
className="flex w-full items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50"
|
}}
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" />
|
Logout
|
||||||
Logout
|
</Menu.Item>
|
||||||
</button>
|
</Menu.Dropdown>
|
||||||
</div>
|
</Menu>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{headerRight}
|
{headerRight}
|
||||||
</div>
|
</Group>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user