From 70589229878bc888430459f69fb6fc107b280c41 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 06:19:35 +0000 Subject: [PATCH 01/11] changes --- .../src/modules/billing/billing.service.ts | 15 +++++++++++++++ .../src/modules/payment/payment.service.ts | 5 ++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 536129122..71398896f 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -953,6 +953,21 @@ export class BillingService { .getRepository(Invoice) .update({ id: invoice.id }, { paymentId: result.intentId }); + // DEMO: manually fire the gateway `payment.succeeded` callback here, without + // waiting for real gateway settlement. Runs AFTER the paymentId link above so + // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO: + // remove — real settlement flips this via the `${source}.invoice.paid` handler. + if (!result.immediateSuccess) { + await this.payment.handlePaymentEvent({ + eventType: "payment.succeeded", + eventId: `demo-${result.intentId}`, + referenceId: invoice.sourceId, + intentId: result.intentId, + providerTxnId: result.providerTxnId, + paidAt: (result.paidAt ?? new Date()).toISOString(), + }); + } + if (result.immediateSuccess) { await this.settleByPaymentId( result.intentId, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index d773ebe1f..5b8a3ddca 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -479,7 +479,7 @@ export class PaymentService { alreadyFinalized?: boolean; reason?: string; }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); + this.logger.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, @@ -490,13 +490,12 @@ export class PaymentService { reason: `No local intent for reference ${event.referenceId}`, }; } - console.log(`Processing payment succeeded event for intent: }`, intent); const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); - console.log( + this.logger.log( `Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`, ); From 18f47481d70a3ece7fc6dbc40f57506e350c6b60 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 06:31:23 +0000 Subject: [PATCH 02/11] Handover customer sign --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 2 + .../seed-paid-import-export-mile-demo.ts | 28 ++ .../paid-import-export-mile-demo.seeder.ts | 299 ++++++++++++++++++ .../portal/src/constants/URLS.ts | 1 + .../components/WarehousePaymentsSection.tsx | 82 ++++- .../services/warehouse-invoices.service.ts | 28 ++ 7 files changed, 434 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts create mode 100644 apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index a0a119861..ef0b211d5 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -22,6 +22,7 @@ "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts", "seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts", + "seed:paid-import-export-mile-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-paid-import-export-mile-demo.ts", "seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts", "seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts", "auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index f20184e59..b44a942ea 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -63,6 +63,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; +import { PaidImportExportMileDemoSeeder } from "./seed/paid-import-export-mile-demo.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from "./modules/wagons/wagons.module"; @@ -165,6 +166,7 @@ import { LoggerMiddleware } from "./logger.middleware"; ExportDjiboutiInterchangeDemoSeeder, MarshallingDemoTrainsSeeder, ApprovedFirstLastMileDemoBookingsSeeder, + PaidImportExportMileDemoSeeder, ], }) export class AppModule implements OnApplicationBootstrap { diff --git a/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts new file mode 100644 index 000000000..6b2a422e8 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-paid-import-export-mile-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { PaidImportExportMileDemoSeeder } from '../seed/paid-import-export-mile-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(PaidImportExportMileDemoSeeder); + await seeder.run(); + console.log('Paid import/export mile demo bookings seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Paid import/export mile demo booking seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts new file mode 100644 index 000000000..708afb86b --- /dev/null +++ b/apps/edr-freight-api/src/seed/paid-import-export-mile-demo.seeder.ts @@ -0,0 +1,299 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; + +import { BookingContainer } from '../modules/bookings/entities/booking-container.entity'; +import { Booking } from '../modules/bookings/entities/booking.entity'; +import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity'; +import { FirstMile } from '../modules/first-mile/entities/first-mile.entity'; +import { LastMile } from '../modules/last-mile/entities/last-mile.entity'; +import { ContainerType } from '../modules/rule-engine/entities/container-type.entity'; +import { ServiceType } from '../modules/rule-engine/entities/service-type.entity'; +import { Yard } from '../modules/rule-engine/entities/yard.entity'; + +const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE'; +const COMPANY_TIN = 'PAIDMILE001'; +const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; + +const YARDS = [ + { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, + { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, +]; + +const CONTAINER_TYPES = [ + { code: '20FT', label: '20FT', sizeFt: 20 }, + { code: '40FT', label: '40FT', sizeFt: 40 }, +]; + +/** + * Six paid, approved container bookings that mirror the real trucking legs: + * - EXPORT (Ethiopia -> Djibouti) carries a FIRST-MILE leg (factory -> rail terminal). + * - IMPORT (Djibouti -> Ethiopia) carries a LAST-MILE leg (dry port -> final delivery). + * Each booking is paymentStatus PAID and its single mile leg is marked paid + ready to transit. + */ +const DEMO_BOOKINGS = [ + // ── IMPORT: last mile only ───────────────────────────────────────────── + { + reference: 'PAID-IMP-001', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 8, + totalWeightTons: 224, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-01T08:00:00.000Z', + lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa', + lastMileDeliveryLat: 8.8808, + lastMileDeliveryLng: 38.7876, + }, + { + reference: 'PAID-IMP-002', + tradeDirection: 'IMPORT', + containerCode: '20FT', + quantity: 12, + totalWeightTons: 240, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-02T08:00:00.000Z', + lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa', + lastMileDeliveryLat: 8.9137, + lastMileDeliveryLng: 38.7815, + }, + { + reference: 'PAID-IMP-003', + tradeDirection: 'IMPORT', + containerCode: '40FT', + quantity: 6, + totalWeightTons: 180, + originCode: 'DJIBOUTI', + destinationCode: 'ADDIS_ABABA', + scheduledDate: '2026-07-03T08:00:00.000Z', + lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa', + lastMileDeliveryLat: 8.9806, + lastMileDeliveryLng: 38.8736, + }, + // ── EXPORT: first mile only ──────────────────────────────────────────── + { + reference: 'PAID-EXP-001', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 7, + totalWeightTons: 196, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-01T10:00:00.000Z', + firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa', + firstMilePickupLat: 8.9806, + firstMilePickupLng: 38.8736, + }, + { + reference: 'PAID-EXP-002', + tradeDirection: 'EXPORT', + containerCode: '20FT', + quantity: 11, + totalWeightTons: 220, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-02T10:00:00.000Z', + firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa', + firstMilePickupLat: 8.8808, + firstMilePickupLng: 38.7876, + }, + { + reference: 'PAID-EXP-003', + tradeDirection: 'EXPORT', + containerCode: '40FT', + quantity: 4, + totalWeightTons: 128, + originCode: 'ADDIS_ABABA', + destinationCode: 'DJIBOUTI', + scheduledDate: '2026-07-03T10:00:00.000Z', + firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa', + firstMilePickupLat: 8.9137, + firstMilePickupLng: 38.7815, + }, +] as const; + +@Injectable() +export class PaidImportExportMileDemoSeeder { + private readonly logger = new Logger(PaidImportExportMileDemoSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await manager.getRepository(Yard).upsert( + YARDS.map((yard) => ({ ...yard, isActive: true })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ServiceType).upsert( + { + code: SERVICE_TYPE_CODE, + serviceName: 'Rail Container with Paid First/Last Mile', + description: 'Demo service type for paid import/export bookings with a single mile leg', + canBeBookedAlone: true, + includesFirstMile: true, + includesLastMile: true, + includesCustoms: false, + priorityBonusPoints: 0, + isActive: true, + displayOrder: 11, + }, + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(ContainerType).upsert( + CONTAINER_TYPES.map((containerType, index) => ({ + ...containerType, + wagonsPerUnit: 1, + isReefer: false, + isOpenTop: false, + isActive: true, + displayOrder: index + 1, + })), + { conflictPaths: { code: true } }, + ); + + await manager.getRepository(Company).upsert( + { + name: 'Paid Import/Export Mile Demo Customer', + type: CompanyType.Customer, + status: CompanyStatus.Active, + tin: COMPANY_TIN, + vatNumber: COMPANY_TIN, + fanNumber: 'PMD0000000000001', + country: 'Ethiopia', + address: 'Addis Ababa', + phone: '251900000202', + email: COMPANY_EMAIL, + website: null, + contactPersonName: 'Paid Mile Demo', + contactPersonPhone: '251900000202', + generalManagerName: 'Demo Manager', + generalManagerEmail: COMPANY_EMAIL, + generalManagerPhone: '251900000202', + }, + { conflictPaths: { tin: true } }, + ); + + const [serviceType, company, yards, containerTypes] = await Promise.all([ + manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }), + manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }), + manager.getRepository(Yard).find(), + manager.getRepository(ContainerType).find(), + ]); + + const yardByCode = new Map(yards.map((yard) => [yard.code, yard])); + const containerTypeByCode = new Map( + containerTypes.map((containerType) => [containerType.code, containerType]), + ); + + for (const demoBooking of DEMO_BOOKINGS) { + const origin = yardByCode.get(demoBooking.originCode); + const destination = yardByCode.get(demoBooking.destinationCode); + const containerType = containerTypeByCode.get(demoBooking.containerCode); + + if (!origin || !destination || !containerType) { + throw new Error(`paid_import_export_mile_demo_dependency_missing:${demoBooking.reference}`); + } + + const isImport = demoBooking.tradeDirection === 'IMPORT'; + const wagonsRequired = + Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1); + const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity; + + await manager.getRepository(Booking).upsert( + { + reference: demoBooking.reference, + companyId: company.id, + status: 'APPROVED', + scheduledDate: new Date(demoBooking.scheduledDate), + estimatedShipmentDate: new Date(demoBooking.scheduledDate), + totalAmount: demoBooking.totalWeightTons * 25, + paymentStatus: 'PAID', + contractType: 'NEW', + serviceTypeId: serviceType.id, + // Only the leg that matches the trade direction carries an address. + firstMilePickupAddress: isImport ? null : demoBooking.firstMilePickupAddress, + firstMilePickupLat: isImport ? null : demoBooking.firstMilePickupLat, + firstMilePickupLng: isImport ? null : demoBooking.firstMilePickupLng, + lastMileDeliveryAddress: isImport ? demoBooking.lastMileDeliveryAddress : null, + lastMileDeliveryLat: isImport ? demoBooking.lastMileDeliveryLat : null, + lastMileDeliveryLng: isImport ? demoBooking.lastMileDeliveryLng : null, + equipmentReturn: 'WITHOUT_RETURN', + originYardId: origin.id, + destinationYardId: destination.id, + tradeDirection: demoBooking.tradeDirection, + freightType: 'CONTAINER', + cargoTypeId: null, + cargoFreeText: 'Demo container cargo', + shippingLineId: null, + cargoTotalWeightVgm: demoBooking.totalWeightTons, + isHazardous: false, + isReefer: false, + paymentCurrency: 'ETB', + approvedByStaffAt: new Date(), + priorityScore: 20, + wagonsRequired, + schedulingStatus: 'NOT_SCHEDULED', + versionNumber: 1, + }, + { conflictPaths: { reference: true } }, + ); + + const booking = await manager.getRepository(Booking).findOneByOrFail({ + reference: demoBooking.reference, + }); + + await manager.getRepository(BookingContainer).delete({ bookingId: booking.id }); + await manager.getRepository(BookingContainer).insert({ + id: randomUUID(), + bookingId: booking.id, + containerTypeId: containerType.id, + quantity: demoBooking.quantity, + vgmPerUnitTons, + totalVgmTons: demoBooking.totalWeightTons, + wagonsRequired, + weightLimitRuleId: null, + isOverweight: vgmPerUnitTons > 35, + overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null, + }); + + // Reset any existing legs for idempotency, then create the single paid leg. + await manager.getRepository(FirstMile).delete({ bookingId: booking.id }); + await manager.getRepository(LastMile).delete({ bookingId: booking.id }); + + const paidAmount = demoBooking.totalWeightTons * 25; + + if (isImport) { + await manager.getRepository(LastMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 22, + exactKm: null, + vehicleId: null, + }); + } else { + await manager.getRepository(FirstMile).insert({ + bookingId: booking.id, + status: 'READY_TO_TRANSIT', + advancedPayment: paidAmount, + remainingPayment: 0, + paid: true, + estimatedKm: 18, + exactKm: null, + vehicleId: null, + }); + } + } + }); + + this.logger.log( + 'Seeded 6 paid bookings: 3 import (last-mile) + 3 export (first-mile).', + ); + } +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index ce2c35c1a..4e20106a6 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -170,5 +170,6 @@ export const URL_CONSTANTS = { BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`, DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`, RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`, + PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`, }, }; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx index 146e31c1e..acd4dbb25 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/WarehousePaymentsSection.tsx @@ -1,16 +1,24 @@ -import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; -import { Download, Receipt } from "lucide-react"; +import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { CreditCard, Download, Receipt } from "lucide-react"; +import { useState } from "react"; import toast from "react-hot-toast"; +import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { warehouseInvoicesService, type PortalWarehouseInvoice, } from "@/services/warehouse-invoices.service"; import { saveBlob } from "@/utils/download"; +import { PaymentMethodModal } from "./PaymentMethodModal"; import { CardTitle, SectionCard } from "./layout"; +/** Warehouse fee invoices the customer can still settle online. */ +const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]); +const isPayable = (inv: PortalWarehouseInvoice) => + PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0; + const money = (amount: number | string | null | undefined, currency: string) => `${Number(amount ?? 0).toLocaleString()} ${currency}`; @@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) { } /** - * Warehouse fee invoices linked to this booking — display + PDF download only. - * Paying them online is tracked separately (in-system demurrage/storage - * payment). Renders nothing when the booking has no warehouse fees. Carries - * `id="warehouse-payments"` so the invoice detail page can deep-link here. + * Warehouse fee invoices linked to this booking. Customers can pay outstanding + * demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the + * delivery handover; paid invoices expose the receipt PDF. The backoffice cash + * `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking + * has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail + * page can deep-link here. */ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { const { data: invoices = [] } = useQuery({ @@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { queryFn: () => warehouseInvoicesService.listForBooking(bookingId), }); + const [payInvoice, setPayInvoice] = useState(null); + + const payMutation = useMutation({ + mutationFn: (method: PaymentMethod) => { + if (!payInvoice) throw new Error("No invoice selected for payment."); + return warehouseInvoicesService.payOnline(payInvoice.id, { + method, + platform: "web", + }); + }, + onSuccess: (data, method) => { + if (!payInvoice) return; + // Redirect to the provider (or the fallback checkout page) — same as the + // booking "Pay now" flow, so behaviour is identical everywhere. + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method }); + window.location.href = redirectUrl; + }, + }); + + const payError = payMutation.isError + ? payMutation.error instanceof Error + ? payMutation.error.message + : "Could not start payment. Please try again." + : null; + + const closePayModal = () => { + if (!payMutation.isPending) { + setPayInvoice(null); + payMutation.reset(); + } + }; + if (invoices.length === 0) return null; const download = async (inv: PortalWarehouseInvoice) => { @@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) { + {isPayable(inv) && ( + + )} + + payMutation.mutate(method)} + processing={payMutation.isPending} + error={payError} + /> ); } diff --git a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts index f1fa4e841..91cd6a0d2 100644 --- a/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts +++ b/apps/edr-freight-web/portal/src/services/warehouse-invoices.service.ts @@ -1,5 +1,10 @@ import { URL_CONSTANTS } from "@/constants/URLS"; import { client } from "../utils/api"; +import type { + InitiateResponse, + PaymentMethod, + PaymentPlatform, +} from "./payments.service"; const W = URL_CONSTANTS.WAREHOUSE_INVOICES; @@ -51,4 +56,27 @@ export const warehouseInvoicesService = { const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" }); return data; }, + + /** + * Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage + * invoice. Returns the payment intent + `clientAction` to redirect the browser + * to the provider (mirrors the booking `/pay` flow). The backoffice cash + * `/pay` (record-a-payment) path is unaffected. + */ + payOnline: async ( + id: string, + payload: { + method: PaymentMethod; + platform?: PaymentPlatform; + payerAccount?: string; + returnUrl?: string; + failureUrl?: string; + }, + ): Promise => { + const { data } = await client.post(W.PAY_ONLINE(id), { + platform: "web", + ...payload, + }); + return data.data ?? data; + }, }; From 1c625cfb827aaf4d6894c50cc54a45935691e0a8 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 4 Jul 2026 10:10:26 +0300 Subject: [PATCH 03/11] feat: ( iam ) OTP-gate registration via IAM signup + set-password --- .../src/modules/auth/auth.controller.ts | 20 +- .../src/modules/auth/auth.dto.ts | 17 +- .../modules/auth/passenger-auth.service.ts | 101 +++++++- .../modules/bookings/guest-booking.service.ts | 5 +- .../portal/src/app/register/page.tsx | 60 ++--- .../portal/src/app/verify-account/page.tsx | 216 ++++++++++++++++++ .../portal/src/lib/api/auth.ts | 4 + .../portal/src/lib/auth-store.ts | 31 +-- 8 files changed, 370 insertions(+), 84 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/app/verify-account/page.tsx diff --git a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts index d53ef40d7..a6f596bf6 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -3,7 +3,7 @@ import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiBearerAuth } from '@nes import { Throttle, SkipThrottle } from '@nestjs/throttler'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PassengerAuthService } from './passenger-auth.service'; -import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; +import { RegisterDto, LoginDto, ResendRegistrationCodeDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; @ApiTags('Passenger Auth') @@ -14,14 +14,28 @@ export class AuthController { @Post('register') @IsPublic() - @ApiOperation({ summary: 'Register new passenger account' }) - @ApiResponse({ status: 201, description: 'Account created. Returns token + user.' }) + @ApiOperation({ summary: 'Register new passenger account (sends SMS verification code)' }) + @ApiResponse({ + status: 201, + description: + 'Account created as pending. A verification code is sent via SMS — complete signup via PATCH /v1/auth/set-password.', + }) @ApiResponse({ status: 409, description: 'Email or phone already registered' }) @ApiBody({ type: RegisterDto }) register(@Request() req: any, @Body() dto: RegisterDto) { return this.passengerAuthService.register(dto, req); } + @Post('register/resend-code') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Resend the registration verification code for a pending account' }) + @ApiResponse({ status: 200, description: 'Verification code re-sent if the account is pending.' }) + @ApiBody({ type: ResendRegistrationCodeDto }) + resendRegistrationCode(@Request() req: any, @Body() dto: ResendRegistrationCodeDto) { + return this.passengerAuthService.resendRegistrationCode(dto, req); + } + @Post('login') @IsPublic() @HttpCode(HttpStatus.OK) diff --git a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts index 6f43e7212..d0a691b0f 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.dto.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.dto.ts @@ -1,6 +1,6 @@ -import { IsEmail, IsString, MinLength, ValidateNested } from 'class-validator'; +import { IsEmail, IsString, ValidateNested } from 'class-validator'; import { Type } from 'class-transformer'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; export class NameDto { @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) @@ -29,15 +29,16 @@ export class RegisterDto { @ValidateNested() @Type(() => NameDto) name: NameDto; +} - @ApiProperty({ example: 'SecurePass123', minLength: 8, format: 'password' }) - @IsString() - @MinLength(8) - password: string; +export class ResendRegistrationCodeDto { + @ApiProperty({ example: 'kelemu@email.com' }) + @IsEmail() + email: string; - @ApiProperty({ example: 'SecurePass123', format: 'password' }) + @ApiProperty({ example: '+251912345678' }) @IsString() - confirmPassword: string; + phoneNumber: string; } export class LoginDto { diff --git a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts index 0213bb8f6..1784261e5 100644 --- a/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts +++ b/apps/edr-passenger-api/src/modules/auth/passenger-auth.service.ts @@ -50,14 +50,17 @@ export class PassengerAuthService { const iamAuthService = await this.resolveIamAuthService(req); - const { token, refreshToken } = await iamAuthService.signupWithPassword({ + // IAM `signup` creates the user as PENDING/isActive=false with NO credential and + // SMS-sends a 6-digit verification code. The account cannot log in until the code is + // redeemed via PATCH /v1/auth/set-password. We intentionally discard the session + // token `signup` returns — the account is not verified yet, so it must never reach + // the client. + await iamAuthService.signup({ email: dto.email, username: dto.username, phoneNumber: dto.phoneNumber, userType: EUserType.INDIVIDUAL, name: dto.name, - password: dto.password, - confirmPassword: dto.confirmPassword, }); const iamRows = await this.dataSource.query( @@ -70,20 +73,98 @@ export class PassengerAuthService { } const iamUserId = iamRows[0].id; - let passengerId: string; + // The Prisma "passenger satellite" (Passenger + wallet + loyalty) is NOT provisioned + // here — `login()` lazy-provisions it on first successful login, so satellites exist + // only for verified users who complete set-password and sign in. + return { + iamUserId, + email: dto.email, + phoneNumber: dto.phoneNumber, + requiresPasswordSetup: true, + }; + } + + /** + * Immediate-activation account creation used by the payment-gated guest-checkout + * "create account" path only. Unlike the public `register()` (OTP-gated), this creates a + * ready-to-use account from the password entered at checkout and provisions the passenger + * satellite synchronously so the booking can attach to it. Do NOT wire this to the public + * registration form — that flow must stay behind SMS verification. + */ + async registerWithPassword( + dto: { + email: string; + username: string; + phoneNumber: string; + name: { en: string; am: string }; + password: string; + }, + req: any, + ): Promise<{ iamUserId: string; passengerId: string }> { + const existing = await this.dataSource.query<{ id: string }[]>( + `SELECT id FROM iam.users WHERE email = $1 OR phone_number = $2 LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (existing.length) throw new ConflictException('Email or phone already registered'); + + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.signupWithPassword({ + email: dto.email, + username: dto.username, + phoneNumber: dto.phoneNumber, + userType: EUserType.INDIVIDUAL, + name: dto.name, + password: dto.password, + confirmPassword: dto.password, + }); + + const iamRows = await this.dataSource.query( + `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE email = $1 LIMIT 1`, + [dto.email], + ); + if (!iamRows.length) { + await this.compensateIamSignup(dto.email); + throw new InternalServerErrorException('Account creation failed. Please try again.'); + } + const iamUserId = iamRows[0].id; + try { const result = await this.provisionPassengerSatellite({ iamUserId, auditAction: 'USER_REGISTERED' }); - passengerId = result.passengerId; + return { iamUserId, passengerId: result.passengerId }; } catch { await this.compensateIamSignup(dto.email); throw new InternalServerErrorException('Account creation failed. Please try again.'); } + } - return { - token, - refreshToken, - user: { id: iamUserId, iamUserId, email: dto.email, fullName: dto.name.en, passengerId }, - }; + async resendRegistrationCode( + dto: { email: string; phoneNumber: string }, + req: any, + ): Promise<{ sent: boolean }> { + // Only regenerate for accounts still pending password setup. A fully-registered user + // should use forgot-password instead. Always return { sent: true } to avoid leaking + // whether the email/phone maps to a pending account (enumeration guard). + const users = await this.dataSource.query<{ email: string; phone_number: string }[]>( + `SELECT email, phone_number FROM iam.users + WHERE email = $1 AND phone_number = $2 AND has_set_password = false LIMIT 1`, + [dto.email, dto.phoneNumber], + ); + if (!users.length) return { sent: true }; + + const iamAuthService = await this.resolveIamAuthService(req); + try { + await iamAuthService.generateVerificationCode({ + email: users[0].email, + phoneNumber: users[0].phone_number, + type: EOtpType.VERIFY_PHONE_NUMBER, + }); + } catch (err) { + this.logger.error( + `[PassengerAuthService] resend registration code failed for ${dto.email}`, + (err as Error).message, + ); + } + return { sent: true }; } async login(dto: LoginDto, req: any) { diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 6907d14a3..d2c65db42 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -886,18 +886,17 @@ export class GuestBookingService { ): Promise<{ guestPassengerId: string; iamUserId: string | null; createdAccount: boolean }> { if (dto.createAccount && firstPassenger.email && dto.password) { const guestName = firstPassenger.passengerName ?? 'Guest'; - const result = await this.passengerAuthService.register( + const result = await this.passengerAuthService.registerWithPassword( { email: firstPassenger.email, username: firstPassenger.email, phoneNumber: firstPassenger.phone || `+251900000000`, name: { en: guestName, am: guestName }, password: dto.password, - confirmPassword: dto.password, }, req, ); - return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true }; + return { guestPassengerId: result.passengerId, iamUserId: result.iamUserId, createdAccount: true }; } // Create guest passenger with basic profile diff --git a/apps/edr-passenger-web/portal/src/app/register/page.tsx b/apps/edr-passenger-web/portal/src/app/register/page.tsx index c335d9e98..a39810be1 100644 --- a/apps/edr-passenger-web/portal/src/app/register/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/register/page.tsx @@ -9,18 +9,11 @@ import { useAuthStore } from '@/lib/auth-store'; import { useState } from 'react'; import { Train, ShieldCheck } from 'lucide-react'; -const registerSchema = z - .object({ - fullName: z.string().min(2, 'Full name is required'), - email: z.string().email('Invalid email address'), - phone: z.string().min(9, 'Phone number is required'), - password: z.string().min(8, 'Password must be at least 8 characters'), - confirmPassword: z.string(), - }) - .refine((data) => data.password === data.confirmPassword, { - message: 'Passwords do not match', - path: ['confirmPassword'], - }); +const registerSchema = z.object({ + fullName: z.string().min(2, 'Full name is required'), + email: z.string().email('Invalid email address'), + phone: z.string().min(9, 'Phone number is required'), +}); type RegisterForm = z.infer; @@ -38,14 +31,17 @@ export default function RegisterPage() { setLoading(true); setError(''); try { - await registerUser({ + const result = await registerUser({ fullName: data.fullName, email: data.email, phone: data.phone, - password: data.password, - confirmPassword: data.confirmPassword, }); - router.push('/booking/search'); + const params = new URLSearchParams({ + email: result.email, + userId: result.iamUserId, + phone: result.phoneNumber, + }); + router.push(`/verify-account?${params.toString()}`); } catch (err: any) { if (err.response?.status === 409) { setError('An account with this email or phone number already exists.'); @@ -67,7 +63,7 @@ export default function RegisterPage() {

Create account

-

Book faster and manage your trips

+

We'll text you a code to verify your phone

@@ -120,36 +116,8 @@ export default function RegisterPage() { )}
-
- - - {errors.password && ( -

{errors.password.message}

- )} -
- -
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
- diff --git a/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx new file mode 100644 index 000000000..f9f0b61c9 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/verify-account/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { Suspense, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import Link from 'next/link'; +import { Train, ArrowLeft, ShieldCheck } from 'lucide-react'; +import { iamAuthApi } from '@/lib/api/auth'; +import { useAuthStore } from '@/lib/auth-store'; + +// Mirrors the IAM set-password requirement (class-validator @IsStrongPassword defaults): +// min length 8, with lower- and upper-case letters, a number, and a symbol. +function isStrongPassword(pw: string): boolean { + return ( + pw.length >= 8 && + /[a-z]/.test(pw) && + /[A-Z]/.test(pw) && + /[0-9]/.test(pw) && + /[^A-Za-z0-9]/.test(pw) + ); +} + +function VerifyAccountContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const login = useAuthStore((s) => s.login); + + const email = searchParams.get('email') || ''; + const userId = searchParams.get('userId') || ''; + const phone = searchParams.get('phone') || ''; + const linkValid = Boolean(email && userId); + + const [verificationCode, setVerificationCode] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [resending, setResending] = useState(false); + const [resent, setResent] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + if (!verificationCode.trim()) { + setError('Enter the verification code sent to your phone.'); + return; + } + if (!isStrongPassword(newPassword)) { + setError('Password must be at least 8 characters and include upper- and lower-case letters, a number, and a symbol.'); + return; + } + if (newPassword !== confirmPassword) { + setError('Passwords do not match.'); + return; + } + setLoading(true); + try { + // Completes signup: PATCH /v1/auth/set-password with the SMS code, which activates + // the account and sets the password. + await iamAuthApi.resetPassword({ + userId, + email, + verificationCode: verificationCode.trim(), + newPassword, + confirmPassword, + }); + // Auto-login with the freshly-set password; login lazy-provisions the passenger record. + await login(email, newPassword); + router.push('/booking/search'); + } catch (err: any) { + const msg = err.response?.data?.message || err.message || ''; + setError(msg || 'Could not verify your account. Check the code and try again, or resend it.'); + setLoading(false); + } + }; + + const handleResend = async () => { + setError(''); + setResent(false); + setResending(true); + try { + await iamAuthApi.resendRegistrationCode({ email, phoneNumber: phone }); + setResent(true); + } catch { + setError('Could not resend the code. Please try again in a moment.'); + } finally { + setResending(false); + } + }; + + return ( +
+
+
+
+
+ +
+
+

Verify your account

+ {linkValid && ( +

+ Enter the code we sent to your phone and choose a password for{' '} + {email}. +

+ )} +
+ +
+ {!linkValid ? ( +
+
+ This verification link is invalid or incomplete. Please start registration again. +
+ + Back to registration + +
+ ) : ( +
+ {error && ( +
+ {error} +
+ )} + {resent && !error && ( +
+ +

A new code has been sent to your phone.

+
+ )} + +
+ + { setVerificationCode(e.target.value); setError(''); }} + className="input-field tracking-widest" + placeholder="123456" + maxLength={6} + required + /> +
+ +
+ + { setNewPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +

+ At least 8 characters with upper & lower case, a number, and a symbol. +

+
+ +
+ + { setConfirmPassword(e.target.value); setError(''); }} + className="input-field" + placeholder="••••••••" + autoComplete="new-password" + minLength={8} + required + /> +
+ + + + + + + + Back to registration + + + )} +
+
+
+ ); +} + +export default function VerifyAccountPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/portal/src/lib/api/auth.ts b/apps/edr-passenger-web/portal/src/lib/api/auth.ts index aa5272173..14e9a16a1 100644 --- a/apps/edr-passenger-web/portal/src/lib/api/auth.ts +++ b/apps/edr-passenger-web/portal/src/lib/api/auth.ts @@ -11,6 +11,10 @@ export const iamAuthApi = { forgotPassword: (email: string) => axios.post(`${API_URL}/v1/auth/forgot-password`, { email }), + // Re-sends the registration verification code for a still-pending account. + resendRegistrationCode: (data: { email: string; phoneNumber: string }) => + axios.post(`${API_URL}/auth/register/resend-code`, data), + // Completes the forgot-password flow using the link sent via SMS: // ${FE_BASE_URL}/reset-password?email=..&userId=..&verificationCode=.. resetPassword: (data: { diff --git a/apps/edr-passenger-web/portal/src/lib/auth-store.ts b/apps/edr-passenger-web/portal/src/lib/auth-store.ts index 2157cfc0a..0d6e46fd9 100644 --- a/apps/edr-passenger-web/portal/src/lib/auth-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/auth-store.ts @@ -31,7 +31,7 @@ interface AuthState { isAuthenticated: boolean; isInitialized: boolean; login: (email: string, password: string) => Promise; - register: (data: RegisterData) => Promise; + register: (data: RegisterData) => Promise; logout: () => Promise; setUser: (user: User, token: string) => void; updateUser: (userData: Partial) => void; @@ -43,8 +43,12 @@ interface RegisterData { fullName: string; email: string; phone: string; - password: string; - confirmPassword: string; +} + +interface RegisterResult { + iamUserId: string; + email: string; + phoneNumber: string; } export const useAuthStore = create((set, get) => ({ @@ -118,25 +122,24 @@ export const useAuthStore = create((set, get) => ({ set({ user, token, isAuthenticated: true }); }, - register: async (data: RegisterData) => { + register: async (data: RegisterData): Promise => { // Shape required by the passenger-api RegisterDto; username = email by convention. + // Registration no longer takes a password — the account is created as pending and + // an SMS verification code is sent. The user completes signup on the verify-account + // page (set-password). No token is issued here; the user is NOT logged in yet. const payload = { email: data.email, username: data.email, phoneNumber: data.phone, name: { en: data.fullName, am: data.fullName }, - password: data.password, - confirmPassword: data.confirmPassword, }; const response: any = await apiClient.post('/auth/register', payload); - const { token, user } = response.data || response; - - if (typeof window !== 'undefined') { - localStorage.setItem('auth_token', token); - localStorage.setItem('auth_user', JSON.stringify(user)); - } - - set({ user, token, isAuthenticated: true }); + const result = response.data || response; + return { + iamUserId: result.iamUserId, + email: result.email, + phoneNumber: result.phoneNumber, + }; }, logout: async () => { From 74118165c6530cb3f9c58f8221e376e325dd4e76 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:16:23 +0000 Subject: [PATCH 04/11] chages --- .../src/modules/train-scheduling/booking-batch.service.ts | 5 ++++- .../src/modules/train-scheduling/train-scheduling.service.ts | 4 +++- .../src/components/contracts/GlUpcomingWindowsSection.tsx | 2 ++ 3 files changed, 9 insertions(+), 2 deletions(-) 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 46175c7ef..0b239990b 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 @@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit { private async openRouteDayGroups(): Promise { const open = ( await this.trainSchedulesRepository.findAll({ - where: { bookingWindowStatus: "OPEN" }, + where: [ + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft }, + { bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled }, + ], }) ).filter((s) => s.windowPhase == null); const groups = new Map(); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..3b36fdc9a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -2049,7 +2049,9 @@ export class TrainSchedulingService { await this.trainSchedulesRepository.updateStatus( id, TrainScheduleStatusEnum.Cancelled, - {}, + // Retire the booking window so a canceled schedule never lingers as an + // "open window" in booking-window lists or the legacy batch fill. + { bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' }, manager, ); if (schedule.trainSetId) { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx index e5e998721..dc95729c1 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlUpcomingWindowsSection.tsx @@ -235,6 +235,8 @@ export function GlUpcomingWindowsSection() { const rows = (data ?? []).filter( (w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w), ); + // Canceled schedules are retired to windowPhase='DONE' server-side, so the + // guard above already excludes them; they never reach the upcoming list. // Open lanes first, then by opening time. return rows.sort((a, b) => { const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow); From e8e3e01f312398088f9d473395867957577be05c Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:36:53 +0000 Subject: [PATCH 05/11] release order document fix --- apps/edr-freight-api/Dockerfile | 11 ++++++++++- .../train-scheduling/train-scheduling.service.ts | 7 +++++-- .../warehouses/warehouse-release-document.service.ts | 12 ++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index f9107ed23..b781fb4c0 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -7,6 +7,9 @@ RUN apk add --no-cache libc6-compat # `--mount=type=cache,target=/pnpm/store` cache actually persists deps across builds. ENV PNPM_HOME="/pnpm" ENV PATH="$PNPM_HOME:$PATH" +# Puppeteer uses the system Chromium installed in the runner stage — skip the +# ~150MB bundled-Chromium download during pnpm install. +ENV PUPPETEER_SKIP_DOWNLOAD=true RUN corepack enable WORKDIR /app @@ -32,8 +35,14 @@ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm deploy --filter="@edr/freight-api" --prod --legacy /deploy FROM node:24.15.0-alpine AS runner -RUN apk add --no-cache libc6-compat +# Chromium + fonts for headless PDF rendering (puppeteer). Alpine ships the +# binary at /usr/bin/chromium-browser, which the PDF renderer auto-detects +# (also pinned via PUPPETEER_EXECUTABLE_PATH). Without this, PDF generation +# falls back to a degraded hand-built layout. +RUN apk add --no-cache libc6-compat \ + chromium nss freetype harfbuzz ca-certificates ttf-freefont ENV NODE_ENV=production +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser WORKDIR /app RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 --ingroup nodejs nestjs diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts index 32a046356..86e37451e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts @@ -1265,7 +1265,9 @@ export class TrainSchedulingService { performedBy: 'DOCUMENT_GENERATION', }); const html = this.buildImportLoadListHtml(loadList); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (would mislabel this as a + // gate-clearance / release order when Chromium is unavailable). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list'); const reference = loadList.trainNumber ?? loadList.trainScheduleId; return { filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`, @@ -1283,7 +1285,8 @@ export class TrainSchedulingService { } const html = this.buildExportLoadListHtml(schedule); - const buffer = await this.pdfDocuments.htmlToPdfBuffer(html); + // Generic render — NOT the release-order fallback (see importLoadListDocument). + const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list'); const reference = schedule.trainNumber ?? schedule.id; return { filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts index f8c0dd355..68e630e0b 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService { }); } + /** + * Render arbitrary document HTML to PDF via the shared renderer WITHOUT the + * release-order fallback. Non-release documents (e.g. the import/export + * marshalling load list) must use this so a Chromium-less fallback degrades to + * a plain-text dump of *their own* content — instead of masquerading as a + * "Warehouse Gate Clearance / Release Order", which the release-specific + * fallback would otherwise draw regardless of the input HTML. + */ + renderDocumentHtml(html: string, label = 'Document'): Promise { + return this.pdf.htmlToPdfBuffer(html, { label }); + } + private htmlToBasicPdfBuffer(html: string): Buffer { const doc = this.extractReleaseDocument(html); const body: string[] = [ From 145240d3bded71b8da3c36ded965f89c27e6d93d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 4 Jul 2026 07:43:03 +0000 Subject: [PATCH 06/11] refactor: remove gate pass granting logic from clearance services and UI - Removed the gate pass granting functionality from the BookingClearanceService and ContractClearanceService, replacing it with a new method to retrieve gate pass status from train schedules. - Updated the ContractsController to eliminate endpoints related to gate pass granting. - Refactored the UI components (ExportClearanceStepper and PhasedClearanceActionPanel) to reflect the new gate pass securing process, linking to the train scheduling interface instead. - Cleaned up related constants and query hooks, removing unused code and references to the gate pass functionality. - Adjusted types in the contracts to accommodate changes in the gate pass handling logic. --- .../contracts/booking-clearance.service.ts | 12 +- .../contracts/contract-clearance.service.ts | 14 +- .../modules/contracts/contracts.controller.ts | 40 -- .../contracts/dto/phased-clearance.dto.ts | 8 - .../contracts/gl-operations.service.ts | 222 +++-------- .../contracts/ExportClearanceStepper.tsx | 102 ++--- .../contracts/PhasedClearanceActionPanel.tsx | 103 +----- .../backoffice/src/constants/QUERY_KEYS.ts | 1 - .../backoffice/src/constants/URLS.ts | 5 - .../src/hooks/contracts/useContracts.ts | 9 - .../contracts/GlDjiboutiClearanceListPage.tsx | 349 +++--------------- .../src/services/contracts.service.ts | 29 -- packages/types/src/freight/contracts.ts | 26 +- 13 files changed, 136 insertions(+), 784 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index 3f169c49c..61ac93925 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -205,7 +205,7 @@ export class BookingClearanceService { const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId); const bookingMilestone = (code: string) => milestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = await this.glOperationsService.gatepassForBooking(bookingId); const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState(milestones, files); @@ -242,14 +242,8 @@ export class BookingClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 532d26359..2c79ba42f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -278,7 +278,9 @@ export class ContractClearanceService { } const bookingMilestone = (code: string) => bookingMilestones.find((m) => m.milestoneCode === code); - const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED'); + const gatepass = cycle?.bookingId + ? await this.glOperationsService.gatepassForBooking(cycle.bookingId) + : { granted: false, grantedAt: null }; const t1ClosedMilestone = bookingMilestone('T1_CLOSED'); const riskMilestone = bookingMilestone('RISK_ASSIGNED'); const secondDuty = this.glOperationsService.secondDutyState( @@ -344,14 +346,8 @@ export class ContractClearanceService { workflowFiles, t1, train, - gatepassGranted: gatepassMilestone?.status === 'COMPLETED', - gatepassAt: - gatepassMilestone?.status === 'COMPLETED' - ? (gatepassMilestone.metadata?.gatepassAt ?? - (gatepassMilestone.triggeredAt - ? gatepassMilestone.triggeredAt.toISOString() - : null)) - : null, + gatepassGranted: gatepass.granted, + gatepassAt: gatepass.grantedAt, t1Closed: t1ClosedMilestone?.status === 'COMPLETED', t1ClosedAt: t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt 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 06ac31d68..a22c7cad4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -77,7 +77,6 @@ import { } from './dto/gl-operations.dto'; import { AdviseContractDutyDto, - GatepassDto, RoAmendmentDto, } from './dto/phased-clearance.dto'; @@ -688,30 +687,6 @@ export class ContractsController { return this.clearanceService.djQueue(filter); } - @Get('clearance/dj-schedules') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' }) - djClearanceSchedules() { - return this.glOperationsService.djSchedules(); - } - - @Post('clearance/schedules/:scheduleId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ - summary: 'GL DJ grants the gate pass for every customs booking on a train schedule', - }) - grantScheduleGatepass( - @Param('scheduleId', ParseUUIDPipe) scheduleId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantScheduleGatepass( - scheduleId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - // ── Path A self-clearance — Operations reviews the customer's own docs ─────── @Get('clearance/ops-queue') @@ -947,21 +922,6 @@ export class ContractsController { return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user)); } - @Post('bookings/:bookingId/gatepass') - @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' }) - grantGatepass( - @Param('bookingId', ParseUUIDPipe) bookingId: string, - @Body() dto: GatepassDto, - @CurrentUser() user: AuthUserPayload, - ) { - return this.glOperationsService.grantGatepass( - bookingId, - dto?.gatepassAt, - resolveAuthUserId(user), - ); - } - @Post('bookings/:bookingId/final-invoice') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) @UseInterceptors(FileInterceptor('file')) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts index 6b784073b..34a903427 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/phased-clearance.dto.ts @@ -36,11 +36,3 @@ export class RoAmendmentDto { note?: string; } -export class GatepassDto { - @ApiPropertyOptional({ - description: 'When the gate pass was granted (ISO datetime; defaults to now)', - }) - @IsOptional() - @IsString() - gatepassAt?: string; -} diff --git a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts index 8fed3a8ff..e639f0867 100644 --- a/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts @@ -4,7 +4,7 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { DataSource, In, IsNull } from 'typeorm'; +import { DataSource, IsNull } from 'typeorm'; import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types'; import { BillingService } from '../billing/billing.service'; @@ -17,7 +17,6 @@ import { ClearanceIncident, IncidentType, } from './entities/clearance-incident.entity'; -import { ClearanceMilestone } from './entities/clearance-milestone.entity'; import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity'; import { ClearanceMilestoneService } from './clearance-milestone.service'; import { @@ -198,6 +197,7 @@ export class GlOperationsService { } return { + scheduleId: schedule?.id ?? null, wagonAllocated, departedAt: schedule?.actualDepartureAt ? new Date(schedule.actualDepartureAt).toISOString() @@ -208,6 +208,41 @@ export class GlOperationsService { }; } + /** + * Gate pass status for a booking, sourced from the train schedule's Djibouti + * gate-pass operation (secured via the train-scheduling "Save as Secured" + * action) rather than a clearance milestone. For EXPORT bookings this also + * backfills the arrival-chain milestones once secured, same as the retired + * clearance-side grant action used to. + */ + async gatepassForBooking( + bookingId: string, + ): Promise<{ granted: boolean; grantedAt: string | null }> { + const train = await this.trainState(bookingId); + if (!train.scheduleId) return { granted: false, grantedAt: null }; + const operation = await this.dataSource + .getRepository(ImportDjiboutiOperation) + .findOne({ where: { trainScheduleId: train.scheduleId } }); + const grantedAt = operation?.gatepassGrantedAt + ? new Date(operation.gatepassGrantedAt).toISOString() + : null; + + if (grantedAt) { + const booking = await this.getBooking(bookingId); + if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') { + const milestones = await this.milestoneService.listForBooking(bookingId); + const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); + for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { + if (byCode.get(code)?.status === 'PENDING') { + await this.milestoneService.completeForBooking(bookingId, code); + } + } + } + } + + return { granted: Boolean(grantedAt), grantedAt }; + } + /** * T1 transit-document lifecycle state for an import shipment booking. Wagon * allocation opens the upload window; train departure locks it; train arrival @@ -302,8 +337,11 @@ export class GlOperationsService { 'The transport document must be uploaded before T1 can be closed.', ); } - if (!done('GATEPASS_GRANTED')) { - throw new BadRequestException('Grant the gate pass before closing T1.'); + const gatepass = await this.gatepassForBooking(bookingId); + if (!gatepass.granted) { + throw new BadRequestException( + 'Secure the Djibouti gate pass on the train schedule before closing T1.', + ); } // Export bookings seeded before T1_CLOSED joined the catalog lack the row. await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection); @@ -322,182 +360,6 @@ export class GlOperationsService { 'ARRIVED_AT_DJIBOUTI', ]; - /** - * GL Djibouti grants the gate pass for a customs booking, capturing the time. - * Export: requires the train to have arrived at Djibouti; back-fills the - * arrival-chain milestones. Import: requires wagon allocation (pre-loading). - */ - async grantGatepass( - bookingId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> { - const booking = await this.getBooking(bookingId); - if (!booking.customsClearingEnabled) { - throw new BadRequestException('Gate pass applies to customs bookings only.'); - } - const tradeDirection = booking.tradeDirection ?? 'IMPORT'; - const milestones = await this.milestoneService.listForBooking(bookingId); - const byCode = new Map(milestones.map((m) => [m.milestoneCode, m])); - - const existing = byCode.get('GATEPASS_GRANTED'); - if (existing?.status === 'COMPLETED') { - return { - bookingId, - gatepassAt: - existing.metadata?.gatepassAt ?? - (existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''), - }; - } - - const train = await this.trainState(bookingId); - if (tradeDirection === 'EXPORT') { - if (!train.arrivedAt) { - throw new BadRequestException( - 'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.', - ); - } - for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) { - if (byCode.get(code)?.status === 'PENDING') { - await this.milestoneService.completeForBooking(bookingId, code, userId); - } - } - } else if (!train.wagonAllocated) { - throw new BadRequestException( - 'Wagons must be allocated before the gate pass can be granted.', - ); - } - - const at = gatepassAt?.trim() || new Date().toISOString(); - await this.milestoneService.completeWithMetadataForBooking( - bookingId, - 'GATEPASS_GRANTED', - { gatepassAt: at }, - userId, - ); - return { bookingId, gatepassAt: at }; - } - - /** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */ - async djSchedules(): Promise { - const schedules = await this.dataSource.getRepository(TrainSchedule).find({ - relations: { - scheduleBookings: { booking: true }, - originStation: true, - destinationStation: true, - }, - order: { scheduledDepartureDate: 'DESC' }, - }); - - const withCustoms = schedules - .filter((s) => s.status !== 'CANCELLED') - .map((s) => ({ - schedule: s, - customs: (s.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)), - })) - .filter((s) => s.customs.length > 0); - - const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id)); - const gatepassRows = bookingIds.length - ? await this.dataSource.getRepository(ClearanceMilestone).find({ - where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' }, - }) - : []; - const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m])); - - return withCustoms.map(({ schedule, customs }) => { - const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))]; - return { - id: schedule.id, - trainNumber: schedule.trainNumber ?? null, - routeName: null, - origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, - destination: - schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, - status: schedule.status, - scheduledDepartureDate: schedule.scheduledDepartureDate - ? new Date(schedule.scheduledDepartureDate).toISOString() - : null, - actualDepartureAt: schedule.actualDepartureAt - ? new Date(schedule.actualDepartureAt).toISOString() - : null, - actualArrivalAt: schedule.actualArrivalAt - ? new Date(schedule.actualArrivalAt).toISOString() - : null, - freightType: - freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null, - customsBookings: customs.map((b) => { - const m = gatepassByBooking.get(b.id); - const granted = m?.status === 'COMPLETED'; - return { - bookingId: b.id, - reference: b.reference ?? b.id, - tradeDirection: b.tradeDirection ?? 'IMPORT', - contractId: b.contractId ?? null, - gatepassGranted: granted, - gatepassAt: granted - ? (m?.metadata?.gatepassAt ?? - (m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null)) - : null, - }; - }), - }; - }); - } - - /** - * One-click gate pass for every customs booking on a train schedule. Per-booking - * guard failures are collected, not fatal. Import schedules also get the - * schedule-level ImportDjiboutiOperation gate pass so loading unblocks. - */ - async grantScheduleGatepass( - scheduleId: string, - gatepassAt?: string, - userId?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> { - const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({ - where: { id: scheduleId }, - relations: { scheduleBookings: { booking: true } }, - }); - if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`); - - const customs = (schedule.scheduleBookings ?? []) - .map((sb) => sb.booking) - .filter((b): b is Booking => Boolean(b?.customsClearingEnabled)); - if (customs.length === 0) { - throw new BadRequestException('No customs bookings ride this schedule.'); - } - - let granted = 0; - const skipped: Array<{ bookingId: string; error: string }> = []; - for (const booking of customs) { - try { - await this.grantGatepass(booking.id, gatepassAt, userId); - granted += 1; - } catch (e) { - skipped.push({ - bookingId: booking.id, - error: e instanceof Error ? e.message : 'Failed', - }); - } - } - - if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) { - const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation); - let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } }); - if (!operation) { - operation = opRepo.create({ trainScheduleId: scheduleId }); - } - if (!operation.gatepassGrantedAt) { - operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date(); - await opRepo.save(operation); - } - } - - return { granted, skipped }; - } /** * GL Djibouti raises the post-offload final invoice (export): manual amount + diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx index d7cd9207b..b13e38961 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ExportClearanceStepper.tsx @@ -14,7 +14,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { DateInput, DateTimePicker } from "@mantine/dates"; +import { DateInput } from "@mantine/dates"; import { AlertTriangle, CheckCircle2, @@ -397,15 +397,10 @@ export function ExportClearanceStepper({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -526,68 +513,21 @@ function GatepassStep({ done={false} pendingLabel={ arrived - ? "Train arrived — GL Djibouti can grant the gate pass." + ? "Train arrived — secure the gate pass on the train schedule." : "Available once the train arrives at Djibouti." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 82ac61382..d0b7f2c87 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -4,7 +4,6 @@ import { Badge, Button, Group, - Modal, NumberInput, Paper, SegmentedControl, @@ -15,7 +14,6 @@ import { Text, TextInput, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { TransitPermitMultiUpload, @@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({ : } > - + void; -}) { - const [opened, setOpened] = useState(false); - const [at, setAt] = useState(new Date()); - const [loading, setLoading] = useState(false); +/** + * Gate pass status, read-only. Secured on the train schedule's "Save as + * Secured" action (train-scheduling-v2) — clearance no longer grants it directly. + */ +function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) { + const scheduleId = clearance.train?.scheduleId ?? null; if (clearance.gatepassGranted) { return ( @@ -852,68 +836,21 @@ function ImportGatepassStep({ done={false} pendingLabel={ wagonAllocated - ? "Wagons allocated — GL Djibouti can grant the gate pass." + ? "Wagons allocated — secure the gate pass on the train schedule." : "Available once wagons are allocated." } doneLabel="" /> - {canAct && bookingId ? ( - <> - - setOpened(false)} - title={Grant gate pass} - radius="md" - size="sm" - > - - setAt(v ? new Date(v) : null)} - required - /> - - - - - - - + {scheduleId ? ( + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 3275ef662..bd1c51e47 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -70,7 +70,6 @@ export const QUERY_KEYS = { ["contracts", "clearance-queue", region ?? "ET"] as const, clearanceHistory: (region?: string) => ["contracts", "clearance-history", region ?? "ET"] as const, - djSchedules: ["contracts", "clearance-dj-schedules"] as const, milestones: (id: string) => ["contracts", "milestones", id] as const, capacity: (id: string) => ["contracts", "capacity", id] as const, bookingMilestones: (bookingId: string) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index d0f09f508..c22427881 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -232,11 +232,6 @@ export const URL_CONSTANTS = { `/contracts/bookings/${bookingId}/t1-documents`, BOOKING_T1_CLOSE: (bookingId: string) => `/contracts/bookings/${bookingId}/t1-close`, - CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules", - CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) => - `/contracts/clearance/schedules/${scheduleId}/gatepass`, - BOOKING_GATEPASS: (bookingId: string) => - `/contracts/bookings/${bookingId}/gatepass`, BOOKING_FINAL_INVOICE: (bookingId: string) => `/contracts/bookings/${bookingId}/final-invoice`, BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) => 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 56229415f..04a4720aa 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/contracts/useContracts.ts @@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) { }); } -/** Train schedules carrying customs bookings — GL DJ gate-pass table. */ -export function useDjClearanceSchedules(enabled = true) { - return useQuery({ - queryKey: QUERY_KEYS.CONTRACTS.djSchedules, - queryFn: () => contractsService.getDjClearanceSchedules(), - enabled, - }); -} - /** Path A self-clearance queue (Operations reviews non-customs contracts). */ export function useOpsClearanceQueue(enabled = true) { return useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 3957fb7a5..0b7456851 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,326 +1,65 @@ -import { useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - Badge, - Button, - Card, - Group, - Loader, - Modal, - Stack, - Tabs, - Text, -} from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { ChevronRight, Ship, Train, Truck } from "lucide-react"; -import { DataTable, type ColumnDef } from "@edr/ui-common"; -import type { Freight } from "@edr/types"; -import toast from "react-hot-toast"; +import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core"; +import { ChevronRight, Ship } from "lucide-react"; import { PageContainer } from "@/components/page/PageContainer"; import { PageHeader } from "@/components/page/PageHeader"; -import { - useDjClearanceQueue, - useDjClearanceSchedules, -} from "@/hooks/contracts/useContracts"; -import { contractsService } from "@/services/contracts.service"; +import { useDjClearanceQueue } from "@/hooks/contracts/useContracts"; export default function GlDjiboutiClearanceListPage() { const navigate = useNavigate(); const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue(); - const schedulesQuery = useDjClearanceSchedules(); const contractItems = contractQueue?.items ?? []; - const scheduleItems = schedulesQuery.data ?? []; - - const [gatepassTarget, setGatepassTarget] = - useState(null); - const [gatepassAt, setGatepassAt] = useState(new Date()); - const [granting, setGranting] = useState(false); - - const columns = useMemo[]>( - () => [ - { - header: "Train", - accessorKey: "trainNumber", - cell: ({ row }) => ( - - {row.original.trainNumber ?? "—"} - - ), - }, - { - header: "Route", - id: "route", - cell: ({ row }) => ( - - {row.original.origin ?? "—"} → {row.original.destination ?? "—"} - - ), - }, - { - header: "Scheduled departure", - id: "scheduled", - cell: ({ row }) => ( - - {row.original.scheduledDepartureDate - ? new Date(row.original.scheduledDepartureDate).toLocaleDateString() - : "—"} - - ), - }, - { - header: "Departed", - id: "departed", - cell: ({ row }) => ( - - {row.original.actualDepartureAt - ? new Date(row.original.actualDepartureAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Arrived", - id: "arrived", - cell: ({ row }) => ( - - {row.original.actualArrivalAt - ? new Date(row.original.actualArrivalAt).toLocaleString() - : "—"} - - ), - }, - { - header: "Status", - accessorKey: "status", - cell: ({ row }) => ( - - {row.original.status} - - ), - }, - { - header: "Customs bookings", - id: "customs", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const directions = [...new Set(bookings.map((b) => b.tradeDirection))]; - return ( - - - {bookings.length} - - {directions.map((d) => ( - - {d} - - ))} - - ); - }, - }, - { - header: "Gate pass", - id: "gatepass", - cell: ({ row }) => { - const bookings = row.original.customsBookings; - const allGranted = - bookings.length > 0 && bookings.every((b) => b.gatepassGranted); - const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null; - if (allGranted) { - return ( - - Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""} - - ); - } - return ( - - ); - }, - }, - ], - [], - ); return ( - - - Contracts ({contractItems.length}) - }> - Schedules ({scheduleItems.length}) - - - - - {contractsLoading ? ( - - - - ) : ( - - {contractItems.length === 0 ? ( - - No Djibouti customs contracts yet. - - ) : ( - contractItems.map((c) => ( - navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} - > - - - -
- {c.reference} - - {c.tradeDirection} · {c.status} - -
-
- - - Contract - - - -
-
- )) - )} -
- )} -
- - - void schedulesQuery.refetch(), - } - : undefined - } - emptyMessage="No train schedules carry customs bookings yet." - /> - -
- - setGatepassTarget(null)} - title={ - - - - Gate pass — train {gatepassTarget?.trainNumber ?? ""} + {contractsLoading ? ( + + + + ) : ( + + {contractItems.length === 0 ? ( + + No Djibouti customs contracts yet. - - } - radius="md" - size="sm" - > - - - Grants the gate pass for all{" "} - {gatepassTarget?.customsBookings.length ?? 0} customs booking - {(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this - train. - - setGatepassAt(v ? new Date(v) : null)} - required - /> - - - - + ) : ( + contractItems.map((c) => ( + navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)} + > + + + +
+ {c.reference} + + {c.tradeDirection} · {c.status} + +
+
+ + + Contract + + + +
+
+ )) + )}
-
+ )}
); } - -function statusColor(status: string): string { - switch (status) { - case "SCHEDULED": - return "blue"; - case "DISPATCHED": - return "yellow"; - case "ARRIVED": - return "edr-green"; - default: - return "gray"; - } -} diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 8860df62a..7ad051ce7 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -391,35 +391,6 @@ export const contractsService = { return unwrap(response.data) as Freight.ClearanceT1State; }, - /** Train schedules carrying customs bookings — GL DJ gate-pass table. */ - getDjClearanceSchedules: async (): Promise => { - const response = await client.get(C.CLEARANCE_DJ_SCHEDULES); - return unwrap(response.data) as Freight.DjClearanceSchedule[]; - }, - - /** Gate pass for every customs booking on a train schedule (captures time). */ - grantScheduleGatepass: async ( - scheduleId: string, - gatepassAt?: string, - ): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => { - const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), { - gatepassAt, - }); - return unwrap(response.data) as { - granted: number; - skipped: Array<{ bookingId: string; error: string }>; - }; - }, - - /** Gate pass for a single customs booking (captures time). */ - grantGatepass: async ( - bookingId: string, - gatepassAt?: string, - ): Promise<{ bookingId: string; gatepassAt: string }> => { - const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt }); - return unwrap(response.data) as { bookingId: string; gatepassAt: string }; - }, - /** GL DJ raises the post-offload final invoice (amount + invoice document). */ sendFinalInvoice: async ( bookingId: string, diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index fe653573d..829ae3217 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -265,6 +265,7 @@ export interface ClearanceT1State { /** Train link state for the booking tied to a customs clearance flow. */ export interface ClearanceTrainState { + scheduleId: string | null; wagonAllocated: boolean; departedAt: string | null; arrivedAt: string | null; @@ -305,31 +306,6 @@ export interface ClearanceSecondDuty { paid: boolean; } -/** A customs booking riding a train schedule, as shown on the GL DJ schedules tab. */ -export interface DjClearanceScheduleBooking { - bookingId: string; - reference: string; - tradeDirection: string; - contractId: string | null; - gatepassGranted: boolean; - gatepassAt: string | null; -} - -/** Train schedule row for the GL Djibouti gate-pass table. */ -export interface DjClearanceSchedule { - id: string; - trainNumber: string | null; - routeName: string | null; - origin: string | null; - destination: string | null; - status: string; - scheduledDepartureDate: string | null; - actualDepartureAt: string | null; - actualArrivalAt: string | null; - freightType: string | null; - customsBookings: DjClearanceScheduleBooking[]; -} - export interface ContractClearanceView { contractId: string; /** Overall contract status (e.g. CLEARANCE_UNDER_REVIEW). */ From 8916182a6248018d7058e76c6223f15db3517946 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 4 Jul 2026 07:56:23 +0000 Subject: [PATCH 07/11] Truck assign containers --- .../bookings/booking-transition.service.ts | 11 +++++++ .../CustomerTruckAssignmentCard.tsx | 33 +++++++++++++++---- packages/types/src/freight/index.ts | 4 +++ 3 files changed, 41 insertions(+), 7 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 35fc7fd54..06edbf04e 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 @@ -1086,6 +1086,9 @@ export class BookingTransitionService { offeredAmount: number; paymentDeadline: Date; } | null; + /** Flat list of physical container numbers on this booking (for the + * customer truck-assignment container picker). */ + containerNumbers: string[]; } > { // This enrichment runs AFTER the transition has committed. A failure here @@ -1141,12 +1144,20 @@ export class BookingTransitionService { `enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`, ); } + // Physical container numbers entered at booking time (booking_container + // units), flattened for the customer truck-assignment container picker. + const containerNumbers = (booking.bookingContainers ?? []) + .flatMap((bc) => bc.units ?? []) + .map((unit) => unit.containerNumber) + .filter((n): n is string => Boolean(n)); + return { ...booking, latestChangeRequestNote: note?.note ?? null, contractSummary: summary, nextStep, activeBatchOffer, + containerNumbers, }; } } diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx index 37e65ab2b..c6ec6bf5d 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/CustomerTruckAssignmentCard.tsx @@ -38,6 +38,11 @@ export function CustomerTruckAssignmentCard({ ); const [error, setError] = useState(null); + // Physical container numbers on this booking — the customer picks which one to + // load onto the truck instead of typing it. Falls back to free entry when the + // booking has no container numbers recorded. + const containerOptions = booking.containerNumbers ?? []; + const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions()); const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions()); @@ -120,13 +125,27 @@ export function CustomerTruckAssignmentCard({ onChange={(value) => setTruckType(value ?? "")} disabled={assigned} /> - setContainerNumberToLoad(e.currentTarget.value.toUpperCase())} - readOnly={assigned} - /> + {containerOptions.length > 0 ? ( +