From 15fc21a6c99b5ac2509c77c258b622adcb24f766 Mon Sep 17 00:00:00 2001 From: natib21 Date: Fri, 10 Jul 2026 15:23:49 +0000 Subject: [PATCH 01/21] gps --- .github/workflows/deploy.yml | 4 +- .../gps-tracking/gps-tracking.module.ts | 7 +- apps/edr-gps-tracker/Dockerfile | 42 ++++ apps/edr-gps-tracker/nest-cli.json | 8 + apps/edr-gps-tracker/package.json | 39 ++++ apps/edr-gps-tracker/src/app.module.ts | 19 ++ .../src/config/database.config.ts | 28 +++ .../src/gps/entities/gps-device.entity.ts | 48 ++++ .../src/gps/entities/gps-position.entity.ts | 41 ++++ .../src/gps/gps-ingest.service.ts | 72 ++++++ apps/edr-gps-tracker/src/gps/gps.module.ts | 14 ++ .../edr-gps-tracker/src/gps/gps.repository.ts | 25 +++ .../src/gps/gt06/gt06.codec.ts | 207 ++++++++++++++++++ .../src/gps}/gt06/gt06.server.ts | 46 ++-- apps/edr-gps-tracker/src/main.ts | 32 +++ apps/edr-gps-tracker/tsconfig.json | 15 ++ docker-compose.yaml | 25 ++- pnpm-lock.yaml | 61 ++++++ 18 files changed, 709 insertions(+), 24 deletions(-) create mode 100644 apps/edr-gps-tracker/Dockerfile create mode 100644 apps/edr-gps-tracker/nest-cli.json create mode 100644 apps/edr-gps-tracker/package.json create mode 100644 apps/edr-gps-tracker/src/app.module.ts create mode 100644 apps/edr-gps-tracker/src/config/database.config.ts create mode 100644 apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts create mode 100644 apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts create mode 100644 apps/edr-gps-tracker/src/gps/gps-ingest.service.ts create mode 100644 apps/edr-gps-tracker/src/gps/gps.module.ts create mode 100644 apps/edr-gps-tracker/src/gps/gps.repository.ts create mode 100644 apps/edr-gps-tracker/src/gps/gt06/gt06.codec.ts rename apps/{edr-freight-api/src/modules/gps-tracking => edr-gps-tracker/src/gps}/gt06/gt06.server.ts (67%) create mode 100644 apps/edr-gps-tracker/src/main.ts create mode 100644 apps/edr-gps-tracker/tsconfig.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 62530611c..057cb0c1b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -31,6 +31,7 @@ jobs: "freight-api" "freight-portal" "freight-backoffice" + "gps-tracker" "passenger-api" "passenger-portal" "passenger-backoffice" @@ -71,6 +72,7 @@ jobs: echo "$CHANGED" | grep -q "^apps/edr-freight-api/" && SERVICES+=("freight-api") echo "$CHANGED" | grep -q "^apps/edr-freight-web/portal/" && SERVICES+=("freight-portal") echo "$CHANGED" | grep -q "^apps/edr-freight-web/backoffice/" && SERVICES+=("freight-backoffice") + echo "$CHANGED" | grep -q "^apps/edr-gps-tracker/" && SERVICES+=("gps-tracker") echo "$CHANGED" | grep -q "^apps/edr-passenger-api/" && SERVICES+=("passenger-api") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/portal/" && SERVICES+=("passenger-portal") echo "$CHANGED" | grep -q "^apps/edr-passenger-web/backoffice/" && SERVICES+=("passenger-backoffice") @@ -109,7 +111,7 @@ jobs: - name: Resolve project and build env file run: | case "${{ matrix.service }}" in - freight-api|freight-portal|freight-backoffice) + freight-api|freight-portal|freight-backoffice|gps-tracker) echo "PROJECT=edr-freight" >> "$GITHUB_ENV" echo "BUILD_ENV_FILE=freight-web.build.env" >> "$GITHUB_ENV" ;; diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts index da527fff0..860c51f4f 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts +++ b/apps/edr-freight-api/src/modules/gps-tracking/gps-tracking.module.ts @@ -6,12 +6,15 @@ import { GpsPosition } from './entities/gps-position.entity'; import { GpsDeviceRepository, GpsPositionRepository } from './gps-tracking.repository'; import { GpsTrackingService } from './gps-tracking.service'; import { GpsTrackingController } from './gps-tracking.controller'; -import { Gt06Server } from './gt06/gt06.server'; +// NOTE: the GT06 TCP listener now lives in the standalone @edr/gps-tracker app. +// This module is REST-only — it reads gps_devices / gps_positions that the +// tracker app writes to the shared DB. Do not re-add Gt06Server here, or two +// processes would fight for the tracker socket. @Module({ imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])], controllers: [GpsTrackingController], - providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService, Gt06Server], + providers: [GpsDeviceRepository, GpsPositionRepository, GpsTrackingService], exports: [GpsTrackingService], }) export class GpsTrackingModule {} diff --git a/apps/edr-gps-tracker/Dockerfile b/apps/edr-gps-tracker/Dockerfile new file mode 100644 index 000000000..7fa103207 --- /dev/null +++ b/apps/edr-gps-tracker/Dockerfile @@ -0,0 +1,42 @@ +# syntax=docker/dockerfile:1 +# Build from monorepo root: docker build -f apps/edr-gps-tracker/Dockerfile . + +FROM node:24.15.0-alpine AS base +RUN apk add --no-cache libc6-compat +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable +WORKDIR /app + +FROM base AS pruner +COPY . . +RUN pnpm dlx turbo prune "@edr/gps-tracker" --docker + +FROM base AS installer +COPY --from=pruner /app/out/json/ . +COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ + --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm install --frozen-lockfile + +FROM base AS builder +COPY --from=installer /app/ . +COPY --from=pruner /app/out/full/ . +RUN pnpm turbo build --filter="@edr/gps-tracker..." + +FROM base AS deployer +COPY --from=builder /app/ . +RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ + pnpm deploy --filter="@edr/gps-tracker" --prod --legacy /deploy + +FROM node:24.15.0-alpine AS runner +RUN apk add --no-cache libc6-compat +ENV NODE_ENV=production +WORKDIR /app +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 --ingroup nodejs nestjs +COPY --from=deployer --chown=nestjs:nodejs /deploy . +USER nestjs +# GT06 GPS tracker TCP listener (raw TCP, not HTTP). Change via GT06_TCP_PORT. +EXPOSE 5023 +CMD ["node", "dist/main.js"] diff --git a/apps/edr-gps-tracker/nest-cli.json b/apps/edr-gps-tracker/nest-cli.json new file mode 100644 index 000000000..89d7d6c57 --- /dev/null +++ b/apps/edr-gps-tracker/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": false + } +} diff --git a/apps/edr-gps-tracker/package.json b/apps/edr-gps-tracker/package.json new file mode 100644 index 000000000..bcdbc4dbe --- /dev/null +++ b/apps/edr-gps-tracker/package.json @@ -0,0 +1,39 @@ +{ + "name": "@edr/gps-tracker", + "version": "0.0.0", + "private": true, + "description": "Standalone GT06 GPS tracker TCP ingester. Listens for tracker sockets and writes fixes to the shared freight DB. No HTTP; the /gps REST API stays in @edr/freight-api.", + "scripts": { + "clean": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true}); fs.rmSync('.tsbuildinfo',{force:true});\"", + "predev": "pnpm run clean", + "dev": "nest start --watch --clearScreen false", + "prebuild": "pnpm run clean", + "build": "nest build", + "start": "node dist/main.js", + "lint": "eslint src", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@edr/api-common": "workspace:*", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/typeorm": "^11.0.1", + "dotenv": "^17.4.2", + "pg": "^8.13.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.30" + }, + "devDependencies": { + "@edr/eslint-config": "workspace:*", + "@edr/tsconfig": "workspace:*", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@types/node": "^20.14.0", + "@types/pg": "^8.6.7", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.5.4" + } +} diff --git a/apps/edr-gps-tracker/src/app.module.ts b/apps/edr-gps-tracker/src/app.module.ts new file mode 100644 index 000000000..836cddefa --- /dev/null +++ b/apps/edr-gps-tracker/src/app.module.ts @@ -0,0 +1,19 @@ +import { Module } from "@nestjs/common"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { TypeOrmModule, TypeOrmModuleOptions } from "@nestjs/typeorm"; + +import databaseConfig from "./config/database.config"; +import { GpsModule } from "./gps/gps.module"; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true, load: [databaseConfig] }), + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: (config: ConfigService): TypeOrmModuleOptions => + config.get("database")!, + }), + GpsModule, + ], +}) +export class AppModule {} diff --git a/apps/edr-gps-tracker/src/config/database.config.ts b/apps/edr-gps-tracker/src/config/database.config.ts new file mode 100644 index 000000000..d93874ae8 --- /dev/null +++ b/apps/edr-gps-tracker/src/config/database.config.ts @@ -0,0 +1,28 @@ +import { registerAs } from "@nestjs/config"; +import { TypeOrmModuleOptions } from "@nestjs/typeorm"; + +import { GpsDevice } from "../gps/entities/gps-device.entity"; +import { GpsPosition } from "../gps/entities/gps-position.entity"; + +/** + * DB config for the GPS ingester. Points at the SAME database as + * @edr/freight-api and touches only the two GPS tables, which are explicitly + * schema-qualified to `freight` on the entities — so no search_path handler is + * needed here. This app NEVER runs migrations or synchronize: @edr/freight-api + * owns the schema (the AddGpsTracking migration creates these tables). + */ +export default registerAs("database", (): TypeOrmModuleOptions => { + return { + type: "postgres", + host: process.env.DB_HOST ?? "localhost", + port: parseInt(process.env.DB_PORT ?? "5433", 10), + username: process.env.DB_USER ?? "postgres", + password: process.env.DB_PASSWORD ?? "", + database: process.env.DB_NAME ?? "edr_freight", + entities: [GpsDevice, GpsPosition], + migrations: [], + migrationsRun: false, + synchronize: false, + logging: process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], + }; +}); diff --git a/apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts b/apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts new file mode 100644 index 000000000..de6ba73ea --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/entities/gps-device.entity.ts @@ -0,0 +1,48 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** + * A physical GPS tracker (GT06), keyed by IMEI. Same table as + * @edr/freight-api's GpsDevice; the Vehicle relation is intentionally dropped + * here — the ingester only needs the `vehicleId` column to stamp positions, not + * the Vehicle entity graph. + */ +@Entity({ name: "gps_devices", schema: "freight" }) +@Index(["vehicleId"]) +export class GpsDevice extends BaseEntity { + @Column({ name: "imei", type: "varchar", length: 20, unique: true }) + imei!: string; + + @Column({ name: "name", type: "varchar", nullable: true }) + name?: string | null; + + @Column({ name: "vehicle_id", type: "uuid", nullable: true }) + vehicleId?: string | null; + + @Column({ name: "status", type: "varchar", length: 16, default: "REGISTERED" }) + status!: string; + + @Column({ name: "last_seen_at", type: "timestamptz", nullable: true }) + lastSeenAt?: Date | null; + + @Column({ name: "last_lat", type: "numeric", precision: 10, scale: 6, nullable: true }) + lastLat?: number | null; + + @Column({ name: "last_lng", type: "numeric", precision: 10, scale: 6, nullable: true }) + lastLng?: number | null; + + @Column({ name: "last_speed", type: "numeric", precision: 6, scale: 2, nullable: true }) + lastSpeed?: number | null; + + @Column({ name: "last_course", type: "int", nullable: true }) + lastCourse?: number | null; + + @Column({ name: "last_fix_at", type: "timestamptz", nullable: true }) + lastFixAt?: Date | null; + + @Column({ name: "voltage_level", type: "int", nullable: true }) + voltageLevel?: number | null; + + @Column({ name: "gsm_level", type: "int", nullable: true }) + gsmLevel?: number | null; +} diff --git a/apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts b/apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts new file mode 100644 index 000000000..9d1bb949a --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/entities/gps-position.entity.ts @@ -0,0 +1,41 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity, Index } from "typeorm"; + +/** One GPS fix from a tracker (append-only history). Same table as freight-api. */ +@Entity({ name: "gps_positions", schema: "freight" }) +@Index(["deviceId", "gpsTime"]) +@Index(["vehicleId", "gpsTime"]) +export class GpsPosition extends BaseEntity { + @Column({ name: "device_id", type: "uuid" }) + deviceId!: string; + + @Column({ name: "imei", type: "varchar", length: 20 }) + imei!: string; + + @Column({ name: "vehicle_id", type: "uuid", nullable: true }) + vehicleId?: string | null; + + @Column({ name: "lat", type: "numeric", precision: 10, scale: 6 }) + lat!: number; + + @Column({ name: "lng", type: "numeric", precision: 10, scale: 6 }) + lng!: number; + + @Column({ name: "speed", type: "numeric", precision: 6, scale: 2, default: 0 }) + speed!: number; + + @Column({ name: "course", type: "int", default: 0 }) + course!: number; + + @Column({ name: "satellites", type: "int", default: 0 }) + satellites!: number; + + @Column({ name: "positioned", type: "boolean", default: false }) + positioned!: boolean; + + @Column({ name: "gps_time", type: "timestamptz" }) + gpsTime!: Date; + + @Column({ name: "alarm", type: "int", default: 0 }) + alarm!: number; +} diff --git a/apps/edr-gps-tracker/src/gps/gps-ingest.service.ts b/apps/edr-gps-tracker/src/gps/gps-ingest.service.ts new file mode 100644 index 000000000..37fa452ea --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gps-ingest.service.ts @@ -0,0 +1,72 @@ +import { Injectable, Logger } from "@nestjs/common"; + +import { GpsDeviceRepository, GpsPositionRepository } from "./gps.repository"; +import { GpsDevice } from "./entities/gps-device.entity"; +import { Gt06Gps, Gt06Status } from "./gt06/gt06.codec"; + +/** + * Write path for GT06 ingestion: upserts device state and appends position + * history. Mirrors the ingestion half of @edr/freight-api's GpsTrackingService + * (the REST/query half stays in freight-api). Auto-registers unknown IMEIs on + * first contact. + */ +@Injectable() +export class GpsIngestService { + private readonly logger = new Logger(GpsIngestService.name); + + constructor( + private readonly devices: GpsDeviceRepository, + private readonly positions: GpsPositionRepository, + ) {} + + /** Find the device for an IMEI, auto-registering it on first contact. */ + private async ensureDevice(imei: string): Promise { + const existing = await this.devices.findByImei(imei); + if (existing) return existing; + this.logger.log(`Auto-registering new GPS tracker ${imei}`); + return this.devices.create({ imei, status: "REGISTERED", lastSeenAt: new Date() }); + } + + async handleLogin(imei: string): Promise { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { lastSeenAt: new Date(), status: "ONLINE" }); + } + + async handleHeartbeat(imei: string, status: Gt06Status): Promise { + const device = await this.ensureDevice(imei); + await this.devices.update(device.id, { + lastSeenAt: new Date(), + status: "ONLINE", + voltageLevel: status.voltageLevel, + gsmLevel: status.gsmLevel, + }); + } + + async handleFix(imei: string, gps: Gt06Gps, alarm = 0, status?: Gt06Status): Promise { + const device = await this.ensureDevice(imei); + const now = new Date(); + await this.devices.update(device.id, { + lastSeenAt: now, + status: "ONLINE", + lastLat: gps.latitude, + lastLng: gps.longitude, + lastSpeed: gps.speed, + lastCourse: gps.course, + lastFixAt: new Date(gps.time), + ...(status ? { voltageLevel: status.voltageLevel, gsmLevel: status.gsmLevel } : {}), + }); + await this.positions.create({ + deviceId: device.id, + imei, + vehicleId: device.vehicleId ?? null, + lat: gps.latitude, + lng: gps.longitude, + speed: gps.speed, + course: gps.course, + satellites: gps.satellites, + positioned: gps.positioned, + gpsTime: new Date(gps.time), + alarm, + }); + } +} diff --git a/apps/edr-gps-tracker/src/gps/gps.module.ts b/apps/edr-gps-tracker/src/gps/gps.module.ts new file mode 100644 index 000000000..b51f0cc71 --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gps.module.ts @@ -0,0 +1,14 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { GpsDevice } from "./entities/gps-device.entity"; +import { GpsPosition } from "./entities/gps-position.entity"; +import { GpsDeviceRepository, GpsPositionRepository } from "./gps.repository"; +import { GpsIngestService } from "./gps-ingest.service"; +import { Gt06Server } from "./gt06/gt06.server"; + +@Module({ + imports: [TypeOrmModule.forFeature([GpsDevice, GpsPosition])], + providers: [GpsDeviceRepository, GpsPositionRepository, GpsIngestService, Gt06Server], +}) +export class GpsModule {} diff --git a/apps/edr-gps-tracker/src/gps/gps.repository.ts b/apps/edr-gps-tracker/src/gps/gps.repository.ts new file mode 100644 index 000000000..2a8c2f1e8 --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gps.repository.ts @@ -0,0 +1,25 @@ +import { BaseRepository } from "@edr/api-common"; +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { GpsDevice } from "./entities/gps-device.entity"; +import { GpsPosition } from "./entities/gps-position.entity"; + +@Injectable() +export class GpsDeviceRepository extends BaseRepository { + constructor(@InjectRepository(GpsDevice) repository: Repository) { + super(repository); + } + + findByImei(imei: string): Promise { + return this.repository.findOne({ where: { imei } }); + } +} + +@Injectable() +export class GpsPositionRepository extends BaseRepository { + constructor(@InjectRepository(GpsPosition) repository: Repository) { + super(repository); + } +} diff --git a/apps/edr-gps-tracker/src/gps/gt06/gt06.codec.ts b/apps/edr-gps-tracker/src/gps/gt06/gt06.codec.ts new file mode 100644 index 000000000..d54f906a2 --- /dev/null +++ b/apps/edr-gps-tracker/src/gps/gt06/gt06.codec.ts @@ -0,0 +1,207 @@ +/** + * GT06 GPS-tracker protocol codec. + * + * Frame: 0x78 0x78 | len(1) | protocol(1) | content(N) | serial(2) | crc(2) | 0x0D 0x0A + * `len` counts protocol..crc (= 5 + N). CRC-ITU (CRC-16/X.25) is computed over + * len..serial (inclusive) and equals the 2 crc bytes. + */ + +const START = 0x7878; +const STOP = 0x0d0a; + +export const GT06_PROTOCOL = { + LOGIN: 0x01, + LOCATION: 0x12, + HEARTBEAT: 0x13, + STRING: 0x15, + ALARM: 0x16, + ADDRESS_BY_PHONE: 0x1a, + SERVER_COMMAND: 0x80, +} as const; + +/** CRC-16/X.25 (a.k.a. CRC-ITU) used by GT06 — reflected, poly 0x8408, init/xorout 0xFFFF. */ +export function crcItu(bytes: Buffer): number { + let fcs = 0xffff; + for (const b of bytes) { + fcs ^= b; + for (let i = 0; i < 8; i++) { + fcs = fcs & 1 ? (fcs >> 1) ^ 0x8408 : fcs >> 1; + } + } + return (~fcs) & 0xffff; +} + +export interface Gt06Gps { + time: string; // ISO (UTC) + satellites: number; + latitude: number; + longitude: number; + speed: number; // km/h + course: number; // 0-360 + positioned: boolean; +} + +export interface Gt06Lbs { + mcc: number; + mnc: number; + lac: number; + cellId: number; +} + +export interface Gt06Status { + terminalInfo: number; + voltageLevel: number; + gsmLevel: number; + alarm: number; // former byte of alarm/language + charging: boolean; + accOn: boolean; + gpsTracking: boolean; + oilCut: boolean; +} + +export type Gt06Packet = + | { type: 'login'; protocol: number; serial: number; imei: string } + | { type: 'location'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs } + | { type: 'heartbeat'; protocol: number; serial: number; status: Gt06Status } + | { type: 'alarm'; protocol: number; serial: number; gps: Gt06Gps; lbs: Gt06Lbs; status: Gt06Status } + | { type: 'unknown'; protocol: number; serial: number }; + +/** Terminal ID (8 BCD bytes) → 15-digit IMEI (drops the leading pad nibble). */ +function decodeImei(buf: Buffer): string { + return buf.toString('hex').replace(/^0/, ''); +} + +function decodeDateTime(buf: Buffer, off: number): string { + const year = 2000 + buf[off]; + const month = buf[off + 1]; + const day = buf[off + 2]; + const hour = buf[off + 3]; + const min = buf[off + 4]; + const sec = buf[off + 5]; + return new Date(Date.UTC(year, month - 1, day, hour, min, sec)).toISOString(); +} + +/** Convert a GT06 lat/long raw uint32 to decimal degrees (magnitude only). */ +function rawToDegrees(raw: number): number { + return raw / 30000 / 60; +} + +function decodeGps(buf: Buffer, off: number): Gt06Gps { + const time = decodeDateTime(buf, off); + const lenSat = buf[off + 6]; + const satellites = lenSat & 0x0f; + const latRaw = buf.readUInt32BE(off + 7); + const lonRaw = buf.readUInt32BE(off + 11); + const speed = buf[off + 15]; + const cs = buf.readUInt16BE(off + 16); + const hi = (cs >> 8) & 0xff; + const positioned = Boolean(hi & 0x10); // BYTE_1 Bit4 + const isWest = Boolean(hi & 0x08); // BYTE_1 Bit3 (1 = West) + const isNorth = Boolean(hi & 0x04); // BYTE_1 Bit2 (1 = North) + const course = cs & 0x03ff; // BYTE_1 Bit1-0 + BYTE_2 + let latitude = rawToDegrees(latRaw); + let longitude = rawToDegrees(lonRaw); + if (!isNorth) latitude = -latitude; + if (isWest) longitude = -longitude; + return { time, satellites, latitude, longitude, speed, course, positioned }; +} + +function decodeStatus(buf: Buffer, off: number): Gt06Status { + const terminalInfo = buf[off]; + const voltageLevel = buf[off + 1]; + const gsmLevel = buf[off + 2]; + const alarm = buf[off + 3]; // alarm/language former byte + return { + terminalInfo, + voltageLevel, + gsmLevel, + alarm, + oilCut: Boolean(terminalInfo & 0x80), + gpsTracking: Boolean(terminalInfo & 0x40), + charging: Boolean(terminalInfo & 0x04), + accOn: Boolean(terminalInfo & 0x02), + }; +} + +function decodeLbs(buf: Buffer, off: number): Gt06Lbs { + return { + mcc: buf.readUInt16BE(off), + mnc: buf[off + 2], + lac: buf.readUInt16BE(off + 3), + cellId: buf.readUIntBE(off + 5, 3), + }; +} + +function decodeFrame(frame: Buffer): Gt06Packet | null { + // frame = 78 78 len ...content... serial(2) crc(2) 0D 0A + const len = frame[2]; + const protocol = frame[3]; + const serialOff = 3 + (len - 4); // after protocol + content, before serial(2)+crc(2) + const serial = frame.readUInt16BE(serialOff); + const contentOff = 4; // start of content (after protocol) + + switch (protocol) { + case GT06_PROTOCOL.LOGIN: + return { type: 'login', protocol, serial, imei: decodeImei(frame.subarray(contentOff, contentOff + 8)) }; + case GT06_PROTOCOL.LOCATION: + return { type: 'location', protocol, serial, gps: decodeGps(frame, contentOff), lbs: decodeLbs(frame, contentOff + 18) }; + case GT06_PROTOCOL.HEARTBEAT: + return { type: 'heartbeat', protocol, serial, status: decodeStatus(frame, contentOff) }; + case GT06_PROTOCOL.ALARM: { + const gps = decodeGps(frame, contentOff); + // content: date(6)+lenSat(1)+lat(4)+lng(4)+speed(1)+course(2)=18, lbsLen(1), lbs(8), status(1+1+1+2) + const lbs = decodeLbs(frame, contentOff + 18 + 1); + const status = decodeStatus(frame, contentOff + 18 + 1 + 8); + return { type: 'alarm', protocol, serial, gps, lbs, status }; + } + default: + return { type: 'unknown', protocol, serial }; + } +} + +/** + * Pull all complete frames out of a stream buffer. Returns the decoded packets + * (skipping CRC-failed ones) and the trailing bytes that form a partial frame. + */ +export function parseStream(buffer: Buffer): { packets: Gt06Packet[]; rest: Buffer } { + const packets: Gt06Packet[] = []; + let i = 0; + while (i + 5 <= buffer.length) { + if (buffer.readUInt16BE(i) !== START) { + i += 1; // resync + continue; + } + const len = buffer[i + 2]; + const frameLen = 2 + 1 + len + 2; // start + lenByte + (protocol..crc) + stop + if (i + frameLen > buffer.length) break; // incomplete + const frame = buffer.subarray(i, i + frameLen); + if (frame.readUInt16BE(frameLen - 2) === STOP) { + // CRC over len..serial (frame[2 .. frameLen-4]); crc bytes are frameLen-4..frameLen-3. + const crcCalc = crcItu(frame.subarray(2, frameLen - 4)); + const crcRecv = frame.readUInt16BE(frameLen - 4); + if (crcCalc === crcRecv) { + const pkt = decodeFrame(frame); + if (pkt) packets.push(pkt); + } + i += frameLen; + } else { + i += 1; // bad frame, resync + } + } + return { packets, rest: buffer.subarray(i) }; +} + +/** Build a server → terminal ACK (login/heartbeat/alarm) echoing the serial. */ +export function buildAck(protocol: number, serial: number): Buffer { + const body = Buffer.alloc(3); // protocol + serial(2) + body[0] = protocol; + body.writeUInt16BE(serial, 1); + const len = body.length + 2; // + crc(2) + const forCrc = Buffer.concat([Buffer.from([len]), body]); + const crc = crcItu(forCrc); + return Buffer.concat([ + Buffer.from([0x78, 0x78, len]), + body, + Buffer.from([(crc >> 8) & 0xff, crc & 0xff, 0x0d, 0x0a]), + ]); +} diff --git a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts b/apps/edr-gps-tracker/src/gps/gt06/gt06.server.ts similarity index 67% rename from apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts rename to apps/edr-gps-tracker/src/gps/gt06/gt06.server.ts index a2095fa12..68ee12bb0 100644 --- a/apps/edr-freight-api/src/modules/gps-tracking/gt06/gt06.server.ts +++ b/apps/edr-gps-tracker/src/gps/gt06/gt06.server.ts @@ -1,8 +1,13 @@ -import { Injectable, Logger, OnApplicationBootstrap, OnModuleDestroy } from '@nestjs/common'; -import * as net from 'net'; +import { + Injectable, + Logger, + OnApplicationBootstrap, + OnModuleDestroy, +} from "@nestjs/common"; +import * as net from "net"; -import { GpsTrackingService } from '../gps-tracking.service'; -import { buildAck, GT06_PROTOCOL, parseStream } from './gt06.codec'; +import { GpsIngestService } from "../gps-ingest.service"; +import { buildAck, GT06_PROTOCOL, parseStream } from "./gt06.codec"; interface Session { buffer: Buffer; @@ -14,7 +19,7 @@ const MAX_BUFFER = 64 * 1024; /** * Raw TCP listener for GT06 GPS trackers. Trackers open a socket, send a login * (IMEI), then stream location/heartbeat/alarm packets; we decode, persist via - * {@link GpsTrackingService}, and ACK login/heartbeat/alarm so the device keeps + * {@link GpsIngestService}, and ACK login/heartbeat/alarm so the device keeps * the connection alive. Disabled when GT06_TCP_PORT=0. */ @Injectable() @@ -23,18 +28,20 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { private server?: net.Server; private readonly sessions = new Map(); - constructor(private readonly gps: GpsTrackingService) {} + constructor(private readonly gps: GpsIngestService) {} onApplicationBootstrap(): void { const port = Number(process.env.GT06_TCP_PORT ?? 5023); if (!port) { - this.logger.log('GT06 TCP listener disabled (GT06_TCP_PORT=0)'); + this.logger.log("GT06 TCP listener disabled (GT06_TCP_PORT=0)"); return; } - const host = process.env.GT06_TCP_HOST ?? '0.0.0.0'; + const host = process.env.GT06_TCP_HOST ?? "0.0.0.0"; this.server = net.createServer((socket) => this.onConnection(socket)); - this.server.on('error', (err) => this.logger.error(`GT06 server error: ${String(err)}`)); - this.server.listen(port, host, () => this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`)); + this.server.on("error", (err) => this.logger.error(`GT06 server error: ${String(err)}`)); + this.server.listen(port, host, () => + this.logger.log(`GT06 GPS tracker listener on ${host}:${port}`), + ); } onModuleDestroy(): void { @@ -45,9 +52,9 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { private onConnection(socket: net.Socket): void { this.sessions.set(socket, { buffer: Buffer.alloc(0), imei: null }); - socket.on('data', (chunk) => void this.onData(socket, chunk)); - socket.on('error', () => this.sessions.delete(socket)); - socket.on('close', () => this.sessions.delete(socket)); + socket.on("data", (chunk) => void this.onData(socket, chunk)); + socket.on("error", () => this.sessions.delete(socket)); + socket.on("close", () => this.sessions.delete(socket)); } private async onData(socket: net.Socket, chunk: Buffer): Promise { @@ -71,23 +78,24 @@ export class Gt06Server implements OnApplicationBootstrap, OnModuleDestroy { private async handle( socket: net.Socket, session: Session, - pkt: ReturnType['packets'][number], + pkt: ReturnType["packets"][number], ): Promise { switch (pkt.type) { - case 'login': + case "login": session.imei = pkt.imei; await this.gps.handleLogin(pkt.imei); socket.write(buildAck(GT06_PROTOCOL.LOGIN, pkt.serial)); break; - case 'heartbeat': + case "heartbeat": if (session.imei) await this.gps.handleHeartbeat(session.imei, pkt.status); socket.write(buildAck(GT06_PROTOCOL.HEARTBEAT, pkt.serial)); break; - case 'location': + case "location": if (session.imei) await this.gps.handleFix(session.imei, pkt.gps); break; - case 'alarm': - if (session.imei) await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status); + case "alarm": + if (session.imei) + await this.gps.handleFix(session.imei, pkt.gps, pkt.status.alarm, pkt.status); socket.write(buildAck(GT06_PROTOCOL.ALARM, pkt.serial)); break; default: diff --git a/apps/edr-gps-tracker/src/main.ts b/apps/edr-gps-tracker/src/main.ts new file mode 100644 index 000000000..0e8f7987a --- /dev/null +++ b/apps/edr-gps-tracker/src/main.ts @@ -0,0 +1,32 @@ +import "reflect-metadata"; +import * as dotenv from "dotenv"; +dotenv.config(); +import { Logger } from "@nestjs/common"; +import { NestFactory } from "@nestjs/core"; + +import { AppModule } from "./app.module"; + +/** + * Standalone GT06 GPS ingester. Boots a Nest application context (NO HTTP + * server) so only the DB connection and Gt06Server come up; the TCP listener + * binds from Gt06Server.onApplicationBootstrap. The /gps REST API lives in + * @edr/freight-api, which reads the same tables this process writes. + */ +async function bootstrap() { + const logger = new Logger("gps-tracker"); + const port = Number(process.env.GT06_TCP_PORT ?? 5023); + if (!port) { + logger.error( + "GT06_TCP_PORT=0 disables the listener — this process would idle. Set a port.", + ); + process.exit(1); + } + + const app = await NestFactory.createApplicationContext(AppModule); + app.enableShutdownHooks(); + logger.log( + `GT06 ingester up — TCP ${process.env.GT06_TCP_HOST ?? "0.0.0.0"}:${port}`, + ); +} + +void bootstrap(); diff --git a/apps/edr-gps-tracker/tsconfig.json b/apps/edr-gps-tracker/tsconfig.json new file mode 100644 index 000000000..52598cb95 --- /dev/null +++ b/apps/edr-gps-tracker/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@edr/tsconfig/nestjs.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "./.tsbuildinfo", + "preserveWatchOutput": true, + "module": "node16", + "moduleResolution": "node16" + }, + "include": ["src"] +} diff --git a/docker-compose.yaml b/docker-compose.yaml index 863c1b5e2..d3c89c4a9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -16,12 +16,33 @@ services: - npmrc ports: - "${FREIGHT_API_PORT:-3001}:${FREIGHT_API_PORT:-3001}" - # GT06 GPS tracker TCP ingestion (raw TCP — must be reachable by tracker SIMs). - - "${GT06_TCP_PORT:-5023}:${GT06_TCP_PORT:-5023}" env_file: - apps/edr-freight-api/.env extra_hosts: - "paymentcallback.triaplc.com:10.18.7.179" + + # Standalone GT06 GPS tracker ingester (@edr/gps-tracker). Raw TCP only, no + # HTTP. Writes freight.gps_devices / freight.gps_positions in the shared + # freight DB; the /gps REST API stays in freight-api. Never runs migrations. + gps-tracker: + build: + context: . + dockerfile: apps/edr-gps-tracker/Dockerfile + secrets: + - npmrc + depends_on: + - freight-api + ports: + # Raw TCP — reachable by tracker SIMs. Not HTTP; no L7 proxy can host-route it. + - "${GT06_TCP_PORT:-5023}:5023" + environment: + GT06_TCP_PORT: "5023" + GT06_TCP_HOST: "0.0.0.0" + env_file: + # Reuses the freight DB credentials (DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME). + - apps/edr-freight-api/.env + restart: unless-stopped + passenger-api: build: context: . diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c04cd634..9ded80852 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -676,6 +676,67 @@ importers: specifier: ^2.1.2 version: 2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0) + apps/edr-gps-tracker: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/typeorm': + specifier: ^11.0.1 + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + pg: + specifier: ^8.13.0 + version: 8.21.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: ^0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/pg': + specifier: ^8.6.7 + version: 8.20.0 + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + apps/edr-passenger-api: dependencies: '@edr/types': From 68c4f69cff6861e761847fd214e05b64c0969b10 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 10 Jul 2026 20:20:39 +0300 Subject: [PATCH 02/21] Help content updates --- .../portal/src/app/help/page.tsx | 56 +------------------ .../portal/src/components/ThemeToggle.tsx | 24 ++------ 2 files changed, 7 insertions(+), 73 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/help/page.tsx b/apps/edr-passenger-web/portal/src/app/help/page.tsx index eb8a6a150..c3f84ed91 100644 --- a/apps/edr-passenger-web/portal/src/app/help/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/help/page.tsx @@ -60,16 +60,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [ answer: 'Yes. When booking as a logged-in user you can save passenger profiles. On subsequent bookings you can select a saved passenger instead of re-entering their details.', }, - { - question: 'How do I modify my booking?', - answer: - 'Log in and go to your profile, find the booking, and select Modify. Changes are allowed up to 24 hours before departure. Fare differences may apply.', - }, - { - question: 'What is the cancellation policy?', - answer: - 'Cancellations made at least 48 hours before departure receive a full refund. Cancellations within 48 hours may be subject to a fee. Refunds are returned to your original payment method or wallet.', - }, ], }, { @@ -84,28 +74,13 @@ const FAQ_CATEGORIES: FAQCategory[] = [ { question: 'What are the passenger age categories?', answer: - 'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child in a booking travels free, and any additional children pay the full fare.', + 'Adults are passengers aged 5 years and above and pay 100% of the fare. Children are passengers under 5 years old — the first child per adult travels free, and any additional children pay the full fare.', }, { question: 'How is a child\'s age determined?', answer: 'Age is calculated automatically from the date of birth you enter for each passenger. Make sure to enter the correct date of birth so the right fare is applied.', }, - { - question: 'Example: how much does a family of 2 adults + 3 children pay?', - answer: - 'The first child is free, so you pay for 2 adults + 2 children = 4× the base fare for that seat class and distance.', - }, - { - question: 'What seat classes are available?', - answer: - 'Three classes are available: Economy Regular (standard seating), Economy Bed (sleeping berth in economy), and VIP Bed (premium sleeping berth). Each has its own base fare.', - }, - { - question: 'What is the nationality field for?', - answer: - 'Nationality determines which ID verification path applies. Ethiopian nationals are verified via the Verifayda national ID system. Djiboutian and other international passengers use their passport instead.', - }, ], }, { @@ -153,11 +128,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [ answer: 'On the search page, tap the From or To field and browse or search the full list of stations. Each station shows its code and country.', }, - { - question: 'Are prices shown in my local currency?', - answer: - 'All transactions are processed in Ethiopian Birr (ETB). You can view prices in ETB, Djiboutian Franc (DJF), or US Dollar (USD) by selecting your preferred display currency on the fare or booking screen.', - }, ], }, { @@ -167,22 +137,12 @@ const FAQ_CATEGORIES: FAQCategory[] = [ { question: 'What payment methods are accepted?', answer: - 'We accept Telebirr, CBE Birr, eBirr, credit/debit cards, and EDR Wallet balance. You can choose your preferred method at checkout.', - }, - { - question: 'What is the EDR Wallet?', - answer: - 'The EDR Wallet is a stored-value account linked to your profile. You can top it up and use it to pay for tickets instantly. Your wallet balance and transaction history are available in your profile.', - }, - { - question: 'When will I receive my refund?', - answer: - 'Refunds are processed within 5–7 business days to your original payment method. If you paid via EDR Wallet, the refund is credited to your wallet immediately.', + 'We accept Telebirr, Waafi, D-Money, CBE Birr, and more. You can choose your preferred method at checkout.', }, { question: 'Is my payment information secure?', answer: - 'Yes. We do not store card details. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.', + 'Yes. All payments are processed through certified payment providers. Transactions are encrypted end-to-end.', }, ], }, @@ -226,16 +186,6 @@ const FAQ_CATEGORIES: FAQCategory[] = [ answer: 'Tap "Forgot password" on the login page, enter your registered email, and follow the reset link sent to your inbox.', }, - { - question: 'How do I set up Verifayda on my account?', - answer: - 'Go to your profile and find the Fayda Setup section. Enter your national ID to link your verified identity to your account. This enables faster booking as your details are pre-filled.', - }, - { - question: 'Can I use the app in multiple languages?', - answer: - 'Yes. The app supports English, Amharic (አማርኛ), Afaan Oromoo, and French. Change your language from the navigation bar.', - }, ], }, ]; diff --git a/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx b/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx index 501c85005..5368876d2 100644 --- a/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx +++ b/apps/edr-passenger-web/portal/src/components/ThemeToggle.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Moon, Sun, Monitor } from 'lucide-react'; +import { Moon, Sun } from 'lucide-react'; import { useTheme } from './ThemeProvider'; import { useEffect, useState } from 'react'; @@ -12,27 +12,11 @@ export default function ThemeToggle() { setMounted(true); }, []); - const cycleTheme = () => { - if (theme === 'light') { - setTheme('dark'); - } else if (theme === 'dark') { - setTheme('system'); - } else { - setTheme('light'); - } - }; + const cycleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light'); - const getIcon = () => { - if (theme === 'light') return ; - if (theme === 'dark') return ; - return ; - }; + const getIcon = () => theme === 'dark' ? : ; - const getLabel = () => { - if (theme === 'light') return 'Light'; - if (theme === 'dark') return 'Dark'; - return 'System'; - }; + const getLabel = () => theme === 'dark' ? 'Dark' : 'Light'; // Prevent hydration mismatch by not rendering until mounted if (!mounted) { From 952d68be07ea25c738d498647aa67011ad01bb83 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 10 Jul 2026 21:30:20 +0300 Subject: [PATCH 03/21] feat: ( fayda ) implement fayda for passengers --- .../src/app/booking/passengers/page.tsx | 301 ++++++++++++------ 1 file changed, 196 insertions(+), 105 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index d389bd9cb..34cf1fe5d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -72,6 +72,36 @@ function clearPendingFaydaIndex() { window.sessionStorage.removeItem(FAYDA_PENDING_INDEX_KEY); } +// Verification is a full-page redirect out to Fayda and back (same flow on desktop and mobile — +// no popup). The in-progress form only lives in React memory, which the reload wipes, so we +// snapshot it to sessionStorage before leaving and restore it (in the form's defaultValues) on +// return. sessionStorage survives a same-tab navigation, including the cross-origin round trip. +const FAYDA_FORM_SNAPSHOT_KEY = 'edr_fayda_form_snapshot'; + +function saveFaydaFormSnapshot(snapshot: unknown) { + if (typeof window === 'undefined') return; + try { + window.sessionStorage.setItem(FAYDA_FORM_SNAPSHOT_KEY, JSON.stringify(snapshot)); + } catch { + // sessionStorage full/unavailable — verification still works, only unsaved fields are lost. + } +} + +function getFaydaFormSnapshot(): { passengers?: any[]; createAccount?: boolean } | null { + if (typeof window === 'undefined') return null; + try { + const raw = window.sessionStorage.getItem(FAYDA_FORM_SNAPSHOT_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function clearFaydaFormSnapshot() { + if (typeof window === 'undefined') return; + window.sessionStorage.removeItem(FAYDA_FORM_SNAPSHOT_KEY); +} + // Fayda may return gender as "MALE"/"M" etc — normalize to the form's expected values function normalizeFaydaGender(raw: unknown): 'Male' | 'Female' | '' { const g = String(raw || '').trim().toUpperCase(); @@ -85,11 +115,13 @@ function DobPickerModal({ onChange, error, passengerType = 'ADULT', + disabled = false, }: { value: string; onChange: (iso: string) => void; error?: string; passengerType?: 'ADULT' | 'CHILD'; + disabled?: boolean; }) { const [open, setOpen] = useState(false); const [manualMode, setManualMode] = useState(false); @@ -286,9 +318,10 @@ function DobPickerModal({ @@ -1207,11 +1229,20 @@ export default function SearchPage() { {/* Search */} ) : ( @@ -1328,11 +1359,20 @@ export default function SearchPage() { {/* Search */} )} diff --git a/apps/edr-passenger-web/portal/src/app/contact/page.tsx b/apps/edr-passenger-web/portal/src/app/contact/page.tsx index 669a99d0b..9f0d2d474 100644 --- a/apps/edr-passenger-web/portal/src/app/contact/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/contact/page.tsx @@ -275,8 +275,8 @@ export default function Contact() { }; const contactInfo = [ - { icon: Phone, title: t('contact.phone'), value: '+251 911 000 000', link: 'tel:+251911000000' }, - { icon: Mail, title: t('contact.email'), value: 'support@edr.et', link: 'mailto:support@edr.et' }, + { icon: Phone, title: t('contact.phone'), value: '9546', link: 'tel:9546' }, + { icon: Mail, title: t('contact.email'), value: 'edr_@edrsc.com', link: 'mailto:edr_@edrsc.com' }, { icon: MapPin, title: t('contact.address'), value: 'Addis Ababa, Ethiopia', link: '#' }, ]; diff --git a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx index 6d47b8b5a..0acb5d9ea 100644 --- a/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppSidebar.tsx @@ -14,12 +14,18 @@ import { } from 'lucide-react'; import Link from 'next/link'; import Image from 'next/image'; +import dynamic from 'next/dynamic'; import { usePathname } from 'next/navigation'; import { useEffect, useState } from 'react'; import { useAuthStore } from '@/lib/auth-store'; -import ChangePasswordModal from '@/components/ChangePasswordModal'; import { BOOKING_STEPS } from '@/components/ProgressIndicator'; +// AppSidebar renders on every page via the root layout, so anything imported +// here ships to every visitor's first load — but this modal is only ever +// reachable by an already-authenticated user opening the account dropdown. +// Code-split it out instead of paying for it on every page/every visitor. +const ChangePasswordModal = dynamic(() => import('@/components/ChangePasswordModal'), { ssr: false }); + // Mirrors booking/layout.tsx's stepMap — the linear booking flow routes that // get a vertical step list instead of the standard nav highlighting. const BOOKING_STEP_MAP: Record = { @@ -203,10 +209,12 @@ export default function AppSidebar() { )} - setShowChangePassword(false)} - /> + {showChangePassword && ( + setShowChangePassword(false)} + /> + )} ); } diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index 6ff31aeaf..ca22538be 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -358,7 +358,7 @@ function drawFooter(doc: jsPDF, createdAt: string): void { hairline(doc, PAGE_MARGIN, footerY, pageWidth - PAGE_MARGIN); doc.setFontSize(7.5); doc.setTextColor(...MUTED); doc.setFont('helvetica', 'normal'); - doc.text('support@edr.com · +251-11-XXX-XXXX · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); + doc.text('edr_@edrsc.com · 9546 · www.edr.com', pageWidth / 2, footerY + 6, { align: 'center' }); doc.setFontSize(6.5); doc.text(`Issued ${new Date(createdAt).toLocaleString('en-US')}`, pageWidth / 2, footerY + 10.5, { align: 'center' }); } From 9ed473c309aa739ea290c0da6be2fa7d744c104a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 18:56:39 +0000 Subject: [PATCH 05/21] Implement clearance-first booking flow and completion process for customs contracts --- .../bookings/booking-transition.service.ts | 9 ++ .../bookings/bookings.repository.spec.ts | 3 + .../contracts/booking-request.service.ts | 14 +- .../contracts/clearance-milestone.service.ts | 25 ++++ .../contract-booking.consolidation.spec.ts | 11 +- .../contracts/contract-booking.service.ts | 128 ++++++++++++++++-- .../modules/contracts/contracts.controller.ts | 10 +- apps/edr-freight-web/backoffice/src/App.tsx | 12 ++ .../contracts/GlCreateBookingForm.tsx | 64 +++++++-- .../backoffice/src/constants/URLS.ts | 2 + .../src/hooks/contracts/useContracts.ts | 22 +++ .../bookings/DocumentClearanceDetailPage.tsx | 33 ++++- .../pages/contracts/ShipmentRequestsPage.tsx | 2 +- .../src/services/contracts.service.ts | 25 ++++ .../bookings/clearance/BookingActionModal.tsx | 5 +- .../bookings/clearance/ClearanceFlow.tsx | 11 +- .../bookings/clearance/useClearanceFlow.ts | 17 ++- .../contracts/NewShipmentRequestPage.tsx | 15 +- 18 files changed, 369 insertions(+), 39 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts index 8b6e8a2f8..7b1522446 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.service.ts @@ -988,6 +988,15 @@ export class BookingTransitionService { "OPERATION_CHANGES_REQUESTED", ]); + // A bare initiated instance (clearance-first flow) carries no cargo or + // price — it must go through the contract completion endpoint, which + // persists cargo, prices, invoices and only then lands here itself. + if (booking.contractId && !(Number(booking.totalAmount) > 0)) { + throw new BadRequestException( + "This booking must be completed (cargo and shipment day) before requesting operation.", + ); + } + const date = new Date(scheduledDate); if (Number.isNaN(date.getTime())) { throw new BadRequestException("A valid schedule date is required"); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts index 7d4ff199c..b2937e98d 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.spec.ts @@ -7,6 +7,7 @@ function mockQueryBuilder() { const qb = { leftJoinAndSelect: jest.fn().mockReturnThis(), leftJoin: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), @@ -15,6 +16,8 @@ function mockQueryBuilder() { take: jest.fn().mockReturnThis(), getMany: jest.fn(), getManyAndCount: jest.fn().mockResolvedValue([[], 0]), + getCount: jest.fn().mockResolvedValue(0), + getRawAndEntities: jest.fn().mockResolvedValue({ entities: [], raw: [] }), }; return qb; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts index 17270c738..1005409e0 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.service.ts @@ -113,6 +113,17 @@ export class BookingRequestService { }, }; + // Clearance-first flow: the request immediately initiates a BARE booking + // instance (no cargo, no date, no price) that enters per-booking phased + // customs clearance. GL no longer screens the request up front — it + // reviews the documents in the clearance queue and completes the booking + // (container numbers, VGM, shipment day) once clearance is ready. The + // instance is created first so a failure leaves no half-linked request. + const booking = await this.contractBookingService.initiateForShipmentRequest( + contract, + { contractRouteId: dto.contractRouteId, userId }, + ); + const reference = await this.generateReference(); const request = await this.repo.create({ reference, @@ -120,7 +131,8 @@ export class BookingRequestService { requestedByUserId: userId ?? null, contractRouteId: dto.contractRouteId ?? null, scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null, - status: 'PENDING', + status: 'ACCEPTED', + createdBookingId: booking.id, requestedLines, notes: dto.notes ?? null, } as never); diff --git a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts index 81ed305e4..ed3597d57 100644 --- a/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/clearance-milestone.service.ts @@ -58,6 +58,31 @@ export class ClearanceMilestoneService { await this.seed(postBooking, { bookingId }); } + /** + * Seed whichever pre/post-booking milestones the booking is still missing, + * keyed by milestoneCode. Plain seeding is a blind insert, so paths that can + * run more than once (completing an initiated instance whose pre-booking + * milestones were seeded at initiation, or a consolidation pairing replay) + * must go through this instead — a duplicate timeline breaks the phase + * derivation. + */ + async ensureBookingMilestones( + bookingId: string, + tradeDirection: string, + ): Promise { + const existing = await this.repo.find({ where: { bookingId } }); + const have = new Set(existing.map((m) => m.milestoneCode)); + const { preBooking, postBooking } = splitMilestones(tradeDirection); + await this.seed( + preBooking.filter((d) => !have.has(d.code)), + { bookingId }, + ); + await this.seed( + postBooking.filter((d) => !have.has(d.code)), + { bookingId }, + ); + } + private async seed( defs: MilestoneDef[], scope: { contractId?: string; clearanceCycleId?: string; bookingId?: string }, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts index fa851b409..7225d6e3f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.consolidation.spec.ts @@ -36,6 +36,7 @@ describe('ContractBookingService — drawdown consolidation gate', () => { const milestoneService = { seedPostBookingMilestones: jest.fn().mockResolvedValue(undefined), seedPreBookingMilestonesOnBooking: jest.fn().mockResolvedValue(undefined), + ensureBookingMilestones: jest.fn().mockResolvedValue(undefined), ...overrides.milestoneService, }; const contractsRepository = { @@ -144,9 +145,13 @@ describe('ContractBookingService — drawdown consolidation gate', () => { await service.onConsolidationPaired({ bookingIds: ['b-1'] }); expect(invoiceService.ensureInvoiceForBooking).toHaveBeenCalledTimes(1); - // GENERAL customs → per-booking pre + post milestones. - expect(milestoneService.seedPreBookingMilestonesOnBooking).toHaveBeenCalled(); - expect(milestoneService.seedPostBookingMilestones).toHaveBeenCalled(); + // GENERAL customs → per-booking milestones, via the idempotent ensure so a + // pairing replay (or an initiated instance's pre-seeded timeline) never + // duplicates rows. + expect(milestoneService.ensureBookingMilestones).toHaveBeenCalledWith( + 'b-1', + 'EXPORT', + ); }); it('onConsolidationPaired ignores a booking still PENDING_CONSOLIDATION', async () => { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 74d9cb1ea..a0da43784 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -426,17 +426,100 @@ export class ContractBookingService { } /** - * Complete a bare initiated booking after Operations finalized its per-booking - * clearance (CLEARANCE_READY) or returned it for changes + * Initiate a BARE booking instance for a GENERAL + customs shipment request + * (Path B, clearance-first). Called by BookingRequestService.submit AFTER it + * validated the contract (general customs, active, capacity) — the request + * itself carries the quantities; the instance carries none. Pre-booking + * customs milestones are seeded immediately so the instance enters the same + * phased ET/DJ clearance a ONE_TIME customs contract runs, just per booking. + * GL completes the booking (cargo + day) via {@link completeUnderContract} + * once the clearance reaches CLEARANCE_READY. + */ + async initiateForShipmentRequest( + contract: Contract, + opts: { contractRouteId?: string; userId?: string | null }, + ): Promise { + const generalCustoms = + contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); + if (!generalCustoms) { + throw new BadRequestException( + 'Shipment-request initiation applies only to general customs contracts.', + ); + } + if (contract.contractValidUntil && contract.contractValidUntil.getTime() < Date.now()) { + throw new BadRequestException('Contract validity has expired — no new bookings.'); + } + + const route = await this.resolveRoute(contract, opts.contractRouteId); + + const booking = await insertWithGeneratedReference( + () => this.generateReference(), + (reference) => + this.bookingsRepository.create({ + reference, + companyId: contract.companyId ?? null, + companyProfileId: contract.companyProfileId ?? null, + isGovernment: contract.isGovernment, + governmentInstitution: contract.governmentInstitution ?? null, + status: 'AWAITING_DOCUMENTS', + bookingType: 'ONE_TIME', + contractId: contract.id, + contractRouteId: route?.id ?? null, + contractKind: contract.contractKind, + createdByRole: 'CUSTOMER', + createdByUserId: opts.userId ?? null, + scheduledDate: null, + serviceTypeId: contract.serviceTypeId, + paymentCurrency: contract.paymentCurrency, + contractType: 'NEW', + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? null, + equipmentReturn: contract.equipmentReturn ?? 'WITHOUT_RETURN', + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + tradeDirection: contract.tradeDirection, + freightType: contract.freightType, + cargoTypeId: this.resolveCargoTypeId(contract, {}), + isHazardous: contract.isHazardous, + isReefer: contract.isReefer, + cargoTotalWeightVgm: 0, + firstMilePickupAddress: contract.firstMilePickupAddress ?? null, + firstMilePickupLat: contract.firstMilePickupLat ?? null, + firstMilePickupLng: contract.firstMilePickupLng ?? null, + lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null, + lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null, + lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null, + } as never), + ); + + // Pre-booking phase only — the post-booking milestones (loading, transit) + // are seeded when GL completes the booking, mirroring the ONE_TIME flow + // where GL's booking creation seeds them. + await this.milestoneService.seedPreBookingMilestonesOnBooking( + booking.id, + contract.tradeDirection, + ); + + return (await this.bookingsRepository.findByIdWithFiles(booking.id)) ?? booking; + } + + /** + * Complete a bare initiated booking after its per-booking clearance is + * finalized (CLEARANCE_READY) or operations returned it for changes * (OPERATION_CHANGES_REQUESTED). This is the deferred half of * {@link createUnderContract}: cargo lines, quantity-cap drawdown, booking * window + open-departure checks, pricing, consolidation and invoicing all run * here — the same gates a one-time shipment passes at creation. + * + * Actor rules mirror {@link assertGate}: a customs (Path B) instance is + * completed by GL Ethiopia only; a non-customs (Path A) instance by the + * customer (or staff). */ async completeUnderContract( contractId: string, bookingId: string, dto: CreateBookingUnderContractDto, + actorPermissions?: unknown, ): Promise { const contract = await this.contractsRepository.findByIdWithRelations(contractId); if (!contract) throw new NotFoundException(`Contract ${contractId} not found`); @@ -450,6 +533,18 @@ export class ContractBookingService { 'Clearance must be finalized before the booking can be completed.', ); } + // Path B: only GL Ethiopia completes a customs instance — the customer + // never enters shipment data on a customs contract. + if (contract.customsClearingEnabled) { + const isGlActor = + actorPermissions != null && + hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking); + if (!isGlActor) { + throw new ForbiddenException( + 'Customs-clearance bookings are completed by Global Logistics on behalf of the customer.', + ); + } + } if (!dto.scheduledDate) { throw new BadRequestException('A binding shipment day is required'); } @@ -457,6 +552,15 @@ export class ContractBookingService { throw new BadRequestException('Contract validity has expired — no new bookings.'); } + // Completion is booking time: the route's booking window must be open — + // the same config-driven gate a direct one-time booking passes at create. + await this.trainSchedulingService.assertBookingWindowOpen({ + originYardId: booking.originYardId ?? null, + destinationYardId: booking.destinationYardId ?? null, + scheduledDate: dto.scheduledDate, + direction: contract.tradeDirection ?? null, + }); + const freightType = contract.freightType; const hasCargo = (booking.bookingContainers?.length ?? 0) > 0 || @@ -541,8 +645,13 @@ export class ContractBookingService { } } - // Invoice the now-priced booking (idempotent, non-blocking). - await this.finalizeContractBooking(booking.id, contract, false); + // Invoice the now-priced booking and, for a customs instance, seed the + // post-booking milestones (pre-booking ones exist since initiation — + // ensure* fills only what is missing). Idempotent, non-blocking. + const generalCustoms = + contract.contractKind === 'GENERAL' && + Boolean(contract.customsClearingEnabled); + await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); } @@ -627,12 +736,11 @@ export class ContractBookingService { clearanceStatus: 'ACTIVE_SHIPMENT_IN_PROGRESS', } as never); } else if (generalCustoms) { - // Per-booking clearance: seed full milestone timeline on the booking. - await this.milestoneService.seedPreBookingMilestonesOnBooking( - bookingId, - contract.tradeDirection, - ); - await this.milestoneService.seedPostBookingMilestones( + // Per-booking clearance: seed the full milestone timeline on the booking. + // ensure* skips codes that already exist — an initiated instance carries + // its pre-booking milestones from initiation, and a consolidation pairing + // replay must not duplicate the timeline. + await this.milestoneService.ensureBookingMilestones( bookingId, contract.tradeDirection, ); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index d5dc9793e..95b734c60 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -826,8 +826,16 @@ export class ContractsController { @Param('id', ParseUUIDPipe) id: string, @Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: CreateBookingUnderContractDto, + @CurrentUser() user: AuthUserPayload, ) { - return this.contractBookingService.completeUnderContract(id, bookingId, dto); + // Customs (Path B) instances may only be completed by GL Ethiopia — the + // service checks the actor's contracts:create_booking permission. + return this.contractBookingService.completeUnderContract( + id, + bookingId, + dto, + user, + ); } @Post(':id/validate-shipment') diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a94f7516a..d529c2817 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -865,6 +865,18 @@ const App = () => { } /> + {/* Completion of an initiated (bare) instance after per-booking + clearance — same form, submits to the complete endpoint. */} + + + + } + /> } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 32ec2c511..bb94fea08 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -145,13 +145,37 @@ function bulkUnitOfMeasure( } export default function GlCreateBookingForm() { - const { id } = useParams<{ id: string }>(); + // With `bookingId` the form runs in COMPLETION mode: the bare instance + // (auto-initiated by the customer's shipment request) already finished its + // per-booking customs clearance, and this form supplies the deferred cargo + // (container numbers, VGM) + binding shipment day. Same window gate, same + // validation and price confirmation — the submit completes the existing + // booking instead of creating a new one. + const { id, bookingId: completeBookingId } = useParams<{ + id: string; + bookingId?: string; + }>(); const [searchParams] = useSearchParams(); - const requestId = searchParams.get("requestId"); + const requestIdParam = searchParams.get("requestId"); const navigate = useNavigate(); const { data: contract, isLoading } = useContractDetail(id); const mutations = useContractMutations(id ?? ""); + // Completion mode without an explicit ?requestId=: find the shipment request + // that initiated this instance so the quantities still prefill. + const { data: contractRequests } = useQuery({ + queryKey: ["shipment-requests-for-contract", id], + queryFn: () => contractsService.listBookingRequests(id!), + enabled: Boolean(id) && Boolean(completeBookingId) && !requestIdParam, + }); + const requestId = + requestIdParam ?? + (completeBookingId + ? (contractRequests?.find( + (r) => r.createdBookingId === completeBookingId, + )?.id ?? null) + : null); + const { data: bookingRequest } = useQuery({ queryKey: ["shipment-request", requestId], queryFn: () => contractsService.getBookingRequest(requestId!), @@ -636,6 +660,18 @@ export default function GlCreateBookingForm() { const payload = buildPayload(); if (!payload) return; + if (completeBookingId) { + // Completion mode: cargo + day land on the already-cleared instance — + // the request was linked and accepted at submission time. + mutations.completeBooking.mutate( + { bookingId: completeBookingId, payload }, + { + onSuccess: () => navigate(`/dashboard/clearance/${completeBookingId}`), + }, + ); + return; + } + mutations.createBooking.mutate(payload, { onSuccess: async (booking) => { if (requestId) { @@ -685,10 +721,12 @@ export default function GlCreateBookingForm() { - New Shipment Booking + {completeBookingId ? "Complete Shipment Booking" : "New Shipment Booking"} - Book a shipment on behalf of the customer for contract {contract.reference}. + {completeBookingId + ? `Clearance is finalized — enter the cargo details and shipment day to complete the booking under contract ${contract.reference}.` + : `Book a shipment on behalf of the customer for contract ${contract.reference}.`} @@ -1421,7 +1466,10 @@ export default function GlCreateBookingForm() { color="edr-green" radius="md" leftSection={} - loading={mutations.createBooking.isPending} + loading={ + mutations.createBooking.isPending || + mutations.completeBooking.isPending + } disabled={ validateShipmentMutation.isPending || pairingErrors.length > 0 || @@ -1429,7 +1477,7 @@ export default function GlCreateBookingForm() { } onClick={handleSubmit} > - Confirm & book + {completeBookingId ? "Confirm & complete" : "Confirm & book"} diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index c6f98c8a2..ca9f6342d 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -211,6 +211,8 @@ export const URL_CONSTANTS = { CLEARANCE_HISTORY: "/contracts/clearance/history", OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history", BOOKINGS: (id: string) => `/contracts/${id}/bookings`, + BOOKINGS_COMPLETE: (id: string, bookingId: string) => + `/contracts/${id}/bookings/${bookingId}/complete`, VALIDATE_SHIPMENT: (id: string) => `/contracts/${id}/validate-shipment`, CAPACITY: (id: string) => `/contracts/${id}/capacity`, // Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking. diff --git a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts index 69e4e1739..076316114 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -214,6 +214,27 @@ export function useContractMutations(contractId: string) { onError: () => toast.error("Failed to create booking"), }); + const completeBooking = useMutation({ + mutationFn: ({ + bookingId, + payload, + }: { + bookingId: string; + payload: Freight.CreateBookingUnderContractDto; + }) => + contractsService.completeBookingUnderContract( + contractId, + bookingId, + payload, + ), + onSuccess: () => { + toast.success("Booking completed"); + void invalidateContractDetail(qc, contractId); + }, + onError: (e: Error) => + toast.error(e.message || "Failed to complete booking"), + }); + const isPending = staffAccept.isPending || requestChanges.isPending || @@ -233,6 +254,7 @@ export function useContractMutations(contractId: string) { generateContract, signContract, createBooking, + completeBooking, isPending, }; } diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx index 2cb5404e7..efe9692dc 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/DocumentClearanceDetailPage.tsx @@ -1,10 +1,11 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "react-router-dom"; +import { useNavigate, useParams } from "react-router-dom"; import { Alert, Badge, Box, + Button, Grid, Group, Loader, @@ -21,6 +22,7 @@ import { CheckCircle2, Clock, PackageCheck, + PackagePlus, ShieldCheck, } from "lucide-react"; import type { Freight } from "@edr/types"; @@ -38,10 +40,14 @@ import { useBookingMilestones } from "@/hooks/contracts/useContracts"; import { bookingsService } from "@/services/bookings.service"; import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail } from "@/hooks/bookings/useBookings"; +import { useAuth } from "@/auth/useAuth"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; export default function DocumentClearanceDetailPage() { const params = useParams<{ id?: string; bookingId?: string }>(); const id = params.id ?? params.bookingId; + const navigate = useNavigate(); + const { user } = useAuth(); const { view, viewer } = useFileViewer(); const { data: booking } = useBookingDetail(id); @@ -76,6 +82,15 @@ export default function DocumentClearanceDetailPage() { booking?.contractKind === "GENERAL" && Boolean(clearance?.phase); + // Bare initiated instance whose clearance is done: GL completes the booking + // (container numbers, VGM, shipment day) via the completion form. + const canCompleteBooking = + booking?.status === "CLEARANCE_READY" && + Boolean(booking?.contractId) && + Boolean(booking?.customsClearingEnabled) && + !(Number(booking?.totalAmount ?? 0) > 0) && + hasPermission(user, FREIGHT_PERMS.contracts.createBooking); + const docsPhaseComplete = clearance?.milestones?.some( (m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED", @@ -146,6 +161,22 @@ export default function DocumentClearanceDetailPage() { ) } + action={ + canCompleteBooking ? ( + + ) : undefined + } /> diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx index 7f2bf52d8..4eb93cafd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx @@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() { => { + const result = await postContract<{ + booking?: { id: string; reference: string }; + id?: string; + reference?: string; + warnings?: string[]; + }>(C.BOOKINGS_COMPLETE(id, bookingId), payload); + const booking = result.booking ?? result; + return { + id: booking.id ?? "", + reference: booking.reference ?? "", + warnings: result.warnings, + }; + }, + /** * Pre-create validation + authoritative price preview: the same * BookingPricingService pass that prices the booking on create (rail + diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index 32ea4f654..cbb163509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -106,7 +106,10 @@ function BookingActionModalBody({ Complete booking ) : ( - flow.isReady && ( + // Customs bare instances await GL completion — no customer + // proceed button (the server rejects it anyway). + flow.isReady && + !flow.awaitingGlCompletion && ( - - )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index e4ab0a913..ab93c1cf6 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -313,14 +313,18 @@ export default function ReviewPage() { } // Build booking request for authenticated users - // For package bookings, free children (first child per adult, no seat assigned) - // are excluded from the passengers array — the backend derives them from adultCount/childCount. - const bookingPassengers = passengers.filter((p, i) => { + // Package bookings only: free children (first child per adult) don't go through + // seat selection and have no seatId, so they're excluded here — the backend derives + // them from adultCount/childCount instead. Regular bookings DO seat every passenger + // (including the free child, who still gets a real seatId and a $0 fare handled by + // the backend), so they must stay in the array or that passenger — and their + // ticket/seat/childCount — silently never gets created. + const bookingPassengers = passengers.filter((_p, i) => { if (packageId) { const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; return !isFreePkgChild; } - return !(isChild(p) && isFirstChild(passengers, i)); + return true; }); bookingData = { @@ -371,14 +375,18 @@ export default function ReviewPage() { if (priceTierId) bookingData.priceTierId = priceTierId; } else { // For guests: send full passenger details array - // For package bookings, free children (first child per adult, no seat assigned) - // are excluded from the passengers array — the backend derives them from adultCount/childCount. - const guestBookingPassengers = passengers.filter((p, i) => { + // Package bookings only: free children (first child per adult) don't go through + // seat selection and have no seatId, so they're excluded here — the backend derives + // them from adultCount/childCount instead. Regular bookings DO seat every passenger + // (including the free child, who still gets a real seatId and a $0 fare handled by + // the backend), so they must stay in the array or that passenger — and their + // ticket/seat/childCount — silently never gets created. + const guestBookingPassengers = passengers.filter((_p, i) => { if (packageId) { const isFreePkgChild = i >= adultPassengerCount && (i - adultPassengerCount) < adultPassengerCount; return !isFreePkgChild; } - return !(isChild(p) && isFirstChild(passengers, i)); + return true; }); bookingData = { diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index d8e485fd6..a18aab817 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -806,8 +806,12 @@ export default function SearchPage() { /> )} - {/* ── 90vh hero with banner image ── */} -
+ {/* ── Hero with banner image (desktop only — mobile is content-driven, no + forced height, so it doesn't push the Packages section below the fold). + Desktop height is intentionally short of a full viewport so the Packages + section peeks into view without scrolling — a full 94vh hero was hiding + it entirely on common screen sizes. ── */} +
{/* Background image with zoom - fully isolated */}
{/* Mobile-only heading — desktop keeps the version overlaid on the hero image above */}
-

+

Where are you headed today?

-

- Book your train journey across East Africa -

@@ -897,166 +898,174 @@ export default function SearchPage() {
- {/* Mobile: stacked */} + {/* Mobile: stacked, but From/To and Date/Return Date pair up into two + columns each to save vertical space (station names/dates truncate + rather than wrap) — same fields, same behavior, just denser. */}
-
- - - {hasInteracted && errors.originStationId && ( -

- {errors.originStationId.message} -

- )} -
-
-
- -
- - {hasInteracted && errors.destinationStationId && ( -

- {errors.destinationStationId.message} -

- )} -
-
- -
- { - setValue( - "departureDate", - `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, - ); - trigger("departureDate"); + onClick={() => { + setHasInteracted(true); + window.scrollTo({ + top: 0, + behavior: "instant" as ScrollBehavior, + }); + setStationModal("origin"); }} - minDate={new Date()} - placeholder="Select date" - error={!!errors.departureDate} - /> + className="w-full" + > +
+ + + {originStation?.name ?? "Departure"} + +
+ + {hasInteracted && errors.originStationId && ( +

+ {errors.originStationId.message} +

+ )} +
+
+
+ + +
+ + {hasInteracted && errors.destinationStationId && ( +

+ {errors.destinationStationId.message} +

+ )}
- {errors.departureDate && ( -

- {errors.departureDate.message} -

- )}
- {tripType === "ROUND_TRIP" && ( -
+
+
{ setValue( - "returnDate", + "departureDate", `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, ); - trigger("returnDate"); + trigger("departureDate"); }} - minDate={ - departureDate - ? new Date(departureDate + "T00:00:00") - : new Date() - } - placeholder="Select return date" - error={!!errors.returnDate} + minDate={new Date()} + placeholder="Departure date" + error={!!errors.departureDate} />
- {errors.returnDate && ( + {errors.departureDate && (

- {errors.returnDate.message} + {errors.departureDate.message}

)}
- )} + {tripType === "ROUND_TRIP" && ( +
+ +
+ { + setValue( + "returnDate", + `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`, + ); + trigger("returnDate"); + }} + minDate={ + departureDate + ? new Date(departureDate + "T00:00:00") + : new Date() + } + placeholder="Return date" + error={!!errors.returnDate} + /> +
+ {errors.returnDate && ( +

+ {errors.returnDate.message} +

+ )} +
+ )} +
{/* Pax + Nationality combined trigger */} diff --git a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts index ca22538be..7ab72e1a5 100644 --- a/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts +++ b/apps/edr-passenger-web/portal/src/lib/generate-voucher.ts @@ -103,6 +103,29 @@ function hairline(doc: jsPDF, x1: number, y: number, x2: number): void { // ─── header ──────────────────────────────────────────────────────────────── +// Fetched once and reused for the lifetime of the page — re-fetching this same static +// asset on every passenger/every voucher adds a real network round-trip in the middle of +// what needs to stay close to the original click's synchronous execution window (iOS +// Safari silently blocks a file save triggered too long after user activation). +let logoCache: Promise<{ dataUrl: string; width: number; height: number }> | null = null; +function loadLogo(): Promise<{ dataUrl: string; width: number; height: number }> { + if (!logoCache) { + logoCache = (async () => { + const logoImg = await fetch('/edr-logo.png'); + const logoBlob = await logoImg.blob(); + const dataUrl = await new Promise((resolve) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.readAsDataURL(logoBlob); + }); + const img = new Image(); + await new Promise((resolve) => { img.onload = resolve; img.src = dataUrl; }); + return { dataUrl, width: img.width, height: img.height }; + })(); + } + return logoCache; +} + async function drawHeader(doc: jsPDF, margin: number): Promise { const pageWidth = doc.internal.pageSize.getWidth(); const bandHeight = 24; @@ -111,17 +134,9 @@ async function drawHeader(doc: jsPDF, margin: number): Promise { doc.rect(0, 0, pageWidth, bandHeight, 'F'); try { - const logoImg = await fetch('/edr-logo.png'); - const logoBlob = await logoImg.blob(); - const logoDataUrl = await new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result as string); - reader.readAsDataURL(logoBlob); - }); - const img = new Image(); - await new Promise((resolve) => { img.onload = resolve; img.src = logoDataUrl; }); + const { dataUrl: logoDataUrl, width, height } = await loadLogo(); const logoH = 13; - const logoW = (img.width / img.height) * logoH; + const logoW = (width / height) * logoH; const textX = margin + logoW + 5; doc.addImage(logoDataUrl, 'PNG', margin, (bandHeight - logoH) / 2, logoW, logoH); doc.setTextColor(255, 255, 255); @@ -365,9 +380,7 @@ function drawFooter(doc: jsPDF, createdAt: string): void { // ─── public API ────────────────────────────────────────────────────────────── -/** Generates and downloads one PDF voucher for a single passenger. */ -export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { - const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); +async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData): Promise { const pageW = doc.internal.pageSize.getWidth(); const margin = PAGE_MARGIN; @@ -388,6 +401,12 @@ export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): P y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW); drawInstructions(doc, y, margin, pageW); drawFooter(doc, data.createdAt); +} + +/** Generates and downloads one PDF voucher for a single passenger. */ +export const generatePassengerVoucherPDF = async (data: PassengerVoucherData): Promise => { + const doc = new jsPDF({ orientation: 'portrait', unit: 'mm', format: 'a4' }); + await drawPassengerVoucherPage(doc, data); const safeName = (data.passengerName || 'Passenger').replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, ''); doc.save(`Voucher_${safeName}.pdf`); @@ -404,14 +423,28 @@ interface VoucherData { currency: string; bookingType: string; createdAt: string; + // One ticket per passenger, matched below by passengerName — see bookings.service.ts's + // getByRef(). Optional/absent falls back to a client-generated placeholder number. + tickets?: Array<{ passengerName?: string; barcodePayload?: string }>; } export const generateVoucherPDF = async (booking: VoucherData): Promise => { + // Separate file per passenger, saved back-to-back with no macrotask (setTimeout) between + // them — a setTimeout delay here would push later saves outside the click's synchronous + // user-activation window and risk iOS Safari silently blocking them. The awaited work + // inside generatePassengerVoucherPDF is itself just microtasks (cached logo, QR encode), + // which doesn't have that effect. for (let i = 0; i < booking.passengers.length; i++) { const p = booking.passengers[i]; + const matchedTicket = + booking.tickets?.find((t) => t.passengerName === p.fullName) ?? booking.tickets?.[i] ?? null; + // No fabricated placeholder — a made-up TKT-... number reads as real and is misleading + // if it doesn't match what's actually on file. + const ticketNumber = matchedTicket?.barcodePayload || 'Not yet issued'; + await generatePassengerVoucherPDF({ bookingRef: booking.bookingRef, - ticketNumber: `TKT-${booking.bookingRef}-${(i + 1).toString().padStart(2, '0')}`, + ticketNumber, passengerName: p.fullName, seatNumber: p.seat?.number, status: booking.status, @@ -421,7 +454,5 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise => currency: booking.currency, createdAt: booking.createdAt, }); - // small delay so browsers don't block multiple sequential downloads - if (i < booking.passengers.length - 1) await new Promise(r => setTimeout(r, 400)); } }; From 1121e9ce82d466aba141c0488ad4bc3f5377859a Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 21:51:59 +0000 Subject: [PATCH 08/21] Implement clearance-first booking flow and completion process for customs contracts --- .../booking-transition.clearance.spec.ts | 7 +- .../modules/bookings/clearance.util.spec.ts | 6 +- .../src/modules/bookings/clearance.util.ts | 10 +- .../booking-batch.service.spec.ts | 49 +++++++ .../train-scheduling/booking-batch.service.ts | 123 +++++++++++++++- .../booking-journey.service.ts | 9 +- .../booking-notifier.service.ts | 16 +++ .../booking-window.service.spec.ts | 6 + .../booking-window.service.ts | 7 + .../warehouses/warehouse-inventory.service.ts | 16 ++- .../warehouse-scheduling-adapter.service.ts | 8 +- .../contracts/GlCreateBookingForm.tsx | 15 +- .../ContractCustomerAction.tsx | 136 +++++++++++++----- .../components/ClearanceCard.tsx | 54 +++++-- .../components/StatusHero.tsx | 23 ++- .../clearance/BookingActionButton.tsx | 19 ++- .../bookings/clearance/BookingActionModal.tsx | 13 +- .../bookings/clearance/bookingNextAction.ts | 42 +++++- .../src/pages/contracts/booking-window.ts | 29 ++-- 19 files changed, 482 insertions(+), 106 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts index 72c83e136..890ae6344 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-transition.clearance.spec.ts @@ -14,9 +14,10 @@ describe('BookingTransitionService — finalizeClearance gate', () => { serviceType: { includesCustoms: false }, // no output set → only the input gate }; - // Input set has two required docs. + // Input set has two required docs. Non-customs bookings resolve to the + // ONE_TIME self-clearance document set. const inputSetting = { - code: 'clearance_import_container_without_customs', + code: 'contract_clearance_selfclear_import_container', fields: [ { fileKey: 'commercial_invoice', isRequired: true }, { fileKey: 'packing_list', isRequired: true }, @@ -198,7 +199,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', ( */ describe('BookingTransitionService — submitClearanceDocuments required-fields gate', () => { const inputSetting = { - code: 'clearance_import_container_without_customs', + code: 'contract_clearance_selfclear_import_container', fields: [ { fileKey: 'commercial_invoice', fileLabel: 'Commercial invoice', isRequired: true }, { fileKey: 'packing_list', fileLabel: 'Packing list', isRequired: true }, diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts index ac21b2dce..969de5583 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.spec.ts @@ -8,8 +8,10 @@ describe('clearance.util — clearanceSettingCode', () => { expect(clearanceSettingCode('IMPORT', 'CONTAINER', true)).toBe( 'clearance_import_container_with_customs', ); + // Non-customs bookings self-clear with the same document set a ONE_TIME + // self-clear contract uses. expect(clearanceSettingCode('IMPORT', 'CONTAINER', false)).toBe( - 'clearance_import_container_without_customs', + 'contract_clearance_selfclear_import_container', ); }); @@ -18,7 +20,7 @@ describe('clearance.util — clearanceSettingCode', () => { 'clearance_export_bulk_with_customs', ); expect(clearanceSettingCode('EXPORT', 'BULK', false)).toBe( - 'clearance_export_bulk_without_customs', + 'contract_clearance_selfclear_export_bulk', ); }); diff --git a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts index 6d7b86c8f..1cc6503df 100644 --- a/apps/edr-freight-api/src/modules/bookings/clearance.util.ts +++ b/apps/edr-freight-api/src/modules/bookings/clearance.util.ts @@ -29,8 +29,14 @@ export function clearanceSettingCode( const op = operationFor(tradeDirection); if (!op) return null; const freight = freightFor(freightType); - const customs = includesCustoms ? 'with_customs' : 'without_customs'; - return `clearance_${op}_${freight}_${customs}`; + // Non-customs (Path A) bookings self-clear: the customer proves his own + // clearance with the SAME smaller document set a ONE_TIME self-clear + // contract uses (customs declaration, release permit, …) — not the + // GL-oriented booking sets. + if (!includesCustoms) { + return `contract_clearance_selfclear_${op}_${freight}`; + } + return `clearance_${op}_${freight}_with_customs`; } /** The GL-output (customs output) setting code, keyed on op + freight. */ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 24e908761..a87b22c3e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -700,6 +700,16 @@ describe('BookingBatchService — PAID reconcile', () => { bookingsRepository.findBatchPoolByCorridorDay .mockResolvedValueOnce([waiting]) .mockResolvedValue([]); + // expire()'s paid-guard and reserve()'s idempotency guard both re-read the + // booking fresh — answer with the matching row, not the paidBooking default + // (which would make the guard rescue-allocate the lapsed reservation). + const byId: Record = { lapsed, waiting }; + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + byId[opts?.where?.id ?? ''] ?? null, + ); await service.settleDueReservations(trainId); @@ -725,6 +735,14 @@ describe('BookingBatchService — PAID reconcile', () => { return Promise.resolve(reads === 1 ? [lapsed] : []); }); bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // expire()'s paid-guard re-reads the booking fresh — answer with the + // (unpaid) lapsed row, not the paidBooking default. + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + opts?.where?.id === 'lapsed' ? lapsed : null, + ); await Promise.all([ service.settleDueReservations(trainId), @@ -733,6 +751,37 @@ describe('BookingBatchService — PAID reconcile', () => { expect(notifier.expired).toHaveBeenCalledTimes(1); }); + + it('never expires a reservation whose payment landed — allocates it instead', async () => { + const latePaid = booking('late-paid', 50, { + status: 'SELECTED_FOR_BATCH', + paymentDeadline: new Date(Date.now() - 60_000), + }); + bookingsRepository.findReservedForSchedule + .mockResolvedValueOnce([latePaid]) + .mockResolvedValue([]); + bookingsRepository.findBatchPoolByCorridorDay.mockResolvedValue([]); + // The payment webhook flipped paymentStatus between the settle's list + // read and expire()'s fresh re-read — the deadline had already passed. + dataSource + .getRepository() + .findOne.mockImplementation( + async (opts: { where?: { id?: string } }) => + opts?.where?.id === 'late-paid' + ? { ...latePaid, paymentStatus: 'PAID' } + : null, + ); + + await service.settleDueReservations(trainId); + + // Money was taken → the booking boards. Never expired. + expect(notifier.expired).not.toHaveBeenCalled(); + expect(notifier.secured).toHaveBeenCalledTimes(1); + expect(trainScheduleBookingsRepository.createMany).toHaveBeenCalledWith( + [{ trainScheduleId: trainId, bookingId: 'late-paid' }], + expect.anything(), + ); + }); }); }); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d51108043..cda6de85f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -428,7 +428,19 @@ export class BookingBatchService implements OnModuleInit { where: { id: bookingId }, relations: { company: true }, }); - if (!booking?.trainScheduleId) return; + if (!booking) return; + if (!booking.trainScheduleId) { + // A paid booking with no train is money taken and nothing boarding — + // scream so staff pin it to a schedule manually (batch board / assign). + if (booking.paymentStatus === "PAID" || booking.status === "PAID") { + this.logger.error( + `PAID booking ${booking.reference ?? bookingId} has no train_schedule_id — ` + + `its reservation was likely expired before the payment landed. ` + + `Assign it to a schedule manually from the batch board.`, + ); + } + return; + } const isBatchPaid = booking.status === "SELECTED_FOR_BATCH" || @@ -2124,8 +2136,38 @@ export class BookingBatchService implements OnModuleInit { * Expire an unpaid reservation and free its capacity. With day-level pooling we * also clear `trainScheduleId` so the booking is no longer pinned to the train * it failed to pay for — it's back in the day pool for staff to act on. + * `reason` picks the customer message: 'payment' (pay window lapsed) or + * 'no-capacity' (no train on the chosen day could take the booking). + * + * PAID GUARD: a booking whose payment has landed is never expired — money was + * taken, so it boards, even when the webhook arrived after the deadline or the + * settle read a stale row. It allocates onto the train it was selected for; if + * the wagon planner then finds no physical wagon, the booking stays linked and + * staff assign wagons manually. Consolidated bookings are exempt from the + * rescue: the shared wagon is both-or-neither, and settleReserved owns that + * pair decision. */ - private async expire(booking: Booking): Promise { + private async expire( + booking: Booking, + reason: "payment" | "no-capacity" = "payment", + ): Promise { + if (!booking.consolidationPartnerId) { + const fresh = await this.dataSource + .getRepository(Booking) + .findOne({ where: { id: booking.id }, relations: { company: true } }); + const paid = + fresh != null && + (fresh.paymentStatus === "PAID" || fresh.status === "PAID"); + const paidScheduleId = fresh?.trainScheduleId ?? booking.trainScheduleId; + if (paid && paidScheduleId) { + this.logger.log( + `[BATCH] expire skipped for ${booking.reference} — payment already ` + + `landed; allocating on schedule ${paidScheduleId} instead`, + ); + await this.allocate(paidScheduleId, fresh, "paid"); + return; + } + } const freedScheduleId = booking.trainScheduleId; await this.bookingsRepository.update(booking.id, { trainScheduleId: null, @@ -2146,13 +2188,84 @@ export class BookingBatchService implements OnModuleInit { // (emits `booking.invoice.expired`). Domain owns the reaction; billing stays // source-agnostic. await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, "PREPAID"); - this.notifier.expired(booking); + if (reason === "no-capacity") { + this.notifier.expiredNoCapacity(booking); + } else { + this.notifier.expired(booking); + } this.logger.log( - `[BATCH] EXPIRED ${booking.reference} — payment window passed; freed its ` + - `wagons back to the pool for top-up`, + `[BATCH] EXPIRED ${booking.reference} — ` + + (reason === "no-capacity" + ? "no train on its day had capacity left" + : "payment window passed; freed its wagons back to the pool for top-up"), ); } + /** + * End-of-day sweep: once a schedule's window cycle concludes and NO other + * train on the same route-day can still run a cycle, the waiting pool for + * that day is dead — a FULLY_EXECUTED booking left in it would wait forever. + * Expire every leftover commercial booking and tell the customers to rebook + * another day. Government bookings are never auto-expired (they preempt). + * Returns how many bookings were expired. + */ + async expireLeftoverDayPool(scheduleId: string): Promise { + const schedule = await this.trainSchedulesRepository.findById(scheduleId); + if (!schedule?.scheduledDepartureDate) return 0; + const day = eatDay(schedule.scheduledDepartureDate); + const group: RouteDayGroup = { + originYardId: schedule.originStationId, + destinationYardId: schedule.destinationStationId, + day, + }; + + // Another train on this route-day that can still take bookings keeps the + // pool alive — when IT concludes, its own sweep runs this check again. + const siblings = await this.trainSchedulesRepository.findAll({ + where: [ + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Draft, + }, + { + originStationId: group.originYardId, + destinationStationId: group.destinationYardId, + status: TrainScheduleStatusEnum.Scheduled, + }, + ], + }); + const anotherTrainStillOpen = siblings.some( + (s) => + s.id !== schedule.id && + s.scheduledDepartureDate != null && + eatDay(s.scheduledDepartureDate) === day && + s.windowPhase !== "DONE" && + s.bookingWindowStatus !== "FULL", + ); + if (anotherTrainStillOpen) return 0; + + const corridorYards = await this.corridorYardsForRouteDay(group); + const pool = corridorYards.length + ? await this.bookingsRepository.findBatchPoolByCorridorDay(corridorYards, day) + : await this.bookingsRepository.findBatchPoolByRouteDay( + group.originYardId, + group.destinationYardId, + day, + ); + const leftovers = pool.filter((b) => !b.isGovernment); + for (const booking of leftovers) { + await this.expire(booking, "no-capacity"); + } + if (leftovers.length) { + this.logger.log( + `[BATCH] ${this.groupLabel(group)}: no train left with capacity — ` + + `expired ${leftovers.length} waiting booking(s)`, + ); + } + return leftovers.length; + } + /** * Union of stop yards across the day's fillable schedules on this corridor — * the same pool scope fillRouteDay uses, so full-route AND sub-corridor bookings diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 2df741bd3..426bd37be 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -14,6 +14,7 @@ import { ClearanceMilestoneService } from '../contracts/clearance-milestone.serv import { Yard } from '../rule-engine/entities/yard.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; @@ -139,7 +140,7 @@ export class BookingJourneyService { .leftJoinAndSelect('booking.originYard', 'originYard') .leftJoinAndSelect('booking.destinationYard', 'destinationYard') .innerJoin( - 'freight.train_schedule_bookings', + TrainScheduleBooking, 'tsb', 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', { scheduleId }, @@ -218,8 +219,10 @@ export class BookingJourneyService { const bookings = await this.dataSource .getRepository(Booking) .createQueryBuilder('booking') + // Entity-class join: a raw 'freight.table' string is parsed by TypeORM as + // an alias.property path ("freight" alias was not found) — runtime 500. .innerJoin( - 'freight.train_schedule_bookings', + TrainScheduleBooking, 'tsb', 'tsb.booking_id = booking.id AND tsb.train_schedule_id = :scheduleId AND tsb.deleted_at IS NULL', { scheduleId }, @@ -350,7 +353,7 @@ export class BookingJourneyService { .createQueryBuilder('alloc') .innerJoinAndSelect('alloc.trainSetWagon', 'slot') .innerJoin( - 'freight.train_schedules', + TrainSchedule, 'schedule', 'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId', { scheduleId }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index c189825fd..edd807152 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -141,6 +141,22 @@ export class BookingNotifierService { this.inApp(b, 'Payment window expired', msg); } + /** + * Every train on the booking's chosen day filled up (or no further train runs) + * before the waiting list reached this booking — it expired unplaced. HIGH so + * the customer hears about it by email/SMS and rebooks another day. + */ + expiredNoCapacity(b: Booking): void { + const msg = + `Booking ${b.reference ?? b.id} could not be placed: every train for your selected day ` + + `is full and no other train is scheduled that day. The booking has expired — ` + + `please rebook for another day. No re-approval is needed.`; + void this.notifyContact(b, msg, 'EXPIRED (NO CAPACITY)'); + this.inApp(b, 'No capacity — booking expired', msg, { + priority: NotificationPriority.HIGH, + }); + } + scheduleFull(b: Booking): void { this.logger.warn( `SCHEDULE FULL — ${this.ref(b)} could not be placed; change schedule, pick another day, or cancel.`, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts index 3286da0eb..72229cfd6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.spec.ts @@ -19,6 +19,7 @@ describe('BookingWindowService — window state machine', () => { isScheduleFull: jest.Mock; hasLiveReservations: jest.Mock; refreshWindowStatus: jest.Mock; + expireLeftoverDayPool: jest.Mock; }; let trainSchedulesRepository: { findById: jest.Mock; findAll: jest.Mock }; let trainSchedulingService: { finalizeSchedule: jest.Mock; getWindowConfig: jest.Mock }; @@ -73,6 +74,7 @@ describe('BookingWindowService — window state machine', () => { // No reservation is mid-pay-window by default, so the cycle concludes. hasLiveReservations: jest.fn().mockResolvedValue(false), refreshWindowStatus: jest.fn().mockResolvedValue(undefined), + expireLeftoverDayPool: jest.fn().mockResolvedValue(0), }; trainSchedulesRepository = { findById: jest.fn().mockResolvedValue(null), @@ -186,6 +188,8 @@ describe('BookingWindowService — window state machine', () => { expect(batch.setWindow).toHaveBeenCalledWith(scheduleId, 'FULL'); expect(s.windowPhase).toBe('DONE'); expect(trainSchedulingService.finalizeSchedule).toHaveBeenCalledWith(scheduleId); + // The day's leftover waiting list is swept once this train is done. + expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId); }); it('conclude: NOT full + a cycle fits before departure → REOPEN (back to PRE_WINDOW)', async () => { @@ -210,6 +214,8 @@ describe('BookingWindowService — window state machine', () => { }); await concludeCycle(s, new Date('2026-07-01T02:30:04.000Z')); expect(s.windowPhase).toBe('DONE'); + // No further train can run for this day → leftover waiting list is swept. + expect(batch.expireLeftoverDayPool).toHaveBeenCalledWith(scheduleId); }); it('no transition fires before its deadline (idempotent tick)', async () => { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index a730b6aa6..25a7b1aed 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -357,6 +357,10 @@ export class BookingWindowService implements OnModuleInit { this.logger.log( `[WINDOW] ${schedule.id} conclude → train FULL — window DONE, finalizing`, ); + // This train is done. If no other train on the route-day can still take + // the waiting list, those bookings have nowhere to go — expire + notify + // them now instead of leaving them FULLY_EXECUTED forever. + await this.bookingBatchService.expireLeftoverDayPool(schedule.id); return; } @@ -390,6 +394,9 @@ export class BookingWindowService implements OnModuleInit { `[WINDOW] ${schedule.id} conclude → not full but no cycle fits before ` + `departure — window DONE`, ); + // No further cycle on this train. Same sweep as the FULL branch: if no + // sibling train can still take the day's waiting list, expire + notify. + await this.bookingBatchService.expireLeftoverDayPool(schedule.id); return; } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8f7a71fc1..f13a8c78f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -2,7 +2,11 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nes import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { Booking } from '../bookings/entities/booking.entity'; import { Cargo } from '../cargoes/entities/cargoes.entity'; +import { Company } from '../companies/entities/company.entity'; +import { Container } from '../container-management/entities/container.entity'; +import { CargoType } from '../rule-engine/entities/cargo-type.entity'; import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service'; import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity'; import { LastMileService } from '../last-mile/last-mile.service'; @@ -3651,23 +3655,25 @@ export class WarehouseInventoryService { .leftJoinAndSelect('inv.warehouse', 'warehouse') .leftJoinAndSelect('inv.yard', 'yard') .leftJoinAndSelect('inv.zone', 'zone') - .leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') - .leftJoin('freight.companies', 'company', 'company.id = booking.company_id') + // Entity-class joins: TypeORM parses a raw 'freight.table' string as an + // alias.property path ("freight" alias was not found) — runtime 500. + .leftJoin(Booking, 'booking', 'booking.id = inv.booking_id') + .leftJoin(Company, 'company', 'company.id = booking.company_id') .leftJoin( - 'freight.containers', + Container, 'container', `((inv.container_id IS NOT NULL AND container.id = inv.container_id) OR (inv.container_id IS NULL AND container.booking_id = inv.booking_id)) AND container.deleted_at IS NULL`, ) .leftJoin( - 'freight.cargoes', + Cargo, 'cargo', `((inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) OR (inv.cargo_id IS NULL AND cargo.booking_id = inv.booking_id)) AND cargo.deleted_at IS NULL`, ) - .leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') + .leftJoin(CargoType, 'cargo_type', 'cargo_type.id = cargo.cargo_type_id') .addSelect('booking.reference', 'b_reference') .addSelect('company.name', 'c_name') .addSelect('container.container_number', 'ct_number') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts index dcf685c9f..49bcc996f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-scheduling-adapter.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@nestjs/common'; import { DataSource } from 'typeorm'; +import { Booking } from '../bookings/entities/booking.entity'; +import { Route } from '../routes/entities/route.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; /** @@ -50,9 +52,11 @@ export class WarehouseSchedulingAdapterService { .leftJoinAndSelect('inv.warehouse', 'warehouse') .leftJoinAndSelect('inv.yard', 'yard') .leftJoinAndSelect('inv.zone', 'zone') - .innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id') + // Entity-class joins: TypeORM parses a raw 'freight.table' string as an + // alias.property path ("freight" alias was not found) — runtime 500. + .innerJoin(Booking, 'booking', 'booking.id = inv.booking_id') .innerJoin( - 'freight.routes', + Route, 'route', 'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)', { routeId }, diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index bb94fea08..a520c35e5 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -198,18 +198,17 @@ export default function GlCreateBookingForm() { ); // Next future window across all routes, used for the "next window" notice — - // the train dispatching soonest among those not yet open, matching the - // departure-date ordering of the window cards. + // the next moment booking OPENS (chronological), which may belong to a + // later-departing train. Departure-first ordering here named the soonest + // train's later opening as "next" while another lane opened earlier. const nextWindow = useMemo(() => { const now = Date.now(); return (bookingWindows ?? []) .filter((w) => w.windowOpensAt && new Date(w.windowOpensAt).getTime() > now) - .sort((a, b) => { - const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; - const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; - if (da !== db) return da - db; - return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(); - })[0]; + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(), + )[0]; }, [bookingWindows]); const [scheduledDate, setScheduledDate] = useState(""); diff --git a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx index 9e73dcb58..86c37585c 100644 --- a/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx +++ b/apps/edr-freight-web/portal/src/components/customer-actions/ContractCustomerAction.tsx @@ -1,7 +1,14 @@ -import { Button, Group, type ButtonProps } from "@mantine/core"; +import { + Button, + Group, + Modal, + Text, + ThemeIcon, + type ButtonProps, +} from "@mantine/core"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { LucideIcon } from "lucide-react"; -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import toast from "react-hot-toast"; import { useNavigate } from "react-router-dom"; @@ -57,7 +64,9 @@ export function ContractCustomerAction({ } if (action.type === "pay") { - return ; + return ( + + ); } if (action.type === "initiate") { @@ -118,6 +127,7 @@ export function InitiateBookingButton({ }) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const [confirmOpen, setConfirmOpen] = useState(false); const mutation = useMutation({ mutationFn: () => @@ -137,6 +147,7 @@ export function InitiateBookingButton({ toast.success( "Booking initiated — upload your clearance documents to start the review.", ); + setConfirmOpen(false); navigate(`/bookings/${booking.id}`); }, onError: (e: Error) => @@ -144,37 +155,87 @@ export function InitiateBookingButton({ }); return ( - + <> + { + if (!mutation.isPending) setConfirmOpen(false); + }} + centered + radius="lg" + size="md" + closeOnClickOutside={!mutation.isPending} + closeOnEscape={!mutation.isPending} + withCloseButton={!mutation.isPending} + title={ + + + + + Initiate a new booking? + + } + > + + This creates a new shipment booking under contract{" "} + + {contract.reference} + + . You'll upload the clearance documents next, and the shipment + quantity is drawn down from your contract's reserved capacity. + + + + + + + + ); } @@ -191,7 +252,12 @@ export function ContractCustomerActionCell({ return ( {docButton} - + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx index 5bbece55e..dcc455ada 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/ClearanceCard.tsx @@ -1,6 +1,13 @@ import { useState } from "react"; import { Alert, Button, Group, Text } from "@mantine/core"; -import { CheckCircle2, ClipboardList, Clock, Upload } from "lucide-react"; +import { + CheckCircle2, + ClipboardList, + Clock, + PackagePlus, + Upload, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -12,15 +19,19 @@ import { CardTitle, SectionCard } from "./layout"; /** * Customer-facing clearance section on the booking detail page: a compact - * status summary with a single action button. The document grid, re-uploads, - * and the shipment-day picker all live in the shared {@link BookingActionModal} - * (the same modal the My Shipments list uses), so the flow behaves identically - * from both entry points. + * status summary with a single action button. The document grid and re-uploads + * live in the shared {@link BookingActionModal} (the same modal the My + * Shipments list uses); a finished bare instance instead shows a "Book" button + * that navigates to the booking form. */ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const [modalOpen, setModalOpen] = useState(false); + const navigate = useNavigate(); const status = booking.status as string; const action = getBookingNextAction(booking); + // BOOK: clearance finished on a bare instance — go straight to the booking + // form (cargo + shipment day + window check) instead of opening the modal. + const isBookAction = action?.kind === "BOOK" && Boolean(action.to); if (status === "OPERATION_REQUESTED") { return ( @@ -36,7 +47,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { const summary = status === "CLEARANCE_READY" ? ( }> - Clearance is complete. Pick a shipment day and proceed to operation. + {isBookAction + ? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window." + : "Clearance is complete. Pick a shipment day and proceed to operation."} ) : status === "DOCUMENTS_UNDER_REVIEW" ? ( }> @@ -59,8 +72,16 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { @@ -70,15 +91,18 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) { {summary} - Use “{action?.label ?? "the action button"}” to manage your clearance - documents. + {isBookAction + ? "Use “Book” to enter the cargo details and schedule your shipment." + : `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`} - setModalOpen(false)} - /> + {!isBookAction && ( + setModalOpen(false)} + /> + )} ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index 82c692a90..ba8ca3db7 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -66,6 +66,10 @@ export function StatusHero({ }) { const status = booking.status; const stage = resolveStage(booking); + // Contract-drawdown instance in the clearance gate: it was INITIATED with one + // click (no cargo/date yet), not submitted through the wizard. + const isInitiatedInstance = + status === "AWAITING_DOCUMENTS" && Boolean(booking.contractId); // Legacy bookings never reach the ARRIVED status — they light up the Arrival // stage from the train's ARRIVED state while staying IN_TRANSIT, so the // headline is overridden here. Bookings with a per-booking journey carry the @@ -78,7 +82,14 @@ export function StatusHero({ "Your shipment reached its destination yard and is being unloaded and prepared for release.", stage, } - : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); + : isInitiatedInstance + ? { + title: "Booking initiated — clearance documents needed", + description: + "Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -123,6 +134,11 @@ export function StatusHero({ current={stage} tone={draft ? "ink" : "green"} negative={negative} + // Contract drawdowns are initiated with one click, not submitted + // through the wizard — relabel the stage for them. + labelOverrides={ + booking.contractId ? { 1: "Initiated" } : undefined + } /> )} @@ -132,10 +148,13 @@ export function StatusHero({ function ProgressTracker({ current, tone = "green", + labelOverrides, }: { current: number; tone?: "green" | "ink"; negative?: boolean; + /** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */ + labelOverrides?: Record; }) { const last = PROGRESS_STAGES.length - 1; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; @@ -227,7 +246,7 @@ function ProgressTracker({ ta="center" c={state === "idle" ? "#9AA8B5" : "#10202F"} > - {stage.label} + {labelOverrides?.[idx] ?? stage.label}
); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx index 03dfa6ac8..ec9b8c509 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionButton.tsx @@ -1,6 +1,13 @@ import { Box, Button } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; -import { AlertCircle, ArrowRight, PencilLine, Upload } from "lucide-react"; +import { + AlertCircle, + ArrowRight, + PackagePlus, + PencilLine, + Upload, +} from "lucide-react"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -19,6 +26,7 @@ const ICON_BY_KIND: Record< UPLOAD_DOCUMENTS: Upload, FIX_DOCUMENTS: AlertCircle, SCHEDULE_OPERATION: ArrowRight, + BOOK: PackagePlus, }; interface BookingActionButtonProps { @@ -39,6 +47,7 @@ export function BookingActionButton({ size = "sm", }: BookingActionButtonProps) { const [opened, { open, close }] = useDisclosure(false); + const navigate = useNavigate(); // Staff returned the booking for changes — let the customer update the docs // they submitted and resubmit, in place. @@ -49,6 +58,9 @@ export function BookingActionButton({ const Icon = action ? ICON_BY_KIND[action.kind] : PencilLine; const label = action ? action.label : "Update & resubmit"; + // BOOK navigates to the booking form (cargo + day + window check) — the + // same page a one-time booking uses — instead of opening the modal. + const navigateTo = action?.kind === "BOOK" ? action.to : undefined; return ( // Mantine modals portal to , but React events still bubble through @@ -65,7 +77,8 @@ export function BookingActionButton({ leftSection={} onClick={(e) => { e.stopPropagation(); - open(); + if (navigateTo) navigate(navigateTo); + else open(); }} > {label} @@ -77,7 +90,7 @@ export function BookingActionButton({ opened={opened} onClose={close} /> - ) : ( + ) : navigateTo ? null : ( )} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx index cbb163509..8f93e040c 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/BookingActionModal.tsx @@ -48,24 +48,25 @@ function BookingActionModalBody({ const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose }); return ( + // Sized and styled to match the contract clearance modal + // (ContractClearanceAction) so both flows read as the same surface. - - {action?.title ?? "Booking"} + + {action?.title ?? "Clearance documents"} {reference} } - overlayProps={{ backgroundOpacity: 0.5, blur: 4 }} + overlayProps={{ blur: 2, backgroundOpacity: 0.55 }} styles={{ body: { paddingTop: 8 } }} > {flow.isLoading || !flow.clearance ? ( diff --git a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts index 5f88349dc..1ebb047a6 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/clearance/bookingNextAction.ts @@ -9,7 +9,8 @@ import type { Freight } from "@edr/types"; export type BookingActionKind = | "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs | "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them - | "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation + | "SCHEDULE_OPERATION" // CLEARANCE_READY (legacy with cargo) — pick a day and proceed + | "BOOK"; // CLEARANCE_READY bare instance — navigate to the booking form export interface BookingNextAction { kind: BookingActionKind; @@ -17,6 +18,8 @@ export interface BookingNextAction { label: string; /** Modal title. */ title: string; + /** Set for navigation actions (BOOK) — the button navigates instead of opening the modal. */ + to?: string; } const ACTION_BY_STATUS: Record = { @@ -37,6 +40,18 @@ const ACTION_BY_STATUS: Record = { }, }; +type ActionBooking = Pick< + Freight.IBooking, + "id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled" +>; + +/** Initiated instance still carrying no cargo/price (clearance-first flow). */ +function isBareInstance(booking: ActionBooking): boolean { + return ( + Boolean(booking.contractId) && !(Number(booking.totalAmount ?? 0) > 0) + ); +} + /** * Resolve the customer's next clearance/operation action for a booking, or * `null` when there's nothing for them to do at this stage. Pure + cheap so it @@ -47,8 +62,27 @@ const ACTION_BY_STATUS: Record = { * "under review" state when nothing is actually queried. */ export function getBookingNextAction( - booking: Pick, + booking: ActionBooking, ): BookingNextAction | null { + if (booking.status === "CLEARANCE_READY" && isBareInstance(booking)) { + // Customs (Path B): GL completes the booking — the customer can only view + // the finished clearance in the modal. + if (booking.customsClearingEnabled) { + return { + kind: "SCHEDULE_OPERATION", + label: "View clearance", + title: "Clearance complete", + }; + } + // Non-customs (Path A): straight to the booking form — cargo + shipment + // day + window check, the same page a one-time booking uses. + return { + kind: "BOOK", + label: "Book", + title: "Book your shipment", + to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`, + }; + } return ACTION_BY_STATUS[booking.status as string] ?? null; } @@ -58,9 +92,7 @@ export function getBookingNextAction( * booking that needs documents updated and resubmitting. Used to decide whether * to render {@link BookingActionButton}. */ -export function bookingHasInlineAction( - booking: Pick, -): boolean { +export function bookingHasInlineAction(booking: ActionBooking): boolean { return ( booking.status === "CHANGES_REQUESTED" || getBookingNextAction(booking) !== null diff --git a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts index b7a1e8c60..ab25284ba 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/booking-window.ts @@ -27,21 +27,30 @@ export function hasOpenWindow(windows: MyBookingWindow[]): boolean { /** * The next upcoming (not-yet-open) window the customer should come back for — - * the one whose train dispatches soonest, so it lines up with the departure-date - * ordering of the cards. Returns `null` when nothing upcoming carries an opening - * time. (`windowOpensAt` is still required so the banner can name a come-back time.) + * the one that OPENS soonest from now. Two guards matter here: + * - only openings strictly in the future qualify. A train mid-cycle + * (doc-review/payment) still reports the window that already opened and + * closed; showing that past time as "next" told customers to come back for + * a window that was over. + * - ordered by opening time, not departure date — "next window" is the next + * moment booking opens, which may belong to a later-departing train. + * Returns `null` when nothing upcoming carries a future opening time. */ export function soonestUpcomingWindow( windows: MyBookingWindow[], ): MyBookingWindow | null { + const now = Date.now(); const upcoming = windows - .filter((w) => !w.isOpenNow && w.windowOpensAt) - .sort((a, b) => { - const da = a.departureDate ? new Date(a.departureDate).getTime() : Infinity; - const db = b.departureDate ? new Date(b.departureDate).getTime() : Infinity; - if (da !== db) return da - db; - return new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(); - }); + .filter( + (w) => + !w.isOpenNow && + w.windowOpensAt && + new Date(w.windowOpensAt).getTime() > now, + ) + .sort( + (a, b) => + new Date(a.windowOpensAt!).getTime() - new Date(b.windowOpensAt!).getTime(), + ); return upcoming[0] ?? null; } From 8e2b8c9b875025553c45a63517fa9e33319ae6b0 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sat, 11 Jul 2026 00:52:57 +0300 Subject: [PATCH 09/21] Admin rule settings, minor issues resolution --- .../src/modules/agents/agents.controller.ts | 3 +++ .../src/modules/bookings/bookings.controller.ts | 4 +++- .../modules/currencies/currencies.controller.ts | 6 +++--- .../excess-baggage/excess-baggage.controller.ts | 5 +++++ .../src/modules/fare-engine/currency.controller.ts | 5 ++++- .../src/modules/fleet/fleet.controller.ts | 13 +++++++++++++ .../src/modules/packages/packages.controller.ts | 7 ++++--- .../modules/passengers/passengers.controller.ts | 4 +++- .../src/modules/promos/promos.controller.ts | 5 +++-- .../src/modules/schedules/routes.controller.ts | 10 +++++++--- .../src/modules/schedules/schedules.controller.ts | 13 +++++++++---- .../seat-classes/seat-classes.controller.ts | 4 +++- .../src/modules/seat-classes/seat-classes.dto.ts | 5 +++++ .../src/modules/stations/stations.controller.ts | 5 +++-- .../src/modules/tickets/tickets.controller.ts | 4 +++- .../backoffice/src/app/tariff-rates/page.tsx | 1 + .../backoffice/src/components/layout/Sidebar.tsx | 14 +++++++------- 17 files changed, 79 insertions(+), 29 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts index 1f7fcd288..21f71a8c6 100644 --- a/apps/edr-passenger-api/src/modules/agents/agents.controller.ts +++ b/apps/edr-passenger-api/src/modules/agents/agents.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { AgentsService } from './agents.service'; import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Agents') @Controller('agents') @@ -36,6 +37,8 @@ export class AgentsController { } @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete agent profile' }) deleteAgent(@Param('id') id: string) { return this.service.deleteAgent(id); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts index 9a2053943..23a3b6c5a 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -7,6 +7,7 @@ import { GuestBookingService } from './guest-booking.service'; import { CreateBookingDto, ModifyBookingDto, CancelBookingDto } from './bookings.dto'; import { CreateGuestBookingDto, GetSavedPassengersDto } from './guest-booking.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Booking') @Controller('bookings') @@ -466,7 +467,8 @@ export class BookingsController { } @Delete(':id') - @SetMetadata('isPublic', true) + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ description: 'Permanently deletes a booking record' }) diff --git a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts index 093436aae..690134388 100644 --- a/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts +++ b/apps/edr-passenger-api/src/modules/currencies/currencies.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Post, Patch, Delete, Body, Param, HttpCode } from '@nestjs/common'; -import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { CurrenciesService } from './currencies.service'; import { CreateCurrencyDto, UpdateCurrencyDto } from './currencies.dto'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; @@ -31,7 +31,7 @@ export class CurrenciesController { } @Delete(':id') - @PassengerStaff(PASSENGER_PERMS.currencies.manage) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') deleteCurrency(@Param('id') id: string) { return this.currenciesService.deleteCurrency(id); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts index 551cc8881..842f13ca6 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.controller.ts @@ -8,6 +8,7 @@ import { InitiateExcessPaymentDto, } from './excess-baggage.dto'; import { JwtGuard as IamJwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; class UpsertBaggageAllowanceDto { @IsString() seatClassId: string; @@ -70,6 +71,8 @@ export class ExcessBaggageAgentController { } @Delete('allowances/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete baggage allowance rule' }) deleteAllowance(@Param('id') id: string) { return this.service.deleteAllowance(id); @@ -94,6 +97,8 @@ export class ExcessBaggageAgentController { } @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete excess baggage charge (admin only)' }) deleteCharge(@Param('id') id: string) { return this.service.deleteCharge(id); diff --git a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts index 1d35f6b61..0cd1eda71 100644 --- a/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts +++ b/apps/edr-passenger-api/src/modules/fare-engine/currency.controller.ts @@ -1,9 +1,10 @@ import { Body, Controller, Delete, Get, Param, Patch, Put, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiParam, ApiProperty, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; import { CurrencyService } from '../currency/currency.service'; import { UpsertExchangeRateDto } from './currency.dto'; import { IsNumber, IsPositive, IsOptional, IsString } from 'class-validator'; import { Type } from 'class-transformer'; +import { PassengerAdmin } from '../../common/passenger-guards'; class UpdateExchangeRateDto { @ApiProperty({ example: 3.5 }) @Type(() => Number) @IsNumber() @IsPositive() rate: number; @@ -38,6 +39,8 @@ export class CurrencyController { } @Delete(':id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete an exchange rate record by ID' }) @ApiParam({ name: 'id', description: 'CurrencyExchangeRate UUID' }) @ApiResponse({ status: 200, description: 'Rate deleted' }) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts index 7422e45b0..d3864e2b2 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiBody, ApiR import { FleetService } from './fleet.service'; import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Fleet') @Controller('fleet') @@ -38,6 +39,8 @@ export class FleetController { } @Delete('coach-types/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a coach type' }) @ApiParam({ name: 'id', description: 'Coach Type UUID' }) @ApiResponse({ status: 200, description: 'Coach type deleted' }) @@ -74,6 +77,8 @@ export class FleetController { } @Delete('classes/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a class' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -111,6 +116,8 @@ export class FleetController { } @Delete('seat-classes/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a class (DEPRECATED - use /fleet/classes)' }) @ApiParam({ name: 'id', description: 'Class UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -147,6 +154,8 @@ export class FleetController { } @Delete('trains/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a train service' }) @ApiParam({ name: 'id', description: 'Train UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -310,6 +319,8 @@ export class FleetController { } @Delete('coaches/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a coach' }) @ApiParam({ name: 'id', description: 'Coach UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -329,6 +340,8 @@ export class FleetController { } @Delete('assignments/:id') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'Assignment UUID' }) @ApiResponse({ status: 200, description: 'Assignment removed' }) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index 07e324336..314f8aeb4 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -6,6 +6,7 @@ import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDt import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Packages') @Controller('packages') @@ -41,7 +42,7 @@ export class PackagesController { } @Delete('inquiries/:id') - @UseGuards(IamGuard) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete inquiry (backoffice)' }) deleteInquiry(@Param('id') id: string) { @@ -139,7 +140,7 @@ export class PackagesController { } @Delete(':id') - @UseGuards(IamGuard) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete package (admin)' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete even with active bookings' }) @@ -180,7 +181,7 @@ export class PackagesController { } @Delete('tiers/:tierId') - @UseGuards(IamGuard) + @PassengerAdmin() @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete price tier (admin)' }) deleteTier(@Param('tierId') tierId: string) { diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts index f0b719386..cfa4a8805 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -4,6 +4,7 @@ import { SkipThrottle, Throttle } from '@nestjs/throttler'; import { PassengersService } from './passengers.service'; import { CreateTravelerProfileDto, CreateSavedRouteDto, VerifyFaydaDto, SavePassengersDto, RegisterPassengerDto } from './passengers.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; import { VerifaydaService } from '../verifayda/verifayda.service'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; import { PrismaService } from '../../common/prisma.service'; @@ -503,7 +504,8 @@ Returns saved passenger details with generated IDs and confirmation.`, } @Delete(':id') - @SetMetadata('isPublic', true) + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete passenger (admin only)', description: 'Permanently deletes a passenger record and associated data' diff --git a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts index 8bdf8f868..6a547116e 100644 --- a/apps/edr-passenger-api/src/modules/promos/promos.controller.ts +++ b/apps/edr-passenger-api/src/modules/promos/promos.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { PromosService } from './promos.service'; import { CreatePromotionDto } from './promos.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Promotions') @Controller('promos') @@ -64,8 +65,8 @@ export class PromosController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete promo (admin)' }) delete(@Param('id') id: string) { return this.service.delete(id); diff --git a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts index f432cf072..e751278ce 100644 --- a/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/routes.controller.ts @@ -3,6 +3,7 @@ import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } import { RoutesService } from './routes.service'; import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto, SetRouteCoachTemplateDto } from './routes.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Routes') @Controller('routes') @@ -48,7 +49,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`, updateRoute(@Param('id') id: string, @Body() dto: UpdateRouteDto) { return this.service.updateRoute(id, dto); } @Delete(':id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @@ -75,7 +77,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`, addStop(@Param('id') id: string, @Body() dto: AddRouteStopDto) { return this.service.addStop(id, dto); } @Delete(':id/stops/:sequence') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a stop from a route by sequence number' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiParam({ name: 'sequence', description: 'Stop sequence number to remove' }) @@ -119,7 +122,8 @@ Route stops carry distanceKm for fare-by-distance calculations.`, } @Delete(':id/coaches') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Clear the default coach lineup for this route' }) @ApiParam({ name: 'id', description: 'Route UUID' }) @ApiResponse({ status: 200, description: 'Template cleared' }) diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts index 3cb95104d..9fc2ba851 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.controller.ts @@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de import { SchedulesService } from './schedules.service'; import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Schedule') @Controller('schedules') @@ -56,7 +57,8 @@ export class SchedulesController { } @Delete('fares/:id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a fare rule' }) @ApiParam({ name: 'id', description: 'FareRule UUID' }) @ApiResponse({ status: 200, description: 'Fare rule deleted' }) @@ -80,7 +82,8 @@ export class SchedulesController { updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); } @Delete('segment-fares/:id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a segment fare rule' }) @ApiParam({ name: 'id', description: 'SegmentFareRule UUID' }) deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); } @@ -110,7 +113,8 @@ export class SchedulesController { } @Delete(':id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a schedule' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean }) @@ -203,7 +207,8 @@ export class SchedulesController { getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); } @Delete(':id/coaches/:coachId') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Remove a coach assignment' }) @ApiParam({ name: 'id', description: 'TrainSchedule UUID' }) @ApiParam({ name: 'coachId', description: 'Coach UUID' }) diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts index 86eb287a4..4eb6c212b 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.controller.ts @@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de import { SeatClassesService } from './seat-classes.service'; import { CreateSeatClassDto, UpdateSeatClassDto } from './seat-classes.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Seat Classes') @Controller('seat-classes') @@ -42,7 +43,8 @@ export class SeatClassesController { updateSeatClass(@Param('id') id: string, @Body() dto: UpdateSeatClassDto) { return this.service.updateSeatClass(id, dto); } @Delete(':id') - @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete a seat class' }) @ApiParam({ name: 'id', description: 'Seat class UUID' }) @ApiResponse({ status: 200, description: 'Seat class deleted' }) diff --git a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts index 1f061bf53..1ea743aa6 100644 --- a/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts +++ b/apps/edr-passenger-api/src/modules/seat-classes/seat-classes.dto.ts @@ -29,6 +29,11 @@ export class CreateSeatClassDto { @IsInt() basePrice: number; + @ApiPropertyOptional({ example: 1200, description: 'Flat insurance fee in minor units' }) + @IsOptional() + @IsInt() + insuranceFeeMinor?: number; + @ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() diff --git a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts index 2b1842c2a..0f367043b 100644 --- a/apps/edr-passenger-api/src/modules/stations/stations.controller.ts +++ b/apps/edr-passenger-api/src/modules/stations/stations.controller.ts @@ -4,6 +4,7 @@ import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.de import { StationsService } from './stations.service'; import { CreateStationDto } from './stations.dto'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Stations') @Controller('stations') @@ -133,8 +134,8 @@ export class StationsController { } @Delete(':id') - @UseGuards(JwtGuard) - @ApiBearerAuth('JWT-auth') + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Delete station' }) @ApiQuery({ name: 'cascade', required: false, type: Boolean, description: 'Force delete with all related data' }) @ApiResponse({ status: 200, description: 'Station deleted successfully' }) diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts index 8d85012dd..7f49d77b1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards, Delete, Patch, Se import { ApiTags, ApiOperation, ApiBearerAuth, ApiBody, ApiQuery } from '@nestjs/swagger'; import { TicketsService } from './tickets.service'; import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin } from '../../common/passenger-guards'; @ApiTags('Tickets') @Controller('tickets') @@ -9,7 +10,8 @@ export class TicketsController { constructor(private service: TicketsService) {} @Post('generate/:bookingId') - @SetMetadata('isPublic', true) + @PassengerAdmin() + @ApiBearerAuth('IAM-auth') @ApiOperation({ summary: 'Generate ticket for booking (confirmation page)', description: 'Creates a ticket when confirmation page is reached and permanently holds all associated seats with SeatBlock records.' diff --git a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx index 30d736e37..590c30848 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tariff-rates/page.tsx @@ -406,6 +406,7 @@ export default function TariffRatesPage() { name="insuranceFeeMinor" className="input" defaultValue={editingClass ? (editingClass.insuranceFeeMinor / 100).toFixed(2) : '0.00'} + key={editingClass?.id ?? 'new-insurance'} min="0" step="0.01" placeholder="e.g. 25.00" diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 39fd91ca5..982094cf3 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -90,21 +90,21 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Financial', items: [ - { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, + // { name: 'Pricing & Fares', href: '/pricing', icon: DollarSign, permission: PERMS.admin }, { name: 'Tariff Rates', href: '/tariff-rates', icon: Banknote, permission: PERMS.admin }, - { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin }, + // { name: 'Fare Rules', href: '/fare-management', icon: Settings, permission: PERMS.admin }, { name: 'Payments', href: '/payments', icon: CreditCard, permission: PERMS.payments.view }, { name: 'Currencies', href: '/currencies', icon: Banknote, permission: PERMS.currencies.manage }, - { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, + // { name: 'Promo Codes', href: '/promos', icon: Gift, permission: PERMS.admin }, { name: 'Payment Methods', href: '/payment-methods', icon: CreditCard, permission: PERMS.payments.view }, - { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view }, + // { name: 'Wallet Accounts', href: '/wallet-accounts', icon: Wallet, permission: PERMS.payments.view }, ] }, { title: 'Customer Services', items: [ - { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, - { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, + // { name: 'Loyalty Program', href: '/loyalty', icon: Gift, permission: PERMS.passengers.view }, + // { name: 'Support Center', href: '/support', icon: MessageSquare, permission: PERMS.bookings.view }, { name: 'Notifications', href: '/notifications', icon: Bell, permission: PERMS.notifications.send }, ] }, @@ -126,7 +126,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'System', items: [ - { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, + // { name: 'Agents', href: '/agents', icon: Briefcase, permission: PERMS.agents.view }, { name: 'Users', href: '/settings/users', icon: Users, permission: PERMS.admin }, { name: 'Settings', href: '/settings', icon: Settings, permission: PERMS.admin }, { name: 'Health', href: '/health', icon: Activity, permission: PERMS.admin }, From 2df0b4c3f131d529b42fde0c442fd487cffcb492 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Sat, 11 Jul 2026 01:09:40 +0300 Subject: [PATCH 10/21] Fix oassenger information back button redirect url issue --- .../portal/src/app/booking/passengers/page.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx index 34cf1fe5d..64f19fdb4 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/passengers/page.tsx @@ -679,7 +679,7 @@ type FormData = z.infer>; function PassengersForm() { const router = useRouter(); - const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount } = useBookingStore(); + const { searchCriteria, passengers: storedPassengers, setPassengers, setCreateAccount, packageId } = useBookingStore(); const { user, isAuthenticated, updateUser } = useAuthStore(); const isInitialized = useAuthStore((s) => s.isInitialized); const [faydaEnabled, setFaydaEnabled] = useState(true); @@ -1474,6 +1474,15 @@ function PassengersForm() {
- + + + + @@ -804,10 +952,10 @@ function ArticleEditorModal({ )} {parsed.clauses.map((clause, i) => ( - + - {i + 1}.{" "} + {clause.number}.{" "} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts index 5e051cce9..eaec70ed3 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/hooks.ts @@ -3,6 +3,7 @@ import { Freight } from "@edr/types"; import useAuth from "@/hooks/useAuth"; import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket"; import { api } from "@/services/api"; +import { isBookingLive } from "@/pages/bookings/BookingDetailPage/utils"; import { ACTIVE_STATUSES } from "./constants"; export function useMyPortalData(selectedProfileId?: string) { @@ -24,6 +25,17 @@ export function useMyPortalData(selectedProfileId?: string) { sortOrder: "DESC", companyProfileId: selectedProfileId, }, + // The home tiles read each booking's status directly, but staff/system + // transitions (operations accepting an order, batch selection, clearance + // review) never push here. Poll while any booking is still live so those + // changes surface — e.g. an accepted order leaving "Operation request + // under review" — and stop once everything has settled. + refetchInterval: (query) => { + const items = + (query.state.data as { items?: Freight.IBooking[] } | undefined) + ?.items ?? []; + return items.some((b) => isBookingLive(b.status)) ? 30_000 : false; + }, }), ); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx index ba8ca3db7..16d412703 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/StatusHero.tsx @@ -3,7 +3,15 @@ import { Check, MoveRight } from "lucide-react"; import type { Freight } from "@edr/types"; -import { ARRIVAL_STAGE, PROGRESS_STAGES, STATUS_MAP, resolveStage } from "../constants"; +import { + ARRIVAL_STAGE, + CONTRACT_ARRIVAL_STAGE, + CONTRACT_PROGRESS_STAGES, + PROGRESS_STAGES, + STATUS_MAP, + resolveContractStage, + resolveStage, +} from "../constants"; import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils"; import { SectionCard } from "./layout"; @@ -65,7 +73,19 @@ export function StatusHero({ children?: React.ReactNode; }) { const status = booking.status; - const stage = resolveStage(booking); + // Contract-drawdown bookings (initiated under a contract) follow a dedicated + // wizard — initiated → submitted → accepted → payment → … — instead of the + // direct booking's Request/Approval/Contract stages. + const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status); + const stages = isContractDrawdown + ? CONTRACT_PROGRESS_STAGES + : PROGRESS_STAGES; + const arrivalStage = isContractDrawdown + ? CONTRACT_ARRIVAL_STAGE + : ARRIVAL_STAGE; + const stage = isContractDrawdown + ? resolveContractStage(booking) + : resolveStage(booking); // Contract-drawdown instance in the clearance gate: it was INITIATED with one // click (no cargo/date yet), not submitted through the wizard. const isInitiatedInstance = @@ -75,7 +95,7 @@ export function StatusHero({ // headline is overridden here. Bookings with a per-booking journey carry the // ARRIVED status themselves and use its own STATUS_MAP copy. const cfg = - stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE + stage === arrivalStage && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE ? { title: "Train arrived at destination", description: @@ -89,7 +109,14 @@ export function StatusHero({ "Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.", stage, } - : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); + : isContractDrawdown && status === "FULLY_EXECUTED" + ? { + title: "Accepted by operations", + description: + "Operations accepted your order. Complete payment once your train is selected to secure the slot.", + stage, + } + : (STATUS_MAP[status] ?? STATUS_MAP.DRAFT); const negative = isNegative(status); const draft = isDraftLike(status); @@ -132,13 +159,9 @@ export function StatusHero({ {children ?? ( )} @@ -147,16 +170,16 @@ export function StatusHero({ function ProgressTracker({ current, + stages = PROGRESS_STAGES, tone = "green", - labelOverrides, }: { current: number; + /** Which stage set to render — direct or contract-drawdown. */ + stages?: typeof PROGRESS_STAGES; tone?: "green" | "ink"; negative?: boolean; - /** Per-stage-index label replacements (e.g. "Submitted" → "Initiated"). */ - labelOverrides?: Record; }) { - const last = PROGRESS_STAGES.length - 1; + const last = stages.length - 1; const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371"; const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4"; @@ -172,7 +195,7 @@ function ProgressTracker({ } >
- {PROGRESS_STAGES.map((stage, idx) => { + {stages.map((stage, idx) => { const state = idx < current ? "done" : idx === current ? "active" : "idle"; const Icon = stage.icon; @@ -246,7 +269,7 @@ function ProgressTracker({ ta="center" c={state === "idle" ? "#9AA8B5" : "#10202F"} > - {labelOverrides?.[idx] ?? stage.label} + {stage.label}
); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts index e4fac9ca7..ae3d20e4e 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/constants.ts @@ -95,6 +95,125 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex( (s) => s.label === "Arrival", ); +/** + * Progress stages for a CONTRACT-DRAWDOWN booking (created under a contract via + * initiate → clearance → book). These bookings never pass through the direct + * wizard's Request/Approval/Contract stages — the contract is already executed. + * Their journey is: initiated (bare instance in clearance) → submitted (booking + * completed, sent to operations) → accepted (operations accepted) → payment → + * loading → transit → arrival → unloading → complete. + */ +export const CONTRACT_PROGRESS_STAGES = [ + { + // The instance was initiated with one click and is going through per-booking + // clearance (upload → review → ready). One-time drawdowns without clearance + // start here too until they are booked. + label: "Initiated", + icon: FileText, + statuses: [ + "DRAFT", + "CHANGES_REQUESTED", + "AWAITING_DOCUMENTS", + "DOCUMENTS_UNDER_REVIEW", + "CLEARANCE_READY", + ], + }, + { + // The customer (or GL) completed the booking — cargo + shipment day — and it + // is submitted to operations for acceptance. + label: "Submitted", + icon: ClipboardCheck, + statuses: [ + "OPERATION_REQUEST_PENDING", + "OPERATION_CHANGES_REQUESTED", + "OPERATION_PRICE_PENDING_CONFIRM", + "OPERATION_REQUESTED", + ], + }, + { + // Operations accepted the order — it now sits in the batch holding pool + // awaiting a train and its pay window. + label: "Accepted", + icon: ShieldCheck, + statuses: ["FULLY_EXECUTED", "READY_FOR_ASSIGNMENT"], + }, + { + label: "Payment", + icon: ShieldCheck, + statuses: [ + "SELECTED_FOR_BATCH", + "PAYMENT_VERIFICATION_IN_PROGRESS", + "EXPIRED", + "PRICE_CHANGED_PENDING_CONFIRM", + ], + }, + { + label: "Loading", + icon: Ship, + statuses: [ + "PAID", + "PNR_GENERATED", + "PENDING_CONSOLIDATION", + "CONSOLIDATED", + "WAGON_ASSIGNED", + "INVOICED", + "ROAD_DISPATCH_PENDING", + ], + }, + { + label: "In Transit", + icon: Train, + statuses: ["IN_TRANSIT"], + }, + { + // Lights up from the assigned train's ARRIVED state while the booking is + // still IN_TRANSIT — no status of its own (see resolveContractStage). + label: "Arrival", + icon: MapPin, + statuses: [], + }, + { + label: "Unloading", + icon: PackageOpen, + statuses: ["ARRIVED"], + }, + { + label: "Complete", + icon: PackageCheck, + statuses: ["COMPLETED", "DELIVERED"], + }, +]; + +/** Contract-drawdown Arrival stage index. */ +export const CONTRACT_ARRIVAL_STAGE = CONTRACT_PROGRESS_STAGES.findIndex( + (s) => s.label === "Arrival", +); + +/** status → contract-drawdown stage index, derived from the stage array. */ +const CONTRACT_STAGE_BY_STATUS: Record = {}; +CONTRACT_PROGRESS_STAGES.forEach((stage, index) => { + stage.statuses.forEach((status) => { + CONTRACT_STAGE_BY_STATUS[status] = index; + }); +}); + +/** + * Contract-drawdown stage for a booking, factoring in the assigned train's + * status the same way {@link resolveStage} does for direct bookings. + */ +export function resolveContractStage(booking: { + status: string; + trainScheduleStatus?: string | null; +}): number { + if ( + booking.status === "IN_TRANSIT" && + booking.trainScheduleStatus === "ARRIVED" + ) { + return CONTRACT_ARRIVAL_STAGE; + } + return CONTRACT_STAGE_BY_STATUS[booking.status] ?? 0; +} + /** * Stage for a booking, factoring in the assigned train's operational status: * a booking with per-booking journey data reaches ARRIVED (the Unloading diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx index a06a89ca0..9cb059503 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/index.tsx @@ -2,6 +2,7 @@ import { Box, Center, Loader, Stack, Text } from "@mantine/core"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { AlertTriangle } from "lucide-react"; import { useParams } from "react-router-dom"; +import type { Freight } from "@edr/types"; import { api } from "@/services/api"; @@ -9,7 +10,7 @@ import { ChangesRequestedView } from "./ChangesRequestedView"; import { DraftBookingView } from "./DraftBookingView"; import { PageShell, SectionCard } from "./components/layout"; import { ReadonlyBookingView } from "./ReadonlyBookingView"; -import { isDraftLike } from "./utils"; +import { isBookingLive, isDraftLike } from "./utils"; export default function BookingDetailPage() { const { id } = useParams<{ id: string }>(); @@ -21,7 +22,22 @@ export default function BookingDetailPage() { isError, error, } = useQuery( - api.bookings.get.queryOptions({ input: { id: id! }, enabled: !!id }), + api.bookings.get.queryOptions({ + input: { id: id! }, + enabled: !!id, + // Staff/system transitions (operations accepting an order, batch + // selection, clearance review, transit) happen without any customer + // action and can't push to this open page. Poll while the booking is + // still live so those changes surface — e.g. an accepted operation + // request leaving the "under review" state — and stop once it settles. + refetchInterval: (query) => + isBookingLive( + (query.state.data as Freight.IBooking | undefined)?.status, + ) + ? 20_000 + : false, + refetchOnWindowFocus: true, + }), ); const refetchBooking = () => { @@ -94,5 +110,7 @@ export default function BookingDetailPage() { ); } - return ; + return ( + + ); } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts index 031a2d38c..432dcf8f5 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/utils.ts @@ -6,6 +6,27 @@ export const isNegative = (s: string) => s === "CANCELLED" || s === "REJECTED"; export const isDraftLike = (s: string) => s === "DRAFT" || s === "CHANGES_REQUESTED"; +/** + * Terminal booking statuses — nothing changes server-side once a booking lands + * here, so the customer view has no reason to keep polling. + */ +const SETTLED_STATUSES = new Set([ + "COMPLETED", + "DELIVERED", + "CANCELLED", + "REJECTED", +]); + +/** + * True while a booking can still change from a staff/system action the customer + * did not trigger (operations accepting an order, batch selection, clearance + * review, transit progress). Used to poll the customer-facing booking queries so + * those transitions surface without a manual reload — e.g. an accepted operation + * request flipping out of "under review". + */ +export const isBookingLive = (s?: string | null) => + !!s && !SETTLED_STATUSES.has(s); + export function fmtDate(value?: string | null) { if (!value) return "—"; const d = new Date(value); diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index 6ffa29b8c..4a57c120f 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -254,12 +254,6 @@ export const contractFormSchema = z path: ["cargoTypePath"], message: "Select a commodity.", }); - } else if (!data.cargoFreeText?.trim()) { - ctx.addIssue({ - code: "custom", - path: ["cargoFreeText"], - message: "Describe the cargo.", - }); } } // GENERAL contracts are uncapped: no quantity cap is collected, so the diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 51b9bcc93..26b455258 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -10,7 +10,6 @@ import { Stack, Switch, Text, - TextInput, } from "@mantine/core"; import type { Freight } from "@edr/types"; import { @@ -202,22 +201,6 @@ export function Step3CargoScope({ /> )} - {parentId && commodityOptions.length > 0 && ( - ( - - )} - /> - )} )} From dd87c2a7223b2656328248c9494176ac142ac52f Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 23:29:28 +0000 Subject: [PATCH 13/21] wagon work space, container validation --- .../contracts/contract-booking.service.ts | 144 ++++++ .../wagons/dto/bulk-set-wagon-status.dto.ts | 12 + .../wagons/dto/bulk-transfer-wagons.dto.ts | 11 + .../src/modules/wagons/wagons.controller.ts | 18 + .../src/modules/wagons/wagons.service.ts | 100 +++- .../wagons/WagonYardWorkspaceModal.tsx | 441 ++++++++++++++++++ .../bookings/DocumentClearanceDetailPage.tsx | 4 + .../ContractTemplateEditorPage.tsx | 27 +- .../pages/contracts/GlClearanceDetailPage.tsx | 57 ++- .../src/pages/fleet/FleetResourcePage.tsx | 81 ++-- .../backoffice/src/services/api.ts | 24 + .../backoffice/src/services/wagon.service.ts | 6 + 12 files changed, 878 insertions(+), 47 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts create mode 100644 apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index a0da43784..c09f2ccb0 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Inject, Injectable, @@ -192,6 +193,11 @@ export class ContractBookingService { // their only chance to hard-block an unbalanceable set. Entry order is // irrelevant (the check sorts by weight before pairing). await this.assert20ftPairableAtCreate(dto); + // A container number may appear once per train (same day + route). + await this.assertContainerNumbersAvailable(dto, { + originYardId: route?.originYardId ?? null, + destinationYardId: route?.destinationYardId ?? null, + }); } // Denormalize route/direction/freight onto the booking for the scheduling engine. @@ -575,6 +581,15 @@ export class ContractBookingService { if (freightType === 'CONTAINER') { await this.assertWithinMaxCapacity(contract, dto); await this.assert20ftPairableAtCreate(dto); + // A container number may appear once per train (same day + route). + await this.assertContainerNumbersAvailable( + dto, + { + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + }, + booking.id, + ); await this.persistContainers(booking.id, contract, dto); } await this.bookingsRepository.update(booking.id, { @@ -653,6 +668,10 @@ export class ContractBookingService { Boolean(contract.customsClearingEnabled); await this.finalizeContractBooking(booking.id, contract, generalCustoms); await this.maybeCompleteContract(contract); + } else if (freightType === 'CONTAINER') { + // Resubmit only re-picks the shipment day — the persisted container + // numbers must be free on the newly chosen train day too. + await this.assertPersistedContainersAvailable(booking, dto.scheduledDate); } // Binding day + open-departure validation, status OPERATION_REQUEST_PENDING @@ -1344,6 +1363,131 @@ export class ContractBookingService { * balanced onto wagons (pair diff over the global cap). Same rule the * shipment-form preview reports as `pairingErrors`, enforced server-side. */ + /** + * A physical container rides one train only. Reject the submission when a + * container number is entered twice in the same booking (the portal checks + * this client-side, the API must not trust it) or already sits on another + * customer's active booking for the same train — same shipment day AND same + * route (origin/destination yards). + */ + private async assertContainerNumbersAvailable( + dto: CreateBookingUnderContractDto, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise { + const numbers = (dto.containers ?? []).flatMap((line) => + (line.units ?? []) + .map((u) => (u.containerNumber ?? '').trim().toUpperCase()) + .filter((n) => n.length > 0), + ); + if (!numbers.length) return; + + const seen = new Set(); + const withinBooking = new Set(); + for (const n of numbers) { + if (seen.has(n)) withinBooking.add(n); + seen.add(n); + } + if (withinBooking.size) { + throw new BadRequestException( + `Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`, + ); + } + + // Intercity bookings have no shipment day yet — nothing to clash with. + if (!dto.scheduledDate) return; + + await this.assertNumbersFreeOnTrain( + numbers, + dto.scheduledDate, + route, + excludeBookingId, + ); + } + + /** + * Same train guard for a booking whose containers are already persisted + * (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its + * stored numbers must be free on the newly chosen day for its route. + */ + private async assertPersistedContainersAvailable( + booking: Booking, + scheduledDate: string, + ): Promise { + const rows: Array<{ containerNumber: string }> = await this.dataSource + .getRepository(BookingContainerUnit) + .createQueryBuilder('unit') + .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') + .select('unit.container_number', 'containerNumber') + .where('line.booking_id = :bookingId', { bookingId: booking.id }) + .getRawMany(); + const numbers = rows.map((r) => r.containerNumber).filter(Boolean); + if (!numbers.length) return; + await this.assertNumbersFreeOnTrain( + numbers, + scheduledDate, + { + originYardId: booking.originYardId, + destinationYardId: booking.destinationYardId, + }, + booking.id, + ); + } + + /** + * Reject when any of `numbers` sits on another active booking of the same + * train — same day and same route. Bookings without route yards (legacy + * rows) are matched on the day alone rather than let through. + */ + private async assertNumbersFreeOnTrain( + numbers: string[], + scheduledDate: string, + route: { originYardId?: string | null; destinationYardId?: string | null }, + excludeBookingId?: string, + ): Promise { + const qb = this.dataSource + .getRepository(BookingContainerUnit) + .createQueryBuilder('unit') + .innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id') + .innerJoin(Booking, 'b', 'b.id = line.booking_id') + .select('unit.container_number', 'containerNumber') + .addSelect('b.reference', 'reference') + .where('unit.container_number IN (:...numbers)', { numbers }) + .andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate }) + .andWhere('b.status NOT IN (:...terminal)', { + terminal: TERMINAL_BOOKING_STATUSES, + }) + .andWhere('b.deleted_at IS NULL'); + if (route.originYardId && route.destinationYardId) { + // Same train = same day + same corridor. A clashing booking whose yards + // were never denormalized still blocks (NULL yards match any route). + qb.andWhere( + '(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)', + { originYardId: route.originYardId }, + ).andWhere( + '(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)', + { destinationYardId: route.destinationYardId }, + ); + } + if (excludeBookingId) { + qb.andWhere('b.id != :excludeBookingId', { excludeBookingId }); + } + const clashes: Array<{ containerNumber: string; reference: string }> = + await qb.getRawMany(); + + if (clashes.length) { + const detail = [ + ...new Map(clashes.map((c) => [c.containerNumber, c])).values(), + ] + .map((c) => `${c.containerNumber} (booking ${c.reference})`) + .join(', '); + throw new ConflictException( + `Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` + + 'A container can only be on one booking per train — remove it or pick another shipment day.', + ); + } + } + private async assert20ftPairableAtCreate( dto: CreateBookingUnderContractDto, ): Promise { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts new file mode 100644 index 000000000..6f28418aa --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-set-wagon-status.dto.ts @@ -0,0 +1,12 @@ +import { WagonStatus } from '@edr/types'; +import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator'; + +export class BulkSetWagonStatusDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; + + @IsEnum(WagonStatus) + status!: WagonStatus; +} diff --git a/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts new file mode 100644 index 000000000..bf1f7f783 --- /dev/null +++ b/apps/edr-freight-api/src/modules/wagons/dto/bulk-transfer-wagons.dto.ts @@ -0,0 +1,11 @@ +import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator'; + +export class BulkTransferWagonsDto { + @IsArray() + @ArrayNotEmpty() + @IsUUID('4', { each: true }) + wagonIds!: string[]; + + @IsUUID() + toYardId!: string; +} diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index 1d5287dba..556907cde 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -10,12 +10,16 @@ import { Query, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '@edr/api-common'; +import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; +import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { WagonsService } from './wagons.service'; @ApiTags('wagons') @@ -78,6 +82,20 @@ export class WagonsController { unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.unassignFromTrain(id); } + + @Post('bulk-transfer') + @FleetManage() + @ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' }) + bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) { + return this.wagonsService.bulkTransfer(dto, user?.id); + } + + @Post('bulk-status') + @FleetManage() + @ApiOperation({ summary: 'Set the status of multiple wagons' }) + bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) { + return this.wagonsService.bulkSetStatus(dto); + } } // Separate controller for train‑specific reorder (registered in module) diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index b010c0351..ad39fbf7a 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -1,15 +1,18 @@ import { WagonMovementKind, WagonStatus } from '@edr/types'; import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm'; +import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto'; import { ReorderWagonsDto } from './dto/reorder-wagons.dto'; +import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto'; +import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto'; import { Wagon } from './entities/wagon.entity'; import { WagonMovement } from './entities/wagon-movement.entity'; import { Train } from '../trains/entities/train.entity'; +import { Yard } from '../rule-engine/entities/yard.entity'; @Injectable() export class WagonsService { @@ -168,6 +171,101 @@ export class WagonsService { return this.wagonRepo.save(wagon); } + /** + * Relocate many wagons to one destination yard in a single transaction. Each + * wagon whose yard actually changes gets a `wagon_movements` ledger row (kind + * `Manual`) so the yard history stays auditable — mirrors the single-wagon + * `update` path. Wagons already in the destination yard are skipped. + */ + async bulkTransfer( + dto: BulkTransferWagonsDto, + userId?: string | null, + ): Promise<{ moved: number }> { + const { wagonIds, toYardId } = dto; + if (!wagonIds.length) return { moved: 0 }; + + const yard = await this.dataSource + .getRepository(Yard) + .findOne({ where: { id: toYardId } }); + if (!yard) throw new NotFoundException('Destination yard not found'); + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const wagons = await queryRunner.manager.find(Wagon, { + where: { id: In(wagonIds) }, + }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more wagons not found'); + } + + let moved = 0; + for (const wagon of wagons) { + const previousYardId = wagon.currentYardId ?? null; + if (previousYardId === toYardId) continue; + wagon.currentYardId = toYardId; + // Drop the eager relation so the scalar FK wins on save (see `update`). + wagon.currentYard = null; + await queryRunner.manager.save(Wagon, wagon); + await queryRunner.manager.save( + queryRunner.manager.create(WagonMovement, { + wagonId: wagon.id, + fromYardId: previousYardId, + toYardId, + kind: WagonMovementKind.Manual, + movedByUserId: userId ?? null, + occurredAt: new Date(), + }), + ); + moved++; + } + + await queryRunner.commitTransaction(); + return { moved }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + + /** + * Set the same status on many wagons in one transaction (e.g. flip a batch + * from Available to Assigned in the yard workspace). Only the `status` column + * is touched — train assignment is managed through the assign/unassign flow. + */ + async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> { + const { wagonIds, status } = dto; + if (!wagonIds.length) return { updated: 0 }; + + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const wagons = await queryRunner.manager.find(Wagon, { + where: { id: In(wagonIds) }, + }); + if (wagons.length !== wagonIds.length) { + throw new NotFoundException('One or more wagons not found'); + } + + for (const wagon of wagons) { + wagon.status = status; + } + await queryRunner.manager.save(Wagon, wagons); + + await queryRunner.commitTransaction(); + return { updated: wagons.length }; + } catch (err) { + await queryRunner.rollbackTransaction(); + throw err; + } finally { + await queryRunner.release(); + } + } + async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx new file mode 100644 index 000000000..ef9621376 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -0,0 +1,441 @@ +import { Freight } from "@edr/types"; +import { + Badge, + Box, + Button, + Card, + Divider, + Grid, + Group, + Loader, + Modal, + NumberInput, + Select, + SimpleGrid, + Stack, + Text, + ThemeIcon, + Title, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; +import type { Wagon } from "@/services/wagon.service"; + +export interface WagonYardWorkspaceModalProps { + opened: boolean; + onClose: () => void; +} + +const numberOrZero = (v: number | string): number => { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; +}; + +/** + * Yard workspace: pick a yard + wagon type (the two selects filter each other + * to only in-inventory combinations), see how many wagons of that type sit in + * that yard and how they split Available / Assigned, then bulk-transfer a + * quantity to another yard or flip a quantity between Available and Assigned. + */ +const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => { + const { toast } = useToast(); + + const { data: wagons = [], isLoading: wagonsLoading } = useQuery( + api.wagons.list.queryOptions({ input: {} }), + ); + const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); + const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); + + const [yardId, setYardId] = useState(null); + const [typeId, setTypeId] = useState(null); + + const [transferYardId, setTransferYardId] = useState(null); + const [transferQty, setTransferQty] = useState(1); + const [toAssignedQty, setToAssignedQty] = useState(1); + const [toAvailableQty, setToAvailableQty] = useState(1); + + const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions()); + const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); + + const yardLabel = useMemo(() => { + const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); + return (id: string) => byId.get(id) ?? id; + }, [yards]); + + const typeLabel = useMemo(() => { + const byId = new Map( + wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]), + ); + return (id: string) => byId.get(id) ?? id; + }, [wagonTypes]); + + // Only wagons that currently sit in a yard participate in the workspace. + const yardWagons = useMemo( + () => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)), + [wagons], + ); + + // Each select is constrained by the other's current value so only real + // (yard, type) combinations that hold stock can be picked. + const yardOptions = useMemo(() => { + const ids = new Set(); + for (const w of yardWagons) { + if (typeId && w.wagonTypeId !== typeId) continue; + ids.add(w.currentYardId); + } + return [...ids] + .map((id) => ({ value: id, label: yardLabel(id) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [yardWagons, typeId, yardLabel]); + + const typeOptions = useMemo(() => { + const ids = new Set(); + for (const w of yardWagons) { + if (yardId && w.currentYardId !== yardId) continue; + ids.add(w.wagonTypeId); + } + return [...ids] + .map((id) => ({ value: id, label: typeLabel(id) })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [yardWagons, yardId, typeLabel]); + + const matching = useMemo(() => { + if (!yardId || !typeId) return [] as Wagon[]; + return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId); + }, [yardWagons, yardId, typeId]); + + const availableWagons = useMemo( + () => matching.filter((w) => w.status === Freight.WagonStatus.Available), + [matching], + ); + const assignedWagons = useMemo( + () => matching.filter((w) => w.status === Freight.WagonStatus.Assigned), + [matching], + ); + // Available first so a partial transfer moves idle wagons before assigned ones. + const transferPool = useMemo(() => { + const rest = matching.filter( + (w) => + w.status !== Freight.WagonStatus.Available && + w.status !== Freight.WagonStatus.Assigned, + ); + return [...availableWagons, ...assignedWagons, ...rest]; + }, [matching, availableWagons, assignedWagons]); + + const total = matching.length; + const availableCount = availableWagons.length; + const assignedCount = assignedWagons.length; + + const destinationYardOptions = useMemo( + () => + yards + .filter((y) => y.id !== yardId) + .map((y) => ({ value: y.id, label: y.label || y.code || y.id })) + .sort((a, b) => a.label.localeCompare(b.label)), + [yards, yardId], + ); + + const bothSelected = Boolean(yardId && typeId); + + // Reset the action inputs whenever the yard/type selection changes. + useEffect(() => { + setTransferYardId(null); + setTransferQty(1); + setToAssignedQty(1); + setToAvailableQty(1); + }, [yardId, typeId]); + + // Reset the whole workspace when it is reopened. + useEffect(() => { + if (!opened) { + setYardId(null); + setTypeId(null); + } + }, [opened]); + + const showError = (err: unknown, fallback: string) => { + const message = + (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback; + toast({ title: fallback, description: String(message), variant: "destructive" }); + }; + + const handleTransfer = async () => { + const n = numberOrZero(transferQty); + if (!transferYardId || n < 1) return; + const ids = transferPool.slice(0, n).map((w) => w.id); + if (!ids.length) return; + try { + const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId }); + toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` }); + setTransferQty(1); + setTransferYardId(null); + } catch (err) { + showError(err, "Transfer failed"); + } + }; + + const handleFlip = async ( + pool: Wagon[], + qty: number | string, + status: Freight.WagonStatus, + label: string, + reset: () => void, + ) => { + const n = numberOrZero(qty); + if (n < 1) return; + const ids = pool.slice(0, n).map((w) => w.id); + if (!ids.length) return; + try { + const res = await setStatus.mutateAsync({ wagonIds: ids, status }); + toast({ title: `${res.updated} wagon(s) set to ${label}` }); + reset(); + } catch (err) { + showError(err, "Status update failed"); + } + }; + + const busy = transfer.isPending || setStatus.isPending; + + return ( + + + + +
+ Wagon Yard Workspace + + Move and re-status wagons by yard and type + +
+ + } + > + + {/* ---- Selectors ---- */} + + + + + + + {wagonsLoading ? ( + + + + ) : !bothSelected ? ( + + + Select a yard and a wagon type to see how many wagons are there and act on them. + + + ) : ( + <> + {/* ---- Counts ---- */} + + + + + + + + + + {/* ---- Transfer ---- */} + + + + + + + Transfer to another yard + + + + { + setListFilterValues((prev) => ({ + ...prev, + [filter.key]: value ?? "ALL", + })); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + size="sm" + radius="lg" + w={200} + searchable={filter.data.length > 8} + comboboxProps={{ withinPortal: true }} + styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }} + /> ))} ) : hasStatusColumn && statusFilterOptions.length > 1 ? ( @@ -586,6 +598,13 @@ const FleetResourcePage = () => {
+ {slug === "wagons" ? ( + setWagonWorkspaceOpen(false)} + /> + ) : null} + {slug === "wagons" ? ( [["wagons"]], ), + + bulkTransfer: endpoint< + { wagonIds: string[]; toYardId: string }, + { moved: number } + >( + "wagons", + "bulkTransfer", + ({ wagonIds, toYardId }) => + wagonService.bulkTransfer(wagonIds, toYardId).then((r) => r.data), + undefined, + () => [["wagons"]], + ), + + bulkSetStatus: endpoint< + { wagonIds: string[]; status: Wagon["status"] }, + { updated: number } + >( + "wagons", + "bulkSetStatus", + ({ wagonIds, status }) => + wagonService.bulkSetStatus(wagonIds, status).then((r) => r.data), + undefined, + () => [["wagons"]], + ), }, trains: { diff --git a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts index e30320988..e096a3126 100644 --- a/apps/edr-freight-web/backoffice/src/services/wagon.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/wagon.service.ts @@ -83,4 +83,10 @@ export const wagonService = { create: (data: Partial) => apiClient.post('/wagons', data), update: (id: string, data: Partial) => apiClient.patch(`/wagons/${id}`, data), delete: (id: string) => apiClient.delete(`/wagons/${id}`), + /** Relocate many wagons to one yard in a single call (writes movement ledger). */ + bulkTransfer: (wagonIds: string[], toYardId: string) => + apiClient.post<{ moved: number }>('/wagons/bulk-transfer', { wagonIds, toYardId }), + /** Set the same status on many wagons in a single call. */ + bulkSetStatus: (wagonIds: string[], status: Freight.WagonStatus) => + apiClient.post<{ updated: number }>('/wagons/bulk-status', { wagonIds, status }), }; From 3f03581d8c9f88be03b677fd27454333f4eda8f2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 10 Jul 2026 23:50:35 +0000 Subject: [PATCH 14/21] wagon work space, container validation --- .../wagons/WagonYardWorkspaceModal.tsx | 546 +++++++++++------- .../src/features/clearance/requestedCargo.tsx | 89 +++ .../bookings/DocumentClearanceDetailPage.tsx | 38 +- .../contracts/ContractClearanceListPage.tsx | 39 +- 4 files changed, 497 insertions(+), 215 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/features/clearance/requestedCargo.tsx diff --git a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx index ef9621376..67506caed 100644 --- a/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/wagons/WagonYardWorkspaceModal.tsx @@ -10,15 +10,16 @@ import { Loader, Modal, NumberInput, + Progress, Select, - SimpleGrid, + Slider, Stack, + Switch, Text, ThemeIcon, - Title, } from "@mantine/core"; import { useMutation, useQuery } from "@tanstack/react-query"; -import { ArrowRightLeft, PackageCheck, Repeat, Warehouse } from "lucide-react"; +import { ArrowRight, ArrowRightLeft, CheckCircle2, CircleSlash, Layers, Warehouse } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { api } from "@/services/api"; @@ -30,23 +31,93 @@ export interface WagonYardWorkspaceModalProps { onClose: () => void; } -const numberOrZero = (v: number | string): number => { +const AVAILABLE = Freight.WagonStatus.Available; +const ASSIGNED = Freight.WagonStatus.Assigned; + +const clampInt = (v: number | string, max: number): number => { const n = typeof v === "number" ? v : Number(v); - return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0; + if (!Number.isFinite(n) || n < 0) return 0; + return Math.min(Math.floor(n), max); }; +/** NumberInput + Slider + All/Half presets, kept in sync and bounded to `max`. */ +const QuantityField = ({ + value, + onChange, + max, + disabled, +}: { + value: number; + onChange: (n: number) => void; + max: number; + disabled?: boolean; +}) => { + const set = (v: number | string) => onChange(clampInt(v, max)); + const off = disabled || max === 0; + return ( + + + + `${v}`} + color="edr-green" + /> + + + + + {value > 0 ? ( + + ) : null} + + + ); +}; + +const LegendDot = ({ color, label, value }: { color: string; label: string; value: number }) => ( + + + + {label} + + + {value} + + +); + /** - * Yard workspace: pick a yard + wagon type (the two selects filter each other - * to only in-inventory combinations), see how many wagons of that type sit in - * that yard and how they split Available / Assigned, then bulk-transfer a - * quantity to another yard or flip a quantity between Available and Assigned. + * Bulk yard operations. Pick a yard + wagon type (the two selects filter each + * other to combinations that actually hold stock), read the live Available / + * Assigned split, then move a quantity to another yard or flip a quantity + * between Available and Assigned — replacing one-wagon-at-a-time edits. */ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalProps) => { const { toast } = useToast(); - const { data: wagons = [], isLoading: wagonsLoading } = useQuery( - api.wagons.list.queryOptions({ input: {} }), - ); + const { data: wagons = [], isLoading } = useQuery(api.wagons.list.queryOptions({ input: {} })); const { data: yards = [] } = useQuery(api.routes.yards.queryOptions()); const { data: wagonTypes = [] } = useQuery(api.wagonTypes.list.queryOptions()); @@ -54,33 +125,35 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const [typeId, setTypeId] = useState(null); const [transferYardId, setTransferYardId] = useState(null); - const [transferQty, setTransferQty] = useState(1); - const [toAssignedQty, setToAssignedQty] = useState(1); - const [toAvailableQty, setToAvailableQty] = useState(1); + const [transferQty, setTransferQty] = useState(0); + const [freeAfterMove, setFreeAfterMove] = useState(false); + const [toAssignedQty, setToAssignedQty] = useState(0); + const [toAvailableQty, setToAvailableQty] = useState(0); const transfer = useMutation(api.wagons.bulkTransfer.mutationOptions()); const setStatus = useMutation(api.wagons.bulkSetStatus.mutationOptions()); - const yardLabel = useMemo(() => { + const yardName = useMemo(() => { const byId = new Map(yards.map((y) => [y.id, y.label || y.code || y.id])); return (id: string) => byId.get(id) ?? id; }, [yards]); - const typeLabel = useMemo(() => { - const byId = new Map( - wagonTypes.map((t) => [t.id, `${t.code}${t.name ? ` - ${t.name}` : ""}`]), - ); - return (id: string) => byId.get(id) ?? id; + const typeInfo = useMemo(() => { + const byId = new Map(wagonTypes.map((t) => [t.id, t])); + return { + label: (id: string) => { + const t = byId.get(id); + return t ? `${t.code}${t.name ? ` - ${t.name}` : ""}` : id; + }, + code: (id: string) => byId.get(id)?.code ?? id, + }; }, [wagonTypes]); - // Only wagons that currently sit in a yard participate in the workspace. const yardWagons = useMemo( () => wagons.filter((w): w is Wagon & { currentYardId: string } => Boolean(w.currentYardId)), [wagons], ); - // Each select is constrained by the other's current value so only real - // (yard, type) combinations that hold stock can be picked. const yardOptions = useMemo(() => { const ids = new Set(); for (const w of yardWagons) { @@ -88,9 +161,9 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro ids.add(w.currentYardId); } return [...ids] - .map((id) => ({ value: id, label: yardLabel(id) })) + .map((id) => ({ value: id, label: yardName(id) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [yardWagons, typeId, yardLabel]); + }, [yardWagons, typeId, yardName]); const typeOptions = useMemo(() => { const ids = new Set(); @@ -99,36 +172,32 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro ids.add(w.wagonTypeId); } return [...ids] - .map((id) => ({ value: id, label: typeLabel(id) })) + .map((id) => ({ value: id, label: typeInfo.label(id) })) .sort((a, b) => a.label.localeCompare(b.label)); - }, [yardWagons, yardId, typeLabel]); + }, [yardWagons, yardId, typeInfo]); const matching = useMemo(() => { if (!yardId || !typeId) return [] as Wagon[]; return yardWagons.filter((w) => w.currentYardId === yardId && w.wagonTypeId === typeId); }, [yardWagons, yardId, typeId]); - const availableWagons = useMemo( - () => matching.filter((w) => w.status === Freight.WagonStatus.Available), + const availableWagons = useMemo(() => matching.filter((w) => w.status === AVAILABLE), [matching]); + const assignedWagons = useMemo(() => matching.filter((w) => w.status === ASSIGNED), [matching]); + const otherWagons = useMemo( + () => matching.filter((w) => w.status !== AVAILABLE && w.status !== ASSIGNED), [matching], ); - const assignedWagons = useMemo( - () => matching.filter((w) => w.status === Freight.WagonStatus.Assigned), - [matching], + // Available first, then assigned, then the rest — a partial move relocates + // idle wagons before touching assigned ones. + const transferPool = useMemo( + () => [...availableWagons, ...assignedWagons, ...otherWagons], + [availableWagons, assignedWagons, otherWagons], ); - // Available first so a partial transfer moves idle wagons before assigned ones. - const transferPool = useMemo(() => { - const rest = matching.filter( - (w) => - w.status !== Freight.WagonStatus.Available && - w.status !== Freight.WagonStatus.Assigned, - ); - return [...availableWagons, ...assignedWagons, ...rest]; - }, [matching, availableWagons, assignedWagons]); const total = matching.length; const availableCount = availableWagons.length; const assignedCount = assignedWagons.length; + const otherCount = otherWagons.length; const destinationYardOptions = useMemo( () => @@ -141,15 +210,16 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const bothSelected = Boolean(yardId && typeId); - // Reset the action inputs whenever the yard/type selection changes. + // Reset action inputs when the selection changes. useEffect(() => { setTransferYardId(null); - setTransferQty(1); - setToAssignedQty(1); - setToAvailableQty(1); + setTransferQty(0); + setFreeAfterMove(false); + setToAssignedQty(0); + setToAvailableQty(0); }, [yardId, typeId]); - // Reset the whole workspace when it is reopened. + // Reset the whole workspace when closed. useEffect(() => { if (!opened) { setYardId(null); @@ -157,6 +227,11 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro } }, [opened]); + // Keep quantities within bounds as counts shift after each action. + useEffect(() => setTransferQty((q) => Math.min(q, total)), [total]); + useEffect(() => setToAssignedQty((q) => Math.min(q, availableCount)), [availableCount]); + useEffect(() => setToAvailableQty((q) => Math.min(q, assignedCount)), [assignedCount]); + const showError = (err: unknown, fallback: string) => { const message = (err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? fallback; @@ -164,15 +239,22 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro }; const handleTransfer = async () => { - const n = numberOrZero(transferQty); - if (!transferYardId || n < 1) return; - const ids = transferPool.slice(0, n).map((w) => w.id); + if (!transferYardId || transferQty < 1) return; + const ids = transferPool.slice(0, transferQty).map((w) => w.id); if (!ids.length) return; try { const res = await transfer.mutateAsync({ wagonIds: ids, toYardId: transferYardId }); - toast({ title: `Transferred ${res.moved} wagon(s) to ${yardLabel(transferYardId)}` }); - setTransferQty(1); + if (freeAfterMove) { + await setStatus.mutateAsync({ wagonIds: ids, status: AVAILABLE }); + } + toast({ + title: `Moved ${res.moved} wagon(s) to ${yardName(transferYardId)}${ + freeAfterMove ? " · set Available" : "" + }`, + }); + setTransferQty(0); setTransferYardId(null); + setFreeAfterMove(false); } catch (err) { showError(err, "Transfer failed"); } @@ -180,14 +262,13 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro const handleFlip = async ( pool: Wagon[], - qty: number | string, + qty: number, status: Freight.WagonStatus, label: string, reset: () => void, ) => { - const n = numberOrZero(qty); - if (n < 1) return; - const ids = pool.slice(0, n).map((w) => w.id); + if (qty < 1) return; + const ids = pool.slice(0, qty).map((w) => w.id); if (!ids.length) return; try { const res = await setStatus.mutateAsync({ wagonIds: ids, status }); @@ -199,100 +280,141 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro }; const busy = transfer.isPending || setStatus.isPending; + const pct = (n: number) => (total > 0 ? (n / total) * 100 : 0); return (
- Wagon Yard Workspace + Wagon Yard Operations - Move and re-status wagons by yard and type + Move and re-status wagons in bulk — no one-by-one edits
} > - {/* ---- Selectors ---- */} - - - - - + {/* ---- Selection ---- */} + + + + } + nothingFoundMessage="No wagon types here" + radius="md" + /> + + + - {wagonsLoading ? ( + {isLoading ? ( ) : !bothSelected ? ( - - - Select a yard and a wagon type to see how many wagons are there and act on them. - + + + + + + Pick a yard and a wagon type + + You'll see how many wagons of that type sit in that yard, how many are available + vs assigned, and can move or re-status them all at once. + + ) : ( <> - {/* ---- Counts ---- */} - - - - - + {/* ---- Overview hero ---- */} + + + +
+ + {total} + +
+
+ + {typeInfo.code(typeId!)} wagons + + + + {yardName(yardId!)} + +
+
+ + + + {otherCount > 0 ? : null} + +
- + + + {availableCount > 0 ? {availableCount} : null} + + + {assignedCount > 0 ? {assignedCount} : null} + + + {otherCount > 0 ? {otherCount} : null} + + +
- - {/* ---- Transfer ---- */} + {/* ---- Actions ---- */} + + {/* Transfer */} - - + + - Transfer to another yard + Move to another yard - - + +
+ + How many wagons + + +