From e6c2735f9b56712fe4b0e7764d035d1a027eb135 Mon Sep 17 00:00:00 2001 From: hagiye Date: Thu, 25 Jun 2026 13:06:11 +0300 Subject: [PATCH 01/15] Fix inventory status column --- .../1790000000000-CreateWarehouseModule.ts | 1 + ...nsureWarehouseInventoryInspectionStatus.ts | 39 +++++++++++++++++++ .../modules/last-mile/last-mile.service.ts | 3 +- .../warehouses/scheduling-read.facade.ts | 2 + .../warehouses/ReceiveInventoryModal.tsx | 6 +++ .../backoffice/src/constants/apiConfig.ts | 5 ++- .../src/pages/warehouses/ArrivalQueuePage.tsx | 6 +++ .../backoffice/src/types/warehouse.ts | 1 + 8 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts diff --git a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts index b921a7194..0f9021ff2 100644 --- a/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts +++ b/apps/edr-freight-api/src/migrations/1790000000000-CreateWarehouseModule.ts @@ -78,6 +78,7 @@ export class CreateWarehouseModule1790000000000 implements MigrationInterface { weight NUMERIC(14,3) NOT NULL DEFAULT 0, volume NUMERIC(12,3) NULL, status VARCHAR(32) NOT NULL DEFAULT 'ARRIVED_AT_WAREHOUSE', + inspection_status VARCHAR(20) NULL, arrived_at TIMESTAMPTZ NULL, inspected_at TIMESTAMPTZ NULL, ready_for_loading_at TIMESTAMPTZ NULL, diff --git a/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts new file mode 100644 index 000000000..523772b39 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1821000000001-EnsureWarehouseInventoryInspectionStatus.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Catch-up for environments where AddWarehouseInspection ran before the + * warehouse module table existed. Production needs this column for unload and + * inspection flows because the WarehouseInventory entity maps inspectionStatus. + */ +export class EnsureWarehouseInventoryInspectionStatus1821000000001 implements MigrationInterface { + private readonly table = 'freight.warehouse_inventory'; + + public async up(queryRunner: QueryRunner): Promise { + if (!(await queryRunner.hasColumn(this.table, 'inspection_status'))) { + await queryRunner.addColumn( + this.table, + new TableColumn({ + name: 'inspection_status', + type: 'varchar', + length: '20', + isNullable: true, + }), + ); + } + + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_inspection_status + ON freight.warehouse_inventory(inspection_status) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP INDEX IF EXISTS freight.idx_warehouse_inventory_inspection_status + `); + + if (await queryRunner.hasColumn(this.table, 'inspection_status')) { + await queryRunner.dropColumn(this.table, 'inspection_status'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts index 55d5d2965..35eb17356 100644 --- a/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile/last-mile.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; -import { FindOptionsWhere } from 'typeorm'; +import { DataSource, FindOptionsWhere } from 'typeorm'; import { BookingsRepository } from '../bookings/bookings.repository'; import { DriversService } from '../drivers/drivers.service'; @@ -32,6 +32,7 @@ export class LastMileService { private readonly logger = new Logger(LastMileService.name); constructor( + private readonly dataSource: DataSource, private readonly lastMileRepository: LastMileRepository, private readonly bookingsRepository: BookingsRepository, private readonly vehiclesService: VehiclesService, diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 2a61d9d78..e1ef089b5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -34,6 +34,7 @@ export interface ImportTrainItemRow { weight: number | null; arrivalTime: string | null; currentStatus: string | null; + inspectionStatus: string | null; lastMileRequested: boolean; pickupOption: string; } @@ -242,6 +243,7 @@ export class SchedulingReadFacade { b.cargo_total_weight_vgm AS "weight", COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime", COALESCE(inv.status, b.status) AS "currentStatus", + inv.inspection_status AS "inspectionStatus", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", CASE WHEN b.last_mile_delivery_address IS NOT NULL THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption" diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 3cd08461f..d340b33d5 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -687,6 +687,7 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { Cargo Type Weight Arrival + Inspection Current Status Last Mile Pickup Option @@ -709,6 +710,11 @@ function ImportTrainDetailTable({ scheduleId }: { scheduleId: string }) { {it.cargoType ?? '—'} {formatNumber(Number(it.weight))} {formatDate(it.arrivalTime)} + + + {it.inspectionStatus ?? 'Not inspected'} + + {it.currentStatus ?? '—'} diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 030b051a1..dbdaba360 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = + import.meta.env.VITE_BASE_API_URL || + import.meta.env.VITE_API_URL || + 'https://edrfreightapi.triaplc.com'; // export const API_BASE_URL = 'http://localhost:3001'; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index d0ea713a8..b1a7e9369 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -67,6 +67,7 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { Weight Arrival Status + Inspection Pickup @@ -88,6 +89,11 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { {item.currentStatus ?? 'PENDING'} + + + {item.inspectionStatus ?? 'Not inspected'} + + {item.pickupOption.replace(/_/g, ' ')} ))} diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index d5141f23e..0debb3639 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -510,6 +510,7 @@ export interface ImportTrainItem { weight: number | null; arrivalTime: string | null; currentStatus: string | null; + inspectionStatus: string | null; lastMileRequested: boolean; pickupOption: string; } From 4efef9bab3a4fce88f8115ca9cf90580eaaeaa6c Mon Sep 17 00:00:00 2001 From: hagiye Date: Fri, 26 Jun 2026 13:20:26 +0300 Subject: [PATCH 02/15] inspection status column fix --- apps/edr-freight-api/package.json | 1 + .../src/scripts/seed-warehouse-demo.ts | 40 +++++++++++++++++++ .../backoffice/src/auth/http.ts | 2 +- .../components/cargoes/CargoFormDialog.tsx | 2 +- .../components/warehouses/WarehouseTable.tsx | 2 +- .../backoffice/src/constants/apiConfig.ts | 2 +- .../src/pages/fleet/config/vehicles.ts | 5 +-- 7 files changed, 47 insertions(+), 7 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 580173cc1..7d774bec6 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -17,6 +17,7 @@ "type-check": "tsc --noEmit", "seed:demo-scheduling": "ts-node -r tsconfig-paths/register src/scripts/seed-demo-scheduling.ts", "seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts", + "seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts", "seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts", "seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts", "seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" diff --git a/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts new file mode 100644 index 000000000..24118d882 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-warehouse-demo.ts @@ -0,0 +1,40 @@ +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 { Batch14TestDataSeeder } from '../seed/batch1-4-test-data.seeder'; +import { Batch5TestDataSeeder } from '../seed/batch5-test-data.seeder'; +import { Batch7TestDataSeeder } from '../seed/batch7-test-data.seeder'; +import { Batch8TestDataSeeder } from '../seed/batch8-test-data.seeder'; +import { IndodeFacilitySeeder } from '../seed/indode-facility.seeder'; +import { PricingDataSeeder } from '../seed/pricing-data.seeder'; +import { WarehouseDemoSeeder } from '../seed/warehouse-demo.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + await app.get(PricingDataSeeder).run(); + await app.get(IndodeFacilitySeeder).run(); + await app.get(Batch14TestDataSeeder).run(); + await app.get(Batch5TestDataSeeder).run(); + await app.get(Batch7TestDataSeeder).run(); + await app.get(Batch8TestDataSeeder).run(); + await app.get(WarehouseDemoSeeder).run(); + + console.log('Warehouse demo data seeded.'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Warehouse demo seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 25712c742..5aa78e2d3 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -1,6 +1,6 @@ import axios from "axios"; -import { API_BASE_URL } from "@/pages/fleet/config/vehicles"; +import { API_BASE_URL } from "@/constants/apiConfig"; import { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, diff --git a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx index ffaf471b3..ff37edb72 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Loader2 } from 'lucide-react'; -import { API_BASE_URL } from "@/pages/fleet/config/vehicles"; +import { API_BASE_URL } from "@/constants/apiConfig"; interface Cargo { id: string; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx index 7028a63a4..82a44f557 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseTable.tsx @@ -181,7 +181,7 @@ export function WarehouseTable({ warehouses, onView, onEdit }: WarehouseTablePro ), - }, + }, ]; return ( diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 0a57a6f31..f2ca55c15 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,3 @@ export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -//export const API_BASE_URL = 'http://localhost:3001'; +//export const API_BASE_URL = 'http://localhost:3001'; \ No newline at end of file diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts index 2c9b0c132..9daa036ae 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/config/vehicles.ts @@ -1,4 +1,5 @@ import type { FleetResourceConfig } from "./resources"; +import { API_BASE_URL } from "@/constants/apiConfig"; const VEHICLE_TYPE_OPTIONS = [ { label: "Truck", value: "TRUCK" }, @@ -92,6 +93,4 @@ export const vehiclesConfig: FleetResourceConfig = { }; export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS }; -// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; - - export const API_BASE_URL = 'http://localhost:3001'; +export { API_BASE_URL }; From 86c7b2e4eb733b66647046ec6d6e4a8c8d5bbc9b Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Fri, 26 Jun 2026 13:57:26 +0300 Subject: [PATCH 03/15] Package inquiry, UAT related update --- apps/edr-passenger-api/prisma/schema.prisma | 23 +++ .../modules/bookings/bookings.controller.ts | 22 +- .../src/modules/bookings/bookings.service.ts | 23 ++- .../excess-baggage.controller.ts | 4 + .../excess-baggage/excess-baggage.service.ts | 38 +++- .../notifications/email-client.service.ts | 57 ++++-- .../notifications/sms-client.service.ts | 4 +- .../modules/packages/packages.controller.ts | 38 +++- .../src/modules/packages/packages.dto.ts | 14 ++ .../src/modules/packages/packages.service.ts | 49 ++++- .../passengers/passengers.controller.ts | 22 +- .../modules/passengers/passengers.service.ts | 21 +- .../src/modules/tickets/tickets.controller.ts | 6 + .../src/modules/tickets/tickets.service.ts | 10 +- .../backoffice/src/app/bookings/page.tsx | 12 +- .../src/app/excess-baggage/page.tsx | 61 ++++-- .../src/app/package-inquiries/layout.tsx | 7 + .../src/app/package-inquiries/page.tsx | 193 ++++++++++++++++++ .../backoffice/src/app/packages/page.tsx | 62 +++++- .../backoffice/src/app/passengers/page.tsx | 10 +- .../backoffice/src/app/tickets/page.tsx | 48 +++-- .../src/components/layout/Sidebar.tsx | 1 + .../backoffice/src/lib/api/index.ts | 16 ++ 23 files changed, 648 insertions(+), 93 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/package-inquiries/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 74965df23..a5b46105d 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1417,6 +1417,7 @@ model TravelPackage { returnSchedule TrainSchedule @relation("PackageReturn", fields: [returnScheduleId], references: [id]) priceTiers PackagePriceTier[] bookings PackageBooking[] + inquiries PackageInquiry[] @@index([status, validFrom]) @@schema("passenger") @@ -1434,6 +1435,7 @@ model PackagePriceTier { package TravelPackage @relation(fields: [packageId], references: [id]) bookings PackageBooking[] + inquiries PackageInquiry[] @@unique([packageId, seatType]) @@schema("passenger") @@ -1501,3 +1503,24 @@ model PackagePaymentIntent { @@schema("passenger") } + +model PackageInquiry { + id String @id @default(uuid()) + packageId String + priceTierId String? + travelerCount Int + contactName String + contactEmail String? + contactPhone String? + notes String? + status String @default("NEW") + enquiredAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + package TravelPackage @relation(fields: [packageId], references: [id]) + priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) + + @@index([packageId]) + @@schema("passenger") +} 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 b04be6bb2..96bd4b387 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -80,15 +80,23 @@ export class BookingsController { @ApiOperation({ description: 'Returns paginated list of bookings. Use `returnLegStatus=OUTBOUND_ONLY` to find round-trip no-shows on the return leg, `INBOUND_ONLY` for passengers who only used the return leg, `BOTH_USED` for fully completed round-trips, and `NEITHER_USED` for confirmed but not yet boarded.' }) - @ApiQuery({ name: 'search', required: false, description: 'Search by booking reference, email, or phone' }) - @ApiQuery({ name: 'status', required: false, description: 'Filter by booking status' }) - @ApiQuery({ name: 'returnLegStatus', required: false, description: 'Filter round-trip leg usage: NEITHER_USED | OUTBOUND_ONLY | INBOUND_ONLY | BOTH_USED | NOT_APPLICABLE' }) - @ApiQuery({ name: 'page', required: false, description: 'Page number' }) - @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'status', required: false }) + @ApiQuery({ name: 'returnLegStatus', required: false }) + @ApiQuery({ name: 'bookingType', required: false }) + @ApiQuery({ name: 'paymentStatus', required: false }) + @ApiQuery({ name: 'dateFrom', required: false }) + @ApiQuery({ name: 'dateTo', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) findAll( @Query('search') search?: string, @Query('status') status?: string, @Query('returnLegStatus') returnLegStatus?: string, + @Query('bookingType') bookingType?: string, + @Query('paymentStatus') paymentStatus?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { @@ -96,6 +104,10 @@ export class BookingsController { search, status, returnLegStatus, + bookingType, + paymentStatus, + dateFrom, + dateTo, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index 7567489bd..193b02ef8 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -28,6 +28,10 @@ interface BookingFilters { search?: string; status?: string; returnLegStatus?: string; + bookingType?: string; + paymentStatus?: string; + dateFrom?: string; + dateTo?: string; page?: number; pageSize?: number; } @@ -195,7 +199,7 @@ export class BookingsService { } async findAll(filters: BookingFilters = {}) { - const { search, status, returnLegStatus, page = 1, pageSize = 20 } = filters; + const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; @@ -227,6 +231,23 @@ export class BookingsService { if (status) where.status = status; if (returnLegStatus) (where as any).returnLegStatus = returnLegStatus; + if (bookingType) where.bookingType = bookingType; + if (dateFrom || dateTo) { + where.createdAt = { + ...(dateFrom ? { gte: new Date(dateFrom) } : {}), + ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), + }; + } + if (paymentStatus) { + const statusMap: Record = { + PAID: 'SUCCEEDED', + PENDING: 'REQUIRES_ACTION', + FAILED: 'FAILED', + REFUNDED: 'REFUNDED', + }; + const mapped = statusMap[paymentStatus] ?? paymentStatus; + where.paymentIntent = { is: { status: mapped } }; + } const [items, total] = await Promise.all([ this.prisma.booking.findMany({ 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 55d55b9e7..12d6dbd7b 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 @@ -35,12 +35,16 @@ export class ExcessBaggageAgentController { getAll( @Query('status') status?: string, @Query('bookingRef') bookingRef?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.getAll({ status, bookingRef, + dateFrom, + dateTo, page: page ? parseInt(page) : undefined, pageSize: pageSize ? parseInt(pageSize) : undefined, }); diff --git a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts index 91396f191..4a110df77 100644 --- a/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts +++ b/apps/edr-passenger-api/src/modules/excess-baggage/excess-baggage.service.ts @@ -7,6 +7,8 @@ import { import { PrismaService } from '../../common/prisma.service'; import { PaymentClientService } from '../payments/payment-client.service'; import { NotificationsService } from '../notifications/notifications.service'; +import { SmsClientService } from '../notifications/sms-client.service'; +import { EmailClientService } from '../notifications/email-client.service'; import { LogExcessBaggageDto, WaiveChargeDto, @@ -30,6 +32,8 @@ export class ExcessBaggageService { private prisma: PrismaService, private paymentClient: PaymentClientService, private notifications: NotificationsService, + private smsClient: SmsClientService, + private emailClient: EmailClientService, ) {} async logCharge(dto: LogExcessBaggageDto) { @@ -101,23 +105,27 @@ export class ExcessBaggageService { const amountStr = (charge.totalMinor / 100).toFixed(2); const msg = `EDR: Excess baggage charge of ${amountStr} ETB for booking ${booking.bookingRef}. Pay here: ${payUrl} (valid 30 min)`; - const recipient = phone ?? email ?? booking.passengerId; - try { - await this.notifications['deliverSms'](recipient, msg); - } catch (err) { - this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`); + if (phone) { + try { + await this.smsClient.sendSms({ to: phone, message: msg }); + } catch (err) { + this.logger.warn(`SMS send failed for excess baggage charge ${charge.id}: ${err}`); + } } if (email) { try { - await this.notifications['deliverEmail']( - recipient, - `EDR — Excess baggage payment required (${booking.bookingRef})`, - msg, - ); + await this.emailClient.sendEmail({ + to: email, + subject: `EDR — Excess baggage payment required (${booking.bookingRef})`, + text: msg, + }); } catch (err) { this.logger.warn(`Email send failed for excess baggage charge ${charge.id}: ${err}`); } } + if (!phone && !email) { + this.logger.warn(`No contact info to send excess baggage payment link for charge ${charge.id}`); + } } async getCharge(id: string) { @@ -227,14 +235,22 @@ export class ExcessBaggageService { async getAll(filters: { status?: string; bookingRef?: string; + dateFrom?: string; + dateTo?: string; page?: number; pageSize?: number; }) { - const { status, bookingRef, page = 1, pageSize = 20 } = filters; + const { status, bookingRef, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; if (status) where.status = status; if (bookingRef) where.booking = { bookingRef: { contains: bookingRef, mode: 'insensitive' } }; + if (dateFrom || dateTo) { + where.createdAt = { + ...(dateFrom ? { gte: new Date(dateFrom) } : {}), + ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), + }; + } const [items, total] = await Promise.all([ this.prisma.excessBaggageCharge.findMany({ diff --git a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts index e50ac8037..5659ba231 100644 --- a/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/email-client.service.ts @@ -5,6 +5,7 @@ import { OnApplicationBootstrap, } from "@nestjs/common"; import { ClientProxy } from "@nestjs/microservices"; +import * as sgMail from "@sendgrid/mail"; import { SendEmail } from "./dtos/email.dto"; @Injectable() @@ -14,9 +15,15 @@ export class EmailClientService implements OnApplicationBootstrap { constructor( @Inject("EMAIL_SERVICE") private readonly emailServiceClient: ClientProxy, - ) {} + ) { + const apiKey = process.env.SENDGRID_API_KEY; + if (apiKey) sgMail.setApiKey(apiKey); + } private readonly enabled = process.env.RABBITMQ_ENABLED !== "false"; + private get sendgridEnabled() { + return !!process.env.SENDGRID_API_KEY; + } async onApplicationBootstrap() { if (!this.enabled) return; @@ -29,22 +36,38 @@ export class EmailClientService implements OnApplicationBootstrap { } async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> { - if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped EMAIL`); - return { queued: false }; + if (this.enabled) { + this.emailServiceClient.emit("send-email", { + ...dto, + appKey: "IFHCRS-LICENSE-MANAGEMENT", + }); + this.logger.log( + `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, + ); + this.logger.debug( + `EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}"`, + ); + return { queued: true }; } - this.emailServiceClient.emit("send-email", { - ...dto, - appKey: "IFHCRS-LICENSE-MANAGEMENT", - }); - // Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered. - this.logger.log( - `EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`, - ); - // Recipient + content are PII — keep them at debug level only. - this.logger.debug( - `EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`, - ); - return { queued: true }; + + if (this.sendgridEnabled) { + try { + await sgMail.send({ + to: dto.to, + from: process.env.SENDGRID_FROM_EMAIL ?? "noreply@edr-platform.com", + subject: dto.subject ?? "EDR Notification", + text: dto.text ?? dto.body ?? "", + ...(dto.html ? { html: dto.html } : {}), + }); + this.logger.log(`EMAIL sent via SendGrid to=${dto.to}`); + return { queued: true }; + } catch (err: any) { + this.logger.error(`SendGrid send failed to=${dto.to}: ${err?.message}`); + return { queued: false }; + } + } + + this.logger.warn(`EMAIL not sent (no transport) — to=${dto.to} subject="${dto.subject ?? ""}"`); + return { queued: false }; } } diff --git a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts index f94c0c20e..04188842d 100644 --- a/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/sms-client.service.ts @@ -32,7 +32,7 @@ export class SmsClientService implements OnApplicationBootstrap { async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> { if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped SMS`); + this.logger.warn(`SMS not sent (RabbitMQ disabled) — to=${dto.to} message="${dto.message}"`); return { queued: false }; } this.smsClient.emit("send-sms", { @@ -51,7 +51,7 @@ export class SmsClientService implements OnApplicationBootstrap { async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> { if (!this.enabled) { - this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`); + this.logger.warn(`BULK SMS not sent (RabbitMQ disabled) — ${dto.messages?.length ?? 0} messages skipped`); return { queued: false }; } const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from })); 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 d7eb79ff0..c88bdc15d 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, Param, Post, Patch, Delete, UseGuards, Request, import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PackagesService } from './packages.service'; -import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto } from './packages.dto'; +import { CreatePackageDto, BookPackageDto, CreatePriceTierDto, UpdatePriceTierDto, CreateInquiryDto, UpdateInquiryStatusDto } from './packages.dto'; import { IamGuard } from '../../common/iam-adapter'; import { JwtGuard } from '../../common/jwt.guard'; import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; @@ -12,6 +12,42 @@ import { OptionalJwtGuard } from '../verifayda/optional-jwt.guard'; export class PackagesController { constructor(private readonly service: PackagesService) {} + @Post('inquiries') + @IsPublic() + @ApiOperation({ summary: 'Submit a package inquiry (public)' }) + createInquiry(@Body() dto: CreateInquiryDto) { + return this.service.createInquiry(dto); + } + + @Get('inquiries') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'List all inquiries (backoffice)' }) + listInquiries( + @Query('packageId') packageId?: string, + @Query('status') status?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + return this.service.listInquiries({ packageId, status, page: page ? +page : 1, pageSize: pageSize ? +pageSize : 20 }); + } + + @Patch('inquiries/:id/status') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Update inquiry status (backoffice)' }) + updateInquiryStatus(@Param('id') id: string, @Body() dto: UpdateInquiryStatusDto) { + return this.service.updateInquiryStatus(id, dto.status); + } + + @Delete('inquiries/:id') + @UseGuards(IamGuard) + @ApiBearerAuth('IAM-auth') + @ApiOperation({ summary: 'Delete inquiry (backoffice)' }) + deleteInquiry(@Param('id') id: string) { + return this.service.deleteInquiry(id); + } + @Get() @IsPublic() @ApiOperation({ summary: 'List active packages' }) diff --git a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts index ec378f325..b4dac126d 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.dto.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.dto.ts @@ -16,6 +16,20 @@ export class CreatePriceTierDto { @IsInt() @Min(0) availableSeats: number; } +export class CreateInquiryDto { + @ApiProperty() @IsUUID() packageId: string; + @ApiPropertyOptional() @IsOptional() @IsUUID() priceTierId?: string; + @ApiProperty({ example: 2 }) @IsInt() @Min(1) travelerCount: number; + @ApiProperty() @IsString() contactName: string; + @ApiPropertyOptional() @IsOptional() @IsString() contactEmail?: string; + @ApiPropertyOptional() @IsOptional() @IsString() contactPhone?: string; + @ApiPropertyOptional() @IsOptional() @IsString() notes?: string; +} + +export class UpdateInquiryStatusDto { + @ApiProperty({ example: 'CONTACTED' }) @IsString() status: string; +} + export class UpdatePriceTierDto { @ApiPropertyOptional() @IsOptional() @IsString() seatType?: string; @ApiPropertyOptional() @IsOptional() @IsString() label?: string; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index 943ffd778..f6c25611c 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -1,7 +1,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { PrismaService } from '../../common/prisma.service'; import { CurrencyService } from '../currency/currency.service'; -import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto } from './packages.dto'; +import { CreatePackageDto, BookPackageDto, UpdatePriceTierDto, CreatePriceTierDto, CreateInquiryDto } from './packages.dto'; import { Currency } from '@prisma/client'; function generateRef(): string { @@ -17,6 +17,53 @@ export class PackagesService { private readonly currencyService: CurrencyService, ) {} + async createInquiry(dto: CreateInquiryDto) { + return this.prisma.packageInquiry.create({ + data: { + packageId: dto.packageId, + priceTierId: dto.priceTierId ?? null, + travelerCount: dto.travelerCount, + contactName: dto.contactName, + contactEmail: dto.contactEmail ?? null, + contactPhone: dto.contactPhone ?? null, + notes: dto.notes ?? null, + enquiredAt: new Date(), + }, + include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true } } }, + }); + } + + async listInquiries({ packageId, status, page = 1, pageSize = 20 }: { packageId?: string; status?: string; page?: number; pageSize?: number }) { + const where: any = {}; + if (packageId) where.packageId = packageId; + if (status) where.status = status; + const skip = (page - 1) * pageSize; + const [items, total] = await Promise.all([ + this.prisma.packageInquiry.findMany({ + where, + include: { package: { select: { id: true, name: true, code: true } }, priceTier: { select: { id: true, label: true, priceMinor: true } } }, + orderBy: { enquiredAt: 'desc' }, + skip, + take: pageSize, + }), + this.prisma.packageInquiry.count({ where }), + ]); + return { items, total, page, pageSize }; + } + + async updateInquiryStatus(id: string, status: string) { + const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } }); + if (!inquiry) throw new NotFoundException('Inquiry not found'); + return this.prisma.packageInquiry.update({ where: { id }, data: { status } }); + } + + async deleteInquiry(id: string) { + const inquiry = await this.prisma.packageInquiry.findUnique({ where: { id } }); + if (!inquiry) throw new NotFoundException('Inquiry not found'); + await this.prisma.packageInquiry.delete({ where: { id } }); + return { deleted: true }; + } + listActive() { const now = new Date(); return this.prisma.travelPackage.findMany({ 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 be3f9ba05..b0a7c8afe 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.controller.ts @@ -24,19 +24,31 @@ export class PassengersController { summary: 'List all passengers with filters (Admin/Agent)', description: 'Returns paginated list of passengers with search filters' }) - @ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' }) - @ApiQuery({ name: 'verified', required: false, description: 'Filter by verification status' }) - @ApiQuery({ name: 'page', required: false, description: 'Page number' }) - @ApiQuery({ name: 'pageSize', required: false, description: 'Items per page' }) + @ApiQuery({ name: 'search', required: false }) + @ApiQuery({ name: 'verified', required: false }) + @ApiQuery({ name: 'gender', required: false }) + @ApiQuery({ name: 'nationality', required: false }) + @ApiQuery({ name: 'dateFrom', required: false }) + @ApiQuery({ name: 'dateTo', required: false }) + @ApiQuery({ name: 'page', required: false }) + @ApiQuery({ name: 'pageSize', required: false }) findAll( @Query('search') search?: string, @Query('verified') verified?: string, + @Query('gender') gender?: string, + @Query('nationality') nationality?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string, ) { return this.service.findAll({ search, - verified: verified ? verified === 'true' : undefined, + verified: verified ? verified === 'true' : undefined, + gender, + nationality, + dateFrom, + dateTo, page: page ? parseInt(page) : 1, pageSize: pageSize ? parseInt(pageSize) : 20 }); diff --git a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts index 34b4a82f5..bee4d4d77 100644 --- a/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts +++ b/apps/edr-passenger-api/src/modules/passengers/passengers.service.ts @@ -8,6 +8,10 @@ import { VerifaydaService } from '../verifayda/verifayda.service'; interface PassengerFilters { search?: string; verified?: boolean; + gender?: string; + nationality?: string; + dateFrom?: string; + dateTo?: string; page?: number; pageSize?: number; } @@ -29,7 +33,7 @@ export class PassengersService { ) {} async findAll(filters: PassengerFilters = {}) { - const { search, verified, page = 1, pageSize = 20 } = filters; + const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters; const skip = (page - 1) * pageSize; const where: any = {}; @@ -48,6 +52,21 @@ export class PassengersService { where.user = { ...(where.user ?? {}), faydaVerified: verified }; } + if (gender) { + where.user = { ...(where.user ?? {}), gender }; + } + + if (nationality) { + where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } }; + } + + if (dateFrom || dateTo) { + where.createdAt = { + ...(dateFrom ? { gte: new Date(dateFrom) } : {}), + ...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}), + }; + } + const [items, total] = await Promise.all([ this.prisma.passenger.findMany({ where, 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 2db2f9748..8afde9ac1 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.controller.ts @@ -37,6 +37,8 @@ export class TicketsController { @ApiQuery({ name: 'originStationId', required: false }) @ApiQuery({ name: 'destinationStationId', required: false }) @ApiQuery({ name: 'arrivalDate', required: false }) + @ApiQuery({ name: 'dateFrom', required: false }) + @ApiQuery({ name: 'dateTo', required: false }) @ApiQuery({ name: 'skip', required: false }) @ApiQuery({ name: 'take', required: false }) listTickets( @@ -45,6 +47,8 @@ export class TicketsController { @Query('originStationId') originStationId?: string, @Query('destinationStationId') destinationStationId?: string, @Query('arrivalDate') arrivalDate?: string, + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, @Query('skip') skip?: string, @Query('take') take?: string, ) { @@ -54,6 +58,8 @@ export class TicketsController { originStationId, destinationStationId, arrivalDate, + dateFrom, + dateTo, skip: skip ? parseInt(skip) : 0, take: take ? parseInt(take) : 50, }); diff --git a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts index 7c51237af..ecc17dc53 100644 --- a/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts +++ b/apps/edr-passenger-api/src/modules/tickets/tickets.service.ts @@ -21,7 +21,7 @@ export class TicketsService { @InjectDataSource() private readonly dataSource: DataSource, ) {} - async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; skip: number; take: number }) { + async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) { const where: any = {}; if (filters.search) { where.OR = [ @@ -31,7 +31,7 @@ export class TicketsService { ]; } if (filters.status) { - where.booking = { ...where.booking, status: filters.status }; + where.status = filters.status; } if (filters.originStationId) { where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } }; @@ -45,6 +45,12 @@ export class TicketsService { end.setDate(end.getDate() + 1); where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, arrivalAt: { gte: start, lt: end } } }; } + if (filters.dateFrom || filters.dateTo) { + where.issuedAt = { + ...(filters.dateFrom ? { gte: new Date(filters.dateFrom) } : {}), + ...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}), + }; + } const [tickets, total] = await Promise.all([ this.prisma.ticket.findMany({ where, diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 9100d05cd..d1b7cdf87 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -47,8 +47,14 @@ export default function BookingsPage() { const queryClient = useQueryClient(); const { data, isLoading, error } = useQuery({ - queryKey: ['bookings', filters], - queryFn: () => bookingsApi.getAll(filters), + queryKey: ['bookings', filters, extraFilters], + queryFn: () => bookingsApi.getAll({ + ...filters, + ...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }), + ...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }), + ...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }), + ...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }), + }), }); const cancelMutation = useMutation({ @@ -257,8 +263,8 @@ export default function BookingsPage() { diff --git a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx index 75ba7d6c4..1eb6b2946 100644 --- a/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/excess-baggage/page.tsx @@ -20,14 +20,21 @@ const STATUS_VARIANT: Record = { export default function ExcessBaggagePage() { const queryClient = useQueryClient(); - const [filters, setFilters] = useState({ status: '', bookingRef: '', page: '1' }); + const [filters, setFilters] = useState({ status: '', bookingRef: '', dateFrom: '', dateTo: '', page: '1' }); + const [showExtraFilters, setShowExtraFilters] = useState(false); const [waiveModal, setWaiveModal] = useState(null); const [waiveReason, setWaiveReason] = useState(''); const [waiveError, setWaiveError] = useState(null); const { data, isLoading } = useQuery({ queryKey: ['excess-baggage', filters], - queryFn: () => excessBaggageApi.getAll({ status: filters.status || undefined, bookingRef: filters.bookingRef || undefined, page: filters.page }), + queryFn: () => excessBaggageApi.getAll({ + status: filters.status || undefined, + bookingRef: filters.bookingRef || undefined, + dateFrom: filters.dateFrom || undefined, + dateTo: filters.dateTo || undefined, + page: filters.page, + }), }); const waiveMutation = useMutation({ @@ -104,14 +111,14 @@ export default function ExcessBaggagePage() { icon: Send, variant: 'secondary' as const, onClick: (c: any) => resendMutation.mutate(c.id), - hidden: (c: any) => c.status !== 'PENDING', + show: (c: any) => c.status === 'PENDING', }, { label: 'Waive', icon: RefreshCw, variant: 'secondary' as const, onClick: (c: any) => { setWaiveModal(c); setWaiveReason(''); setWaiveError(null); }, - hidden: (c: any) => ['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status), + show: (c: any) => !['PAID', 'CASH_COLLECTED', 'WAIVED'].includes(c.status), }, ]; @@ -125,31 +132,41 @@ export default function ExcessBaggagePage() {
-
-
- - setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} - /> -
-
- - setFilters({ ...filters, bookingRef: e.target.value, page: '1' })} /> +
+ +
+ {showExtraFilters && ( +
+
+ + setFilters({ ...filters, dateFrom: e.target.value, page: '1' })} /> +
+
+ + setFilters({ ...filters, dateTo: e.target.value, page: '1' })} /> +
+
+ )}
diff --git a/apps/edr-passenger-web/backoffice/src/app/package-inquiries/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/layout.tsx new file mode 100644 index 000000000..289c6aa12 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/layout.tsx @@ -0,0 +1,7 @@ +'use client'; + +import DashboardLayout from '../dashboard/layout'; + +export default function PackageInquiriesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx new file mode 100644 index 000000000..73f0ad658 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/package-inquiries/page.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import Badge from '@/components/ui/Badge'; +import ActionButton from '@/components/ui/ActionButton'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { packageInquiriesApi, packagesApi } from '@/lib/api'; +import { formatDateTime, formatCurrency } from '@/lib/utils'; + +const STATUSES = ['NEW', 'CONTACTED', 'CONVERTED', 'CLOSED']; + +const statusVariant: Record = { + NEW: 'info', + CONTACTED: 'warning', + CONVERTED: 'success', + CLOSED: 'default', +}; + +export default function PackageInquiriesPage() { + const [filters, setFilters] = useState({ packageId: '', status: '' }); + const [deleteConfirm, setDeleteConfirm] = useState(null); + const [deleteError, setDeleteError] = useState(null); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['package-inquiries', filters], + queryFn: () => packageInquiriesApi.getAll({ ...filters, pageSize: 50 }), + }); + + const { data: packagesData } = useQuery({ + queryKey: ['packages-all-simple'], + queryFn: () => packagesApi.getAll({ pageSize: 100 }), + }); + + const packages: any[] = packagesData?.items || []; + + const statusMutation = useMutation({ + mutationFn: ({ id, status }: { id: string; status: string }) => + packageInquiriesApi.updateStatus(id, status), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['package-inquiries'] }), + }); + + const deleteMutation = useMutation({ + mutationFn: (id: string) => packageInquiriesApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['package-inquiries'] }); + setDeleteConfirm(null); + setDeleteError(null); + }, + onError: (e: any) => setDeleteError(e?.response?.data?.message || e?.message || 'Failed to delete'), + }); + + const columns = [ + { + key: 'contact', + label: 'Contact', + render: (row: any) => ( +
+
{row.contactName}
+
{row.contactEmail || row.contactPhone || '—'}
+
+ ), + }, + { + key: 'package', + label: 'Package', + render: (row: any) => ( +
+
{row.package?.name || '—'}
+
{row.package?.code}
+
+ ), + }, + { + key: 'priceTier', + label: 'Price Tier', + render: (row: any) => row.priceTier ? ( +
+
{row.priceTier.label}
+
{formatCurrency(row.priceTier.priceMinor, 'ETB')} / person
+
+ ) : , + }, + { + key: 'travelerCount', + label: 'Travelers', + render: (row: any) => ( + {row.travelerCount} + ), + }, + { + key: 'enquiredAt', + label: 'Enquired At', + render: (row: any) => ( + {formatDateTime(row.enquiredAt)} + ), + }, + { + key: 'notes', + label: 'Notes', + render: (row: any) => ( + {row.notes || '—'} + ), + }, + { + key: 'status', + label: 'Status', + render: (row: any) => ( + + ), + }, + ]; + + const actions = [ + { + label: 'Delete', + onClick: (row: any) => { setDeleteConfirm(row); setDeleteError(null); }, + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+

Package Inquiries

+

Manage incoming package inquiries

+
+ +
+
+
+ + +
+
+ + +
+
+
+ + + + { setDeleteConfirm(null); setDeleteError(null); }} + onConfirm={() => deleteMutation.mutate(deleteConfirm.id)} + title="Delete Inquiry" + message={`Delete inquiry from ${deleteConfirm?.contactName}? This cannot be undone.`} + confirmText="Delete" + isDanger + isLoading={deleteMutation.isPending} + error={deleteError ?? undefined} + /> +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx index 3ff0f9814..601cb17c6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/packages/page.tsx @@ -29,6 +29,11 @@ const emptyForm = { export default function PackagesPage() { const [page] = useState(1); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [showExtraFilters, setShowExtraFilters] = useState(false); + const [dateFrom, setDateFrom] = useState(''); + const [dateTo, setDateTo] = useState(''); const [form, setForm] = useState(emptyForm); const [modalMode, setModalMode] = useState<'create' | 'edit' | null>(null); const [editingId, setEditingId] = useState(null); @@ -282,6 +287,15 @@ export default function PackagesPage() { const isPending = createMutation.isPending || updateMutation.isPending; + const allItems: any[] = data?.items || []; + const filteredItems = allItems.filter((p) => { + if (search && !p.name.toLowerCase().includes(search.toLowerCase()) && !p.code.toLowerCase().includes(search.toLowerCase())) return false; + if (statusFilter && p.status !== statusFilter) return false; + if (dateFrom && new Date(p.validFrom).toISOString().split('T')[0] < dateFrom) return false; + if (dateTo && new Date(p.validUntil).toISOString().split('T')[0] > dateTo) return false; + return true; + }); + return (
@@ -292,13 +306,47 @@ export default function PackagesPage() { New Package
- +
+
+
+
+ setSearch(e.target.value)} /> +
+ + +
+ {showExtraFilters && ( +
+
+ + setDateFrom(e.target.value)} /> +
+
+ + setDateTo(e.target.value)} /> +
+
+ )} +
+ +
{/* View Modal */} setViewPackage(null)} title="Package Details" size="lg"> diff --git a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx index 96101b16c..9765195e2 100644 --- a/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/passengers/page.tsx @@ -63,8 +63,14 @@ export default function PassengersPage() { }); const { data, isLoading, error } = useQuery({ - queryKey: ['passengers', filters], - queryFn: () => passengersApi.getAll(filters), + queryKey: ['passengers', filters, extraFilters], + queryFn: () => passengersApi.getAll({ + ...filters, + ...(extraFilters.gender && { gender: extraFilters.gender }), + ...(extraFilters.nationality && { nationality: extraFilters.nationality }), + ...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }), + ...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }), + }), }); const PASSENGER_COLS = [ diff --git a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx index f8f717c0b..b0e4e0858 100644 --- a/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/tickets/page.tsx @@ -78,6 +78,8 @@ export default function TicketsPage() { originStationId: filters.originStationId || undefined, destinationStationId: filters.destinationStationId || undefined, arrivalDate: filters.arrivalDate || undefined, + dateFrom: filters.dateFrom || undefined, + dateTo: filters.dateTo || undefined, skip: 0, take: 50, }), @@ -483,7 +485,7 @@ export default function TicketsPage() { Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
)} -
+
setFilters({ ...filters, arrivalDate: e.target.value })} />
-
- - +
+
+ {showExtraFilters && ( +
+
+ + +
+
+ + setFilters({ ...filters, dateFrom: e.target.value })} /> +
+
+ + setFilters({ ...filters, dateTo: e.target.value })} /> +
+
+ )}
{/* Tickets Table */} 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 3722429d4..96798936b 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -60,6 +60,7 @@ const navigationSections = [ title: 'Tourism', items: [ { name: 'Packages', href: '/packages', icon: Package }, + { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index aff2c78e1..caf451291 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -397,6 +397,22 @@ export const packagesApi = { deleteTier: (tierId: string) => apiClient.delete(`/packages/tiers/${tierId}`), }; +// Package Inquiries API +export const packageInquiriesApi = { + getAll: async (params?: any) => { + const cleanParams = Object.fromEntries( + Object.entries(params || {}).filter(([_, v]) => v !== '' && v !== undefined && v !== null) + ) as Record; + const query = new URLSearchParams(cleanParams).toString(); + const response = await apiClient.get(`/packages/inquiries${query ? `?${query}` : ''}`); + if (response?.data) return Array.isArray(response.data) ? { items: response.data } : response; + return Array.isArray(response) ? { items: response } : response; + }, + create: (data: any) => apiClient.post('/packages/inquiries', data), + updateStatus: (id: string, status: string) => apiClient.patch(`/packages/inquiries/${id}/status`, { status }), + remove: (id: string) => apiClient.delete(`/packages/inquiries/${id}`), +}; + // Excess Baggage API export const excessBaggageApi = { logCharge: (data: any) => apiClient.post('/agents/excess-baggage', data), From 9c664ccce61c5b06eb2699a93af5db07608d0794 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 26 Jun 2026 14:20:32 +0300 Subject: [PATCH 04/15] feat(auth): add post-Fayda password setup flow --- .../src/config/fayda.config.ts | 4 +- apps/edr-passenger-api/src/main.ts | 2 +- .../src/modules/auth/auth.controller.ts | 24 +++- .../src/modules/auth/auth.dto.ts | 18 ++- .../modules/auth/passenger-auth.service.ts | 124 +++++++++++++++++- .../modules/verifayda/verifayda.controller.ts | 1 + .../src/modules/verifayda/verifayda.dto.ts | 29 +++- .../modules/verifayda/verifayda.service.ts | 99 +++++++++++++- 8 files changed, 286 insertions(+), 15 deletions(-) diff --git a/apps/edr-passenger-api/src/config/fayda.config.ts b/apps/edr-passenger-api/src/config/fayda.config.ts index e0bce45c6..d8c4b1868 100644 --- a/apps/edr-passenger-api/src/config/fayda.config.ts +++ b/apps/edr-passenger-api/src/config/fayda.config.ts @@ -66,7 +66,9 @@ function decodePrivateJwk(base64: string): FaydaJwk { export default registerAs('fayda', (): FaydaConfig => { const enabled = (process.env.FAYDA_ENABLED ?? 'false').toLowerCase() === 'true'; - const scope = process.env.FAYDA_SCOPE ?? 'openid profile email'; + // `profile` covers name/birthdate/gender/picture; `email`, `phone`, `address` + // are needed so the matching essential claims aren't rejected as out-of-scope. + const scope = process.env.FAYDA_SCOPE ?? 'openid profile email phone address'; const acrValues = process.env.FAYDA_ACR_VALUES ?? 'mosip:idp:acr:generated-code'; const claimsLocales = process.env.FAYDA_CLAIMS_LOCALES ?? 'en am'; const sessionTtl = Number.parseInt(process.env.FAYDA_SESSION_TTL_MINUTES ?? '10', 10); diff --git a/apps/edr-passenger-api/src/main.ts b/apps/edr-passenger-api/src/main.ts index 86d33debb..680e67866 100644 --- a/apps/edr-passenger-api/src/main.ts +++ b/apps/edr-passenger-api/src/main.ts @@ -314,7 +314,7 @@ Payment providers send notifications to: .addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.") .addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.") .addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails") - .addTag("Auth", "Passenger registration, login, OTP, password reset, and profile management") + .addTag("Passenger Auth", "Passenger registration, login, OTP, password reset, Fayda password setup, and profile management") .addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management") .addTag("Config", "System settings, feature flags, and configuration management") .addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion") 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 80e67d3de..d53ef40d7 100644 --- a/apps/edr-passenger-api/src/modules/auth/auth.controller.ts +++ b/apps/edr-passenger-api/src/modules/auth/auth.controller.ts @@ -3,10 +3,10 @@ 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 } from './auth.dto'; +import { RegisterDto, LoginDto, FaydaRequestPasswordSetupDto, FaydaVerifyAndLoginDto } from './auth.dto'; import { JwtGuard } from '../../common/jwt.guard'; -@ApiTags('Auth') +@ApiTags('Passenger Auth') @Controller('auth') @Throttle({ auth: { limit: 5, ttl: 60_000 } }) export class AuthController { @@ -118,4 +118,24 @@ export class AuthController { resetPassword(@Param('id') id: string, @Body() body: { tempPassword: string }) { return this.passengerAuthService.resetUserPassword(id, body.tempPassword); } + + @Post('fayda/request-password-setup') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Send OTP to phone for Fayda-verified account password setup' }) + @ApiResponse({ status: 200, description: 'OTP sent to registered phone number' }) + @ApiBody({ type: FaydaRequestPasswordSetupDto }) + requestFaydaPasswordSetup(@Body() dto: FaydaRequestPasswordSetupDto, @Request() req: any) { + return this.passengerAuthService.requestFaydaPasswordSetup(dto.phoneNumber, req); + } + + @Post('fayda/verify-and-login') + @IsPublic() + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Verify OTP and receive session token for Fayda-verified account' }) + @ApiResponse({ status: 200, description: 'Returns token + requiresPassword flag. Use token with POST /v1/auth/set-fayda-password.' }) + @ApiBody({ type: FaydaVerifyAndLoginDto }) + verifyFaydaAndLogin(@Body() dto: FaydaVerifyAndLoginDto) { + return this.passengerAuthService.verifyFaydaAndLogin(dto.phoneNumber, dto.otp); + } } 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 e12c8cd06..6f43e7212 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 { Type } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; export class NameDto { @ApiProperty({ example: 'ቀለሙ ቀጸላ' }) @@ -49,3 +49,19 @@ export class LoginDto { @IsString() password: string; } + +export class FaydaRequestPasswordSetupDto { + @ApiProperty({ example: '+251911234567', description: 'Phone number of the Fayda-verified account' }) + @IsString() + phoneNumber: string; +} + +export class FaydaVerifyAndLoginDto { + @ApiProperty({ example: '+251911234567' }) + @IsString() + phoneNumber: string; + + @ApiProperty({ example: '123456', description: '6-digit OTP received via SMS' }) + @IsString() + otp: string; +} 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 4192091dd..cd583517c 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 @@ -2,6 +2,7 @@ import { Injectable, ConflictException, InternalServerErrorException, + Logger, UnauthorizedException, } from '@nestjs/common'; import { ModuleRef, ContextIdFactory } from '@nestjs/core'; @@ -10,6 +11,7 @@ import { DataSource } from 'typeorm'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { AuthService as IamAuthService } from '@tria-plc/iamapi-common/module/auth/services/auth.service'; import { EUserType } from '@tria-plc/api-common/utils/enums/user.enum'; +import { EOtpType } from '@tria-plc/iamapi-common/enums/otp.enum'; import { PrismaService } from '../../common/prisma.service'; import { RegisterDto, LoginDto } from './auth.dto'; @@ -19,10 +21,13 @@ type IamUserRow = { name: { en: string; am: string } | null; phone_number: string | null; metadata: Record | null; + verified_by: string | null; }; @Injectable() export class PassengerAuthService { + private readonly logger = new Logger(PassengerAuthService.name); + constructor( private readonly prisma: PrismaService, @InjectDataSource() private readonly dataSource: DataSource, @@ -165,7 +170,7 @@ export class PassengerAuthService { include: { loyalty: true, wallet: true }, }), this.dataSource.query( - `SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = $1 LIMIT 1`, + `SELECT id, email, name, phone_number, metadata, verified_by FROM iam.users WHERE id = $1 LIMIT 1`, [iamUserId], ), ]); @@ -178,7 +183,7 @@ export class PassengerAuthService { email: iam?.email ?? null, phone: iam?.phone_number ?? null, fullName: iam?.name?.en ?? iam?.name?.am ?? null, - faydaVerified: iam?.metadata?.faydaVerified ?? false, + faydaVerified: iam?.verified_by === 'fayda', createdAt: passenger.createdAt, passenger: { id: passenger.id, @@ -396,6 +401,121 @@ export class PassengerAuthService { return { success: true, message: 'Password reset successfully' }; } + async requestFaydaPasswordSetup(phoneNumber: string, req: any): Promise<{ sent: boolean }> { + const phone = this.standardizePhone(phoneNumber); + + const users = await this.dataSource.query<{ id: string; email: string }[]>( + `SELECT id, email FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`, + [phone], + ); + this.logger.log(`requestFaydaPasswordSetup: phone=${phone} found=${users.length > 0}`); + // Return success regardless to avoid phone enumeration + if (!users.length) return { sent: true }; + const u = users[0]; + + const iamAuthService = await this.resolveIamAuthService(req); + await iamAuthService.generateVerificationCode({ + email: u.email, + phoneNumber: phone, + type: EOtpType.SET_PASSWORD, + }); + + return { sent: true }; + } + + async verifyFaydaAndLogin( + phoneNumber: string, + otp: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean; iamUserId: string }> { + const phone = this.standardizePhone(phoneNumber); + + const users = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password + FROM iam.users WHERE phone_number = $1 AND verified_by = 'fayda' LIMIT 1`, + [phone], + ); + if (!users.length) throw new UnauthorizedException('Invalid phone number or OTP'); + const u = users[0]; + + const verifications = await this.dataSource.query<{ + id: string; verification_code: string; attempt_count: number; + }[]>( + `SELECT id, verification_code, attempt_count FROM iam.user_verifications + WHERE user_id = $1 AND otp_type = 'set-password' AND "isUsed" = false AND expires_at > NOW() + ORDER BY created_at DESC LIMIT 1`, + [u.id], + ); + if (!verifications.length) throw new UnauthorizedException('Invalid phone number or OTP'); + const v = verifications[0]; + + if (v.attempt_count >= 5) { + await this.dataSource.query( + `UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id], + ); + throw new UnauthorizedException('Too many attempts. Request a new code.'); + } + + await this.dataSource.query( + `UPDATE iam.user_verifications SET attempt_count = attempt_count + 1 WHERE id = $1`, [v.id], + ); + + const { verifyPassword } = await import('@tria-plc/api-common/utils/argon'); + const valid = await verifyPassword(otp, v.verification_code); + if (!valid) throw new UnauthorizedException('Invalid phone number or OTP'); + + await this.dataSource.query( + `UPDATE iam.user_verifications SET "isUsed" = true WHERE id = $1`, [v.id], + ); + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: 'accepted', + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-otp-setup', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), u.id], + ); + + const { generateToken, generateRefreshToken } = await import('@tria-plc/api-common/utils/token'); + const token = generateToken({ id: sessions[0].id }); + const refreshToken = generateRefreshToken({ id: sessions[0].id }); + + return { token, refreshToken, requiresPassword: !u.has_set_password, iamUserId: u.id }; + } + + private standardizePhone(phone: string): string { + const digits = phone.replace(/\D/g, ''); + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + return `+${digits}`; + } + private async compensateIamSignup(email: string): Promise { try { const rows = await this.dataSource.query<{ id: string }[]>( diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index b306d2cdc..9ba4cd515 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -74,6 +74,7 @@ export class VerifaydaController { purpose: dto.purpose ?? 'VERIFY', platform: dto.platform ?? 'WEB', userId: req.user?.id, + wantsPasswordSetup: dto.wantsPasswordSetup ?? false, }); return { authorizationUrl }; } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts index 4842b479c..8e5e575e0 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.dto.ts @@ -21,6 +21,17 @@ export class StartVerificationDto { @IsOptional() @IsIn(['WEB', 'MOBILE']) platform?: 'WEB' | 'MOBILE'; + + @ApiPropertyOptional({ + type: Boolean, + default: false, + description: + 'Set to true when the user opts in to full account registration (checkbox). ' + + 'When true, the /complete response includes a short-lived token and promptPasswordSetup=true ' + + 'so the frontend can immediately prompt for a password via POST /v1/auth/set-fayda-password.', + }) + @IsOptional() + wantsPasswordSetup?: boolean; } export class CompleteVerificationResultDto { @@ -29,9 +40,12 @@ export class CompleteVerificationResultDto { @ApiProperty() verified: boolean; - @ApiPropertyOptional({ description: 'JWT (LOGIN flow only).' }) + @ApiPropertyOptional({ description: 'JWT. LOGIN: session token for the authenticated user. VERIFY: short-lived token for calling /v1/auth/set-fayda-password.' }) token?: string; + @ApiPropertyOptional() + refreshToken?: string; + @ApiPropertyOptional({ description: 'Authenticated user summary (LOGIN flow only; same shape as /auth/login).', }) @@ -62,6 +76,19 @@ export class CompleteVerificationResultDto { @ApiPropertyOptional({ description: 'Whether the verified identity was saved to IAM. False if the IAM write failed.' }) userDataSaved?: boolean; + + @ApiPropertyOptional({ description: 'IAM user ID of the verified identity (VERIFY flow).' }) + iamUserId?: string; + + @ApiPropertyOptional({ description: 'True when the IAM account has not yet set a password (VERIFY flow).' }) + requiresPassword?: boolean; + + @ApiPropertyOptional({ + description: + 'True when the user opted in to immediate password setup (wantsPasswordSetup=true at start) ' + + 'AND they have not yet set a password. Frontend should navigate to the set-password screen.', + }) + promptPasswordSetup?: boolean; } export class VerifaydaCallbackDto { diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index 407046cb4..e51bf1846 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -8,6 +8,7 @@ import { import { ConfigService } from '@nestjs/config'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { generateToken, generateRefreshToken } from '@tria-plc/api-common/utils/token'; import axios, { AxiosInstance } from 'axios'; import { PrismaService } from '../../common/prisma.service'; import { FaydaConfig, FaydaPlatform } from '../../config/fayda.config'; @@ -47,6 +48,7 @@ export interface StartVerificationInput { purpose: VerifaydaPurpose; platform?: FaydaPlatform; userId?: string; // iamUserId of the authenticated user, if any + wantsPasswordSetup?: boolean; } export interface FaydaUserSummary { @@ -66,6 +68,10 @@ export interface CompleteVerificationResult { purpose: VerifaydaPurpose; verified: boolean; token?: string; + refreshToken?: string; + requiresPassword?: boolean; + promptPasswordSetup?: boolean; + iamUserId?: string; user?: FaydaUserSummary; fullName?: string; email?: string; @@ -145,6 +151,7 @@ export class VerifaydaService { codeVerifier, purpose: input.purpose, platform: input.platform ?? 'WEB', + saveToAccount: input.wantsPasswordSetup ?? false, iamUserId: input.userId ?? null, expiresAt, }, @@ -220,8 +227,18 @@ export class VerifaydaService { const login = await this.issueLoginToken(userId); result = { purpose: 'LOGIN', verified: true, ...login }; } else { - // VERIFY — prove identity, save to IAM, return verified attributes. - const { userDataSaved } = await this.upsertIamUser(normalized); + // VERIFY — prove identity, save to IAM, return verified attributes + short-lived token. + const { iamUserId, userDataSaved } = await this.upsertIamUser(normalized); + + let sessionToken: { token: string; refreshToken: string; requiresPassword: boolean } | undefined; + if (iamUserId) { + try { + sessionToken = await this.createFaydaSession(iamUserId); + } catch (err) { + this.logger.warn(`Fayda session creation failed: ${(err as Error).message}`); + } + } + result = { purpose: 'VERIFY', verified: true, @@ -231,6 +248,11 @@ export class VerifaydaService { birthdate: normalized.birthdate, gender: normalized.gender, userDataSaved, + iamUserId: iamUserId ?? undefined, + token: sessionToken?.token, + refreshToken: sessionToken?.refreshToken, + requiresPassword: sessionToken?.requiresPassword, + promptPasswordSetup: session.saveToAccount && (sessionToken?.requiresPassword ?? false), }; } @@ -298,14 +320,19 @@ export class VerifaydaService { claims_locales: this.faydaConfig.claimsLocales, }); + // Every claim is marked essential so eSignet shows them locked/pre-checked + // on the consent screen — the user cannot toggle any off; they either + // consent to all of them or the whole flow is cancelled (?error=...). const claims = { userinfo: { name: { essential: true }, phone_number: { essential: true }, - email: { essential: false }, + email: { essential: true }, birthdate: { essential: true }, - gender: { essential: false }, - picture: { essential: false }, + gender: { essential: true }, + address: { essential: true }, + nationality: { essential: true }, + picture: { essential: true }, }, id_token: {}, }; @@ -447,12 +474,16 @@ export class VerifaydaService { phoneNumber: normalized.rawPhoneNumber ?? '', }; - // Step 1 — already verified with same Fayda sub + // Step 1 — already linked to this Fayda sub; ensure verified_by is set const bySub = await this.dataSource.query<{ id: string }[]>( `SELECT id FROM iam.users WHERE metadata->>'sub' = $1 LIMIT 1`, [normalized.sub], ); if (bySub.length > 0) { + await this.dataSource.query( + `UPDATE iam.users SET verified_by = 'fayda', updated_at = NOW() WHERE id = $1`, + [bySub[0].id], + ); return { iamUserId: bySub[0].id, userDataSaved: true }; } @@ -497,7 +528,7 @@ export class VerifaydaService { created_at, updated_at ) VALUES ( gen_random_uuid(), $1::jsonb, $2, $3, $4, $5::jsonb, - 'individual', 'accepted', true, false, + 'individual', 'submitted', true, false, false, 'fayda', NOW(), NOW() ) RETURNING id`, @@ -516,6 +547,60 @@ export class VerifaydaService { } } + private async createFaydaSession( + iamUserId: string, + ): Promise<{ token: string; refreshToken: string; requiresPassword: boolean }> { + const rows = await this.dataSource.query<{ + id: string; + email: string; + name: { en: string; am: string } | null; + username: string; + phone_number: string | null; + has_set_password: boolean; + status: string; + }[]>( + `SELECT id, email, name, username, phone_number, has_set_password, status + FROM iam.users WHERE id = $1 LIMIT 1`, + [iamUserId], + ); + if (!rows.length) throw new Error(`IAM user ${iamUserId} not found`); + const u = rows[0]; + + const userInfo = { + id: u.id, + email: u.email ?? '', + name: u.name ?? { en: '', am: '' }, + userType: 'individual', + status: u.status, + hasSetPassword: u.has_set_password, + isPhoneNumberVerified: false, + hasFinishedRegistration: false, + hasFinishedDMSOnboarding: false, + username: u.username, + phoneNumber: u.phone_number ?? '', + roles: [], + permissions: [], + employee: [], + }; + + const sessions = await this.dataSource.query<{ id: string }[]>( + `INSERT INTO iam.sessions + (id, email, device, "userInfo", expiry_time, refresh_count, status, user_id) + VALUES (gen_random_uuid(), $1, 'fayda-verify', $2::jsonb, NOW() + INTERVAL '1 day', 0, 'ACTIVE', $3) + ON CONFLICT (user_id, device) DO UPDATE + SET status = 'ACTIVE', "userInfo" = EXCLUDED."userInfo", + expiry_time = NOW() + INTERVAL '1 day', updated_at = NOW() + RETURNING id`, + [u.email ?? '', JSON.stringify(userInfo), iamUserId], + ); + + const sessionId = sessions[0].id; + const token = generateToken({ id: sessionId }); + const refreshToken = generateRefreshToken({ id: sessionId }); + + return { token, refreshToken, requiresPassword: !u.has_set_password }; + } + private async markSessionFailed( state: string, errorCode: string, From e2dbd272226342e6a3314681a771914a24c0c835 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 26 Jun 2026 14:41:31 +0300 Subject: [PATCH 05/15] Update pnpm-lock.yaml --- pnpm-lock.yaml | 52 +++++++++++++++----------------------------------- 1 file changed, 15 insertions(+), 37 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e78ee926..205ca2bd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,10 +479,10 @@ importers: version: 8.1.6 '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.4.3.tgz - version: file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc) + version: file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6) '@tria-plc/iamapi-common': - specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.4.tgz - version: file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991) + specifier: file:../../local-packages/tria-plc-iamapi-common-0.7.6.tgz + version: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -907,7 +907,7 @@ importers: version: 9.1.2(eslint@8.57.1) eslint-plugin-import: specifier: ^2.31.0 - version: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + version: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-react: specifier: ^7.37.1 version: 7.37.5(eslint@8.57.1) @@ -4065,28 +4065,6 @@ packages: rxjs: ^7.8.0 typeorm: ^0.3.0 - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.4.tgz': - resolution: {integrity: sha512-6Ot921laEp3rZZBXDFX+gL7nPKEyHIRJaHSIP1i+seG20+PCCGA/QHDglcJXSgF5ccnpxBdlxmI/BZbYu7LV/A==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.4.tgz} - version: 0.7.4 - engines: {node: '>=20'} - peerDependencies: - '@nestjs/axios': ^4.0.0 - '@nestjs/common': ^11.0.0 - '@nestjs/core': ^11.0.0 - '@nestjs/jwt': ^11.0.0 - '@nestjs/microservices': ^11.0.0 - '@nestjs/passport': ^11.0.0 - '@nestjs/swagger': ^11.0.0 - '@nestjs/throttler': ^6.0.0 - '@nestjs/typeorm': ^11.0.0 - '@tria-plc/api-common': '*' - axios: ^1.9.0 - class-transformer: ^0.5.1 - class-validator: ^0.14.1 - reflect-metadata: ^0.2.0 - rxjs: ^7.8.0 - typeorm: ^0.3.0 - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz': resolution: {integrity: sha512-AEYNqqP3Iu26N09LdZ6hBFSKqceKdIkedWNFMe3rau5CoflyrzWdoBUIcnFFbQlR9lRywj03HDkxK+MKNlExLA==, tarball: file:local-packages/tria-plc-iamapi-common-0.7.6.tgz} version: 0.7.6 @@ -15213,7 +15191,7 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6)': dependencies: '@nestjs/axios': 4.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))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15224,7 +15202,7 @@ snapshots: '@nestjs/swagger': 7.4.2(@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)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@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) '@nestjs/typeorm': 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))) - '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991) + '@tria-plc/iamapi-common': file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991) argon2: 0.43.1 axios: 1.17.0 change-case: 5.4.4 @@ -15301,7 +15279,7 @@ snapshots: - debug - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.4.tgz(c97ba831ddde82920910406ab5262991)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)': dependencies: '@nestjs/axios': 4.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))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15309,10 +15287,10 @@ snapshots: '@nestjs/jwt': 10.2.0(@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/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)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 7.4.2(@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)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/swagger': 11.4.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))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@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) '@nestjs/typeorm': 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))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(e6b80acddd4bb7fc40e438635b24d1bc) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -15336,7 +15314,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(578386f46cf99fd4720e3e99f196f69e)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-0.7.6.tgz(c97ba831ddde82920910406ab5262991)': dependencies: '@nestjs/axios': 4.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))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15344,10 +15322,10 @@ snapshots: '@nestjs/jwt': 10.2.0(@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/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)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 11.4.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))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/swagger': 7.4.2(@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)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@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) '@nestjs/typeorm': 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))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(f4d5d43aaace93ac25ed0343d18ebc9e) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.4.3.tgz(c061d697b8a1e15b1d1aba893da7b0e6) api-common: 1.2.2 argon2: 0.43.1 axios: 1.17.0 @@ -17874,7 +17852,7 @@ snapshots: eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) @@ -17908,7 +17886,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -17923,7 +17901,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 From d5e8abcf6238fff908d94fb736eb2672e13f9b20 Mon Sep 17 00:00:00 2001 From: hagiye Date: Fri, 26 Jun 2026 23:37:09 +0300 Subject: [PATCH 06/15] get clearance fix --- .../src/contracts/contract-pdf.service.ts | 90 ++++++++- .../train-scheduling.service.ts | 8 +- .../warehouses/scheduling-read.facade.ts | 4 +- .../warehouses/warehouse-inventory.service.ts | 103 +++++++--- .../warehouse-invoice.controller.ts | 23 ++- .../warehouses/warehouse-invoice.service.ts | 164 ++++++++++++++++ .../warehouse-release-document.service.ts | 181 ++++++++++++++++++ .../modules/warehouses/warehouses.module.ts | 4 +- .../components/warehouses/FeePreviewModal.tsx | 26 ++- .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/constants/apiConfig.ts | 5 +- .../warehouses/WarehouseInvoicesPage.tsx | 106 +++++++++- .../src/services/warehouse.service.ts | 8 + 13 files changed, 669 insertions(+), 55 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts diff --git a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts index 9e0acc6fb..db2f7693e 100644 --- a/apps/edr-freight-api/src/contracts/contract-pdf.service.ts +++ b/apps/edr-freight-api/src/contracts/contract-pdf.service.ts @@ -80,8 +80,15 @@ export class ContractPdfService { this.logger.error( `Puppeteer PDF failed (executable=${executablePath ?? 'default'}): ${err}`, ); + const fallback = this.htmlToBasicPdfBuffer(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using basic PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } throw new InternalServerErrorException( - 'Contract PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + 'PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', ); } } @@ -113,4 +120,85 @@ export class ContractPdfService { buffer.subarray(0, 5).toString('ascii') === '%PDF-' ); } + + private htmlToBasicPdfBuffer(html: string): Buffer { + const text = this.htmlToPlainText(html); + const lines = this.wrapLines(text, 92).slice(0, 72); + const body = lines + .map((line, index) => { + const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; + return `${prefix} (${this.escapePdfText(line)}) Tj`; + }) + .join('\n'); + const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) { + pdf += '% fallback padding\n'; + } + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, 'latin1'); + } + + private htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); + } + + private wrapLines(text: string, width: number): string[] { + const wrapped: string[] = []; + for (const rawLine of text.split('\n')) { + const words = rawLine.split(' '); + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > width && line) { + wrapped.push(line); + line = word; + } else { + line = next; + } + } + if (line) wrapped.push(line); + } + return wrapped.length ? wrapped : ['Document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } } 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 256e6bcdd..c56dd6dd1 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 @@ -724,9 +724,7 @@ export class TrainSchedulingService { } }); - const detail = await this.getTrainScheduleById(scheduleId); - const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); - return Object.assign(detail, { warehouseAutomation }); + return this.getTrainScheduleById(scheduleId); } async finalizeSchedule(scheduleId: string) { @@ -1136,7 +1134,9 @@ export class TrainSchedulingService { } }); - return this.getTrainScheduleById(scheduleId); + const detail = await this.getTrainScheduleById(scheduleId); + const warehouseAutomation = await this.runWarehouseArrivalAutomation(scheduleId); + return Object.assign(detail, { warehouseAutomation }); } async getContainerTrainSchedules() { diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index 2a61d9d78..ec39cfb39 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -133,7 +133,7 @@ export class SchedulingReadFacade { `SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId" FROM freight.wagons WHERE deleted_at IS NULL - AND status NOT IN ('RETIRED', 'MAINTENANCE') + AND UPPER(status) NOT IN ('RETIRED', 'MAINTENANCE') ORDER BY wagon_number ASC`, ); } @@ -281,7 +281,7 @@ export class SchedulingReadFacade { ]; params.push( filter.status ? [filter.status] : ['ARRIVED', 'ARRIVED_AT_DJIBOUTI'], - ['DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'], + ['LOADED', 'DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', 'ARRIVED_AT_PORT', 'ARRIVED_AT_DESTINATION'], ); if (filter.scheduleId) { 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 8171983df..c167e3077 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 @@ -6,7 +6,6 @@ import { Cargo } from '../cargoes/entities/cargoes.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'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { BulkInspectDto } from './dto/bulk-inspect.dto'; import { BulkReceiveDto } from './dto/bulk-receive.dto'; import { DeliverInventoryDto } from './dto/deliver-inventory.dto'; @@ -36,10 +35,20 @@ import { SchedulingReadFacade } from './scheduling-read.facade'; import { WarehouseActivityLogService } from './warehouse-activity-log.service'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; /** Wagon states that may receive a load (besides being part of an existing schedule). */ const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; +const normalizeWagonStatus = (status: string | null | undefined) => + (status ?? '') + .trim() + .replace(/[\s-]+/g, '_') + .toUpperCase(); + +const isLoadableWagonStatus = (status: string | null | undefined) => + LOADABLE_WAGON_STATUSES.includes(normalizeWagonStatus(status)); + export interface InventoryInquiryResult { id: string; inventoryId: string | null; @@ -283,7 +292,7 @@ export class WarehouseInventoryService { private readonly allocation: WarehouseAllocationService, private readonly invoices: WarehouseInvoiceService, private readonly inspectionService: WarehouseInspectionService, - private readonly pdfService: ContractPdfService, + private readonly releaseDocuments: WarehouseReleaseDocumentService, private readonly interchangeDocuments: InterchangeDocumentsService, private readonly lastMileService: LastMileService, ) {} @@ -294,18 +303,53 @@ export class WarehouseInventoryService { * inspection / storage / loading steps — only the final release. */ async gateClearance(id: string, performedBy?: string): Promise { - const item = await this.findById(id); + const [item]: Array<{ id: string; warehouseId: string | null }> = await this.dataSource.query( + `SELECT id, warehouse_id AS "warehouseId" + FROM freight.warehouse_inventory + WHERE id = $1 AND deleted_at IS NULL + LIMIT 1`, + [id], + ); + if (!item) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + const blocking = await this.invoices.findBlockingInvoice(id); if (blocking) { throw new BadRequestException( 'Warehouse demurrage/storage fee must be paid before terminal release.', ); } + const now = new Date(); - await this.inventoryRepository.update(id, { - gateClearedAt: now, - releaseDate: item.releaseDate ?? now, - }); + const [gateColumn]: Array<{ exists: boolean }> = await this.dataSource.query( + `SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'freight' + AND table_name = 'warehouse_inventory' + AND column_name = 'gate_cleared_at' + ) AS "exists"`, + ); + if (gateColumn?.exists) { + await this.dataSource.query( + `UPDATE freight.warehouse_inventory + SET gate_cleared_at = $2, + release_date = COALESCE(release_date, $2), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [id, now], + ); + } else { + await this.dataSource.query( + `UPDATE freight.warehouse_inventory + SET release_date = COALESCE(release_date, $2), + updated_at = now() + WHERE id = $1 AND deleted_at IS NULL`, + [id, now], + ); + } + await this.activityLog.record({ activityType: 'INVENTORY_DISPATCHED', inventoryId: id, @@ -892,6 +936,7 @@ export class WarehouseInventoryService { /** Booking statuses that must never be unloaded into warehouse inventory. */ private readonly IMPORT_UNLOAD_BLOCKED_STATUSES = ['DRAFT', 'REJECTED', 'CANCELLED', 'COMPLETED']; private readonly EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES = [ + 'LOADED', 'DISPATCHED', 'IN_TRANSIT', 'ARRIVED_AT_DJIBOUTI', @@ -1688,14 +1733,8 @@ export class WarehouseInventoryService { } async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { - const item = await this.findById(id); - if (!item.releaseDate) { - throw new BadRequestException('A release order must be issued before downloading the exit paper'); - } - const [row] = await this.dataSource.query( `SELECT inv.id, - inv.release_order_reference AS "releaseOrderReference", inv.release_date AS "releaseDate", inv.quantity, inv.weight, @@ -1706,7 +1745,7 @@ export class WarehouseInventoryService { b.freight_type AS "freightType", b.trade_direction AS "tradeDirection", company.name AS "customerName", - container.container_number AS "containerNumber", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", wh.name AS "warehouseName", wh.code AS "warehouseCode", @@ -1720,23 +1759,27 @@ export class WarehouseInventoryService { LEFT JOIN freight.warehouses wh ON wh.id = inv.warehouse_id LEFT JOIN freight.warehouse_yards yard ON yard.id = inv.yard_id LEFT JOIN freight.warehouse_zones zone ON zone.id = inv.zone_id - LEFT JOIN freight.containers container ON ( - (inv.container_id IS NOT NULL AND container.id = inv.container_id) - OR (inv.container_id IS NULL AND container.booking_id = b.id) - ) AND container.deleted_at IS NULL - LEFT JOIN freight.cargoes cargo ON ( - (inv.cargo_id IS NOT NULL AND cargo.id = inv.cargo_id) - OR (inv.cargo_id IS NULL AND cargo.booking_id = b.id) - ) AND cargo.deleted_at IS NULL + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) WHERE inv.id = $1 AND inv.deleted_at IS NULL LIMIT 1`, [id], ); + if (!row) { + throw new NotFoundException(`Inventory item ${id} not found`); + } + if (!row.releaseDate) { + throw new BadRequestException('A release order must be issued before downloading the exit paper'); + } - const reference = row?.releaseOrderReference || `REL-${id.slice(0, 8).toUpperCase()}`; - const bookingReference = row?.bookingReference || item.bookingId || 'N/A'; - const issuedAt = row?.releaseDate ? new Date(row.releaseDate) : new Date(); + const reference = `REL-${id.slice(0, 8).toUpperCase()}`; + const bookingReference = row?.bookingReference || row?.bookingId || 'N/A'; + const issuedAt = new Date(row.releaseDate); const html = this.buildReleaseDocumentHtml({ reference, issuedAt, @@ -1747,17 +1790,17 @@ export class WarehouseInventoryService { tradeDirection: row?.tradeDirection ?? null, containerNumber: row?.containerNumber ?? null, cargoDescription: row?.cargoDescription ?? null, - quantity: Number(row?.quantity ?? item.quantity ?? 0), - weight: Number(row?.weight ?? item.weight ?? 0), + quantity: Number(row?.quantity ?? 0), + weight: Number(row?.weight ?? 0), warehouse: [row?.warehouseName, row?.warehouseCode].filter(Boolean).join(' / ') || null, yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, - inventoryStatus: row?.status ?? item.status, + inventoryStatus: row?.status ?? null, }); return { filename: `release-${String(bookingReference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, - buffer: await this.pdfService.htmlToPdfBuffer(html), + buffer: await this.releaseDocuments.htmlToPdfBuffer(html), }; } @@ -1842,7 +1885,7 @@ export class WarehouseInventoryService { // 4. wagon must be available, or already selected by an existing train schedule. const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId); - if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) { + if (!isLoadableWagonStatus(wagon.status) && !scheduled) { throw new BadRequestException( `Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`, ); diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts index da818b5a3..a66cf91d0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.controller.ts @@ -1,5 +1,6 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { WarehouseInvoiceService } from './warehouse-invoice.service'; @@ -54,6 +55,26 @@ export class WarehouseInvoiceController { return this.invoiceService.findById(id); } + @Get('warehouse-fee-invoices/:id/document') + @ApiOperation({ summary: 'Download sealed warehouse fee invoice PDF' }) + async document(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.invoiceService.document(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + + @Get('warehouse-fee-invoices/:id/receipt') + @ApiOperation({ summary: 'Download sealed warehouse fee payment receipt PDF' }) + async receipt(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { + const { filename, buffer } = await this.invoiceService.receipt(id); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); + res.setHeader('Content-Length', buffer.length); + return res.send(buffer); + } + @Patch('warehouse-fee-invoices/:id/cancel') @ApiOperation({ summary: 'Cancel a warehouse fee invoice' }) cancel(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 29493bc9a..7ec13602d 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -10,6 +10,7 @@ import { WarehouseFeeType } from './entities/warehouse-fee-invoice-item.entity'; import { WarehouseFeeInvoiceItemRepository } from './warehouse-fee-invoice-item.repository'; import { WarehouseFeeInvoiceRepository } from './warehouse-fee-invoice.repository'; import { WarehouseFeeService } from './warehouse-fee.service'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; interface GenerateOptions { confirmZero?: boolean; @@ -34,6 +35,7 @@ export class WarehouseInvoiceService { private readonly invoiceRepository: WarehouseFeeInvoiceRepository, private readonly itemRepository: WarehouseFeeInvoiceItemRepository, private readonly feeService: WarehouseFeeService, + private readonly documents: WarehouseReleaseDocumentService, ) {} // ── Generation ─────────────────────────────────────────────────────────── @@ -157,6 +159,27 @@ export class WarehouseInvoiceService { return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; } + async document(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE'); + return { + filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, + buffer: await this.documents.htmlToPdfBuffer(html), + }; + } + + async receipt(id: string): Promise<{ filename: string; buffer: Buffer }> { + const invoice = await this.findById(id); + if (Number(invoice.paidAmount) <= 0) { + throw new BadRequestException('A receipt is available only after payment is recorded.'); + } + const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT'); + return { + filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, + buffer: await this.documents.htmlToPdfBuffer(html), + }; + } + listForInventory(inventoryId: string): Promise { return this.invoiceRepository.findAll({ where: { inventoryId }, order: { createdAt: 'DESC' } }); } @@ -213,4 +236,145 @@ export class WarehouseInvoiceService { const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); return invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)) ?? null; } + + async assertClearanceAllowed(inventoryId: string): Promise { + const invoices = await this.invoiceRepository.findAll({ where: { inventoryId } }); + const blocking = invoices.find((inv) => BLOCKING_STATUSES.includes(inv.status)); + if (blocking) { + throw new BadRequestException( + `Warehouse demurrage/storage invoice ${blocking.invoiceNumber} must be fully paid before terminal release.`, + ); + } + + if (invoices.some((inv) => inv.status === 'PAID')) return; + + const previews = await this.feeService.previewForInventory(inventoryId, 'USD'); + const payableAmount = previews.reduce((sum, fee) => sum + Number(fee.amount || 0), 0); + if (payableAmount > 0) { + throw new BadRequestException( + 'Generate and fully pay the warehouse demurrage/storage invoice before terminal release.', + ); + } + } + + private buildInvoiceDocumentHtml( + invoice: WarehouseFeeInvoice & { items: unknown[] }, + kind: 'INVOICE' | 'RECEIPT', + ): string { + const esc = (value: unknown) => + String(value ?? '-') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const money = (amount: unknown, currency = invoice.currency) => + `${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`; + const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-'); + const items = invoice.items as Array<{ + id?: string; + description?: string; + feeType?: string; + quantity?: number; + unitRate?: number; + amount?: number; + currency?: string; + chargeableDays?: number | null; + }>; + const lastPayment = [...(invoice.payments ?? [])].pop(); + const sealText = kind === 'RECEIPT' || invoice.status === 'PAID' ? 'EDR PAID' : 'EDR'; + + return ` + + + + Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'} + + + +
+
+
+
Ethio-Djibouti Railway S.C.
+

Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}

+
+
+ Document no. + ${esc(invoice.invoiceNumber)} + Issued: ${esc(date(invoice.issuedAt ?? invoice.createdAt))} +
+
+
${esc(sealText)}
+
+
Status${esc(invoice.status.replace(/_/g, ' '))}
+
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
+
Booking ID${esc(invoice.bookingId)}
+
Inventory ID${esc(invoice.inventoryId)}
+
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
+
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
+
+ + + + + + + + + + + + ${items + .map( + (item) => ` + + + + + + `, + ) + .join('')} + +
DescriptionFee typeQtyRateAmount
${esc(item.description)}${esc((item.feeType ?? '').replace(/_/g, ' '))}${esc(item.quantity ?? item.chargeableDays ?? 0)}${esc(money(item.unitRate, item.currency ?? invoice.currency))}${esc(money(item.amount, item.currency ?? invoice.currency))}
+
+
Subtotal${esc(money(invoice.subtotalAmount))}
+
Tax${esc(money(invoice.taxAmount))}
+
Total${esc(money(invoice.totalAmount))}
+
Paid${esc(money(invoice.paidAmount))}
+
Balance${esc(money(invoice.balanceAmount))}
+
+ +
+ +`; + } + + private safeFilename(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); + } } 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 new file mode 100644 index 000000000..de9fac6e6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-release-document.service.ts @@ -0,0 +1,181 @@ +import { existsSync } from 'fs'; + +import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; + +const MIN_VALID_PDF_BYTES = 2_000; + +const RELEASE_DOCUMENT_PRINT_STYLES = ` +`; + +@Injectable() +export class WarehouseReleaseDocumentService { + private readonly logger = new Logger(WarehouseReleaseDocumentService.name); + + async htmlToPdfBuffer(html: string): Promise { + const preparedHtml = this.injectPdfPrintStyles(html); + const executablePath = this.resolveExecutablePath(); + + try { + const puppeteer = await import('puppeteer'); + const launchOptions: import('puppeteer').LaunchOptions = { + headless: true, + args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'], + ...(executablePath ? { executablePath } : {}), + }; + + const browser = await puppeteer.default.launch(launchOptions); + try { + const page = await browser.newPage(); + await page.setViewport({ width: 794, height: 1123, deviceScaleFactor: 1 }); + await page.setContent(preparedHtml, { waitUntil: 'load', timeout: 60_000 }); + await page.emulateMediaType('print'); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const pdf = await page.pdf({ + format: 'A4', + printBackground: true, + margin: { top: '16mm', bottom: '18mm', left: '14mm', right: '14mm' }, + }); + + const buffer = Buffer.from(pdf); + if (!this.isValidPdf(buffer)) { + throw new Error(`Puppeteer produced invalid release PDF (${buffer.length} bytes)`); + } + this.logger.log( + `Warehouse release PDF rendered (${buffer.length} bytes) via ${executablePath ?? 'bundled Chromium'}`, + ); + return buffer; + } finally { + await browser.close(); + } + } catch (error) { + this.logger.error( + `Warehouse release PDF failed (executable=${executablePath ?? 'default'}): ${error}`, + ); + const fallback = this.htmlToBasicPdfBuffer(preparedHtml); + if (this.isValidPdf(fallback)) { + this.logger.warn( + `Using basic warehouse release PDF fallback (${fallback.length} bytes). Install Chromium or set PUPPETEER_EXECUTABLE_PATH for full layout rendering.`, + ); + return fallback; + } + throw new InternalServerErrorException( + 'Warehouse release PDF could not be generated. Ensure Chromium is installed or set PUPPETEER_EXECUTABLE_PATH.', + ); + } + } + + private injectPdfPrintStyles(html: string): string { + if (html.includes('warehouse-release-document-print-fix')) return html; + if (html.includes('')) { + return html.replace('', `${RELEASE_DOCUMENT_PRINT_STYLES}`); + } + return `${RELEASE_DOCUMENT_PRINT_STYLES}${html}`; + } + + private resolveExecutablePath(): string | undefined { + const fromEnv = process.env.PUPPETEER_EXECUTABLE_PATH?.trim(); + if (fromEnv && existsSync(fromEnv)) return fromEnv; + + const candidates = [ + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/google-chrome-stable', + '/usr/bin/google-chrome', + ]; + return candidates.find((path) => existsSync(path)); + } + + private isValidPdf(buffer: Buffer): boolean { + return buffer.length >= MIN_VALID_PDF_BYTES && buffer.subarray(0, 5).toString('ascii') === '%PDF-'; + } + + private htmlToBasicPdfBuffer(html: string): Buffer { + const text = this.htmlToPlainText(html); + const lines = this.wrapLines(text, 92).slice(0, 72); + const body = lines + .map((line, index) => { + const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; + return `${prefix} (${this.escapePdfText(line)}) Tj`; + }) + .join('\n'); + const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; + + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, + ]; + + let pdf = '%PDF-1.4\n'; + const offsets: number[] = [0]; + objects.forEach((object, index) => { + offsets.push(Buffer.byteLength(pdf, 'latin1')); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + }); + while (Buffer.byteLength(pdf, 'latin1') < MIN_VALID_PDF_BYTES) { + pdf += '% fallback padding\n'; + } + const xrefOffset = Buffer.byteLength(pdf, 'latin1'); + pdf += `xref\n0 ${objects.length + 1}\n`; + pdf += '0000000000 65535 f \n'; + for (const offset of offsets.slice(1)) { + pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + } + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + return Buffer.from(pdf, 'latin1'); + } + + private htmlToPlainText(html: string): string { + return html + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\/(h1|h2|h3|p|div|tr|table|section|header|footer)>/gi, '\n') + .replace(//gi, '\n') + .replace(/<[^>]+>/g, ' ') + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'/g, "'") + .replace(/[^\x09\x0a\x0d\x20-\x7e]/g, '-') + .split('\n') + .map((line) => line.replace(/\s+/g, ' ').trim()) + .filter(Boolean) + .join('\n'); + } + + private wrapLines(text: string, width: number): string[] { + const wrapped: string[] = []; + for (const rawLine of text.split('\n')) { + const words = rawLine.split(' '); + let line = ''; + for (const word of words) { + const next = line ? `${line} ${word}` : word; + if (next.length > width && line) { + wrapped.push(line); + line = word; + } else { + line = next; + } + } + if (line) wrapped.push(line); + } + return wrapped.length ? wrapped : ['Warehouse release document']; + } + + private escapePdfText(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index 2a77fef74..116bfabf1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -3,7 +3,6 @@ import { ConfigService } from '@nestjs/config'; import { ExchangeModule, ExchangeOptions } from '@edr/api-common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { ContractPdfService } from '../../contracts/contract-pdf.service'; import { FilesModule } from '../files/files.module'; import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module'; import { LastMileModule } from '../last-mile/last-mile.module'; @@ -32,6 +31,7 @@ import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseInventoryService } from './warehouse-inventory.service'; import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseLoadingsController } from './warehouse-loadings.controller'; +import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { WarehouseAllocationRuleRepository } from './warehouse-allocation-rule.repository'; import { WarehouseAllocationService } from './warehouse-allocation.service'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; @@ -111,8 +111,8 @@ import { WarehousesService } from './warehouses.service'; WarehouseFeeService, WarehouseInvoiceService, WarehouseSchedulingAdapterService, + WarehouseReleaseDocumentService, SchedulingReadFacade, - ContractPdfService, ], exports: [ WarehousesService, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx index 6167feb07..efdca3ae4 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/FeePreviewModal.tsx @@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa const pdfWindow = window.open('', '_blank'); try { const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem; - const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); - const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; - const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); - toast({ - title: 'Gate clearance recorded', - description: opened - ? 'The release PDF opened in a browser tab.' - : 'The browser blocked the preview tab, so the PDF was downloaded.', - }); + try { + const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId); + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The release PDF opened in a browser tab.' + : 'The browser blocked the preview tab, so the PDF was downloaded.', + }); + } catch (documentError) { + pdfWindow?.close(); + toast({ + title: 'Gate clearance recorded', + description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`, + }); + } onClose(); } catch (error) { pdfWindow?.close(); diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 22236d3d9..3c0fd6bf8 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -358,6 +358,8 @@ export const URL_CONSTANTS = { WAREHOUSE_INVOICES: { BASE: '/warehouse-fee-invoices', BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`, + DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`, + RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`, CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`, PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`, GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index f2ca55c15..c342b6074 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,3 +1,2 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; - -//export const API_BASE_URL = 'http://localhost:3001'; \ No newline at end of file +export const API_BASE_URL = + import.meta.env.VITE_API_URL?.replace(/\/+$/, '') || 'https://edrfreightapi.triaplc.com'; diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index 0407447d4..6b93abc70 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -15,19 +15,21 @@ import { Text, TextInput, } from '@mantine/core'; -import { Ban, CreditCard, Eye, Search } from 'lucide-react'; +import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react'; import { DataTable, type ColumnDef } from '@edr/ui-common'; import { PageContainer, PageHeader } from '@/components/page'; import { useMutation, useQuery } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { warehouseService } from '@/services/warehouse.service'; import { useToast } from '@/hooks/use-toast'; import { WAREHOUSE_INVOICE_STATUSES, type WarehouseFeeInvoice, type WarehouseInvoiceStatus, } from '@/types/warehouse'; +import { openPdfBlob } from '@/components/warehouses/pdf'; const STATUS_COLOR: Record = { DRAFT: 'gray', @@ -158,16 +160,86 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => ); const pay = useMutation(api.warehouses.payInvoice.mutationOptions()); const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions()); + const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const [payAmount, setPayAmount] = useState(''); const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); + const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); + + const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { + try { + const response = await warehouseService.downloadInvoiceDocument(invoice.id); + openPdfBlob(response.data, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Invoice download failed', description: (e as Error)?.message }); + } + }; + + const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => { + try { + const response = await warehouseService.downloadInvoiceReceipt(invoice.id); + openPdfBlob(response.data, `warehouse-receipt-${invoice.invoiceNumber}.pdf`); + } catch (e) { + toast({ variant: 'destructive', title: 'Receipt download failed', description: (e as Error)?.message }); + } + }; + + const handleGateClearance = async (invoice: WarehouseFeeInvoice) => { + if (!invoice.inventoryId) { + toast({ + variant: 'destructive', + title: 'Gate clearance failed', + description: 'This invoice is not linked to an inventory item.', + }); + return; + } + + const pdfWindow = window.open('', '_blank'); + try { + const releasedItem = await gateClear.mutateAsync(invoice.inventoryId); + let documentResponse: Awaited>; + try { + documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId); + } catch (documentError) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Exit paper failed', + description: (documentError as Error)?.message, + }); + return; + } + const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`; + const opened = openPdfBlob(documentResponse.data, filename, pdfWindow); + toast({ + title: 'Gate clearance recorded', + description: opened + ? 'The exit paper opened in a browser tab.' + : 'The browser blocked the preview tab, so the exit paper was downloaded.', + }); + onClose(); + } catch (e) { + pdfWindow?.close(); + toast({ + variant: 'destructive', + title: 'Gate clearance failed', + description: (e as Error)?.message, + }); + } + }; const handlePay = async () => { if (!inv || !payAmount) return; try { - await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } }); - toast({ title: 'Payment recorded' }); + const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } }); setPayAmount(''); + if (paidInvoice.status === 'PAID') { + toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' }); + await downloadReceiptPdf(paidInvoice); + await handleGateClearance(paidInvoice); + } else { + toast({ title: 'Payment recorded' }); + } } catch (e) { toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message }); } @@ -247,6 +319,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () => )} + + {Number(inv.paidAmount) > 0 && ( + + )} + {canGateClear && ( + + )} {inv.status !== 'PAID' && inv.status !== 'CANCELLED' && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx index d0ea713a8..2a254cf84 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ArrivalQueuePage.tsx @@ -37,6 +37,12 @@ const getErrorMessage = (error: unknown) => { return error instanceof Error ? error.message : undefined; }; +const getPendingUnloadBookings = (train: ImportTrain) => + train.pendingUnloadBookings ?? train.totalBookings; + +const isFullyUnloaded = (train: ImportTrain) => + Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0); + function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { const { data: items = [], isLoading } = useImportTrainItems(scheduleId); @@ -105,10 +111,20 @@ export default function ArrivalQueuePage() { const [busyScheduleId, setBusyScheduleId] = useState(null); const unloadTrain = async (train: ImportTrain) => { + if (isFullyUnloaded(train)) { + toast({ + title: 'Already unloaded', + description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`, + }); + return; + } + setBusyScheduleId(train.scheduleId); try { const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult }; const result = res.data; + const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0; + const firstReason = result.results.find((item) => item.reason)?.reason; const details = [ result.skippedCount ? `${result.skippedCount} skipped` : '', result.failedCount ? `${result.failedCount} failed` : '', @@ -117,8 +133,10 @@ export default function ArrivalQueuePage() { .join(', '); toast({ - title: `${result.unloadedCount} booking(s) unloaded`, - description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, + title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`, + description: alreadyUnloaded + ? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.` + : details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`, }); } catch (error) { toast({ @@ -181,6 +199,8 @@ export default function ArrivalQueuePage() { {trains.map((train: ImportTrain) => { const isOpen = openScheduleId === train.scheduleId; + const fullyUnloaded = isFullyUnloaded(train); + const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train); return ( @@ -204,9 +224,14 @@ export default function ArrivalQueuePage() { {train.totalContainers} {train.totalCargoes} - - {train.status} - + + + {fullyUnloaded ? 'UNLOADED' : train.status} + + + {Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded + + @@ -220,12 +245,13 @@ export default function ArrivalQueuePage() { diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index d5141f23e..b3d24a123 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -430,6 +430,9 @@ export interface ImportTrain { totalBookings: number; totalContainers: number; totalCargoes: number; + unloadedBookings?: number; + pendingUnloadBookings?: number; + fullyUnloaded?: boolean; status: string; } From 0438dade75558d15e5dd8b1fc21bfb45cc84da49 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sat, 27 Jun 2026 00:32:17 +0300 Subject: [PATCH 09/15] Invoice and clearance seal approval --- .../src/components/warehouses/warehousePdf.ts | 127 ++++++++++++------ 1 file changed, 88 insertions(+), 39 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts index 6b18526f7..b64ae9384 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts @@ -1,7 +1,15 @@ import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse'; import type { BookingDetail } from '@/types/booking'; -type PdfLine = { text: string; size?: number; bold?: boolean; x?: number; yGap?: number; color?: 'black' | 'green' }; +type PdfLine = { + text: string; + size?: number; + bold?: boolean; + x?: number; + yGap?: number; + color?: 'black' | 'green'; + align?: 'left' | 'center' | 'right'; +}; export interface WarehouseExitPaperContext { invoice: WarehouseFeeInvoice; @@ -58,6 +66,33 @@ const buildCircularSeal = (cx: number, cy: number, label: 'PAID' | 'CLEARED') => 'Q', ].join('\n'); +const estimateTextWidth = (text: string, size: number) => text.length * size * 0.52; + +const textX = (text: string, size: number, align: PdfLine['align'] = 'left', x?: number) => { + if (typeof x === 'number') return x; + if (align === 'center') return Math.max(36, (595 - estimateTextWidth(text, size)) / 2); + if (align === 'right') return Math.max(36, 535 - estimateTextWidth(text, size)); + return 60; +}; + +const lineOp = (x1: number, y1: number, x2: number, y2: number, color = '0.65 0.7 0.76') => + `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + +const textOp = ( + text: string, + x: number, + y: number, + size = 10, + bold = false, + color = '0 0 0', +) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`; + +const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [ + lineOp(60, 242, 535, 242), + textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN), + buildCircularSeal(452, 155, label), +]; + function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { let y = 800; const streamLines = lines.map((line) => { @@ -65,7 +100,7 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { const size = line.size ?? 10; const font = line.bold ? '/F2' : '/F1'; const color = line.color === 'green' ? `${GREEN} rg` : '0 0 0 rg'; - return `BT\n${color}\n${font} ${size} Tf\n${line.x ?? 46} ${y} Td\n(${escapePdfText(line.text)}) Tj\nET`; + return `BT\n${color}\n${font} ${size} Tf\n${textX(line.text, size, line.align, line.x)} ${y} Td\n(${escapePdfText(line.text)}) Tj\nET`; }); const stream = [...rawOps, ...streamLines].join('\n'); const objects = [ @@ -94,34 +129,43 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; + const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`; const lines: PdfLine[] = [ - { text: 'Ethio-Djibouti Railway S.C.', size: 11, bold: true, yGap: 0 }, - { text: `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`, size: 22, bold: true, yGap: 26 }, - { text: `Document No: ${invoice.invoiceNumber}`, bold: true, yGap: 34 }, - { text: `Status: ${invoice.status.replace(/_/g, ' ')}` }, - { text: `Invoice Type: ${invoice.invoiceType.replace(/_/g, ' ')}` }, - { text: `Booking ID: ${invoice.bookingId ?? '-'}` }, - { text: `Inventory ID: ${invoice.inventoryId}` }, - { text: `Issued: ${fmtDate(invoice.issuedAt)}` }, - { text: `Paid At: ${fmtDate(invoice.paidAt)}` }, - { text: 'Items', size: 14, bold: true, yGap: 26 }, + { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, + { text: title, size: 23, bold: true, yGap: 28, align: 'center' }, + { text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' }, + { text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' }, + { text: `Booking: ${invoice.bookingId ?? '-'} Inventory: ${invoice.inventoryId}`, align: 'center' }, + { text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' }, + { text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' }, ...(invoice.items ?? []).flatMap((item) => [ - { text: item.description, bold: true }, + { text: item.description, bold: true, align: 'center' as const }, { text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`, yGap: 13, + align: 'center' as const, }, ]), - { text: 'Totals', size: 14, bold: true, yGap: 28 }, - { text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}` }, - { text: `Tax: ${money(invoice.taxAmount, invoice.currency)}` }, - { text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true }, - { text: `Paid: ${money(invoice.paidAmount, invoice.currency)}` }, - { text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true }, - { text: 'Prepared by EDR warehouse finance', yGap: 34 }, - { text: 'Authorized seal / signature: ______________________________' }, + { text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' }, + { text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' }, + { text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' }, + { text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' }, + { text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' }, + { text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' }, ]; - return buildSimplePdf(lines, paid ? [buildCircularSeal(462, 712, 'PAID')] : []); + const authorizationOps = [ + ...buildAuthorizationBand('PAID'), + textOp('Prepared by EDR warehouse finance', 72, 196, 10), + textOp('Finance officer name / signature / date:', 72, 164, 10), + lineOp(245, 162, 360, 162, '0 0 0'), + ]; + const invoiceOps = [ + lineOp(60, 242, 535, 242), + textOp('Prepared by EDR warehouse finance', 72, 196, 10), + textOp('Finance officer name / signature / date:', 72, 164, 10), + lineOp(245, 162, 360, 162, '0 0 0'), + ]; + return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps); } const firstText = (...values: Array) => { @@ -168,21 +212,26 @@ export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | Warehous const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight)); return buildSimplePdf([ - { text: 'Ethio-Djibouti Railway S.C.', size: 11, bold: true, yGap: 0 }, - { text: 'Warehouse Release / Exit Paper', size: 22, bold: true, yGap: 26 }, - { text: `Release Reference: ${releaseReference}`, bold: true, yGap: 34 }, - { text: `Invoice No: ${invoice.invoiceNumber}` }, - { text: `Booking Reference: ${firstText(booking?.reference, (inventory as unknown as { bookingReference?: string })?.bookingReference, invoice.bookingId, releasedItem?.bookingId)}` }, - { text: `Customer: ${customerName}` }, - { text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code, invoice.warehouseId, releasedItem?.warehouseId)}` }, - { text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code, invoice.yardId, releasedItem?.yardId)}` }, - { text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code, invoice.zoneId, releasedItem?.zoneId)}` }, - { text: `Booking Container: ${containerSummary(booking, inventory)}` }, - { text: `Weight: ${weightTons}` }, - { text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}` }, - { text: `Release Date & Time: ${fmtDate(releasedAt)}` }, - { text: 'This document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30 }, - { text: 'Warehouse officer name / signature / date: ______________________________', yGap: 40 }, - { text: 'Customer or driver name / signature / date: ______________________________', yGap: 28 }, - ], [buildCircularSeal(462, 712, 'CLEARED')]); + { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, + { text: 'Warehouse Release / Exit Paper', size: 23, bold: true, yGap: 28, align: 'center' }, + { text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' }, + { text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' }, + { text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' }, + { text: `Booking Reference: ${firstText(booking?.reference, (inventory as unknown as { bookingReference?: string })?.bookingReference, invoice.bookingId, releasedItem?.bookingId)}`, align: 'center' }, + { text: `Customer: ${customerName}`, align: 'center' }, + { text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code, invoice.warehouseId, releasedItem?.warehouseId)}`, align: 'center' }, + { text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code, invoice.yardId, releasedItem?.yardId)}`, align: 'center' }, + { text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code, invoice.zoneId, releasedItem?.zoneId)}`, align: 'center' }, + { text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' }, + { text: `Weight: ${weightTons}`, align: 'center' }, + { text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' }, + { text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' }, + { text: 'This document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' }, + ], [ + ...buildAuthorizationBand('CLEARED'), + textOp('Warehouse officer name / signature / date:', 72, 190, 10), + lineOp(260, 188, 360, 188, '0 0 0'), + textOp('Customer or driver name / signature / date:', 72, 148, 10), + lineOp(260, 146, 360, 146, '0 0 0'), + ]); } From b2b668a392679d106f395716d595326fdafc2ba6 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sat, 27 Jun 2026 01:13:25 +0300 Subject: [PATCH 10/15] Invoice and clearance seal approval --- .../warehouses/warehouse-inventory.service.ts | 84 +++++++++------- .../warehouses/warehouse-invoice.service.ts | 95 +++++++++++++++++-- .../warehouse-release-document.service.ts | 71 ++++++++++++-- apps/edr-freight-web/backoffice/package.json | 1 + .../warehouses/InventoryDetailModal.tsx | 19 +++- .../src/components/warehouses/warehousePdf.ts | 46 +++++++-- .../backoffice/src/constants/apiConfig.ts | 4 - .../backoffice/src/types/warehouse.ts | 8 ++ 8 files changed, 265 insertions(+), 63 deletions(-) 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 c167e3077..dc3719207 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 @@ -1736,6 +1736,7 @@ export class WarehouseInventoryService { const [row] = await this.dataSource.query( `SELECT inv.id, inv.release_date AS "releaseDate", + inv.release_order_reference AS "releaseOrderReference", inv.quantity, inv.weight, inv.status, @@ -1777,8 +1778,10 @@ export class WarehouseInventoryService { throw new BadRequestException('A release order must be issued before downloading the exit paper'); } - const reference = `REL-${id.slice(0, 8).toUpperCase()}`; - const bookingReference = row?.bookingReference || row?.bookingId || 'N/A'; + const bookingReference = row?.bookingReference || 'N/A'; + const reference = + row?.releaseOrderReference || + (row?.bookingReference ? `REL-${String(row.bookingReference).replace(/^BK-?/i, '')}` : 'REL-N/A'); const issuedAt = new Date(row.releaseDate); const html = this.buildReleaseDocumentHtml({ reference, @@ -1796,6 +1799,7 @@ export class WarehouseInventoryService { yard: [row?.yardName, row?.yardCode].filter(Boolean).join(' / ') || null, zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null, inventoryStatus: row?.status ?? null, + clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT', }); return { @@ -2300,6 +2304,7 @@ export class WarehouseInventoryService { yard: string | null; zone: string | null; inventoryStatus: string | null; + clearanceStatus: string; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -2316,71 +2321,84 @@ export class WarehouseInventoryService { minute: '2-digit', }); const rows = [ - ['Booking reference', data.bookingReference], - ['Customer', data.customerName], - ['Booking status', data.bookingStatus], - ['Freight type', data.freightType], - ['Trade direction', data.tradeDirection], - ['Container number', data.containerNumber], - ['Cargo / goods', data.cargoDescription], + ['Booking Reference', data.bookingReference], + ['Customer / Consignee', data.customerName], + ['Booking Status', data.bookingStatus], + ['Freight Type', data.freightType], + ['Trade Direction', data.tradeDirection], + ['Container Number', data.containerNumber], + ['Cargo / Goods Description', data.cargoDescription], ['Quantity', data.quantity], - ['Weight', `${data.weight.toLocaleString()} kg`], + ['Declared Weight', `${data.weight.toLocaleString()} kg`], ['Warehouse', data.warehouse], ['Yard', data.yard], ['Zone', data.zone], - ['Inventory status', data.inventoryStatus], + ['Inventory Status', data.inventoryStatus], + ['Clearance Status', data.clearanceStatus], ]; return ` - Warehouse Release Exit Paper + Warehouse Gate Clearance / Release Order
-
EDR Warehouse Operations
-

Warehouse Release / Exit Paper

+
Ethio-Djibouti Railway S.C.
+

Warehouse Gate Clearance / Release Order

+
Official warehouse release and exit authorization
- Release reference + Document / Release No. ${esc(data.reference)} Issued: ${esc(issuedAt)}
+
EDR
Warehouse
Cleared
- This document authorizes the listed booking/goods to leave the warehouse after release checks. + This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
+
Release Particulars
${rows.map(([label, value]) => ``).join('')}
${esc(label)}${esc(value)}
+
Authorization Clause
+
+ The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, + cargo details, clearance status, and payment records before permitting exit from the warehouse premises. +
-
Warehouse officer name / signature / date
+
Authorized warehouse officer name / signature / date
Customer or driver name / signature / date
diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts index 7ec13602d..45c471db1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-invoice.service.ts @@ -28,6 +28,22 @@ export interface PayInvoiceDto { const BLOCKING_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID']; const ACTIVE_STATUSES: WarehouseInvoiceStatus[] = ['ISSUED', 'PARTIALLY_PAID', 'PAID']; +export interface InvoiceDocumentDetails { + bookingReference: string | null; + customerName: string | null; + inventoryReference: string | null; + inventoryInfo: string | null; + inventoryStatus: string | null; + containerNumber: string | null; + cargoDescription: string | null; + clearanceStatus: string; + warehouseName: string | null; + yardName: string | null; + zoneName: string | null; +} + +export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial; + @Injectable() export class WarehouseInvoiceService { constructor( @@ -152,16 +168,18 @@ export class WarehouseInvoiceService { } // ── Reads ──────────────────────────────────────────────────────────────── - async findById(id: string): Promise { + async findById(id: string): Promise { const invoice = await this.invoiceRepository.findById(id); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); const items = await this.itemRepository.findAll({ where: { invoiceId: id } }); - return { ...invoice, items } as WarehouseFeeInvoice & { items: unknown[] }; + const details = await this.getInvoiceDocumentDetails(invoice); + return { ...invoice, ...details, items } as WarehouseFeeInvoiceWithDisplay & { items: unknown[] }; } async document(id: string): Promise<{ filename: string; buffer: Buffer }> { const invoice = await this.findById(id); - const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE'); + const details = await this.getInvoiceDocumentDetails(invoice); + const html = this.buildInvoiceDocumentHtml(invoice, 'INVOICE', details); return { filename: `warehouse-invoice-${this.safeFilename(invoice.invoiceNumber)}.pdf`, buffer: await this.documents.htmlToPdfBuffer(html), @@ -173,7 +191,8 @@ export class WarehouseInvoiceService { if (Number(invoice.paidAmount) <= 0) { throw new BadRequestException('A receipt is available only after payment is recorded.'); } - const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT'); + const details = await this.getInvoiceDocumentDetails(invoice); + const html = this.buildInvoiceDocumentHtml(invoice, 'RECEIPT', details); return { filename: `warehouse-receipt-${this.safeFilename(invoice.invoiceNumber)}.pdf`, buffer: await this.documents.htmlToPdfBuffer(html), @@ -257,9 +276,66 @@ export class WarehouseInvoiceService { } } + private async getInvoiceDocumentDetails(invoice: WarehouseFeeInvoice): Promise { + const [row] = await this.dataSource.query( + `SELECT b.reference AS "bookingReference", + company.name AS "customerName", + COALESCE(inv.release_order_reference, b.reference) AS "inventoryReference", + inv.status AS "inventoryStatus", + COALESCE(container.container_number, booking_container.container_number) AS "containerNumber", + COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription", + CONCAT_WS( + ' / ', + NULLIF(inv.status, ''), + NULLIF(COALESCE(container.container_number, booking_container.container_number), ''), + NULLIF(COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description), '') + ) AS "inventoryInfo", + wh.name AS "warehouseName", + yard.name AS "yardName", + zone.name AS "zoneName", + CASE + WHEN inv.release_date IS NOT NULL THEN 'RELEASE ISSUED' + WHEN $2 = 'PAID' THEN 'FEE PAID - READY FOR RELEASE' + ELSE 'PENDING PAYMENT' + END AS "clearanceStatus" + FROM freight.warehouse_fee_invoices fee + LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL + LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL + LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id) + LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL + LEFT JOIN freight.booking_container booking_container ON ( + booking_container.booking_id = b.id + AND booking_container.deleted_at IS NULL + ) + LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL + LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id) + LEFT JOIN freight.warehouses wh ON wh.id = fee.warehouse_id + LEFT JOIN freight.warehouse_yards yard ON yard.id = fee.yard_id + LEFT JOIN freight.warehouse_zones zone ON zone.id = fee.zone_id + WHERE fee.id = $1 + LIMIT 1`, + [invoice.id, invoice.status], + ); + + return { + bookingReference: row?.bookingReference ?? null, + customerName: row?.customerName ?? null, + inventoryReference: row?.inventoryReference ?? null, + inventoryInfo: row?.inventoryInfo ?? null, + inventoryStatus: row?.inventoryStatus ?? null, + containerNumber: row?.containerNumber ?? null, + cargoDescription: row?.cargoDescription ?? null, + warehouseName: row?.warehouseName ?? null, + yardName: row?.yardName ?? null, + zoneName: row?.zoneName ?? null, + clearanceStatus: row?.clearanceStatus ?? (invoice.status === 'PAID' ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT'), + }; + } + private buildInvoiceDocumentHtml( - invoice: WarehouseFeeInvoice & { items: unknown[] }, + invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] }, kind: 'INVOICE' | 'RECEIPT', + details: InvoiceDocumentDetails, ): string { const esc = (value: unknown) => String(value ?? '-') @@ -329,8 +405,13 @@ export class WarehouseInvoiceService {
Status${esc(invoice.status.replace(/_/g, ' '))}
Invoice type${esc(invoice.invoiceType.replace(/_/g, ' '))}
-
Booking ID${esc(invoice.bookingId)}
-
Inventory ID${esc(invoice.inventoryId)}
+
Booking reference${esc(details.bookingReference)}
+
Customer${esc(details.customerName)}
+
Inventory reference${esc(details.inventoryReference)}
+
Inventory info${esc(details.inventoryInfo)}
+
Clearance${esc(details.clearanceStatus)}
+
Warehouse${esc(details.warehouseName)}
+
Yard / Zone${esc([details.yardName, details.zoneName].filter(Boolean).join(' / ') || null)}
Period${esc(date(invoice.periodStart))} - ${esc(date(invoice.periodEnd))}
Payment${esc(lastPayment ? `${lastPayment.method ?? 'MANUAL'} / ${date(lastPayment.paidAt)}` : '-')}
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 de9fac6e6..6b78d05bd 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 @@ -100,20 +100,33 @@ export class WarehouseReleaseDocumentService { private htmlToBasicPdfBuffer(html: string): Buffer { const text = this.htmlToPlainText(html); - const lines = this.wrapLines(text, 92).slice(0, 72); + const lines = this.wrapLines(text, 86).slice(0, 52); const body = lines .map((line, index) => { - const prefix = index === 0 ? '50 790 Td' : '0 -12 Td'; - return `${prefix} (${this.escapePdfText(line)}) Tj`; + const y = 770 - index * 12; + const isTitle = index < 2 || /clearance|release order/i.test(line); + const size = index === 0 ? 13 : isTitle ? 11 : 9.6; + const font = isTitle ? 'F2' : 'F1'; + return this.textOp(line, 48, y, size, font); }) .join('\n'); - const stream = `BT\n/F1 10 Tf\n12 TL\n${body}\nET`; + const stream = [ + this.lineOp(48, 752, 548, 752), + this.circularSealOps(470, 690), + body, + this.lineOp(48, 126, 238, 126, '0 0 0'), + this.textOp('Authorized warehouse officer name / signature / date', 48, 110, 9, 'F1'), + this.lineOp(312, 126, 548, 126, '0 0 0'), + this.textOp('Customer or driver name / signature / date', 312, 110, 9, 'F1'), + this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'), + ].join('\n'); const objects = [ '<< /Type /Catalog /Pages 2 0 R >>', '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>', - '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman >>', + '<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold >>', `<< /Length ${Buffer.byteLength(stream, 'latin1')} >>\nstream\n${stream}\nendstream`, ]; @@ -178,4 +191,50 @@ export class WarehouseReleaseDocumentService { private escapePdfText(value: string): string { return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); } + + private textOp( + text: string, + x: number, + y: number, + size: number, + font: 'F1' | 'F2' = 'F1', + color = '0 0 0', + ): string { + return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`; + } + + private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string { + return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`; + } + + private circularSealOps(cx: number, cy: number): string { + return [ + 'q', + '0.08 0.32 0.18 RG', + '0.08 0.32 0.18 rg', + '2.2 w', + this.circlePath(cx, cy, 51), + 'S', + '0.8 w', + this.circlePath(cx, cy, 41), + 'S', + this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'), + this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'), + this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'), + 'Q', + ].join('\n'); + } + + private circlePath(cx: number, cy: number, r: number): string { + const k = 0.5522847498; + const c = r * k; + return [ + `${cx + r} ${cy} m`, + `${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`, + `${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`, + `${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`, + `${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`, + 'h', + ].join('\n'); + } } diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 8f0e233b4..67fe4e9cb 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite --port 5183", + "prebuild": "node -e \"const fs=require('fs'); fs.rmSync('dist',{recursive:true,force:true});\"", "build": "vite build", "preview": "vite preview --port 5183", "lint": "eslint src", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx index ad079577b..b0b864e4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/InventoryDetailModal.tsx @@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode }) } export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) { + const bookingReference = item?.booking?.reference ?? '-'; + const inventorySummary = [ + item?.status?.replace(/_/g, ' '), + item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null, + item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null, + ] + .filter(Boolean) + .join(' / '); + return ( {!item ? ( @@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM - {item.booking?.reference ?? item.bookingId ?? item.id} + {bookingReference} - Inventory ID: {item.id} + {inventorySummary || 'Inventory information'} @@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM + - - - + + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts index b64ae9384..9f91456c8 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/warehousePdf.ts @@ -130,12 +130,22 @@ function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob { export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') { const paid = kind === 'RECEIPT' || invoice.status === 'PAID'; const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`; + const bookingReference = firstText(invoice.bookingReference); + const customerName = firstText(invoice.customerName); + const inventoryReference = firstText(invoice.inventoryReference); + const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription); + const clearanceStatus = firstText( + invoice.clearanceStatus, + paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT', + ); const lines: PdfLine[] = [ { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, { text: title, size: 23, bold: true, yGap: 28, align: 'center' }, { text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' }, { text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' }, - { text: `Booking: ${invoice.bookingId ?? '-'} Inventory: ${invoice.inventoryId}`, align: 'center' }, + { text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' }, + { text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' }, + { text: `Clearance: ${clearanceStatus}`, align: 'center' }, { text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' }, { text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' }, ...(invoice.items ?? []).flatMap((item) => [ @@ -201,15 +211,33 @@ export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | Warehous const releasedItem = context.releasedItem; const inventory = context.inventory ?? releasedItem; const releasedAt = context.releasedAt ?? new Date(); - const releaseReference = `REL-${invoice.inventoryId.slice(0, 8).toUpperCase()}`; + const releaseReference = firstText( + inventory?.releaseOrderReference, + releasedItem?.releaseOrderReference, + invoice.inventoryReference, + booking?.reference ? `REL-${booking.reference.replace(/^BK-?/i, '')}` : null, + ); const customerName = firstText( booking?.company?.name, booking?.company?.companyName, booking?.company?.label, booking?.company?.contactPersonName, - invoice.customerId, + invoice.customerName, ); const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight)); + const inventoryInfo = firstText( + invoice.inventoryInfo, + invoice.containerNumber, + invoice.cargoDescription, + inventory?.status, + releasedItem?.status, + ); + const bookingReference = firstText( + booking?.reference, + (inventory as unknown as { bookingReference?: string })?.bookingReference, + invoice.bookingReference, + releasedItem?.booking?.reference, + ); return buildSimplePdf([ { text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' }, @@ -217,16 +245,18 @@ export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | Warehous { text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' }, { text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' }, { text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' }, - { text: `Booking Reference: ${firstText(booking?.reference, (inventory as unknown as { bookingReference?: string })?.bookingReference, invoice.bookingId, releasedItem?.bookingId)}`, align: 'center' }, + { text: `Booking Reference: ${bookingReference}`, align: 'center' }, { text: `Customer: ${customerName}`, align: 'center' }, - { text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code, invoice.warehouseId, releasedItem?.warehouseId)}`, align: 'center' }, - { text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code, invoice.yardId, releasedItem?.yardId)}`, align: 'center' }, - { text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code, invoice.zoneId, releasedItem?.zoneId)}`, align: 'center' }, + { text: `Inventory Info: ${inventoryInfo}`, align: 'center' }, + { text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code)}`, align: 'center' }, + { text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code)}`, align: 'center' }, + { text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code)}`, align: 'center' }, { text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' }, { text: `Weight: ${weightTons}`, align: 'center' }, { text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' }, + { text: `Clearance: ${invoice.clearanceStatus ?? 'CLEARED FOR WAREHOUSE EXIT'}`, bold: true, color: 'green', align: 'center' }, { text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' }, - { text: 'This document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' }, + { text: 'This sealed document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' }, ], [ ...buildAuthorizationBand('CLEARED'), textOp('Warehouse officer name / signature / date:', 72, 190, 10), diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 3f552ab15..061305b11 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,10 +1,6 @@ export const API_BASE_URL = -<<<<<<< HEAD - import.meta.env.VITE_API_URL?.replace(/\/+$/, '') || 'https://edrfreightapi.triaplc.com'; -======= import.meta.env.VITE_BASE_API_URL || import.meta.env.VITE_API_URL || 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'http://localhost:3001'; ->>>>>>> 2f06817e4811190cdc9ef8a2975aeca1e31c3484 diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 74008d81b..b935ac2ec 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -737,8 +737,16 @@ export interface WarehouseFeeInvoice { id: string; invoiceNumber: string; bookingId?: string | null; + bookingReference?: string | null; customerId?: string | null; + customerName?: string | null; inventoryId: string; + inventoryReference?: string | null; + inventoryInfo?: string | null; + inventoryStatus?: string | null; + containerNumber?: string | null; + cargoDescription?: string | null; + clearanceStatus?: string | null; facilityId?: string | null; warehouseId?: string | null; yardId?: string | null; From 29bdf687420c2faa9b9aa1f27fa232eb3d8843a5 Mon Sep 17 00:00:00 2001 From: hagiye Date: Sat, 27 Jun 2026 01:22:14 +0300 Subject: [PATCH 11/15] Invoice and clearance seal approval --- .../warehouses/warehouse-inventory.service.ts | 9 ++++++--- .../warehouse-release-document.service.ts | 10 +++++----- .../src/components/warehouses/warehousePdf.ts | 16 +++++++++++----- 3 files changed, 22 insertions(+), 13 deletions(-) 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 dc3719207..50fff5759 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 @@ -2351,7 +2351,7 @@ export class WarehouseInventoryService { .subtitle { margin-top: 6px; font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; } .ref { text-align: right; font-size: 12px; color: #475569; min-width: 210px; } .ref strong { display: block; color: #111827; font-size: 17px; margin: 4px 0 8px; } - .seal { position: absolute; right: 32px; top: 126px; width: 124px; height: 124px; border: 4px double #14532d; border-radius: 999px; color: #14532d; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 17px; line-height: 1.15; transform: rotate(-13deg); opacity: .86; text-transform: uppercase; } + .seal { position: absolute; right: 8px; top: -42px; width: 112px; height: 112px; border: 4px double #14532d; border-radius: 999px; color: #14532d; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 15px; line-height: 1.15; transform: rotate(-13deg); opacity: .86; text-transform: uppercase; } .seal::before { content: ""; position: absolute; inset: 10px; border: 1px solid #14532d; border-radius: inherit; } .notice { margin: 20px 154px 18px 0; padding: 13px 15px; background: #f0fdf4; border: 1px solid #86efac; border-left: 5px solid #14532d; font-size: 13px; line-height: 1.45; } .section-title { margin: 18px 0 8px; font-size: 13px; font-weight: 800; color: #14532d; text-transform: uppercase; letter-spacing: .08em; } @@ -2360,6 +2360,7 @@ export class WarehouseInventoryService { th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 12.5px; vertical-align: top; } .clause { margin-top: 16px; border: 1px solid #cbd5e1; padding: 12px 14px; font-size: 12.5px; line-height: 1.45; } .signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; margin-top: 42px; } + .officer-signature { position: relative; min-height: 98px; padding-right: 132px; } .line { border-top: 1px solid #111827; padding-top: 8px; font-size: 12px; color: #334155; } .footer { margin-top: 22px; border-top: 1px solid #cbd5e1; padding-top: 9px; font-size: 10.5px; color: #475569; line-height: 1.45; } @@ -2378,7 +2379,6 @@ export class WarehouseInventoryService { Issued: ${esc(issuedAt)}
-
EDR
Warehouse
Cleared
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
@@ -2394,7 +2394,10 @@ export class WarehouseInventoryService { cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
-
Authorized warehouse officer name / signature / date
+
+
EDR
Warehouse
Cleared
+
Officer in charge name / signature / date
+
Customer or driver name / signature / date
+
This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.
@@ -2400,15 +2464,10 @@ export class WarehouseInventoryService { cargo details, clearance status, and payment records before permitting exit from the warehouse premises.
-
-
EDR
Warehouse
Cleared
-
Officer in charge name / signature / date
-
+
Officer in charge name / signature / date
+
EDR
Warehouse
Cleared
Customer or driver name / signature / date
- `; @@ -2448,6 +2507,50 @@ export class WarehouseInventoryService { return trimmed ? `${trimmed}\n${note}` : note; } + private assertTruckEntrance(truckEntrance?: TruckEntranceDto): void { + if (!truckEntrance?.truckPlateNumber?.trim()) { + throw new BadRequestException('Truck plate number is required for entrance registration'); + } + if (!truckEntrance.driverName?.trim()) { + throw new BadRequestException('Driver name is required for entrance registration'); + } + if (!truckEntrance.driverPhone?.trim()) { + throw new BadRequestException('Driver phone is required for entrance registration'); + } + if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { + throw new BadRequestException('Entrance tare weight is required for entrance registration'); + } + } + + private generateGrnNumber(direction: string, referenceId: string, date: Date): string { + const stamp = date.toISOString().slice(0, 10).replace(/-/g, ''); + const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase(); + return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`; + } + + private buildReceiveNote(input: { + grnNumber: string; + direction?: string | null; + notes?: string | null; + truckEntrance: TruckEntranceDto; + }): string { + const truck = input.truckEntrance; + const rows = [ + `GRN Number: ${input.grnNumber}`, + input.direction ? `Direction: ${input.direction}` : null, + `Truck Plate: ${truck.truckPlateNumber}`, + truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null, + truck.truckType ? `Truck Type: ${truck.truckType}` : null, + `Driver: ${truck.driverName}`, + `Driver Phone: ${truck.driverPhone}`, + truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, + `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`, + truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, + input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null, + ]; + return rows.filter(Boolean).join('\n'); + } + private async getInventoryAllocationCriteria(item: WarehouseInventory): Promise { const fallbackFreightType = item.containerId ? 'CONTAINER' : item.cargoId ? 'BULK' : null; diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index bf15a4ab8..293b38dd7 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -18,16 +18,20 @@ import { } from '@mantine/core'; import { ChevronDown, ChevronRight, ClipboardCheck, Info, PackageSearch, Train, Truck } from 'lucide-react'; -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { api } from '@/services/api'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { useToast } from '@/hooks/use-toast'; +import { firstMileService } from '@/services/first-mile.service'; import type { + EligibleBooking, ImportTrain, ImportTrainItem, ImportUnloadedItem, ReadyToLoadRow, ReceiveInventoryPayload, + TruckEntrancePayload, } from '@/types/warehouse'; import { BookingSelect } from './BookingSelect'; import { InspectionReportModal } from './InspectionReportModal'; @@ -49,6 +53,106 @@ interface Location { zoneId: string; } +interface TruckEntranceFormState { + truckPlateNumber: string; + trailerPlateNumber: string; + driverName: string; + driverPhone: string; + driverLicenseNumber: string; + truckType: string; + entranceTareWeightKg: number | ''; + exitTareWeightKg: number | ''; +} + +const emptyTruckEntrance = (): TruckEntranceFormState => ({ + truckPlateNumber: '', + trailerPlateNumber: '', + driverName: '', + driverPhone: '', + driverLicenseNumber: '', + truckType: '', + entranceTareWeightKg: '', + exitTareWeightKg: '', +}); + +const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ + truckPlateNumber: form.truckPlateNumber.trim(), + trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, + driverName: form.driverName.trim(), + driverPhone: form.driverPhone.trim(), + driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, + truckType: form.truckType.trim() || undefined, + entranceTareWeightKg: Number(form.entranceTareWeightKg), + exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg), +}); + +function TruckEntranceFields({ + value, + onChange, +}: { + value: TruckEntranceFormState; + onChange: (next: TruckEntranceFormState) => void; +}) { + return ( + + + onChange({ ...value, truckPlateNumber: e.currentTarget.value })} + /> + onChange({ ...value, trailerPlateNumber: e.currentTarget.value })} + /> + + + onChange({ ...value, driverName: e.currentTarget.value })} + /> + onChange({ ...value, driverPhone: e.currentTarget.value })} + /> + + + onChange({ ...value, driverLicenseNumber: e.currentTarget.value })} + /> + onChange({ ...value, truckType: e.currentTarget.value })} + /> + + + onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })} + /> + onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })} + /> + + + ); +} + /** Cascading Warehouse → Yard → Zone selectors (ACTIVE only). */ function LocationSelects({ value, @@ -140,20 +244,105 @@ function EligibleTab({ onChanged?: () => void; }) { const { toast } = useToast(); + const qc = useQueryClient(); const { data: allRows = [], isLoading } = useQuery( api.warehouses.eligibleBookings.queryOptions({ enabled }), ); const rows = useMemo(() => allRows.filter((r) => r.direction === direction), [allRows, direction]); const bulkReceive = useMutation(api.warehouses.bulkReceive.mutationOptions()); const loadPassed = useMutation(api.warehouses.loadPassedExport.mutationOptions()); + const requestFirstMile = useMutation({ + mutationFn: (reference: string) => firstMileService.accept(reference), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['warehouse-inventory'] }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT }); + toast({ title: 'First mile requested', description: 'Booking was added to the existing First Mile workflow.' }); + }, + onError: (error) => { + toast({ variant: 'destructive', title: 'First mile request failed', description: extractErrorMessage(error) }); + }, + }); const [selected, setSelected] = useState>(new Set()); + const [statusTab, setStatusTab] = useState('ALL'); + const [truckOpen, setTruckOpen] = useState(false); + const [pendingReceiveIds, setPendingReceiveIds] = useState([]); + const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId); - const allSelected = rows.length > 0 && selected.size === rows.length; + const canReceiveBooking = (row: EligibleBooking) => + !(direction === 'EXPORT' && row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'); + const statusOptions = useMemo(() => { + const base = [{ value: 'ALL', label: 'All bookings' }]; + if (direction === 'EXPORT') { + return [ + ...base, + { value: 'DIRECT', label: 'Direct truck' }, + { value: 'FIRST_MILE', label: 'First mile' }, + { value: 'FIRST_MILE_READY', label: 'First mile arrived' }, + { value: 'AWAITING_FIRST_MILE', label: 'Awaiting first mile' }, + ]; + } + return [ + ...base, + { value: 'READY_TO_RECEIVE', label: 'Ready to receive' }, + { value: 'PAID', label: 'Paid' }, + ]; + }, [direction]); + const statusFilteredRows = useMemo( + () => + rows.filter((row) => { + switch (statusTab) { + case 'DIRECT': + return !row.hasFirstMile; + case 'FIRST_MILE': + return row.hasFirstMile; + case 'FIRST_MILE_READY': + return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT'; + case 'AWAITING_FIRST_MILE': + return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'; + case 'READY_TO_RECEIVE': + return canReceiveBooking(row); + case 'PAID': + return row.paymentStatus === 'PAID'; + default: + return true; + } + }), + [rows, statusTab], + ); + const statusCounts = useMemo( + () => + Object.fromEntries( + statusOptions.map((option) => [ + option.value, + rows.filter((row) => { + switch (option.value) { + case 'DIRECT': + return !row.hasFirstMile; + case 'FIRST_MILE': + return row.hasFirstMile; + case 'FIRST_MILE_READY': + return row.hasFirstMile && row.firstMileStatus === 'RECEIVED_TO_PORT'; + case 'AWAITING_FIRST_MILE': + return row.hasFirstMile && row.firstMileStatus !== 'RECEIVED_TO_PORT'; + case 'READY_TO_RECEIVE': + return canReceiveBooking(row); + case 'PAID': + return row.paymentStatus === 'PAID'; + default: + return true; + } + }).length, + ]), + ), + [rows, statusOptions], + ); + const selectableRows = statusFilteredRows.filter(canReceiveBooking); + const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length; const someSelected = selected.size > 0 && !allSelected; const toggleAll = () => - setSelected(allSelected ? new Set() : new Set(rows.map((r) => r.id))); + setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); const toggleOne = (id: string) => setSelected((prev) => { const next = new Set(prev); @@ -161,7 +350,7 @@ function EligibleTab({ return next; }); - const receive = async (bookingIds: string[]) => { + const openTruckReceive = (bookingIds: string[]) => { if (!locationReady) { toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' }); return; @@ -170,13 +359,41 @@ function EligibleTab({ toast({ variant: 'destructive', title: 'Select at least one booking' }); return; } + const allowedIds = new Set(selectableRows.map((row) => row.id)); + const filteredIds = bookingIds.filter((id) => allowedIds.has(id)); + if (filteredIds.length === 0) { + toast({ variant: 'destructive', title: 'No selected booking is ready to receive' }); + return; + } + const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null; + setPendingReceiveIds(filteredIds); + setTruckForm({ + ...emptyTruckEntrance(), + truckPlateNumber: row?.firstMileTruckPlateNumber ?? '', + trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '', + }); + setTruckOpen(true); + }; + + const receive = async () => { + if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { + toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); + return; + } try { - const r = await bulkReceive.mutateAsync({ direction, ...location, bookingIds }); + const r = await bulkReceive.mutateAsync({ + direction, + ...location, + bookingIds: pendingReceiveIds, + truckEntrance: toTruckEntrancePayload(truckForm), + }); toast({ title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`, - description: r.skippedCount ? `${r.skippedCount} skipped` : undefined, + description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined), }); setSelected(new Set()); + setTruckOpen(false); + setPendingReceiveIds([]); onChanged?.(); } catch (error) { toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) }); @@ -198,9 +415,19 @@ function EligibleTab({ return ( + setStatusTab(v ?? 'ALL')}> + + {statusOptions.map((option) => ( + + {option.label} ({statusCounts[option.value] ?? 0}) + + ))} + + + - Selected: {selected.size} / {rows.length} eligible + Selected: {selected.size} / {statusFilteredRows.length} eligible {direction === 'EXPORT' && ( @@ -218,9 +445,9 @@ function EligibleTab({ @@ -228,7 +455,7 @@ function EligibleTab({ size="compact-sm" disabled={!locationReady || selected.size === 0} loading={bulkReceive.isPending} - onClick={() => receive([...selected])} + onClick={() => openTruckReceive([...selected])} > Receive Selected @@ -245,7 +472,7 @@ function EligibleTab({ - ) : rows.length === 0 ? ( + ) : statusFilteredRows.length === 0 ? ( No eligible PAID {direction.toLowerCase()} bookings to receive. @@ -275,16 +502,20 @@ function EligibleTab({ Payment Current Status Inspection + {direction === 'EXPORT' && First Mile} Actions - {rows.map((r) => ( + {statusFilteredRows.map((r) => { + const canReceive = canReceiveBooking(r); + return ( toggleOne(r.id)} /> @@ -319,23 +550,73 @@ function EligibleTab({ + {direction === 'EXPORT' && ( + + {r.hasFirstMile ? ( + + + {r.firstMileStatus ?? 'Request needed'} + + + {[r.firstMileTruckPlateNumber, r.firstMileTrailerPlateNumber].filter(Boolean).join(' / ') || 'Truck not assigned'} + + + ) : ( + Direct arrival + )} + + )} - + {direction === 'EXPORT' && r.hasFirstMile && !r.firstMileRequestId ? ( + + ) : ( + + )} - ))} + ); + })} )} + + setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg"> + + + Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}. + + + + + + + + ); } @@ -1203,11 +1484,13 @@ function SingleBookingReceiveModal({ volume: '', notes: '', }); + const [truckForm, setTruckForm] = useState(emptyTruckEntrance()); useEffect(() => { if (opened) { setSelectedBooking(bookingId ?? ''); setForm({ warehouseId: '', yardId: '', zoneId: '', quantity: '', weight: '', volume: '', notes: '' }); + setTruckForm(emptyTruckEntrance()); } }, [opened, bookingId]); @@ -1226,6 +1509,10 @@ function SingleBookingReceiveModal({ toast({ variant: 'destructive', title: 'Quantity and weight are required' }); return; } + if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { + toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); + return; + } const payload: ReceiveInventoryPayload = { bookingId: selectedBooking.trim(), warehouseId: form.warehouseId, @@ -1235,6 +1522,7 @@ function SingleBookingReceiveModal({ weight: Number(form.weight), volume: form.volume === '' ? undefined : Number(form.volume), notes: form.notes.trim() || undefined, + truckEntrance: toTruckEntrancePayload(truckForm), }; try { await receiveMutation.mutateAsync(payload); @@ -1281,6 +1569,8 @@ function SingleBookingReceiveModal({ /> + +