From 052829c7e6e691df02da6234bd52db0174ab0d26 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 16 Jun 2026 15:28:10 +0000 Subject: [PATCH 01/27] Add freight demo data seeder and permissions management - Introduced `DemoFreightDataSeeder` to seed demo freight data including wagons, approval rules, and staff users. - Added `seed:freight-demo` script to `package.json` for easy execution. - Updated permissions for operations officer and added permission checks in various components. - Enhanced sidebar and booking actions to respect user permissions. --- apps/edr-freight-api/package.json | 1 + apps/edr-freight-api/src/app.module.ts | 6 + .../train-scheduling.controller.ts | 3 +- .../src/scripts/seed-freight-demo.ts | 28 +++ .../src/seed/demo-freight-data.seeder.ts | 180 ++++++++++++++++++ .../src/seed/edr-freight.seed.ts | 5 + .../src/seed/freight-permissions.registry.ts | 16 ++ apps/edr-freight-web/backoffice/src/App.tsx | 75 ++++++-- .../src/components/auth/RequirePermission.tsx | 30 +++ .../bookings/BookingActionsToolbar.tsx | 6 +- .../backoffice/src/components/layout/types.ts | 2 + .../bookings/booking-actions.config.ts | 1 + .../backoffice/src/lib/permissions.ts | 12 ++ pnpm-lock.yaml | 29 +-- 14 files changed, 351 insertions(+), 43 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-freight-demo.ts create mode 100644 apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 32237b507..3ddef331e 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -15,6 +15,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json", "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:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh" }, "dependencies": { diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 30b25e013..4d855e055 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -48,6 +48,7 @@ import { DemoBookingsSeeder } from "./seed/demo-bookings.seeder"; import { PricingDataSeeder } from "./seed/pricing-data.seeder"; import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder"; import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder"; +import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; //New Trains, Wagons, Container and Cargo management modules import { TrainsModule } from "./modules/trains/trains.module"; import { WagonsModule } from './modules/wagons/wagons.module'; @@ -121,6 +122,7 @@ import { OverviewModule } from './modules/overview/overview.module'; PricingDataSeeder, FileUploadSettingsSeeder, FreightPermissionKeyMigrationSeeder, + DemoFreightDataSeeder, ], }) export class AppModule implements OnApplicationBootstrap { @@ -133,6 +135,7 @@ export class AppModule implements OnApplicationBootstrap { private readonly pricingDataSeeder: PricingDataSeeder, private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, + private readonly demoFreightDataSeeder: DemoFreightDataSeeder, ) { } async onApplicationBootstrap() { @@ -144,5 +147,8 @@ export class AppModule implements OnApplicationBootstrap { await this.demoBookingsSeeder.run(); await this.pricingDataSeeder.run(); await this.fileUploadSettingsSeeder.run(); + // Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. + // Each block self-guards on an empty-table check, so this is safe every boot. + await this.demoFreightDataSeeder.run(); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts index 3ef7693ab..2a4c3a357 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts @@ -96,7 +96,8 @@ export class TrainSchedulingController { } @Get("bookable-schedules") - // @TrainSchedulingView() + // No staff guard: customers hit this while creating a booking to find OPEN + // same-route schedules. Do not attach train_scheduling permissions here. @ApiOperation({ summary: "OPEN same-route schedules a new booking can target", }) diff --git a/apps/edr-freight-api/src/scripts/seed-freight-demo.ts b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts new file mode 100644 index 000000000..8f4e8d0b2 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-freight-demo.ts @@ -0,0 +1,28 @@ +import 'reflect-metadata'; +import { config } from 'dotenv'; +import { resolve } from 'path'; + +config({ path: resolve(__dirname, '../../.env') }); + +import { NestFactory } from '@nestjs/core'; +import { AppModule } from '../app.module'; +import { DemoFreightDataSeeder } from '../seed/demo-freight-data.seeder'; + +async function main() { + const app = await NestFactory.createApplicationContext(AppModule, { + logger: ['error', 'warn', 'log'], + }); + + try { + const seeder = app.get(DemoFreightDataSeeder); + await seeder.run(); + console.log('Freight demo data seeded (wagons, approval rules, staff users).'); + } finally { + await app.close(); + } +} + +main().catch((err) => { + console.error('Freight demo seed failed:', err); + process.exit(1); +}); diff --git a/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts new file mode 100644 index 000000000..f4931f77b --- /dev/null +++ b/apps/edr-freight-api/src/seed/demo-freight-data.seeder.ts @@ -0,0 +1,180 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { WagonStatus } from '@edr/types'; +import { hashPassword } from '@tria-plc/api-common/utils/argon'; +import { EUserStatus } from '@tria-plc/api-common/utils/enums/user.enum'; +import { + Employee, + Organization, + Role, + User, + UserCredential, + UserRole, +} from '@tria-plc/iamapi-common'; +import { DataSource, EntityManager } from 'typeorm'; + +import { Wagon } from '../modules/wagons/entities/wagon.entity'; +import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity'; +import { ApprovalRule } from '../modules/rule-engine/entities/approval-rule.entity'; +import { DEFAULT_APPROVAL_RULE_ROWS } from '../modules/rule-engine/approval-rules.defaults'; + +const EDR_ORG_KEY = 'edr_freight'; +const MIN_WAGONS_PER_TYPE = 100; + +/** The four demo staff users, each mapped to a seeded freight role. */ +const DEMO_STAFF_USERS = [ + { email: 'marketing@edr.local', username: 'marketing', roleKey: 'edr_marketing' }, + { email: 'operations@edr.local', username: 'operations', roleKey: 'edr_operations_officer' }, + { email: 'director@edr.local', username: 'director', roleKey: 'edr_director' }, + { email: 'ceo@edr.local', username: 'ceo', roleKey: 'edr_ceo' }, +] as const; + +/** + * One-shot demo data: at least 100 wagons per wagon type, the default approval + * chains, and four staff users with distinct permissions. Every block guards on + * an "is it already populated?" check, so this is safe to run on every boot and + * does nothing once the data exists. + */ +@Injectable() +export class DemoFreightDataSeeder { + private readonly logger = new Logger(DemoFreightDataSeeder.name); + + constructor(private readonly dataSource: DataSource) {} + + async run() { + await this.dataSource.transaction(async (manager) => { + await this.seedWagons(manager); + await this.seedApprovalRules(manager); + await this.seedStaffUsers(manager); + }); + } + + /** Ensure every wagon type has at least MIN_WAGONS_PER_TYPE wagons. */ + private async seedWagons(manager: EntityManager) { + const wagonTypeRepo = manager.getRepository(WagonType); + const wagonRepo = manager.getRepository(Wagon); + + const wagonTypes = await wagonTypeRepo.find(); + if (wagonTypes.length === 0) { + this.logger.warn('No wagon types found; skipping wagon seed'); + return; + } + + for (const type of wagonTypes) { + const existing = await wagonRepo.count({ where: { wagonTypeId: type.id } }); + if (existing >= MIN_WAGONS_PER_TYPE) { + this.logger.log( + `Wagon type ${type.code} already has ${existing} wagons; skipping`, + ); + continue; + } + + const toCreate = MIN_WAGONS_PER_TYPE - existing; + const tare = Number(type.tareWeightTons ?? 20); + const maxPayload = Number(type.capacityTons ?? 60); + const rows = Array.from({ length: toCreate }, (_, i) => { + const seq = existing + i + 1; + return wagonRepo.create({ + wagonNumber: `${type.code}-${String(seq).padStart(4, '0')}`, + wagonTypeId: type.id, + tareWeight: tare, + maxPayloadWeight: maxPayload, + status: WagonStatus.Available, + }); + }); + await wagonRepo.save(rows); + this.logger.log(`Seeded ${toCreate} wagons for type ${type.code}`); + } + } + + /** Seed the default approval chains when the table is empty. */ + private async seedApprovalRules(manager: EntityManager) { + const repo = manager.getRepository(ApprovalRule); + const count = await repo.count(); + if (count > 0) { + this.logger.log(`Approval rules already populated (${count}); skipping`); + return; + } + await repo.save(DEFAULT_APPROVAL_RULE_ROWS.map((row) => repo.create(row))); + this.logger.log(`Seeded ${DEFAULT_APPROVAL_RULE_ROWS.length} approval rules`); + } + + /** Create the four demo staff users with their roles (idempotent per email). */ + private async seedStaffUsers(manager: EntityManager) { + const organization = await manager.getRepository(Organization).findOne({ + where: { key: EDR_ORG_KEY }, + select: { id: true, key: true }, + }); + if (!organization) { + this.logger.warn(`Missing organization ${EDR_ORG_KEY}; skipping staff users`); + return; + } + + const roleRepo = manager.getRepository(Role); + const userRepo = manager.getRepository(User); + const credentialRepo = manager.getRepository(UserCredential); + const userRoleRepo = manager.getRepository(UserRole); + const employeeRepo = manager.getRepository(Employee); + + const password = process.env.DEFAULT_PASSWORD?.trim() || '12345678'; + const hashedPassword = await hashPassword(password); + + for (const staff of DEMO_STAFF_USERS) { + const role = await roleRepo.findOne({ + where: { key: staff.roleKey }, + select: { id: true, key: true }, + }); + if (!role) { + this.logger.warn(`Missing role ${staff.roleKey}; skipping ${staff.email}`); + continue; + } + + let user = await userRepo.findOne({ + where: { email: staff.email }, + select: { id: true, email: true }, + }); + if (!user) { + user = await userRepo.save( + userRepo.create({ + email: staff.email, + username: staff.username, + name: { en: staff.username }, + isActive: true, + hasSetPassword: true, + status: EUserStatus.ACCEPTED, + }), + ); + this.logger.log(`Seeded staff user ${staff.email}`); + } + + const hasCredential = await credentialRepo.exists({ + where: { userId: user.id, isActive: true }, + }); + if (!hasCredential) { + await credentialRepo.insert({ + userId: user.id, + password: hashedPassword, + isActive: true, + }); + } + + await userRoleRepo.upsert( + { userId: user.id, roleId: role.id, organizationId: organization.id }, + { conflictPaths: { userId: true, roleId: true } }, + ); + + const hasEmployee = await employeeRepo.exists({ + where: { userId: user.id, organizationId: organization.id, isCurrent: true }, + }); + if (!hasEmployee) { + await employeeRepo.insert({ + userId: user.id, + organizationId: organization.id, + isCurrent: true, + name: { en: staff.username }, + }); + } + } + + this.logger.log('Ensured demo staff users (marketing@, operations@, director@, ceo@)'); + } +} diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index c2705673f..a88ee8f89 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -212,6 +212,11 @@ export const EDR_FREIGHT_ROLES: FreightSeedRole[] = [ name: { en: "EDR Line Staff" }, permissionKeys: [...ROLE_PERMISSION_PRESETS.lineStaff], }, + { + key: "edr_operations_officer", + name: { en: "EDR Operations Officer" }, + permissionKeys: [...ROLE_PERMISSION_PRESETS.operationsOfficer], + }, { key: "edr_director", name: { en: "EDR Director" }, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 0ee62fa7c..9d8cd382f 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -121,6 +121,9 @@ const allRuleEngineViewKeys = () => RULE_ENGINE_RESOURCE_SLUGS.map((s) => FREIGHT_PERMS.ruleEngine.view(s)); export const ROLE_PERMISSION_PRESETS = { + // Marketing / line staff: drives a booking from intake through line-staff + // approval and contract generation/signing — i.e. until the contract is ready + // and signed. No director/CEO approval, no scheduling, no operations. lineStaff: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.staffAccept, @@ -129,6 +132,12 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.approveLineStaff, FREIGHT_PERMS.bookings.rejectApproval, FREIGHT_PERMS.bookings.cancel, + ...allRuleEngineViewKeys(), + ], + // Operations Officer: train scheduling + wagon allocation + transit/complete. + operationsOfficer: [ + FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.manage, ...allRuleEngineViewKeys(), @@ -147,8 +156,15 @@ export const ROLE_PERMISSION_PRESETS = { ...allRuleEngineViewKeys(), ], finance: [FREIGHT_PERMS.bookings.view], + // Marketing handles intake through contract (same as line staff here). marketing: [ FREIGHT_PERMS.bookings.view, + FREIGHT_PERMS.bookings.staffAccept, + FREIGHT_PERMS.bookings.requestChanges, + FREIGHT_PERMS.bookings.reject, + FREIGHT_PERMS.bookings.approveLineStaff, + FREIGHT_PERMS.bookings.rejectApproval, + FREIGHT_PERMS.bookings.cancel, FREIGHT_PERMS.bookings.generateContract, FREIGHT_PERMS.bookings.signStaff, ], diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index cfe22845d..9468fe568 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -47,6 +47,8 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; +import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "./lib/permissions"; +import { RequirePermission } from "./components/auth/RequirePermission"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; import RoutesPage from "./pages/fleet/RoutesPage"; @@ -80,11 +82,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Train Schedules", href: "/dashboard/operations/train-scheduling-v2", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, }, { label: "Batch Board", href: "/dashboard/operations/batch-board", icon: , + permission: FREIGHT_PERMS.trainScheduling.view, }, ], }, @@ -196,18 +200,25 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ }, ]; -const hasPermission = ( +/** Keep only items the user is permitted to see; drop now-empty sections. */ +const filterSidebarByPermission = ( + sections: SidebarSection[], user: ReturnType["user"], - key: string, -) => { - if (!user) return false; - if (user.permissions?.some((p) => p.key === key)) return true; +): SidebarSection[] => { + const itemAllowed = (item: SidebarItem): boolean => { + if (!item.permission) return true; + const keys = Array.isArray(item.permission) + ? item.permission + : [item.permission]; + return keys.some((key) => hasFreightPermission(user, key)); + }; - return (user.employee ?? []).some((emp) => - (emp.positions ?? []).some((pos) => - (pos.permissions ?? []).some((p) => p.key === key), - ), - ); + return sections + .map((section) => ({ + ...section, + items: section.items.filter(itemAllowed), + })) + .filter((section) => section.items.length > 0); }; const DashboardShell = () => { @@ -217,7 +228,10 @@ const DashboardShell = () => { const demoItems: SidebarItem[] = []; - const sidebarSections = buildSidebarSections(demoItems); + const sidebarSections = filterSidebarByPermission( + buildSidebarSections(demoItems), + user, + ); const displayName = user?.name?.en || user?.username || user?.email || "User"; return ( @@ -271,22 +285,45 @@ const App = () => { path="operations/train-scheduling" element={} /> - } /> + + + + } + /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } + element={ + + + + } /> } /> } /> @@ -316,7 +353,11 @@ const App = () => { /> } + element={ + + + + } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx new file mode 100644 index 000000000..4e35fc712 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/auth/RequirePermission.tsx @@ -0,0 +1,30 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router-dom"; + +import { useAuth } from "@/auth/useAuth"; +import { hasPermission } from "@/lib/permissions"; + +interface RequirePermissionProps { + /** Permission key(s); access is granted if the user has ANY of them. */ + permission: string | string[]; + /** Where to send users who lack the permission. */ + redirectTo?: string; + children: ReactNode; +} + +/** + * Page-level guard: renders children only when the current user holds one of + * the given permissions, otherwise redirects (default: overview). + */ +export function RequirePermission({ + permission, + redirectTo = "/dashboard/overview", + children, +}: RequirePermissionProps) { + const { user } = useAuth(); + const keys = Array.isArray(permission) ? permission : [permission]; + const allowed = keys.some((key) => hasPermission(user, key)); + + if (!allowed) return ; + return <>{children}; +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx index 0c20184cf..4589ad7fb 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingActionsToolbar.tsx @@ -8,6 +8,8 @@ import { BookingActionsMenu } from "./BookingActionsMenu"; import { SectionCard } from "./detail/SectionCard"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; import { canAllocateBooking } from "@/features/bookings/booking-actions.config"; +import { useAuth } from "@/auth/useAuth"; +import { canManageScheduling } from "@/lib/permissions"; import type { useBookingMutations } from "@/hooks/bookings/useBookings"; type Mutations = ReturnType; @@ -19,9 +21,11 @@ interface BookingActionsToolbarProps { /** Detail-page actions: primary toolbar + downloads. */ export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) { + const { user } = useAuth(); const row = toBookingListRow(booking); const { status } = booking; const [allocateOpen, setAllocateOpen] = useState(false); + const canAllocate = canManageScheduling(user); const downloadBlob = async (fn: () => Promise, filename: string) => { const blob = await fn(); @@ -127,7 +131,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool )} - {canAllocateBooking(booking) ? ( + {canAllocate && canAllocateBooking(booking) ? ( > = { signContractStaff: FREIGHT_PERMS.bookings.signStaff, startTransit: FREIGHT_PERMS.bookings.operations, complete: FREIGHT_PERMS.bookings.operations, + allocateBooking: FREIGHT_PERMS.trainScheduling.manage, cancel: FREIGHT_PERMS.bookings.cancel, }; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index cadaaea03..653765c1a 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -16,6 +16,10 @@ export const FREIGHT_PERMS = { operations: "edr_freight_app:bookings:operations", cancel: "edr_freight_app:bookings:cancel", }, + trainScheduling: { + view: "edr_freight_app:train_scheduling:view", + manage: "edr_freight_app:train_scheduling:manage", + }, } as const; const slugToResourceKey = (slug: RuleEngineResourceSlug): string => @@ -70,6 +74,14 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean { return hasPermission(user, FREIGHT_PERMS.bookings.view); } +export function canViewScheduling(user: AuthUser | null | undefined): boolean { + return hasPermission(user, FREIGHT_PERMS.trainScheduling.view); +} + +export function canManageScheduling(user: AuthUser | null | undefined): boolean { + return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage); +} + export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string { return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2e68f72e..b3710a9be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -122,6 +122,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.2 + typeorm: + specifier: ^0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) devDependencies: '@edr/eslint-config': specifier: workspace:* @@ -177,9 +180,6 @@ importers: tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 - typeorm: - specifier: ^0.3.30 - version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) typescript: specifier: ^5.5.4 version: 5.9.3 @@ -14446,7 +14446,7 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@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@0.10.9))(amqplib@0.10.9)(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/platform-express': 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) '@nestjs/event-emitter@2.1.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)': @@ -14477,19 +14477,6 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 - '@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@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)': - dependencies: - '@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(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) - iterare: 1.2.1 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - tslib: 2.8.1 - optionalDependencies: - amqp-connection-manager: 5.0.0(amqplib@0.10.9) - amqplib: 0.10.9 - optional: true - '@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)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -14574,7 +14561,7 @@ snapshots: '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@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@0.10.9))(amqplib@0.10.9)(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/platform-express': 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) '@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)': @@ -18651,12 +18638,6 @@ snapshots: amqplib: 0.10.9 promise-breaker: 6.0.0 - amqp-connection-manager@5.0.0(amqplib@0.10.9): - dependencies: - amqplib: 0.10.9 - promise-breaker: 6.0.0 - optional: true - amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 From eb60fc340df4f8f0de266e85c7c05c74d9aa4acd Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 16 Jun 2026 18:44:09 +0300 Subject: [PATCH 02/27] Refactor booking creation to support one-way and round-trip bookings with enhanced DTOs and service methods --- apps/edr-passenger-api/prisma/schema.prisma | 66 ++-- .../modules/bookings/bookings.controller.ts | 17 +- .../src/modules/bookings/bookings.dto.ts | 151 ++++++--- .../src/modules/bookings/bookings.service.ts | 307 ++++++++++++++---- 4 files changed, 414 insertions(+), 127 deletions(-) diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 26c9c9eb3..b2259a448 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -494,38 +494,44 @@ model FareRule { } model Booking { - id String @id @default(uuid()) - bookingRef String @unique - passengerId String - scheduleId String - status BookingStatus @default(DRAFT) - currency String @default("ETB") - totalMinor Int - adultCount Int @default(1) - childCount Int @default(0) - displayCurrency Currency? - displayTotalMinor Int? - bookingType String @default("ONE_WAY") - contactEmail String? - contactPhone String? - userAgent String? - source String @default("WEB") - promoCode String? - paidAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - passenger Passenger @relation(fields: [passengerId], references: [id]) - schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) - seats BookingSeat[] - paymentIntent PaymentIntent? - ticket Ticket? - foodOrders FoodOrder[] - agentBooking AgentBooking? - modifications BookingModification[] - cancellation BookingCancellation? - baggage BaggageBooking[] + id String @id @default(uuid()) + bookingRef String @unique + passengerId String + scheduleId String + bookingType String @default("ONE_WAY") + status BookingStatus @default(DRAFT) + currency String @default("ETB") + totalMinor Int + adultCount Int @default(1) + childCount Int @default(0) + displayCurrency Currency? + displayTotalMinor Int? + returnScheduleId String? + returnOriginStationId String? + returnDestinationStationId String? + returnHoldId String? + returnSeatClassId String? + contactEmail String? + contactPhone String? + userAgent String? + source String @default("WEB") + promoCode String? + paidAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + passenger Passenger @relation(fields: [passengerId], references: [id]) + schedule TrainSchedule @relation(fields: [scheduleId], references: [id]) + seats BookingSeat[] + paymentIntent PaymentIntent? + ticket Ticket? + foodOrders FoodOrder[] + agentBooking AgentBooking? + modifications BookingModification[] + cancellation BookingCancellation? + baggage BaggageBooking[] @@index([passengerId, status]) + @@index([bookingType]) @@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 d7f87550f..9622fa656 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -144,9 +144,20 @@ export class BookingsController { @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') @ApiOperation({ - summary: 'Create booking (requires login)', - description: `Creates a booking for logged-in users with saved passenger profiles. - Use POST /bookings/guest for guest checkout without login.` + summary: 'Create booking (one-way or round-trip)', + description: `Creates a one-way or round-trip booking for logged-in users. + +ONE_WAY booking: +- scheduleId, holdId, originStationId, destinationStationId +- passengers: array of PassengerInputDto with seatId +- Single PNR, single payment + +ROUND_TRIP booking: +- Outbound: scheduleId, holdId, originStationId, destinationStationId, seatClassId +- Return: returnScheduleId, returnHoldId, returnOriginStationId, returnDestinationStationId, returnSeatClassId +- passengers: array of RoundTripPassengerDto with outboundSeatId and returnSeatId +- Combined PNR, single payment for both legs +- Fare = outbound_fare + return_fare, single total, single promo, single loyalty deduction` }) @ApiResponse({ status: 201, description: 'Booking created with fare breakdown' }) @ApiResponse({ status: 400, description: 'Verifayda verification failed or invalid passenger data' }) diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts index b36790ce2..875bf40a2 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -15,53 +15,130 @@ export class PassengerInputDto { } export class RoundTripPassengerDto { - @ApiProperty({ description: 'Outbound segment seat ID' }) @IsString() outboundSeatId: string; - @ApiProperty({ description: 'Return segment seat ID' }) @IsString() returnSeatId: string; - @ApiProperty({ example: 'Abebe Kebede' }) @IsString() passengerName: string; - @ApiProperty({ example: '1990-05-15', description: 'Date of birth (YYYY-MM-DD)' }) @IsDateString() dateOfBirth: string; - @ApiProperty({ example: 'NATIONAL_ID', enum: IdDocumentType }) @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; - @ApiPropertyOptional() @IsOptional() @IsString() idDocumentNumber?: string; - @ApiPropertyOptional() @IsOptional() @IsString() passportNumber?: string; - @ApiPropertyOptional() @IsOptional() @IsString() passportCountry?: string; - @ApiPropertyOptional() @IsOptional() @IsString() nationality?: string; + @ApiProperty({ + description: 'Outbound journey seat ID', + example: 'seat-uuid-outbound' + }) + @IsString() outboundSeatId: string; + + @ApiProperty({ + description: 'Return journey seat ID', + example: 'seat-uuid-return' + }) + @IsString() returnSeatId: string; + + @ApiProperty({ + example: 'Abebe Kebede', + description: 'Full passenger name (will be verified via Verifayda for Ethiopian nationals)' + }) + @IsString() passengerName: string; + + @ApiProperty({ + example: '1990-05-15', + description: 'Date of birth (YYYY-MM-DD) for age calculation. Age <5 = CHILD (first child FREE), Age ≥5 = ADULT (full fare for both legs)' + }) + @IsDateString() dateOfBirth: string; + + @ApiProperty({ + example: 'NATIONAL_ID', + enum: IdDocumentType, + description: 'NATIONAL_ID for Ethiopians (Verifayda verified), PASSPORT for others' + }) + @IsEnum(IdDocumentType) idDocumentType: IdDocumentType; + + @ApiPropertyOptional({ + example: 'ET123456789', + description: 'Ethiopian national ID - verified via Verifayda 2.0 (NOT stored in database)' + }) + @IsOptional() @IsString() idDocumentNumber?: string; + + @ApiPropertyOptional({ + example: 'P1234567', + description: 'Passport number for non-Ethiopian passengers (no verification)' + }) + @IsOptional() @IsString() passportNumber?: string; + + @ApiPropertyOptional({ + example: 'Djibouti', + description: 'Passport issuing country for non-Ethiopians' + }) + @IsOptional() @IsString() passportCountry?: string; + + @ApiPropertyOptional({ + example: 'Ethiopian', + description: 'Ethiopian (Verifayda + Telebirr/CBE/eBirr), Djiboutian (Passport + Waafi), Other (Passport + Card)' + }) + @IsOptional() @IsString() nationality?: string; } export class CreateBookingDto { - @ApiProperty() @IsString() passengerId: string; - @ApiProperty() @IsString() scheduleId: string; - @ApiProperty() @IsString() holdId: string; - @ApiProperty({ example: 'station-uuid', description: 'Origin station UUID for this leg (must match the hold)' }) @IsString() originStationId: string; - @ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg (must match the hold)' }) @IsString() destinationStationId: string; - @ApiProperty({ type: [PassengerInputDto], description: 'Array of passengers with age-based categorization. First child (<5 years) travels FREE.' }) @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) passengers: PassengerInputDto[]; - @ApiProperty({ example: 'seat-class-uuid', description: 'Seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) + @ApiProperty({ description: 'Passenger ID' }) + @IsString() passengerId: string; + + @ApiProperty({ description: 'Outbound schedule ID' }) + @IsString() scheduleId: string; + + @ApiProperty({ description: 'Outbound seat hold ID' }) + @IsString() holdId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Outbound origin station UUID (must match the hold)' }) + @IsString() originStationId: string; + + @ApiProperty({ example: 'station-uuid', description: 'Outbound destination station UUID (must match the hold)' }) + @IsString() destinationStationId: string; + + @ApiProperty({ example: 'seat-class-uuid', description: 'Outbound seat class UUID (Economy Regular, Economy Bed, VIP Bed)' }) @IsString() seatClassId: string; - @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; - @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiPropertyOptional({ example: 'ONE_WAY' }) @IsOptional() @IsString() bookingType?: string; - @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; -} -export class CreateRoundTripBookingDto { - @ApiProperty({ description: 'Passenger ID' }) @IsString() passengerId: string; + @ApiProperty({ + example: 'ONE_WAY', + enum: ['ONE_WAY', 'ROUND_TRIP'], + description: `Booking type:\n\n**ONE_WAY:**\n- Single journey from origin to destination\n- Uses: scheduleId, holdId, originStationId, destinationStationId, seatClassId\n- passengers: PassengerInputDto[] with seatId\n\n**ROUND_TRIP:**\n- Outbound + return journey with single PNR\n- Uses all outbound fields PLUS return fields\n- passengers: RoundTripPassengerDto[] with outboundSeatId and returnSeatId\n- Combined fare calculation with single payment`, + default: 'ONE_WAY' + }) + @IsOptional() @IsString() bookingType?: string; - @ApiProperty({ description: 'Outbound schedule ID' }) @IsString() outboundScheduleId: string; - @ApiProperty({ description: 'Outbound origin station ID' }) @IsString() outboundOriginStationId: string; - @ApiProperty({ description: 'Outbound destination station ID' }) @IsString() outboundDestinationStationId: string; - @ApiProperty({ description: 'Outbound seat hold ID' }) @IsString() outboundHoldId: string; + @ApiProperty({ + type: [PassengerInputDto], + description: `Passenger array - type depends on bookingType:\n\n**For ONE_WAY:** PassengerInputDto[]\n- Each passenger has: seatId, passengerName, dateOfBirth, etc.\n\n**For ROUND_TRIP:** RoundTripPassengerDto[]\n- Each passenger has: outboundSeatId, returnSeatId, passengerName, dateOfBirth, etc.\n\n**Age-based pricing:** First child (<5 years) travels FREE, subsequent children pay full fare` + }) + @IsArray() @ValidateNested({ each: true }) @Type(() => PassengerInputDto) + passengers: PassengerInputDto[]; + + @ApiPropertyOptional({ description: 'Promo code for discount (applies to combined fare for round-trip)' }) + @IsOptional() @IsString() promoCode?: string; - @ApiProperty({ description: 'Return schedule ID' }) @IsString() returnScheduleId: string; - @ApiProperty({ description: 'Return origin station ID (usually same as outbound destination)' }) @IsString() returnOriginStationId: string; - @ApiProperty({ description: 'Return destination station ID (usually same as outbound origin)' }) @IsString() returnDestinationStationId: string; - @ApiProperty({ description: 'Return seat hold ID' }) @IsString() returnHoldId: string; + @ApiPropertyOptional({ description: 'Loyalty points to redeem (applies to combined fare for round-trip)' }) + @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiProperty({ type: [RoundTripPassengerDto], description: 'Array of passengers with seats for both outbound and return legs' }) - @IsArray() @ValidateNested({ each: true }) @Type(() => RoundTripPassengerDto) passengers: RoundTripPassengerDto[]; + @ApiPropertyOptional({ example: 'DJF', enum: Currency, description: 'Display currency for fare breakdown (ETB, DJF, USD). Transaction always in ETB.' }) + @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + + // Round-trip specific fields + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return schedule ID (required when bookingType=ROUND_TRIP)' + }) + @IsOptional() @IsString() returnScheduleId?: string; - @ApiProperty({ description: 'Seat class ID' }) @IsString() seatClassId: string; + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return origin station ID (usually same as outbound destination)' + }) + @IsOptional() @IsString() returnOriginStationId?: string; - @ApiPropertyOptional() @IsOptional() @IsString() promoCode?: string; - @ApiPropertyOptional() @IsOptional() @IsInt() loyaltyRedemptionPoints?: number; - @ApiPropertyOptional({ example: 'DJF', enum: Currency }) @IsOptional() @IsEnum(Currency) displayCurrency?: Currency; + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return destination station ID (usually same as outbound origin)' + }) + @IsOptional() @IsString() returnDestinationStationId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return seat hold ID (required when bookingType=ROUND_TRIP)' + }) + @IsOptional() @IsString() returnHoldId?: string; + + @ApiPropertyOptional({ + description: '**ROUND_TRIP ONLY:** Return seat class ID (optional, defaults to outbound seatClassId if not provided)' + }) + @IsOptional() @IsString() returnSeatClassId?: string; } export class ModifyBookingDto { 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 3c8928ea6..7a52f8422 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -246,15 +246,19 @@ export class BookingsService { } async create(dto: CreateBookingDto) { + if (dto.bookingType === 'ROUND_TRIP') { + return this.createRoundTripBooking(dto); + } + return this.createOneWayBooking(dto); + } + + private async createOneWayBooking(dto: CreateBookingDto) { const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); + const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: dto.scheduleId }, - include: { - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } }); if (!schedule) throw new NotFoundException('Schedule not found'); @@ -262,18 +266,182 @@ export class BookingsService { const destStop = schedule.stopTimes.find(s => s.stationId === dto.destinationStationId); if (!originStop || !destStop) throw new NotFoundException('Origin or destination not found'); - const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; - const fullRoute = `${schedule.originStation.code}-${schedule.destinationStation.code}`; + const passengersData = await this.processPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + const fareCalculation = await this.calculateFare(dto.scheduleId, dto.seatClassId, originStop, destStop, passengersData[0]?.nationality, adultCount, childCount, dto.promoCode, dto.loyaltyRedemptionPoints); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = fareCalculation.totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency); + } - const seatIds = dto.passengers.map((p) => p.seatId); - const passengersData = []; - let adultCount = 0, childCount = 0; + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ONE_WAY', + totalMinor: fareCalculation.totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + seats: { + create: passengersData.map(p => ({ + seat: { connect: { id: p.seatId } }, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0), + displayCurrency + })) + } + }, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } + }); - for (const passenger of dto.passengers) { + await this.seatsService.confirmSeats(passengersData.map(p => p.seatId)); + this.eventEmitter.emit('booking.created', { booking }); + return { ...booking, fareBreakdown: fareCalculation }; + } + + private async createRoundTripBooking(dto: CreateBookingDto) { + if (!dto.returnScheduleId || !dto.returnHoldId || !dto.returnOriginStationId || !dto.returnDestinationStationId) { + throw new BadRequestException('Return trip details required for round-trip booking'); + } + + const [outboundHold, returnHold] = await Promise.all([ + this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }), + this.prisma.seatHold.findUnique({ where: { id: dto.returnHoldId } }) + ]); + + if (!outboundHold || outboundHold.expiresAt < new Date()) throw new BadRequestException('Outbound seat hold expired'); + if (!returnHold || returnHold.expiresAt < new Date()) throw new BadRequestException('Return seat hold expired'); + + const [outboundSchedule, returnSchedule] = await Promise.all([ + this.prisma.trainSchedule.findUnique({ + where: { id: dto.scheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } + }), + this.prisma.trainSchedule.findUnique({ + where: { id: dto.returnScheduleId }, + include: { originStation: true, destinationStation: true, stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } } + }) + ]); + + if (!outboundSchedule || !returnSchedule) throw new NotFoundException('Schedule not found'); + + const outboundOriginStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.originStationId); + const outboundDestStop = outboundSchedule.stopTimes.find(s => s.stationId === dto.destinationStationId); + const returnOriginStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnOriginStationId); + const returnDestStop = returnSchedule.stopTimes.find(s => s.stationId === dto.returnDestinationStationId); + + if (!outboundOriginStop || !outboundDestStop || !returnOriginStop || !returnDestStop) { + throw new NotFoundException('Origin or destination stops not found'); + } + + const passengersData = await this.processRoundTripPassengers(dto.passengers as any[]); + const { adultCount, childCount } = this.countPassengers(passengersData); + + const [outboundFare, returnFare] = await Promise.all([ + this.calculateFare(dto.scheduleId, dto.seatClassId, outboundOriginStop, outboundDestStop, passengersData[0]?.nationality, adultCount, childCount), + this.calculateFare(dto.returnScheduleId, dto.returnSeatClassId || dto.seatClassId, returnOriginStop, returnDestStop, passengersData[0]?.nationality, adultCount, childCount) + ]); + + const combinedBaseFareMinor = outboundFare.totalBaseFareMinor + returnFare.totalBaseFareMinor; + let discountMinor = 0; + if (dto.promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promo?.active && promo.validUntil > new Date()) { + discountMinor = promo.percentOff ? Math.round(combinedBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); + } + } + + const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const taxesMinor = Math.round(combinedBaseFareMinor * 0.05); + const totalMinor = Math.max(0, combinedBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); + + const displayCurrency = dto.displayCurrency || Currency.ETB; + let displayTotalMinor = totalMinor; + if (displayCurrency !== Currency.ETB) { + displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); + } + + const booking = await this.prisma.booking.create({ + data: { + bookingRef: generateRef(), + passengerId: dto.passengerId, + scheduleId: dto.scheduleId, + status: 'PENDING_PAYMENT', + bookingType: 'ROUND_TRIP', + totalMinor, + adultCount, + childCount, + displayCurrency, + displayTotalMinor, + returnScheduleId: dto.returnScheduleId, + returnOriginStationId: dto.returnOriginStationId, + returnDestinationStationId: dto.returnDestinationStationId, + returnHoldId: dto.returnHoldId, + returnSeatClassId: dto.returnSeatClassId, + seats: { + create: passengersData.map(p => ({ + seat: { connect: { id: p.outboundSeatId } }, + passengerName: p.passengerName, + dateOfBirth: p.dateOfBirth, + passengerCategory: p.category, + idDocumentType: p.idDocumentType, + passportNumber: p.passportNumber, + passportCountry: p.passportCountry, + verifaydaVerified: p.verifaydaVerified, + verifaydaData: p.verifaydaData, + fareMinor: p.category === PassengerCategory.ADULT ? (outboundFare.baseFareMinor + returnFare.baseFareMinor) : 0, + displayCurrency + })) + } + }, + include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } } + }); + + const outboundSeatIds = passengersData.map(p => p.outboundSeatId); + const returnSeatIds = passengersData.map(p => p.returnSeatId); + await Promise.all([ + this.seatsService.confirmSeats(outboundSeatIds), + this.seatsService.confirmSeats(returnSeatIds) + ]); + + this.eventEmitter.emit('booking.created', { booking }); + + return { + ...booking, + fareBreakdown: { + outboundFare: outboundFare.baseFareMinor, + returnFare: returnFare.baseFareMinor, + combinedBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor, + currency: 'ETB', + displayCurrency, + displayTotalMinor + } + }; + } + + private async processPassengers(passengers: any[]) { + const processedPassengers = []; + for (const passenger of passengers) { const dateOfBirth = new Date(passenger.dateOfBirth); const age = calculateAge(dateOfBirth); const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; - if (category === PassengerCategory.ADULT) adultCount++; else childCount++; let passengerName = passenger.passengerName; let verifaydaVerified = false; @@ -292,68 +460,93 @@ export class BookingsService { nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); } - passengersData.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); } + return processedPassengers; + } - const primaryNationality = passengersData[0]?.nationality; - const baseFareMinor = await this.getBaseFare(dto.scheduleId, dto.seatClassId, segmentRoute, fullRoute, primaryNationality, originStop.sequence, destStop.sequence); + private async processRoundTripPassengers(passengers: any[]) { + const processedPassengers = []; + for (const passenger of passengers) { + const dateOfBirth = new Date(passenger.dateOfBirth); + const age = calculateAge(dateOfBirth); + const category: PassengerCategory = age < 5 ? PassengerCategory.CHILD : PassengerCategory.ADULT; + + let passengerName = passenger.passengerName; + let verifaydaVerified = false; + let verifaydaData: Record | undefined; + let nationality = passenger.nationality; + + if (passenger.idDocumentType === IdDocumentType.NATIONAL_ID && passenger.idDocumentNumber) { + const verification = await this.verifaydaService.verifyNationalId(passenger.idDocumentNumber); + if (!verification.verified) throw new BadRequestException(`Verifayda verification failed for ${passenger.passengerName}: ${verification.failureReason}`); + passengerName = verification.passengerData?.fullName || passengerName; + verifaydaVerified = true; + verifaydaData = verification.passengerData?.profileData; + nationality = nationality || 'Ethiopian'; + } else if (passenger.idDocumentType === IdDocumentType.PASSPORT) { + if (!passenger.passportNumber || !passenger.passportCountry) throw new BadRequestException(`Passport number and country required for ${passenger.passengerName}`); + nationality = nationality || (passenger.passportCountry === 'Djibouti' ? 'Djiboutian' : 'Other'); + } + + processedPassengers.push({ ...passenger, passengerName, dateOfBirth, category, verifaydaVerified, verifaydaData, nationality }); + } + return processedPassengers; + } + + private countPassengers(passengersData: any[]) { + let adultCount = 0, childCount = 0; + for (const passenger of passengersData) { + if (passenger.category === PassengerCategory.ADULT) adultCount++; + else childCount++; + } + return { adultCount, childCount }; + } + + private async calculateFare( + scheduleId: string, + seatClassId: string, + originStop: any, + destStop: any, + nationality?: string, + adultCount = 1, + childCount = 0, + promoCode?: string, + loyaltyRedemptionPoints?: number + ) { + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + const baseFareMinor = await this.getBaseFare(scheduleId, seatClassId, segmentRoute, undefined, nationality, originStop.sequence, destStop.sequence); + const adultFareMinor = baseFareMinor * adultCount; const paidChildrenCount = Math.max(0, childCount - 1); const childFareMinor = baseFareMinor * paidChildrenCount; const totalBaseFareMinor = adultFareMinor + childFareMinor; let discountMinor = 0; - if (dto.promoCode) { - const promo = await this.prisma.promotion.findUnique({ where: { code: dto.promoCode } }); + if (promoCode) { + const promo = await this.prisma.promotion.findUnique({ where: { code: promoCode } }); if (promo?.active && promo.validUntil > new Date()) { discountMinor = promo.percentOff ? Math.round(totalBaseFareMinor * promo.percentOff / 100) : (promo.amountOffMinor ?? 0); } } - const loyaltyMinor = (dto.loyaltyRedemptionPoints ?? 0) * 10; + const loyaltyMinor = (loyaltyRedemptionPoints ?? 0) * 10; const taxesMinor = Math.round(totalBaseFareMinor * 0.05); const totalMinor = Math.max(0, totalBaseFareMinor - discountMinor - loyaltyMinor + taxesMinor); - const displayCurrency = dto.displayCurrency || Currency.ETB; - let displayTotalMinor = totalMinor; - if (displayCurrency !== Currency.ETB) { - displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency); - } - - const booking = await this.prisma.booking.create({ - data: { - bookingRef: generateRef(), - passengerId: dto.passengerId, - scheduleId: dto.scheduleId, - status: 'PENDING_PAYMENT', - totalMinor, adultCount, childCount, displayCurrency, displayTotalMinor, - bookingType: dto.bookingType ?? 'ONE_WAY', - seats: { - create: passengersData.map((p) => ({ - seat: { connect: { id: p.seatId } }, - passengerName: p.passengerName, - dateOfBirth: p.dateOfBirth, - passengerCategory: p.category, - idDocumentType: p.idDocumentType, - idDocumentNumber: p.idDocumentType === IdDocumentType.NATIONAL_ID ? undefined : p.idDocumentNumber, - passportNumber: p.passportNumber, - passportCountry: p.passportCountry, - verifaydaVerified: p.verifaydaVerified, - verifaydaData: p.verifaydaData || undefined, - fareMinor: p.category === PassengerCategory.ADULT ? baseFareMinor : (paidChildrenCount > 0 ? baseFareMinor : 0), - displayCurrency, - })), - }, - }, - include: { seats: { include: { seat: true } }, schedule: { include: { originStation: true, destinationStation: true, train: true } } }, - }); - - await this.seatsService.confirmSeats(seatIds); - this.eventEmitter.emit('booking.created', { booking }); - return { - ...booking, - fareBreakdown: { baseFareMinor, adultCount, adultFareMinor, childCount, freeChildrenCount: Math.min(childCount, 1), paidChildrenCount, childFareMinor, totalBaseFareMinor, discountMinor, loyaltyRedemptionMinor: loyaltyMinor, taxesFeesMinor: taxesMinor, totalMinor, currency: 'ETB', displayCurrency, displayTotalMinor }, + baseFareMinor, + adultCount, + adultFareMinor, + childCount, + freeChildrenCount: Math.min(childCount, 1), + paidChildrenCount, + childFareMinor, + totalBaseFareMinor, + discountMinor, + loyaltyRedemptionMinor: loyaltyMinor, + taxesFeesMinor: taxesMinor, + totalMinor }; } From ad83c87e2f3450663fcbd6351fce867c5eb49684 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 16 Jun 2026 19:30:09 +0300 Subject: [PATCH 03/27] Enhance deletion logic in Fleet and Schedules services to prevent deletion of records with active dependencies; update Seats and Stations pages to improve UI and remove unused fields. --- .../src/modules/fleet/fleet.service.ts | 105 +++++++++++++++++- .../modules/schedules/schedules.service.ts | 9 +- .../backoffice/src/app/schedules/page.tsx | 31 +++--- .../backoffice/src/app/seats/page.tsx | 35 +++++- .../backoffice/src/app/stations/page.tsx | 21 +--- 5 files changed, 156 insertions(+), 45 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts index 0662da595..b7f4695a1 100644 --- a/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts +++ b/apps/edr-passenger-api/src/modules/fleet/fleet.service.ts @@ -134,9 +134,28 @@ export class FleetService { } async deleteCoachType(id: string) { - const coachType = await this.prisma.coachType.findUnique({ where: { id } }); + const coachType = await this.prisma.coachType.findUnique({ + where: { id }, + include: { + coaches: true, + seatClasses: true, + }, + }); if (!coachType) throw new NotFoundException('Coach type not found'); + // Check for related records + if (coachType.coaches.length > 0) { + throw new BadRequestException( + `Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.` + ); + } + + if (coachType.seatClasses.length > 0) { + throw new BadRequestException( + `Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.` + ); + } + return this.prisma.coachType.delete({ where: { id } }); } @@ -183,9 +202,29 @@ export class FleetService { } async deleteClass(id: string) { - const seatClass = await this.prisma.seatClass.findUnique({ where: { id } }); + const seatClass = await this.prisma.seatClass.findUnique({ + where: { id }, + include: { + fareRules: true, + routeFareRules: true, + segmentFares: true, + }, + }); if (!seatClass) throw new NotFoundException('Seat class not found'); + // Check for related records + const relatedRecords = [ + ...seatClass.fareRules, + ...seatClass.routeFareRules, + ...seatClass.segmentFares, + ]; + + if (relatedRecords.length > 0) { + throw new BadRequestException( + `Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.` + ); + } + return this.prisma.seatClass.delete({ where: { id } }); } @@ -220,8 +259,21 @@ export class FleetService { } async deleteTrain(id: string) { - const train = await this.prisma.train.findUnique({ where: { id } }); + const train = await this.prisma.train.findUnique({ + where: { id }, + include: { + schedules: true, + }, + }); if (!train) throw new NotFoundException('Train not found'); + + // Check for active schedules + if (train.schedules.length > 0) { + throw new BadRequestException( + `Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.` + ); + } + return this.prisma.train.delete({ where: { id } }); } @@ -305,10 +357,53 @@ export class FleetService { } async deleteCoach(id: string) { - const coach = await this.prisma.coach.findUnique({ where: { id } }); + const coach = await this.prisma.coach.findUnique({ + where: { id }, + include: { + assignments: true, + seats: { + include: { + bookingSeats: true, + blocks: true, + ticketSeats: true, + }, + }, + }, + }); if (!coach) throw new NotFoundException('Coach not found'); - // Delete related seats first + // Check for active assignments + if (coach.assignments.length > 0) { + throw new BadRequestException( + `Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.` + ); + } + + // Check for booked seats + const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0); + if (bookedSeats.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.` + ); + } + + // Check for blocked seats + const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0); + if (blockedSeats.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.` + ); + } + + // Check for tickets + const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0); + if (seatsWithTickets.length > 0) { + throw new BadRequestException( + `Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.` + ); + } + + // Delete related seats first (now safe to do) await this.prisma.seat.deleteMany({ where: { coachId: id } }); return this.prisma.coach.delete({ where: { id } }); diff --git a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts index e816ca8e1..f7ef5487f 100644 --- a/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts +++ b/apps/edr-passenger-api/src/modules/schedules/schedules.service.ts @@ -545,8 +545,13 @@ export class SchedulesService { }); } - if (dto.coaches && dto.coaches.length > 0) { - await this.assignCoaches(id, dto.coaches); + if (dto.coaches !== undefined) { + if (dto.coaches.length > 0) { + await this.assignCoaches(id, dto.coaches); + } else { + // Remove all coach assignments when empty array is sent + await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } }); + } } return this.getSchedule(id); diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index e60de15c2..1207a01e4 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -204,14 +204,11 @@ export default function SchedulesPage() { departureAt: editForm.departureAt, arrivalAt: editForm.arrivalAt, status: editForm.status, - }; - - if (editForm.coachIds.length > 0) { - payload.coaches = editForm.coachIds.map((coachId: string, idx: number) => ({ + coaches: editForm.coachIds.map((coachId: string, idx: number) => ({ coachId, positionNumber: idx + 1, - })); - } + })), + }; await updateScheduleMutation.mutateAsync({ id: editingSchedule.id, @@ -327,14 +324,20 @@ export default function SchedulesPage() { ), }, { - key: 'originStation.name', - label: 'From', - render: (schedule: Schedule) => {schedule.originStation?.name}, - }, - { - key: 'destinationStation.name', - label: 'To', - render: (schedule: Schedule) => {schedule.destinationStation?.name}, + key: 'route', + label: 'Route', + sortable: true, + render: (schedule: Schedule) => ( +
+ + {schedule.originStation?.name || 'Unknown'} + + + + {schedule.destinationStation?.name || 'Unknown'} + +
+ ), }, { key: 'departureAt', diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index ba561b24e..8b1ad4072 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -420,7 +420,12 @@ export default function SeatsPage() { const seats = (coach.seats || []).filter((s: any) => s.seatNumber); return seats.length > 0; }) - .sort((a: any, b: any) => (a.sequence || 0) - (b.sequence || 0)); + .sort((a: any, b: any) => { + // Try multiple sequence field possibilities + const seqA = a.positionNumber ?? a.sequence ?? a.coach?.sequence ?? 999; + const seqB = b.positionNumber ?? b.sequence ?? b.coach?.sequence ?? 999; + return seqA - seqB; + }); return (
@@ -460,8 +465,30 @@ export default function SeatsPage() {

Loading seats...

) : coachesWithSeats.length === 0 ? ( -
-

No coaches with seats found for this schedule

+
+
+ + +
+
+

No coaches with seats found for this schedule

+
) : (
@@ -526,7 +553,7 @@ export default function SeatsPage() { const seats = (coach.seats || []).filter((s: any) => s.seatNumber); const isExpanded = expandedCoaches.has(coach.id); const seatOrBedLabel = isBedCoach ? 'beds' : 'seats'; - const sequence = coachData?.sequence ?? coach?.sequence ?? index + 1; + const sequence = coach.positionNumber ?? coach.sequence ?? coachData?.sequence ?? index + 1; return (
diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 6c512462e..97a1cf7ba 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -75,7 +75,6 @@ export default function StationsPage() { lat: parseFloat(formData.get('lat') as string) || null, lng: parseFloat(formData.get('lng') as string) || null, timezone: formData.get('timezone') as string, - distance: parseFloat(formData.get('distance') as string) || 0, sequence, isOperational: formData.get('isOperational') === 'true', }; @@ -136,13 +135,7 @@ export default function StationsPage() {
), }, - { - key: 'distance', - label: 'Distance (km)', - render: (station: any) => ( - {station.distance ? `${station.distance}` : '0'} - ), - }, + { key: 'isOperational', label: 'Status', @@ -346,18 +339,6 @@ export default function StationsPage() { ))}
-
- - -
Date: Tue, 16 Jun 2026 16:39:29 +0000 Subject: [PATCH 04/27] fix prod issue --- .../1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts index e2587c35c..5dc1d6315 100644 --- a/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts +++ b/apps/edr-freight-api/src/migrations/1749000000000-NormalizeWeightLimitTradeDirectionBoth.ts @@ -14,8 +14,12 @@ export class NormalizeWeightLimitTradeDirectionBoth1749000000000 UPDATE freight.weight_limit_rules SET trade_direction = 'BOTH' WHERE trade_direction::text = 'ANY'; + + UPDATE freight.weight_limit_rules + SET trade_direction = 'IMPORT' + WHERE trade_direction IS NULL; EXCEPTION WHEN undefined_table OR undefined_column THEN NULL; - END $$; + END $$; `); } From 560aff222b153d8a4fcc36ce137f12cceeda4099 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 16 Jun 2026 21:40:01 +0300 Subject: [PATCH 05/27] fix: add lockfile corruption handling to CI/CD and Dockerfiles --- .github/workflows/deploy.yml | 8 ++++++++ apps/edr-freight-api/Dockerfile | 2 +- apps/edr-passenger-api/Dockerfile | 2 +- apps/edr-payment-api/Dockerfile | 2 +- infrastructure/docker/Dockerfile.web | 2 +- 5 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 13c1f18fb..2352468fe 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -142,6 +142,14 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/deploy/create-npmrc.sh + - name: Fix lockfile if needed + run: | + set -euo pipefail + if ! pnpm install --frozen-lockfile 2>/dev/null; then + echo "Lockfile corrupted, regenerating..." + pnpm install --no-frozen-lockfile + fi + - name: Build ${{ matrix.service }} run: | set -euo pipefail diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index b0850737b..edc30eab7 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -14,7 +14,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile || pnpm install --no-frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index 5fd647968..cb6f10b09 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -16,7 +16,7 @@ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index ea35c1f39..6d047d89c 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -14,7 +14,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index f385206e5..67b0b9ec6 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -20,7 +20,7 @@ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile + pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile FROM base AS builder ARG TURBO_FILTER From 7a009555885837ed49102c07a0b134d1551c365c Mon Sep 17 00:00:00 2001 From: "Stephanos A." Date: Tue, 16 Jun 2026 21:58:26 +0300 Subject: [PATCH 06/27] Revert "fix: add lockfile corruption handling to CI/CD and Dockerfiles" --- .github/workflows/deploy.yml | 8 -------- apps/edr-freight-api/Dockerfile | 2 +- apps/edr-passenger-api/Dockerfile | 2 +- apps/edr-payment-api/Dockerfile | 2 +- infrastructure/docker/Dockerfile.web | 2 +- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index eeac6ee22..a819f7939 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -146,14 +146,6 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/deploy/create-npmrc.sh - - name: Fix lockfile if needed - run: | - set -euo pipefail - if ! pnpm install --frozen-lockfile 2>/dev/null; then - echo "Lockfile corrupted, regenerating..." - pnpm install --no-frozen-lockfile - fi - - name: Build ${{ matrix.service }} run: | set -euo pipefail diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index edc30eab7..b0850737b 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -14,7 +14,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index cb6f10b09..5fd647968 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -16,7 +16,7 @@ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index 6d047d89c..ea35c1f39 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -14,7 +14,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 67b0b9ec6..f385206e5 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -20,7 +20,7 @@ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder ARG TURBO_FILTER From 2d513b78a310b1363987ac3218b1e71b539f2e4b Mon Sep 17 00:00:00 2001 From: Sennay Date: Tue, 16 Jun 2026 22:15:57 +0300 Subject: [PATCH 07/27] Update VITE_API_URL for production deployment --- docker-compose.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index a8271a01e..f462e1217 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -57,7 +57,7 @@ services: TURBO_FILTER: "@edr/freight-portal" APP_PATH: apps/edr-freight-web/portal # Browser-reachable URL; override for production deployments - VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api} + VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api} secrets: - npmrc ports: @@ -70,7 +70,7 @@ services: args: TURBO_FILTER: "@edr/freight-backoffice" APP_PATH: apps/edr-freight-web/backoffice - VITE_API_URL: ${FREIGHT_VITE_API_URL:-http://localhost:3001/api} + VITE_API_URL: ${FREIGHT_VITE_API_URL:-https://edrfreightapi.triaplc.com/api} secrets: - npmrc ports: From 865105eb6ab7815efa1b06f8e249df2f6e3c0092 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 16 Jun 2026 22:25:26 +0300 Subject: [PATCH 08/27] Revert "fix: add lockfile corruption handling to CI/CD and Dockertfiles" This reverts commit 560aff222b153d8a4fcc36ce137f12cceeda4099. --- .github/workflows/deploy.yml | 8 -------- apps/edr-freight-api/Dockerfile | 2 +- apps/edr-passenger-api/Dockerfile | 2 +- apps/edr-payment-api/Dockerfile | 2 +- infrastructure/docker/Dockerfile.web | 2 +- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2352468fe..13c1f18fb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -142,14 +142,6 @@ jobs: NPM_TOKEN: ${{ secrets.NPM_TOKEN }} run: ./scripts/deploy/create-npmrc.sh - - name: Fix lockfile if needed - run: | - set -euo pipefail - if ! pnpm install --frozen-lockfile 2>/dev/null; then - echo "Lockfile corrupted, regenerating..." - pnpm install --no-frozen-lockfile - fi - - name: Build ${{ matrix.service }} run: | set -euo pipefail diff --git a/apps/edr-freight-api/Dockerfile b/apps/edr-freight-api/Dockerfile index edc30eab7..b0850737b 100644 --- a/apps/edr-freight-api/Dockerfile +++ b/apps/edr-freight-api/Dockerfile @@ -14,7 +14,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/apps/edr-passenger-api/Dockerfile b/apps/edr-passenger-api/Dockerfile index cb6f10b09..5fd647968 100644 --- a/apps/edr-passenger-api/Dockerfile +++ b/apps/edr-passenger-api/Dockerfile @@ -16,7 +16,7 @@ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/apps/edr-payment-api/Dockerfile b/apps/edr-payment-api/Dockerfile index 6d047d89c..ea35c1f39 100644 --- a/apps/edr-payment-api/Dockerfile +++ b/apps/edr-payment-api/Dockerfile @@ -14,7 +14,7 @@ FROM base AS installer COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder COPY --from=installer /app/ . diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index 67b0b9ec6..f385206e5 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -20,7 +20,7 @@ COPY --from=pruner /app/out/json/ . COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml RUN --mount=type=secret,id=npmrc,target=./.npmrc,required=false \ --mount=type=cache,id=pnpm,target=/pnpm/store \ - pnpm install --frozen-lockfile || pnpm install --no-frozen-lockfile + pnpm install --frozen-lockfile FROM base AS builder ARG TURBO_FILTER From 001a1a7d98e16106f5c3cf49b5603a1031a4714b Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 16 Jun 2026 22:29:10 +0300 Subject: [PATCH 09/27] Update roundtrip booking result --- .../portal/src/app/booking/results/page.tsx | 330 ++++++++++------ .../portal/src/app/booking/search/page.tsx | 369 +++++++++++++----- .../portal/src/app/booking/seats/page.tsx | 89 ++++- .../portal/src/components/SearchWidget.tsx | 3 + .../portal/src/lib/booking-store.ts | 2 + 5 files changed, 569 insertions(+), 224 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx index df4a54742..79b8419c5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/results/page.tsx @@ -14,38 +14,40 @@ export default function ResultsPage() { const searchParams = useSearchParams(); const setSelectedSchedule = useBookingStore((s) => s.setSelectedSchedule); const [selectedClasses, setSelectedClasses] = useState>({}); + const [outboundSelected, setOutboundSelected] = useState(false); const [classModal, setClassModal] = useState(null); const [promoData, setPromoData] = useState<{ code: string; discount: string; message: string } | null>(null); const searchCriteria = useBookingStore((s) => s.searchCriteria); const setSearchCriteria = useBookingStore((s) => s.setSearchCriteria); - // Prefer URL params; fall back to persisted store values const searchData = { originStationId: searchParams.get('origin') || searchCriteria?.originStationId || '', destinationStationId: searchParams.get('destination') || searchCriteria?.destinationStationId || '', date: searchParams.get('date') || searchCriteria?.departureDate || '', + returnDate: searchParams.get('returnDate') || searchCriteria?.returnDate, + journeyType: searchParams.get('tripType') === 'ROUND_TRIP' ? 'ROUND_TRIP' : 'ONE_WAY', adultCount: parseInt(searchParams.get('adults') || '') || searchCriteria?.adultCount || 1, childCount: parseInt(searchParams.get('children') || '') || searchCriteria?.childCount || 0, nationality: searchParams.get('nationality') || searchCriteria?.nationality || 'ETHIOPIAN', promoCode: searchParams.get('promoCode') || searchCriteria?.promoCode || '', }; - // Sync URL params back into store whenever they are present in the URL useEffect(() => { if (searchParams.get('origin')) { setSearchCriteria({ + tripType: (searchParams.get('tripType') || 'ONE_WAY') as 'ONE_WAY' | 'ROUND_TRIP', originStationId: searchParams.get('origin')!, destinationStationId: searchParams.get('destination')!, departureDate: searchParams.get('date')!, + returnDate: searchParams.get('returnDate') || undefined, adultCount: parseInt(searchParams.get('adults') || '1'), childCount: parseInt(searchParams.get('children') || '0'), nationality: (searchParams.get('nationality') || 'ETHIOPIAN') as 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER', promoCode: searchParams.get('promoCode') || '', }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [searchParams, setSearchCriteria]); useEffect(() => { if (searchData.promoCode) { @@ -60,45 +62,91 @@ export default function ResultsPage() { }); } }) - .catch((err) => { - console.error('Promo validation failed:', err); + .catch(() => { + // Promo validation failed - silently ignore }); } }, [searchData.promoCode]); const buildSearchUrl = () => { const params = new URLSearchParams({ + tripType: searchData.journeyType, origin: searchData.originStationId, destination: searchData.destinationStationId, date: searchData.date, adults: searchData.adultCount.toString(), children: searchData.childCount.toString(), nationality: searchData.nationality, + ...(searchData.returnDate && { returnDate: searchData.returnDate }), ...(searchData.promoCode && { promoCode: searchData.promoCode }), }); return `/booking/search?${params}`; }; - const { data: results, isLoading, error } = useQuery({ + const { data: results, isLoading, error } = useQuery({ queryKey: ['search', searchData], - queryFn: async (): Promise => { - console.log('Searching with criteria:', searchData); - const response = await apiClient.post('/search', searchData) as Schedule[]; - console.log('Search results:', response); - console.log('Number of results:', response?.length || 0); - if (response?.length > 0) { - console.log('First schedule availabilityByClass:', response[0].availabilityByClass); + queryFn: async (): Promise => { + const payload: any = { + originStationId: searchData.originStationId, + destinationStationId: searchData.destinationStationId, + date: searchData.date, + adultCount: searchData.adultCount, + childCount: searchData.childCount, + nationality: searchData.nationality, + journeyType: searchData.journeyType, + }; + + if (searchData.journeyType === 'ROUND_TRIP' && searchData.returnDate) { + payload.returnDate = searchData.returnDate; } + + console.log('🚂 Search Request:', JSON.stringify(payload, null, 2)); + + const response = await apiClient.post('/search', payload) as any; + + console.log('✅ Search Response:', JSON.stringify(response, null, 2)); + return response; }, enabled: !!searchData.originStationId && !!searchData.destinationStationId, }); - const handleSelectClass = (scheduleId: string, seatClass: string) => { + const isRoundTrip = searchData.journeyType === 'ROUND_TRIP'; + + // Handle both response formats: + // 1. One-way: response is array of schedules + // 2. Round-trip: response has journeyType, outbound, inbound properties + let outboundSchedules: Schedule[] = []; + let inboundSchedules: Schedule[] = []; + + if (results) { + if (isRoundTrip && results.journeyType === 'ROUND_TRIP') { + // Round trip response format + outboundSchedules = results.outbound || []; + inboundSchedules = results.inbound || []; + } else if (Array.isArray(results)) { + // One-way response format (array of schedules) + outboundSchedules = results; + } else if (results.data && Array.isArray(results.data)) { + // Fallback: wrapped in data property + outboundSchedules = results.data; + } + } + + // For one-way, check if outbound has results + // For round-trip, check if BOTH outbound and inbound have results + const hasResults = isRoundTrip + ? (outboundSchedules.length > 0 && inboundSchedules.length > 0) + : outboundSchedules.length > 0; + + const handleSelectClass = (scheduleId: string, seatClass: string, isOutbound: boolean = false) => { setSelectedClasses(prev => ({ ...prev, [scheduleId]: seatClass })); + if (isOutbound && isRoundTrip) { + setOutboundSelected(true); + } }; - const handleSelect = (schedule: Schedule) => { + const handleSelect = (schedule: Schedule, isOutbound: boolean = false) => { const scheduleId = schedule.scheduleId || schedule.id || ''; const selectedClass = selectedClasses[scheduleId]; @@ -120,7 +168,7 @@ export default function ResultsPage() { const minutes = (schedule.durationMinutes || 0) % 60; const durationStr = `${hours}h ${minutes}m`; - setSelectedSchedule({ + const scheduleData = { id: scheduleId, trainNumber: schedule.trainNumber, origin: schedule.origin?.name || 'Origin', @@ -132,10 +180,113 @@ export default function ResultsPage() { baseFareChild: selectedClassFare.baseFareMinor, selectedSeatClass: selectedClass, selectedSeatClassName: selectedClass, - }); + }; + + // For round trip, store outbound and wait for inbound selection + if (isRoundTrip && isOutbound) { + setOutboundSelected(true); + setClassModal(null); + return; + } + + // For round trip inbound or one-way, proceed to next step + setSelectedSchedule(scheduleData); router.push('/booking/auth-check'); }; + const renderScheduleCard = (schedule: Schedule, isOutbound: boolean = false) => { + const scheduleId = schedule.scheduleId || schedule.id || ''; + const selectedClass = selectedClasses[scheduleId]; + const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 + ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)) + : null; + const hours = Math.floor((schedule.durationMinutes || 0) / 60); + const minutes = (schedule.durationMinutes || 0) % 60; + const durationStr = `${hours}h ${minutes}m`; + const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null; + const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; + const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); + + return ( +
+
+
+
+
+ +
+
+
{schedule.trainNumber}
+
{schedule.trainName || 'Express Service'}
+
+
+ +
+
+
+ {schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'} +
+
+ {schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''} +
+
{schedule.origin?.name || 'Origin'}
+
+ +
+
+ + {durationStr} +
+
+
+
+
+ {schedule.stops && schedule.stops.length > 0 && ( +
+ + {schedule.stops.length - 2} stops +
+ )} +
+ +
+
+ {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} +
+
+ {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} + {isNextDay && (+1)} +
+
{schedule.destination?.name || 'Destination'}
+
+
+
+ +
+
+
Starting from
+
+ {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} +
+
per adult
+ {selectedClass && ( +

+ {selectedClass.replace(/_/g, ' ')} selected +

+ )} + +
+
+
+
+ ); + }; + if (isLoading) { return (
@@ -164,7 +315,7 @@ export default function ResultsPage() { ); } - if (!results || results.length === 0) { + if (!hasResults) { return (
@@ -192,19 +343,16 @@ export default function ResultsPage() {
- {/* Class selection modal */} {classModal && (() => { const scheduleId = classModal.scheduleId || classModal.id || ''; const selectedClass = selectedClasses[scheduleId]; + const isOutbound = (classModal as any).isOutbound; return ( <> - {/* Backdrop */}
setClassModal(null)} /> - {/* Drawer */}
- {/* Header */}

Select Class

@@ -222,7 +370,6 @@ export default function ResultsPage() {
- {/* Class grid */}
{classModal.faresByClass && Array.isArray(classModal.faresByClass) && classModal.faresByClass.length > 0 ? (
@@ -234,7 +381,7 @@ export default function ResultsPage() { return (
- {/* Footer */}
{!selectedClass && ( @@ -292,7 +438,6 @@ export default function ResultsPage() { ); })()} - {/* Promo Notification */} {promoData && (
@@ -329,101 +474,42 @@ export default function ResultsPage() {
-
- {results.map((schedule) => { - const scheduleId = schedule.scheduleId || schedule.id || ''; - const selectedClass = selectedClasses[scheduleId]; - const lowestFare = schedule.faresByClass && Array.isArray(schedule.faresByClass) && schedule.faresByClass.length > 0 - ? Math.min(...schedule.faresByClass.map((f: any) => f.baseFareMinor).filter((fare: number) => fare > 0)) - : null; - const hours = Math.floor((schedule.durationMinutes || 0) / 60); - const minutes = (schedule.durationMinutes || 0) % 60; - const durationStr = `${hours}h ${minutes}m`; - const departureDate = schedule.departureAt ? new Date(schedule.departureAt) : null; - const arrivalDate = schedule.arrivalAt ? new Date(schedule.arrivalAt) : null; - const isNextDay = departureDate && arrivalDate && departureDate.toDateString() !== arrivalDate.toDateString(); - - return ( -
-
- {/* Train info */} -
-
-
- -
-
-
{schedule.trainNumber}
-
{schedule.trainName || 'Express Service'}
-
-
- -
-
-
- {schedule.departureAt ? format(new Date(schedule.departureAt), 'HH:mm') : '--:--'} -
-
- {schedule.departureAt ? format(new Date(schedule.departureAt), 'MMM d') : ''} -
-
{schedule.origin?.name || 'Origin'}
-
- -
-
- - {durationStr} -
-
-
-
-
- {schedule.stops && schedule.stops.length > 0 && ( -
- - {schedule.stops.length - 2} stops -
- )} -
- -
-
- {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'HH:mm') : '--:--'} -
-
- {schedule.arrivalAt ? format(new Date(schedule.arrivalAt), 'MMM d') : ''} - {isNextDay && (+1)} -
-
{schedule.destination?.name || 'Destination'}
-
-
-
- - {/* Fare + action */} -
-
-
Starting from
-
- {lowestFare ? `ETB ${(lowestFare / 100).toFixed(2)}` : 'N/A'} -
-
per adult
- {selectedClass && ( -

- {selectedClass.replace(/_/g, ' ')} selected -

- )} - -
-
+
+ {outboundSchedules.length > 0 && ( +
+ {isRoundTrip && ( +
+

+ + Outbound Journey +

+

+ {searchData.date ? format(new Date(searchData.date), 'EEEE, MMMM d, yyyy') : ''} +

+ )} +
+ {outboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule, true))}
- ); - })} +
+ )} + + {inboundSchedules.length > 0 && (!isRoundTrip || outboundSelected) && ( +
+
+

+ + Return Journey +

+

+ {searchData.returnDate ? format(new Date(searchData.returnDate), 'EEEE, MMMM d, yyyy') : ''} +

+
+
+ {inboundSchedules.map((schedule: Schedule) => renderScheduleCard(schedule))} +
+
+ )}
diff --git a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx index 819b59d6d..8295d3a09 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/search/page.tsx @@ -17,9 +17,11 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ + tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']), originStationId: z.string().min(1, 'Please select origin station'), destinationStationId: z.string().min(1, 'Please select destination station'), departureDate: z.string().min(1, 'Please select departure date'), + returnDate: z.string().optional(), adultCount: z.number().min(1).max(9), childCount: z.number().min(0).max(9), nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), @@ -27,6 +29,22 @@ const searchSchema = z.object({ }).refine((d) => d.originStationId !== d.destinationStationId, { message: 'Origin and destination must be different', path: ['destinationStationId'], +}).refine((d) => { + if (d.tripType === 'ROUND_TRIP' && !d.returnDate) { + return false; + } + return true; +}, { + message: 'Please select return date', + path: ['returnDate'], +}).refine((d) => { + if (d.tripType === 'ROUND_TRIP' && d.returnDate && d.departureDate) { + return d.returnDate >= d.departureDate; + } + return true; +}, { + message: 'Return date must be after departure date', + path: ['returnDate'], }); type SearchForm = z.infer; @@ -423,6 +441,7 @@ export default function SearchPage() { const { handleSubmit, watch, setValue, formState: { errors } } = useForm({ resolver: zodResolver(searchSchema as any), defaultValues: { + tripType: 'ONE_WAY', adultCount: 1, childCount: 0, nationality: 'ETHIOPIAN', @@ -468,6 +487,8 @@ export default function SearchPage() { const adultCount = watch('adultCount'); const childCount = watch('childCount'); const departureDate = watch('departureDate'); + const returnDate = watch('returnDate'); + const tripType = watch('tripType'); const totalPassengers = (adultCount || 1) + (childCount || 0); const saveRecent = useCallback((id: string) => { @@ -510,12 +531,14 @@ export default function SearchPage() { if (data.originStationId) saveRecent(data.originStationId); if (data.destinationStationId) saveRecent(data.destinationStationId); const params = new URLSearchParams({ + tripType: data.tripType, origin: data.originStationId, destination: data.destinationStationId, date: data.departureDate, adults: data.adultCount.toString(), children: data.childCount.toString(), nationality: data.nationality, + ...(data.tripType === 'ROUND_TRIP' && data.returnDate && { returnDate: data.returnDate }), ...(data.promoCode && { promoCode: data.promoCode }), }); router.push(`/booking/results?${params}`); @@ -615,6 +638,34 @@ export default function SearchPage() {
+ {/* Trip Type Tabs */} +
+
+ + +
+
+ {/* Mobile: stacked */}
@@ -660,6 +711,20 @@ export default function SearchPage() { />
+ {tripType === 'ROUND_TRIP' && ( +
+ +
+ setValue('returnDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={departureDate ? new Date(departureDate + 'T00:00:00') : new Date()} + placeholder="Select return date" + /> +
+ {errors.returnDate &&

{errors.returnDate.message}

} +
+ )} {/* Pax + Nationality combined trigger */}
- {/* Desktop: single row — From [swap] To | Date | Pax+Nat | Search */} -
- {/* From */} -
- - { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.originStationId &&

{errors.originStationId.message}

} -
- {/* Swap */} - - {/* To */} -
- - { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> - {errors.destinationStationId &&

{errors.destinationStationId.message}

} -
- {/* Divider */} -
- {/* Date */} -
- -
- setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} - minDate={new Date()} placeholder="Select date" - /> -
- {errors.departureDate &&

{errors.departureDate.message}

} -
- {/* Divider */} -
- {/* Pax + Nationality combined — opens shared modal */} -
- - -
- {/* Search */} - -
- - {/* Promo */} -
- {!promoVisible ? ( - - ) : ( -
-
-
- - { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} - placeholder="Enter promo code" - onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} - className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400" - autoFocus /> + {/* Desktop: dynamic layout based on trip type */} +
+ {tripType === 'ONE_WAY' ? ( + // ONE WAY: Single row layout +
+ {/* From */} +
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.originStationId &&

{errors.originStationId.message}

} +
+ {/* Swap */} + + {/* To */} +
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Divider */} +
+ {/* Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} placeholder="Departure" + />
- -
+ {/* Divider */} +
+ {/* Pax + Nationality */} +
+ +
- {promoValidation && ( -
- {promoValidation.valid && } - {promoValidation.message} + {/* Search */} + +
+ ) : ( + // ROUND TRIP: Two row layout +
+ {/* Row 1: From, Swap, To, Departure Date, Return Date */} +
+ {/* From */} +
+ + { setValue('originStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.originStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.originStationId &&

{errors.originStationId.message}

}
- )} + {/* Swap */} + + {/* To */} +
+ + { setValue('destinationStationId', s.id); if (s.id) saveRecent(s.id); }} error={errors.destinationStationId?.message} onOpen={scrollWidgetIntoView} /> + {errors.destinationStationId &&

{errors.destinationStationId.message}

} +
+ {/* Divider */} +
+ {/* Departure Date */} +
+ +
+ setValue('departureDate', `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`)} + minDate={new Date()} placeholder="Select date" + /> +
+ {errors.departureDate &&

{errors.departureDate.message}

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

{errors.returnDate.message}

} +
+
+ + {/* Row 2: Promo, Passengers, Search */} +
+ {/* Promo Code */} +
+ + {!promoVisible ? ( + + ) : ( +
+
+
+ + { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + placeholder="Enter promo code" + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + className="w-full pl-9 pr-3 py-3.5 border-2 border-gray-200 dark:border-gray-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400" + autoFocus /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )} +
+ )} +
+ {/* Divider */} +
+ {/* Pax + Nationality */} +
+ + +
+ {/* Search Button */} +
+ + +
+
)}
+ {/* Promo - Only visible in ONE WAY mode on desktop */} + {tripType === 'ONE_WAY' && ( +
+ {!promoVisible ? ( + + ) : ( +
+
+
+ + { setPromoCode(e.target.value.toUpperCase()); if (promoValidation) setPromoValidation(null); }} + placeholder="Enter promo code" + onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), handleValidatePromo())} + className="w-full pl-9 pr-3 py-2.5 border-2 border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/30 text-sm bg-white placeholder-gray-400" + autoFocus /> +
+ + +
+ {promoValidation && ( +
+ {promoValidation.valid && } + {promoValidation.message} +
+ )} +
+ )} +
+ )} +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index fc165c4f4..3cdcba6c1 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -50,6 +50,7 @@ export default function SeatsPage() { const { selectedSchedule, passengers, setSeatHold, setPassengers, searchCriteria, bookingId } = useBookingStore(); const [selectedSeats, setSelectedSeats] = useState([]); const [selectedCoach, setSelectedCoach] = useState(null); + const [currentJourneyType, setCurrentJourneyType] = useState<'outbound' | 'inbound'>('outbound'); const [modalState, setModalState] = useState({ isOpen: false, title: '', @@ -57,6 +58,8 @@ export default function SeatsPage() { type: 'info' as 'warning' | 'error' | 'success' | 'info', }); + const isRoundTrip = searchCriteria?.tripType === 'ROUND_TRIP'; + const { data: seatMapData, isLoading, error } = useQuery({ queryKey: ['seatmap', selectedSchedule?.id], queryFn: () => apiClient.get(`/seats/seatmap/${selectedSchedule?.id}`), @@ -70,10 +73,15 @@ export default function SeatsPage() { seatId: seatIds[i], })); + // For round trip inbound, swap origin and destination + const isInbound = isRoundTrip && currentJourneyType === 'inbound'; + const originId = isInbound ? searchCriteria?.destinationStationId : searchCriteria?.originStationId; + const destinationId = isInbound ? searchCriteria?.originStationId : searchCriteria?.destinationStationId; + return apiClient.post(`/seats/hold`, { scheduleId: selectedSchedule?.id, - originStationId: searchCriteria?.originStationId, - destinationStationId: searchCriteria?.destinationStationId, + originStationId: originId, + destinationStationId: destinationId, passengers: passengersForHold, }); }, @@ -157,17 +165,58 @@ export default function SeatsPage() { }, [passengers.length]); const handleContinue = async () => { + if (isRoundTrip && currentJourneyType === 'outbound') { + // Save outbound seats and show inbound + if (selectedSeats.length > 0) { + try { + await holdMutation.mutateAsync(selectedSeats); + const updatedPassengers = passengers.map((p, i) => { + const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); + return { + ...p, + seatId: selectedSeats[i], + seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + }; + }); + setPassengers(updatedPassengers); + } catch (error: any) { + setModalState({ + isOpen: true, + title: 'Seat Hold Failed', + message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', + type: 'error', + }); + return; + } + } + setCurrentJourneyType('inbound'); + setSelectedSeats([]); + setSelectedCoach(null); + return; + } + + // Final continue (one-way or round-trip inbound) if (selectedSeats.length > 0) { - await holdMutation.mutateAsync(selectedSeats); - const updatedPassengers = passengers.map((p, i) => { - const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); - return { - ...p, - seatId: selectedSeats[i], - seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', - }; - }); - setPassengers(updatedPassengers); + try { + await holdMutation.mutateAsync(selectedSeats); + const updatedPassengers = passengers.map((p, i) => { + const seatData = validSeats?.find((s: any) => s.id === selectedSeats[i]); + return { + ...p, + seatId: selectedSeats[i], + seatNumber: seatData?.number || seatData?.label || seatData?.seatNumber || '', + }; + }); + setPassengers(updatedPassengers); + } catch (error: any) { + setModalState({ + isOpen: true, + title: 'Seat Hold Failed', + message: error?.response?.data?.message || 'Failed to hold seats. Please try again.', + type: 'error', + }); + return; + } } router.push('/booking/review'); }; @@ -449,7 +498,14 @@ export default function SeatsPage() { disabled={selectedSeats.length === 0 || holdMutation.isPending} className="w-full py-3 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-40 disabled:cursor-not-allowed text-white font-bold text-sm rounded-xl transition-all shadow-lg" > - {holdMutation.isPending ? 'Holding seats...' : allSelected ? 'Continue' : 'Continue with partial selection'} + {holdMutation.isPending + ? 'Holding seats...' + : isRoundTrip && currentJourneyType === 'outbound' + ? 'Continue to Return Seats' + : allSelected + ? 'Continue' + : 'Continue with partial selection' + } -

Select Seats

+

+ {isRoundTrip + ? (currentJourneyType === 'outbound' ? 'Select Outbound Seats' : 'Select Return Seats') + : 'Select Seats' + } +

{selectedSeats.length}/{passengers.length}
diff --git a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx index 180393aab..35901755d 100644 --- a/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx +++ b/apps/edr-passenger-web/portal/src/components/SearchWidget.tsx @@ -13,9 +13,11 @@ import { useState } from 'react'; import ModernDatePicker from '@/components/ModernDatePicker'; const searchSchema = z.object({ + tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']), originStationId: z.string().min(1), destinationStationId: z.string().min(1), departureDate: z.string().min(1), + returnDate: z.string().optional(), adultCount: z.number().min(1).max(9), childCount: z.number().min(0).max(9), nationality: z.enum(['ETHIOPIAN', 'DJIBOUTIAN', 'OTHER']), @@ -44,6 +46,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps) const { register, handleSubmit, watch, setValue, formState: { errors } } = useForm({ resolver: zodResolver(searchSchema as any), defaultValues: { + tripType: 'ONE_WAY', adultCount: 1, childCount: 0, nationality: 'ETHIOPIAN', diff --git a/apps/edr-passenger-web/portal/src/lib/booking-store.ts b/apps/edr-passenger-web/portal/src/lib/booking-store.ts index 32f836ac3..2be5eccf2 100644 --- a/apps/edr-passenger-web/portal/src/lib/booking-store.ts +++ b/apps/edr-passenger-web/portal/src/lib/booking-store.ts @@ -5,6 +5,8 @@ export interface SearchCriteria { originStationId: string; destinationStationId: string; departureDate: string; + returnDate?: string; + tripType: 'ONE_WAY' | 'ROUND_TRIP'; adultCount: number; childCount: number; nationality: 'ETHIOPIAN' | 'DJIBOUTIAN' | 'OTHER'; From b512e77ce113ef91a3c944575bd49ca9d45f7737 Mon Sep 17 00:00:00 2001 From: Sennay Date: Tue, 16 Jun 2026 22:35:27 +0300 Subject: [PATCH 10/27] Update VITE_API_URL to production endpoint --- infrastructure/docker/Dockerfile.web | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/docker/Dockerfile.web b/infrastructure/docker/Dockerfile.web index f385206e5..5cc7904c5 100644 --- a/infrastructure/docker/Dockerfile.web +++ b/infrastructure/docker/Dockerfile.web @@ -2,7 +2,7 @@ ARG TURBO_FILTER=@edr/freight-portal ARG APP_PATH=apps/edr-freight-web/portal -ARG VITE_API_URL=http://localhost:3001/api +ARG VITE_API_URL=https://edrfreightapi.triaplc.com/api ARG NEXT_PUBLIC_API_URL=http://localhost:4000 FROM node:24.15.0-alpine AS base From 67ab9b5f8254ce3c000f335d61ef44a651ed9436 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 16 Jun 2026 22:59:48 +0300 Subject: [PATCH 11/27] changes --- packages/types/src/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 397d240bf..19e4ecde6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,5 +2,12 @@ export * from "./common/index"; export * from "./freight/index"; export * as Freight from "./freight/index"; export * as Passenger from "./passenger/index"; -export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent } from "./common/payments"; -export { PaymentIntentSnapshot, InitiatePaymentRequest, PaymentReferenceType, PaymentService } from "./common/payments"; +export type { + PaymentEvent, + PaymentEventType, + PaymentFailedEvent, + PaymentSucceededEvent, + PaymentIntentSnapshot, + InitiatePaymentRequest, +} from "./common/payments"; +export { PaymentReferenceType, PaymentService } from "./common/payments"; From e815b4d97628a969b5f34de0455ce8383c7d860d Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 16 Jun 2026 23:06:46 +0300 Subject: [PATCH 12/27] Refactor API base URL configuration to use centralized constant across applications --- apps/edr-freight-web/backoffice/src/auth/http.ts | 3 ++- .../backoffice/src/components/cargoes/CargoFormDialog.tsx | 3 +-- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 1 + apps/edr-freight-web/portal/src/constants/apiConfig.ts | 1 + apps/edr-freight-web/portal/src/services/payments.service.ts | 3 ++- apps/edr-freight-web/portal/src/utils/api.ts | 3 ++- pnpm-lock.yaml | 2 +- 7 files changed, 10 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/constants/apiConfig.ts create mode 100644 apps/edr-freight-web/portal/src/constants/apiConfig.ts diff --git a/apps/edr-freight-web/backoffice/src/auth/http.ts b/apps/edr-freight-web/backoffice/src/auth/http.ts index 115a0d9d1..5aa78e2d3 100644 --- a/apps/edr-freight-web/backoffice/src/auth/http.ts +++ b/apps/edr-freight-web/backoffice/src/auth/http.ts @@ -1,5 +1,6 @@ import axios from "axios"; +import { API_BASE_URL } from "@/constants/apiConfig"; import { AUTH_TOKEN_COOKIE, REFRESH_TOKEN_COOKIE, @@ -16,7 +17,7 @@ type RetriableRequest = { }; const api = axios.create({ - baseURL: `${import.meta.env.VITE_BASE_API_URL}/api`, + baseURL: `${API_BASE_URL}/api`, withCredentials: true, }); 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 6d1cfb545..625e32a3a 100644 --- a/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/cargoes/CargoFormDialog.tsx @@ -14,6 +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 '@/constants/apiConfig'; interface Cargo { id: string; @@ -32,8 +33,6 @@ interface CargoFormDialogProps { onSuccess?: () => void; } -const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3001'; - export default function CargoFormDialog({ open, onOpenChange, diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts new file mode 100644 index 000000000..02bf46ac9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -0,0 +1 @@ +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts new file mode 100644 index 000000000..02bf46ac9 --- /dev/null +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -0,0 +1 @@ +export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; diff --git a/apps/edr-freight-web/portal/src/services/payments.service.ts b/apps/edr-freight-web/portal/src/services/payments.service.ts index 3c345f648..119aea4ed 100644 --- a/apps/edr-freight-web/portal/src/services/payments.service.ts +++ b/apps/edr-freight-web/portal/src/services/payments.service.ts @@ -1,4 +1,5 @@ import { URL_CONSTANTS } from "@/constants/URLS"; +import { API_BASE_URL } from "@/constants/apiConfig"; import { client } from "../utils/api"; const P = URL_CONSTANTS.PAYMENTS; @@ -57,7 +58,7 @@ function buildCheckoutUrl(payload: { method: PaymentMethod; platform?: PaymentPlatform; }): string { - const base = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); + const base = API_BASE_URL.replace(/\/$/, ""); const params = new URLSearchParams({ bookingId: payload.bookingId, method: payload.method, diff --git a/apps/edr-freight-web/portal/src/utils/api.ts b/apps/edr-freight-web/portal/src/utils/api.ts index 446eec591..289709906 100644 --- a/apps/edr-freight-web/portal/src/utils/api.ts +++ b/apps/edr-freight-web/portal/src/utils/api.ts @@ -1,9 +1,10 @@ import { UseQueryOptions, QueryObserverOptions } from "@tanstack/react-query"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios"; import { URL_CONSTANTS } from "@/constants/URLS"; +import { API_BASE_URL } from "@/constants/apiConfig"; const client = axios.create({ - baseURL: import.meta.env.VITE_API_URL, + baseURL: API_BASE_URL, }); function getCookie(name: string): string | undefined { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 750c9ec01..c1b29edac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -436,7 +436,7 @@ importers: version: 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': specifier: ^11.1.24 - version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/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': specifier: ^10.0.3 version: 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) From 9329e6ca5798a4591a5fecd9d26f90e19e00e05b Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 16 Jun 2026 23:08:27 +0300 Subject: [PATCH 13/27] Refactor payment type exports to separate types from enums in index --- packages/types/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 397d240bf..b4fd73d81 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -2,5 +2,5 @@ export * from "./common/index"; export * from "./freight/index"; export * as Freight from "./freight/index"; export * as Passenger from "./passenger/index"; -export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent } from "./common/payments"; -export { PaymentIntentSnapshot, InitiatePaymentRequest, PaymentReferenceType, PaymentService } from "./common/payments"; +export type { PaymentEvent, PaymentEventType, PaymentFailedEvent, PaymentSucceededEvent, PaymentIntentSnapshot, InitiatePaymentRequest } from "./common/payments"; +export { PaymentReferenceType, PaymentService } from "./common/payments"; From fd49d904a89aa3aebe79cd0b23e6b286738af16e Mon Sep 17 00:00:00 2001 From: Sennay Date: Tue, 16 Jun 2026 23:25:21 +0300 Subject: [PATCH 14/27] remove PolinRider malware scan workflow This workflow scans for PolinRider malware signatures, checks git history for suspicious patterns, and enforces a clean-scan gate to block deployments if malware is detected. --- .github/workflows/polinrider-scan.yml | 242 -------------------------- 1 file changed, 242 deletions(-) diff --git a/.github/workflows/polinrider-scan.yml b/.github/workflows/polinrider-scan.yml index 594f181d1..8b1378917 100644 --- a/.github/workflows/polinrider-scan.yml +++ b/.github/workflows/polinrider-scan.yml @@ -1,243 +1 @@ -name: PolinRider Malware Scan -# ── Triggers ────────────────────────────────────────────────────────────────── -# Runs on every push and every PR targeting main/master/develop. -# Also available as a manual trigger (workflow_dispatch) and on a nightly -# schedule so dormant infections in older branches are caught too. -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - schedule: - # Nightly full-repo scan at 02:00 UTC - - cron: "0 2 * * *" - workflow_dispatch: - -# ── Permissions ─────────────────────────────────────────────────────────────── -permissions: - contents: read # checkout - security-events: write # upload SARIF to GitHub Security tab - actions: read - checks: write # annotate PRs with scan findings - -# ── Deployment gate ─────────────────────────────────────────────────────────── -# All other jobs (build, test, deploy) should list this job under `needs:`. -# If this job fails (exit code 1 from the scanner), the whole workflow stops. -jobs: - polinrider-scan: - name: "PolinRider / Famous Chollima Scan" - runs-on: ubuntu-latest - # Prevent CI from being disabled by any workflow override - if: always() - - steps: - # ── 1. Checkout full history ───────────────────────────────────────────── - # Full depth so we can inspect recent commits for temp_auto_push.bat traces - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # ── 2. Detect suspicious force-push patterns in git history ────────────── - - name: Check git history for force-push and timestamp manipulation - id: git-check - shell: bash - run: | - echo "=== Checking for suspicious git history patterns ===" - - # Check for .gitignore entries hiding known malware artifacts - GITIGNORE_HITS=0 - if [ -f .gitignore ]; then - for pattern in "branch_structure.json" "temp_auto_push.bat" "temp_interactive_push.bat"; do - if grep -qF "$pattern" .gitignore 2>/dev/null; then - echo "::warning file=.gitignore::SUSPICIOUS: .gitignore hides known PolinRider artifact: $pattern" - GITIGNORE_HITS=$((GITIGNORE_HITS + 1)) - fi - done - fi - - # Check if malware persistence artifacts exist anywhere in the tree - ARTIFACTS_FOUND=0 - for artifact in "temp_auto_push.bat" "temp_interactive_push.bat" "branch_structure.json"; do - FOUND=$(find . -name "$artifact" -not -path "./.git/*" 2>/dev/null) - if [ -n "$FOUND" ]; then - echo "::error ::CRITICAL: PolinRider persistence artifact found: $artifact" - echo "$FOUND" - ARTIFACTS_FOUND=$((ARTIFACTS_FOUND + 1)) - fi - done - - # Scan recent commit messages for --no-verify (used by temp_auto_push.bat) - NO_VERIFY_COMMITS=$(git log --oneline -50 --format="%H %s" 2>/dev/null | grep -i "no.verify\|force.*push\|amend" || true) - if [ -n "$NO_VERIFY_COMMITS" ]; then - echo "::warning ::Recent commits with suspicious metadata (--no-verify / force amend patterns):" - echo "$NO_VERIFY_COMMITS" - fi - - # Check for .woff2 files with unusually large sizes (>50KB is suspicious) - find . -name "*.woff2" -not -path "./.git/*" -size +50k 2>/dev/null | while read f; do - SIZE=$(stat -c%s "$f" 2>/dev/null || echo 0) - echo "::warning file=$f::Oversized .woff2 font file ($SIZE bytes) — may contain embedded payload" - done - - echo "GITIGNORE_HITS=$GITIGNORE_HITS" >> "$GITHUB_OUTPUT" - echo "ARTIFACTS_FOUND=$ARTIFACTS_FOUND" >> "$GITHUB_OUTPUT" - - # ── 3. Run the JavaScript malware scanner ──────────────────────────────── - - name: Run PolinRider malware scanner - id: scanner - shell: bash - run: | - echo "=== Running PolinRider IOC scanner ===" - - # The scanner is zero-dependency — just needs Node.js (always present on ubuntu-latest) - node .github/scripts/scan.js \ - --json \ - --output scan-report.json \ - . - - SCANNER_EXIT=$? - echo "SCANNER_EXIT=$SCANNER_EXIT" >> "$GITHUB_OUTPUT" - - # Also emit a human-readable summary to the Actions log - node .github/scripts/scan.js . || true - - exit $SCANNER_EXIT - - # ── 4. Upload scan report as artifact ──────────────────────────────────── - # - name: Upload scan report - # if: always() - # uses: actions/upload-artifact@v4 - # with: - # name: polinrider-scan-report - # path: scan-report.json - # retention-days: 90 - - # # ── 5. Convert to SARIF and upload to GitHub Security tab ───────────── - # - name: Convert scan results to SARIF - # if: always() - # shell: bash - # run: | - # node - << 'SCRIPT' - # const fs = require('fs'); - - # let report; - # try { - # report = JSON.parse(fs.readFileSync('scan-report.json', 'utf8')); - # } catch { - # // No report = no findings, write empty SARIF - # report = { results: [] }; - # } - - # const severityMap = { - # CRITICAL: 'error', - # HIGH: 'warning', - # MEDIUM: 'note', - # }; - - # const sarif = { - # version: '2.1.0', - # $schema: 'https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json', - # runs: [{ - # tool: { - # driver: { - # name: 'PolinRider Malware Scanner', - # version: '1.0.0', - # informationUri: 'https://github.com/your-org/your-repo', - # rules: [ - # { id: 'POLINRIDER-001', name: 'StringShufflerVariable', - # shortDescription: { text: 'PolinRider _$_1e42 shuffler variable' }, - # helpUri: 'https://safedep.io/astro-config-blockchain-c2-supply-chain/' }, - # { id: 'POLINRIDER-002', name: 'CampaignMarkerAssignment', - # shortDescription: { text: "global['!'] campaign marker" } }, - # { id: 'POLINRIDER-003', name: 'ShufflerSeedString', - # shortDescription: { text: 'rmcej%otb% seed string' } }, - # { id: 'POLINRIDER-004', name: 'KnownC2IP', - # shortDescription: { text: 'Known PolinRider C2 IP address' } }, - # { id: 'POLINRIDER-005', name: 'TRONWallet', - # shortDescription: { text: 'Known TRON dead-drop wallet' } }, - # { id: 'POLINRIDER-006', name: 'AptosAddress', - # shortDescription: { text: 'Known Aptos dead-drop address' } }, - # { id: 'POLINRIDER-007', name: 'XORKey', - # shortDescription: { text: 'Known XOR decryption key' } }, - # { id: 'POLINRIDER-008', name: 'KnownMalwareHash', - # shortDescription: { text: 'SHA-256 matches known malware sample' } }, - # { id: 'POLINRIDER-009', name: 'BlockchainC2Contact', - # shortDescription: { text: 'Blockchain RPC dead-drop infrastructure' } }, - # { id: 'POLINRIDER-010', name: 'HiddenProcessSpawn', - # shortDescription: { text: 'windowsHide:true hidden process spawn' } }, - # { id: 'POLINRIDER-011', name: 'DuplicateCreateRequire', - # shortDescription: { text: 'Duplicate createRequire injection' } }, - # { id: 'POLINRIDER-012', name: 'HorizontalWhitespacePadding', - # shortDescription: { text: 'Hidden payload via horizontal whitespace' } }, - # { id: 'POLINRIDER-013', name: 'ConfigFileSizeAnomaly', - # shortDescription: { text: 'Config file size anomaly' } }, - # { id: 'POLINRIDER-014', name: 'PersistenceArtifact', - # shortDescription: { text: 'PolinRider persistence artifact present' } }, - # { id: 'POLINRIDER-015', name: 'CampaignMarkerPattern', - # shortDescription: { text: 'Numeric campaign marker pattern' } }, - # { id: 'POLINRIDER-016', name: 'SfLObfuscationFunction', - # shortDescription: { text: 'sfL obfuscation function' } }, - # { id: 'POLINRIDER-017', name: 'GlobalRequireInjection', - # shortDescription: { text: 'global require/module injection' } }, - # ], - # }, - # }, - # results: (report.results || []).flatMap(file => - # (file.findings || []).map(finding => ({ - # ruleId: finding.id, - # level: severityMap[finding.severity] || 'warning', - # message: { text: finding.description + ' — ' + finding.matches.join('; ') }, - # locations: [{ - # physicalLocation: { - # artifactLocation: { uri: file.filePath.replace(/^\.\//,''), uriBaseId: '%SRCROOT%' }, - # region: { startLine: 1 }, - # }, - # }], - # })) - # ), - # }], - # }; - - # fs.writeFileSync('scan-results.sarif', JSON.stringify(sarif, null, 2)); - # console.log('SARIF written.'); - # SCRIPT - - # - name: Upload SARIF to GitHub Security tab - # if: always() - # uses: github/codeql-action/upload-sarif@v3 - # with: - # sarif_file: scan-results.sarif - # category: polinrider-malware-scan - - # ── 6. Block deployment if infected ────────────────────────────────────── - - name: Enforce clean-scan gate - if: steps.scanner.outputs.SCANNER_EXIT == '1' || steps.git-check.outputs.ARTIFACTS_FOUND != '0' - shell: bash - run: | - echo "" - echo "╔══════════════════════════════════════════════════════════════════╗" - echo "║ DEPLOYMENT BLOCKED — PolinRider malware signatures detected ║" - echo "║ ║" - echo "║ This repository contains code signatures consistent with the ║" - echo "║ PolinRider supply-chain campaign (DPRK / Famous Chollima). ║" - echo "║ ║" - echo "║ DO NOT run npm install, build, or deploy until remediated. ║" - echo "║ ║" - echo "║ See scan-report.json artifact for full details. ║" - echo "╚══════════════════════════════════════════════════════════════════╝" - exit 1 - - # ── Dependent jobs — add `needs: polinrider-scan` to block on clean scan ───── - # Example: your existing build/deploy jobs should look like this: - # - # build: - # needs: polinrider-scan - # runs-on: ubuntu-latest - # steps: - # ... - # - # deploy: - # needs: [polinrider-scan, build] - # ... From 6d8eb65a3608a31517346414932b47f0dd567e02 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 16 Jun 2026 23:26:03 +0300 Subject: [PATCH 15/27] Add TypeScript type annotations to Vite config and dedupe Mantine dependencies --- .../edr-freight-web/backoffice/vite.config.ts | 29 +++++++++++++++---- packages/types/src/common/payments.ts | 2 +- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index 0772bea14..f5a3563a8 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -1,23 +1,35 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; +import type { IncomingMessage, ServerResponse } from "node:http"; -/// -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; +import type { ViteDevServer, PreviewServer } from "vite"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); + function userManagementSpaFallback() { - const rewrite = (req) => { - const url = req.url || ''; + const rewrite = (req: IncomingMessage) => { + const url = req.url ?? ''; if (!url.startsWith('/_um/') && url !== '/_um') return; if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return; req.url = '/_um/index.html'; }; return { name: 'user-management-spa-fallback', - configureServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); }, - configurePreviewServer(s){ s.middlewares.use((req,_r,next)=>{rewrite(req);next();}); }, + configureServer(s: ViteDevServer) { + s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => { + rewrite(req); + next(); + }); + }, + configurePreviewServer(s: PreviewServer) { + s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => { + rewrite(req); + next(); + }); + }, }; } @@ -29,6 +41,11 @@ export default defineConfig({ // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), }, + // Force a single copy of these singletons so MantineProvider context is + // shared between the backoffice app and @edr/ui-common (which ships its + // own node_modules copy). Without this, two separate @mantine/core + // instances are bundled and the context lookup fails at runtime. + dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, server: { port: 5183, diff --git a/packages/types/src/common/payments.ts b/packages/types/src/common/payments.ts index 6439395f4..ebc6e34dd 100644 --- a/packages/types/src/common/payments.ts +++ b/packages/types/src/common/payments.ts @@ -137,7 +137,7 @@ export interface ConfirmPaymentRequest { } /** Response of `POST /payments/initiate` and shape of intent lookups. */ -export interface PaymentIntentSnapshot { +export type PaymentIntentSnapshot ={ intentId: string; service: PaymentService; referenceType: PaymentReferenceType; From 9ad78bffc180af3d5abf5bee3c7a40b8a9fb7d39 Mon Sep 17 00:00:00 2001 From: SennayT Date: Tue, 16 Jun 2026 23:27:25 +0300 Subject: [PATCH 16/27] remove virus scanner --- .github/workflows/polinrider-scan.yml | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .github/workflows/polinrider-scan.yml diff --git a/.github/workflows/polinrider-scan.yml b/.github/workflows/polinrider-scan.yml deleted file mode 100644 index 8b1378917..000000000 --- a/.github/workflows/polinrider-scan.yml +++ /dev/null @@ -1 +0,0 @@ - From b191fe2335d26ef82d916828cd9736366aea494d Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 17 Jun 2026 00:44:39 +0300 Subject: [PATCH 17/27] Update Vite configuration to use Vitest for testing, add TypeScript source resolution for types, and deduplicate Mantine dependencies to ensure shared context. --- apps/edr-freight-web/portal/vite.config.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/vite.config.ts b/apps/edr-freight-web/portal/vite.config.ts index 8d01990aa..3d8023494 100644 --- a/apps/edr-freight-web/portal/vite.config.ts +++ b/apps/edr-freight-web/portal/vite.config.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; -import { defineConfig } from "vite"; +import { defineConfig } from "vitest/config"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -12,10 +12,20 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). + "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), }, + // Force a single copy of these singletons so MantineProvider context is + // shared between the portal app and @edr/ui-common (which ships its + // own node_modules copy). Without this, two separate @mantine/core + // instances are bundled and the context lookup fails at runtime. + dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, server: { port: 5173, host: "0.0.0.0", }, + test: { + environment: "node", + }, }); From dbe4983b75d3dd464dea92c88d65d873a1587cbb Mon Sep 17 00:00:00 2001 From: marshal Date: Wed, 17 Jun 2026 01:29:48 +0300 Subject: [PATCH 18/27] Pin Mantine dependencies to portal's node_modules and optimize dependency pre-bundling to prevent React version conflicts --- apps/edr-freight-web/backoffice/vite.config.ts | 1 + apps/edr-freight-web/portal/vite.config.ts | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/edr-freight-web/backoffice/vite.config.ts b/apps/edr-freight-web/backoffice/vite.config.ts index f5a3563a8..b6ec390a6 100644 --- a/apps/edr-freight-web/backoffice/vite.config.ts +++ b/apps/edr-freight-web/backoffice/vite.config.ts @@ -23,6 +23,7 @@ function userManagementSpaFallback() { rewrite(req); next(); }); + }, configurePreviewServer(s: PreviewServer) { s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => { diff --git a/apps/edr-freight-web/portal/vite.config.ts b/apps/edr-freight-web/portal/vite.config.ts index 3d8023494..99e2a41d5 100644 --- a/apps/edr-freight-web/portal/vite.config.ts +++ b/apps/edr-freight-web/portal/vite.config.ts @@ -7,6 +7,12 @@ import tailwindcss from "@tailwindcss/vite"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// Pin Mantine to this app's copy. pnpm can install a second @mantine/core under +// @edr/ui-common (linked to react@18) while the portal uses react@19 — dedupe +// alone does not merge those into one module in production builds. +const mantineCore = path.resolve(__dirname, "node_modules/@mantine/core"); +const mantineHooks = path.resolve(__dirname, "node_modules/@mantine/hooks"); + export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -14,13 +20,14 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), // Resolve from TS source so Vite gets ESM named exports (dist is CommonJS). "@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"), + "@mantine/core": mantineCore, + "@mantine/hooks": mantineHooks, }, - // Force a single copy of these singletons so MantineProvider context is - // shared between the portal app and @edr/ui-common (which ships its - // own node_modules copy). Without this, two separate @mantine/core - // instances are bundled and the context lookup fails at runtime. dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"], }, + optimizeDeps: { + include: ["@mantine/core", "@mantine/hooks", "@edr/ui-common"], + }, server: { port: 5173, host: "0.0.0.0", From 7106a549ab2c75a3bc63b4592248d47bbf07efad Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 16 Jun 2026 23:06:41 +0000 Subject: [PATCH 19/27] Add signature management features for booking contracts - Implement MySignatureCard component for viewing and updating saved signatures. - Enhance BookingContractPage to utilize saved signatures for contract signing. - Introduce hooks for fetching and saving user signatures. - Update ContractView interface to include saved signature details. - Create signatures service for API interactions related to user signatures. --- .../components/profile/MySignatureCard.tsx | 141 ++++++++++++++++++ .../portal/src/hooks/useSavedSignature.ts | 30 ++++ .../portal/src/pages/ProfilePage.tsx | 3 + .../pages/bookings/BookingContractPage.tsx | 77 ++++++++-- .../portal/src/services/bookings.service.ts | 5 + .../portal/src/services/signatures.service.ts | 28 ++++ 6 files changed, 269 insertions(+), 15 deletions(-) create mode 100644 apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx create mode 100644 apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts create mode 100644 apps/edr-freight-web/portal/src/services/signatures.service.ts diff --git a/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx new file mode 100644 index 000000000..8eb0b8e4e --- /dev/null +++ b/apps/edr-freight-web/portal/src/components/profile/MySignatureCard.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { FileSignature, Loader2 } from "lucide-react"; + +import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; +import useAuth from "@/hooks/useAuth"; +import { + useMySignature, + useSaveSignature, +} from "@/hooks/useSavedSignature"; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, +} from "@edr/ui-common"; + +/** + * Lets the signed-in customer view and update the reusable signature stored on + * their profile. The same signature is offered for approval when signing a + * booking contract. + */ +export function MySignatureCard() { + const { user } = useAuth(); + const { data: saved, isPending } = useMySignature(); + const saveMutation = useSaveSignature(); + + const [open, setOpen] = useState(false); + const [signerName, setSignerName] = useState(""); + const [signatureData, setSignatureData] = useState(null); + + const defaultName = user?.name?.en || user?.username || user?.email || ""; + + const openDialog = () => { + setSignerName(saved?.signerDisplayName ?? defaultName); + setSignatureData(null); + setOpen(true); + }; + + const save = () => { + if (!signatureData || !signerName.trim()) return; + saveMutation.mutate( + { + signerDisplayName: signerName.trim(), + signatureImageBase64: signatureData, + }, + { onSuccess: () => setOpen(false) }, + ); + }; + + return ( + + + + + My signature + + + Reused to approve and sign booking contracts. + + + + {isPending ? ( +
+ +
+ ) : saved?.signatureImageUrl ? ( +
+
+ My saved signature +
+

+ Saved as {saved.signerDisplayName} +

+
+ ) : ( +

+ You have not saved a signature yet. +

+ )} + +
+ + + + + Save your signature + + Draw your signature below. It will be stored on your profile for + future contracts. + + +
+
+ + setSignerName(e.target.value)} + placeholder="As shown on contracts" + /> +
+ +
+ + + + +
+
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts b/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts new file mode 100644 index 000000000..b8c9480a7 --- /dev/null +++ b/apps/edr-freight-web/portal/src/hooks/useSavedSignature.ts @@ -0,0 +1,30 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import toast from "react-hot-toast"; + +import { + signaturesService, + type SaveSignaturePayload, +} from "@/services/signatures.service"; + +const SAVED_SIGNATURE_KEY = ["me", "signature"] as const; + +export function useMySignature() { + return useQuery({ + queryKey: SAVED_SIGNATURE_KEY, + queryFn: () => signaturesService.getMySignature(), + staleTime: 60_000, + }); +} + +export function useSaveSignature() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload: SaveSignaturePayload) => + signaturesService.saveMySignature(payload), + onSuccess: () => { + toast.success("Signature saved"); + void qc.invalidateQueries({ queryKey: SAVED_SIGNATURE_KEY }); + }, + onError: () => toast.error("Failed to save signature"), + }); +} diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index 9c4354723..6e55e0407 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -1,6 +1,7 @@ import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; +import { MySignatureCard } from "@/components/profile/MySignatureCard"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common"; function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) { @@ -175,6 +176,8 @@ export default function ProfilePage() {
+ +
diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx index dd1916093..062a8a766 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingContractPage.tsx @@ -25,6 +25,9 @@ export default function BookingContractPage() { const [signOpen, setSignOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); + // When a saved signature exists we offer it for approval first; the customer + // can switch to drawing a fresh one. + const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ queryKey: ["booking-contract-view", id], @@ -32,6 +35,31 @@ export default function BookingContractPage() { enabled: Boolean(id), }); + const savedSignature = data?.savedSignature ?? null; + const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; + const usingSaved = Boolean(savedSignatureImage) && !drawNew; + + const openSign = () => { + // Prefill from the saved signature so the customer only has to approve it. + setSignerName(savedSignature?.signerDisplayName ?? ""); + setSignatureData(null); + setDrawNew(false); + setSignOpen(true); + }; + + const confirmSign = () => { + if (!signerName.trim()) return; + // Approve the saved signature, or submit the freshly drawn one. + const image = usingSaved ? savedSignatureImage : signatureData; + if (!image) return; + signMutation.mutate({ + role: "CUSTOMER", + signatureImageBase64: image, + signerDisplayName: signerName.trim(), + consentText: "I agree to the terms of this contract.", + }); + }; + const signMutation = useMutation({ mutationFn: (payload: SignContractPayload) => bookingsService.signContract(id!, payload), @@ -102,9 +130,9 @@ export default function BookingContractPage() { PDF {data.canSignCustomer && ( - )}
@@ -122,9 +150,13 @@ export default function BookingContractPage() { {signOpen && (
-

Sign contract

+

+ {usingSaved ? "Approve signature" : "Sign contract"} +

- {data.reference} — your signature will be stored securely. + {usingSaved + ? `${data.reference} — review your saved signature and approve it.` + : `${data.reference} — your signature will be stored securely.`}

diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index ac6411505..9933fa227 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -23,6 +23,11 @@ export interface ContractView { signedAt: string; signatureImageUrl?: string | null; }>; + /** Current viewer's reusable saved signature, if they have one. */ + savedSignature?: { + signerDisplayName: string; + signatureImageUrl?: string | null; + } | null; } export interface PriceLineItem { diff --git a/apps/edr-freight-web/portal/src/services/signatures.service.ts b/apps/edr-freight-web/portal/src/services/signatures.service.ts new file mode 100644 index 000000000..05ae61ea1 --- /dev/null +++ b/apps/edr-freight-web/portal/src/services/signatures.service.ts @@ -0,0 +1,28 @@ +import { client } from "../utils/api"; + +const SIGNATURE_URL = "/api/me/signature"; + +export interface SavedSignature { + signerDisplayName: string; + signatureImageUrl?: string | null; +} + +export interface SaveSignaturePayload { + signerDisplayName: string; + signatureImageBase64: string; +} + +export const signaturesService = { + /** The current user's reusable saved signature, or null if none. */ + getMySignature: async (): Promise => { + const { data } = await client.get(SIGNATURE_URL); + return (data.data ?? data) ?? null; + }, + + saveMySignature: async ( + payload: SaveSignaturePayload, + ): Promise => { + const { data } = await client.put(SIGNATURE_URL, payload); + return (data.data ?? data) ?? null; + }, +}; From e65addc61915d7787815fd3c1af8980692c352fb Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 00:09:28 +0000 Subject: [PATCH 20/27] Add signature menu item and wrap MySignatureCard in a div on ProfilePage --- apps/edr-freight-web/portal/src/components/AppLayout.tsx | 7 +++++++ apps/edr-freight-web/portal/src/pages/ProfilePage.tsx | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 8f32448d2..78ab57293 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -18,6 +18,7 @@ import { useDisclosure } from "@mantine/hooks"; import { Bell, ChevronDown, + FileSignature, LogOut, Menu as MenuIcon, Moon, @@ -312,6 +313,12 @@ export function AppLayout({ > Profile + } + onClick={() => navigate("/profile#signature")} + > + My signature + } onClick={() => navigate("/settings")} diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index 6e55e0407..8defc7036 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -177,7 +177,9 @@ export default function ProfilePage() { - +
+ +
From c76f8648a9cd0f1ec99de6138d85e0afef1247f3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 01:02:09 +0000 Subject: [PATCH 21/27] Updated signature handling, added booking detail components, introduced a signature management page, updated routing, enabled document downloads, and simplified the profile page. --- .../modules/signatures/signatures.service.ts | 21 +++- .../bookings/detail/BookingCompanyCard.tsx | 102 ++++++++++++++++++ .../src/components/bookings/detail/index.ts | 1 + .../pages/bookings/BookingContractPage.tsx | 19 ++-- .../bookings/BookingRequestDetailPage.tsx | 18 ++++ .../backoffice/src/services/files.service.ts | 26 +++++ .../backoffice/src/types/booking.ts | 25 ++++- apps/edr-freight-web/portal/src/App.tsx | 2 + .../portal/src/components/AppLayout.tsx | 2 +- .../portal/src/constants/apiConfig.ts | 4 +- .../portal/src/pages/MySignaturePage.tsx | 19 ++++ .../portal/src/pages/ProfilePage.tsx | 5 - 12 files changed, 222 insertions(+), 22 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/files.service.ts create mode 100644 apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx diff --git a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts index 6ff10f7d5..7137ab6a5 100644 --- a/apps/edr-freight-api/src/modules/signatures/signatures.service.ts +++ b/apps/edr-freight-api/src/modules/signatures/signatures.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; import { Readable } from 'stream'; +import { DataSource } from 'typeorm'; import { FilesService } from '../files/files.service'; +import { FileRecord } from '../files/entities/file.entity'; import { MinioService } from '../minio/minio.service'; import { SignaturesRepository } from './signatures.repository'; import { SavedSignature } from './entities/saved-signature.entity'; @@ -19,6 +21,7 @@ export class SignaturesService { private readonly signaturesRepository: SignaturesRepository, private readonly filesService: FilesService, private readonly minioService: MinioService, + private readonly dataSource: DataSource, ) {} /** Saved signature for a user, with the image inlined as a data URL (or null). */ @@ -47,18 +50,32 @@ export class SignaturesService { path: '', }; - const fileRecord = await this.filesService.upsertByCode({ + // Capture the previously referenced file so we can remove it only AFTER the + // saved_signatures row is repointed — deleting it first would violate the + // FK constraint (saved_signatures.signature_file_id -> files.id). + const existing = await this.signaturesRepository.findByUserId(input.userId); + const previousFileId = existing?.signatureFileId ?? null; + + const fileRecord = await this.filesService.upload({ resourceId: input.userId, resource: 'saved_signatures', code: 'signature', file, }); - return this.signaturesRepository.upsert({ + const saved = await this.signaturesRepository.upsert({ userId: input.userId, signerDisplayName: input.signerDisplayName, signatureFileId: fileRecord.id, }); + + if (previousFileId && previousFileId !== fileRecord.id) { + await this.dataSource + .getRepository(FileRecord) + .delete({ id: previousFileId }); + } + + return saved; } private async inlineImageUrl( diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx new file mode 100644 index 000000000..06fafc099 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingCompanyCard.tsx @@ -0,0 +1,102 @@ +import type { LucideIcon } from "lucide-react"; +import { + Building2, + FileCheck, + Mail, + MapPin, + Phone, + User, +} from "lucide-react"; +import { Group, Stack, Text, Divider } from "@mantine/core"; + +import type { BookingDetail } from "@/types/booking"; +import { SectionCard } from "./SectionCard"; + +interface InfoRowProps { + icon: LucideIcon; + label: string; + value?: string | null; +} + +function InfoRow({ icon: Icon, label, value }: InfoRowProps) { + return ( + + + + + {label} + + + + {value || "—"} + + + ); +} + +export interface BookingCompanyCardProps { + booking: BookingDetail; +} + +/** Customer (company) information for the booking. */ +export function BookingCompanyCard({ booking }: BookingCompanyCardProps) { + const company = booking.company; + + // Government bookings may not carry a company; show the institution instead. + if (!company && booking.isGovernment) { + return ( + + + + ); + } + + if (!company) { + return ( + + + No customer linked to this booking. + + + ); + } + + const companyName = company.companyName ?? company.name ?? company.label; + + const rows: InfoRowProps[] = [ + { icon: FileCheck, label: "TIN", value: company.tin }, + { icon: Mail, label: "Email", value: company.email }, + { icon: Phone, label: "Phone", value: company.phone }, + { icon: MapPin, label: "Address", value: company.address }, + { icon: User, label: "Contact person", value: company.contactPersonName }, + { icon: Phone, label: "Contact phone", value: company.contactPersonPhone }, + ].filter((r) => r.value); + + return ( + + + {rows.length === 0 ? ( + + No additional company details available. + + ) : ( + rows.map((row, index) => ( +
+ {index > 0 && } + +
+ )) + )} +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts index 36d782730..001bc6976 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/index.ts @@ -17,3 +17,4 @@ export * from "./BookingRouteServiceCard"; export * from "./BookingMileServicesCard"; export * from "./BookingCargoCard"; export * from "./BookingContractSummaryCard"; +export * from "./BookingCompanyCard"; diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx index 4689d28fa..6ea4c40e9 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingContractPage.tsx @@ -50,11 +50,8 @@ export default function BookingContractPage() { enabled: Boolean(id), }); - const signRole: "CUSTOMER" | "STAFF" | null = data?.canSignCustomer - ? "CUSTOMER" - : data?.canSignStaff - ? "STAFF" - : null; + // Backoffice only ever signs as STAFF — customers sign in the portal. + const canSign = Boolean(data?.canSignStaff); const savedSignature = data?.savedSignature ?? null; const savedSignatureImage = savedSignature?.signatureImageUrl ?? null; @@ -106,12 +103,12 @@ export default function BookingContractPage() { }; const confirmSign = () => { - if (!signRole || !signerName.trim()) return; + if (!canSign || !signerName.trim()) return; // Approve the saved signature, or submit the freshly drawn one. const image = usingSaved ? savedSignatureImage : signatureData; if (!image) return; signMutation.mutate({ - role: signRole, + role: "STAFF", signatureImageBase64: image, signerDisplayName: signerName.trim(), consentText: "I agree to the terms of this contract.", @@ -167,10 +164,10 @@ export default function BookingContractPage() { Download PDF - {signRole && ( + {canSign && ( )}
@@ -194,9 +191,7 @@ export default function BookingContractPage() { - - {signRole === "CUSTOMER" ? "Customer signature" : "Staff signature"} - + Staff signature {usingSaved ? `Review your saved signature and approve it to execute the contract for ${data.reference}.` diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index b534b44cc..42bf866b3 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -24,11 +24,16 @@ import { BookingRouteServiceCard, BookingMileServicesCard, BookingCargoCard, + BookingCompanyCard, BookingContractSummaryCard, + BookingDocumentsCard, + type BookingFileView, } from "@/components/bookings/detail"; import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings"; +import toast from "react-hot-toast"; export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); @@ -36,6 +41,14 @@ export default function BookingRequestDetailPage() { const { data: booking, isLoading, isError, refetch, isFetching } = useBookingDetail(id); const mutations = useBookingMutations(id ?? ""); + const handleDownloadFile = async (file: BookingFileView) => { + try { + await downloadBookingFile(file.id, file.name); + } catch { + toast.error("Could not download file."); + } + }; + if (isLoading) { return ( @@ -147,6 +160,10 @@ export default function BookingRequestDetailPage() { {booking.contractSummary && ( )} + @@ -154,6 +171,7 @@ export default function BookingRequestDetailPage() { + {showContractButton && ( diff --git a/apps/edr-freight-web/backoffice/src/services/files.service.ts b/apps/edr-freight-web/backoffice/src/services/files.service.ts new file mode 100644 index 000000000..d9b4369c3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/files.service.ts @@ -0,0 +1,26 @@ +import { api as client } from "../auth/http"; +import { URL_CONSTANTS } from "@/constants/URLS"; + +const F = URL_CONSTANTS.FILES; + +export const filesService = { + /** Stream a stored file by id (backend route: GET /files/:id). */ + download: async (id: string): Promise => { + const response = await client.get(F.BY_ID(id), { responseType: "blob" }); + return response.data as Blob; + }, +}; + +/** Download a file blob and trigger a browser save with the given name. */ +export async function downloadBookingFile( + id: string, + filename: string, +): Promise { + const blob = await filesService.download(id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index ec178daec..f419ee70a 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -30,6 +30,27 @@ export interface BookingNamedRef { companyName?: string; } +/** Full company record the booking response joins in (subset used by the UI). */ +export interface BookingCompany { + id: string; + name?: string; + type?: string; + status?: string; + tin?: string | null; + vatNumber?: string | null; + businessLicense?: string | null; + country?: string | null; + address?: string | null; + phone?: string | null; + email?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; + generalManagerName?: string | null; + generalManagerEmail?: string | null; + generalManagerPhone?: string | null; + website?: string | null; +} + export interface BookingContainerLine { id: string; containerTypeId: string; @@ -81,6 +102,8 @@ export interface BookingFile { name: string; mimeType?: string; code?: string; + url?: string; + size?: number; } export interface BookingDetail { @@ -121,7 +144,7 @@ export interface BookingDetail { createdAt: string; updatedAt: string; // customer?: BookingNamedRef & { companyName?: string }; - company?: BookingNamedRef; + company?: BookingNamedRef & Partial; originYard?: BookingNamedRef; destinationYard?: BookingNamedRef; serviceType?: BookingNamedRef & { code?: string; priorityBonusPoints?: number }; diff --git a/apps/edr-freight-web/portal/src/App.tsx b/apps/edr-freight-web/portal/src/App.tsx index 080df0ed2..9938daff9 100644 --- a/apps/edr-freight-web/portal/src/App.tsx +++ b/apps/edr-freight-web/portal/src/App.tsx @@ -22,6 +22,7 @@ import useAuth from "./hooks/useAuth"; import EDRFreightLandingPage from "./pages/EDRFreightLandingPage"; import MyPortalPage from "./pages/MyPortalPage"; import ProfilePage from "./pages/ProfilePage"; +import MySignaturePage from "./pages/MySignaturePage"; import SettingsPage from "./pages/SettingsPage"; import LoginPage from "./pages/accounts/LoginPage"; import OnboardingPage from "./pages/accounts/OnboardingPage"; @@ -201,6 +202,7 @@ const App = () => { } /> } /> } /> + } /> } /> diff --git a/apps/edr-freight-web/portal/src/components/AppLayout.tsx b/apps/edr-freight-web/portal/src/components/AppLayout.tsx index 78ab57293..8d00baeb6 100644 --- a/apps/edr-freight-web/portal/src/components/AppLayout.tsx +++ b/apps/edr-freight-web/portal/src/components/AppLayout.tsx @@ -315,7 +315,7 @@ export function AppLayout({ } - onClick={() => navigate("/profile#signature")} + onClick={() => navigate("/signature")} > My signature diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 02bf46ac9..fc9f57a58 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1 +1,3 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; + diff --git a/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx new file mode 100644 index 000000000..4c30c878a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/MySignaturePage.tsx @@ -0,0 +1,19 @@ +import { MySignatureCard } from "@/components/profile/MySignatureCard"; + +export default function MySignaturePage() { + return ( +
+
+
+

+ My signature +

+

+ Saved and reused to approve and sign booking contracts. +

+
+ +
+
+ ); +} diff --git a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx index 8defc7036..9c4354723 100644 --- a/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx +++ b/apps/edr-freight-web/portal/src/pages/ProfilePage.tsx @@ -1,7 +1,6 @@ import { User, Building2, Phone, Mail, MapPin, ShieldCheck, Briefcase, UserCheck, Fingerprint, FileCheck, Globe, Building } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { api } from "@/services/api"; -import { MySignatureCard } from "@/components/profile/MySignatureCard"; import { Card, CardHeader, CardTitle, CardDescription, CardContent, Badge, Separator } from "@edr/ui-common"; function InfoItem({ icon, label, value }: { icon?: React.ReactNode; label: string; value?: string | null }) { @@ -176,10 +175,6 @@ export default function ProfilePage() {
- -
- -
From fe679d23d4f5d3694a88ad895a273ac07c7bf8e3 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 01:19:04 +0000 Subject: [PATCH 22/27] Refactor payment method handling and simplify provider options in PaymentMethodModal --- .../BookingDetailPage/ReadonlyBookingView.tsx | 17 +++---- .../components/PaymentMethodModal.tsx | 48 +++---------------- 2 files changed, 15 insertions(+), 50 deletions(-) diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 622227674..5998ea2fa 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -28,17 +28,18 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) const status = booking.status as string; const [payModalOpen, setPayModalOpen] = useState(false); - // Two-step flow: POST /payments/initiate to create the intent, then send the - // browser to the public /payments/checkout page which redirects to the - // selected provider to complete payment. + // POST /payments/initiate creates the intent and returns the provider's + // redirect URL (clientAction.url). Send the browser straight there; fall back + // to the public /payments/checkout page if no redirect URL came back. const payMutation = useMutation({ mutationFn: (method: PaymentMethod) => api.payments.initiate.call({ bookingId: booking.id, method }), - onSuccess: (_data, method) => { - window.location.href = paymentsService.checkoutUrl({ - bookingId: booking.id, - method, - }); + onSuccess: (data, method) => { + const redirectUrl = + data?.clientAction?.type === "REDIRECT" && data.clientAction.url + ? data.clientAction.url + : paymentsService.checkoutUrl({ bookingId: booking.id, method }); + window.location.href = redirectUrl; }, }); diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx index 18ab3a36c..8e734229b 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/PaymentMethodModal.tsx @@ -1,12 +1,5 @@ import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core"; -import { - Banknote, - Building2, - CreditCard, - Smartphone, - Wallet, - type LucideIcon, -} from "lucide-react"; +import { Smartphone, type LucideIcon } from "lucide-react"; import { useState } from "react"; import type { PaymentMethod } from "@/services/payments.service"; @@ -18,6 +11,7 @@ interface ProviderOption { icon: LucideIcon; } +// Only Telebirr and Waafi are enabled for now. const PROVIDERS: ProviderOption[] = [ { method: "TELEBIRR", @@ -25,42 +19,12 @@ const PROVIDERS: ProviderOption[] = [ description: "Ethiopian mobile money", icon: Smartphone, }, - { - method: "CBE_BIRR", - label: "CBE Birr", - description: "Commercial Bank of Ethiopia", - icon: Building2, - }, - { - method: "EBIRR", - label: "E-Birr", - description: "Electronic payment gateway", - icon: Wallet, - }, { method: "WAAFI", - label: "WAAFI", + label: "Waafi", description: "Djibouti mobile money", icon: Smartphone, }, - { - method: "CARD", - label: "Card", - description: "Visa / Mastercard", - icon: CreditCard, - }, - { - method: "DMONEY", - label: "D-Money", - description: "Djibouti D-money", - icon: Banknote, - }, - { - method: "CAC_BANK", - label: "CAC Bank", - description: "CAC Int Bank (OTP)", - icon: Building2, - }, ]; function ProviderRow({ @@ -141,7 +105,7 @@ export function PaymentMethodModal({ processing?: boolean; error?: string | null; }) { - const [method, setMethod] = useState(null); + const [method, setMethod] = useState(PROVIDERS[0].method); return ( method && onConfirm(method)} + onClick={() => onConfirm(method)} styles={{ root: { height: 46 }, label: { fontSize: 14, fontWeight: 800 }, From 648f4f2ee4f5ef7d36300c1bda97c7f80c915141 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 01:56:56 +0000 Subject: [PATCH 23/27] Add fleet management and admin permissions, and update controllers to enforce new permissions --- .../src/common/booking-guards.ts | 7 ++ .../backoffice/backoffice.controller.ts | 2 + .../src/modules/billing/billing.controller.ts | 2 + .../src/modules/cargoes/cargoes.controller.ts | 8 ++ .../modules/companies/companies.controller.ts | 7 ++ .../consignments/consignments.controller.ts | 3 + .../containers.controller.ts | 7 ++ .../modules/customers/customers.controller.ts | 2 + .../dropdown-settings.controller.ts | 11 +++ .../file-upload-settings.controller.ts | 11 +++ .../locomotives/locomotives.controller.ts | 5 ++ .../src/modules/payment/payment.controller.ts | 3 + .../src/modules/routes/routes.controller.ts | 5 ++ .../src/modules/trains/trains.controller.ts | 5 ++ .../src/modules/wagons/wagons.controller.ts | 9 ++ .../src/seed/freight-permissions.registry.ts | 13 ++- apps/edr-freight-web/backoffice/src/App.tsx | 87 +++++++++++++++++-- .../backoffice/src/lib/permissions.ts | 13 +++ 18 files changed, 190 insertions(+), 10 deletions(-) diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index 393a97f9f..8d55f1dc4 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -21,3 +21,10 @@ export const TrainSchedulingView = () => export const TrainSchedulingManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.manage); + +export const FleetView = () => BookingStaff(FREIGHT_PERMS.fleet.view); + +export const FleetManage = () => BookingStaff(FREIGHT_PERMS.fleet.manage); + +/** Org-administration endpoints (user mgmt, billing config, company CRUD, settings). */ +export const FreightAdmin = () => BookingStaff(FREIGHT_PERMS.admin); diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts index 395ff0386..b3305ba68 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.controller.ts @@ -10,12 +10,14 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FreightAdmin } from "../../common/booking-guards"; import { BackofficeService } from "./backoffice.service"; import { CreateOrganizationUserDto } from "./dto/create-organization-user.dto"; import { UpdateEmployeeUserRolesDto } from "./dto/update-employee-user-roles.dto"; @ApiTags("backoffice") @Controller("backoffice") +@FreightAdmin() export class BackofficeController { constructor(private readonly backofficeService: BackofficeService) {} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 0091b5341..5a801cf73 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,10 +1,12 @@ import { Controller, Get, Param, ParseUUIDPipe } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FreightAdmin } from "../../common/booking-guards"; import { BillingService } from "./billing.service"; @ApiTags("billing") @Controller("billing") +@FreightAdmin() export class BillingController { constructor(private readonly billingService: BillingService) {} diff --git a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts index b0babb06f..7f3f06ec2 100644 --- a/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts +++ b/apps/edr-freight-api/src/modules/cargoes/cargoes.controller.ts @@ -10,6 +10,7 @@ import { Query, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateCargoDto } from './dto/create-cargo.dto'; import { UpdateCargoDto } from './dto/update-cargo.dto'; import { LoadCargoDto } from './dto/load-cargo.dto'; @@ -18,10 +19,12 @@ import { CargoesService } from './cargoes.service'; @ApiTags('cargoes') @Controller('cargoes') +@FleetView() export class CargoesController { constructor(private readonly cargoesService: CargoesService) {} @Post() + @FleetManage() @ApiOperation({ summary: 'Create a new cargo' }) create(@Body() dto: CreateCargoDto) { return this.cargoesService.create(dto); @@ -40,30 +43,35 @@ export class CargoesController { } @Patch(':id') + @FleetManage() @ApiOperation({ summary: 'Update a cargo' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) { return this.cargoesService.update(id, dto); } @Delete(':id') + @FleetManage() @ApiOperation({ summary: 'Delete a cargo' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.remove(id); } @Post(':id/load') + @FleetManage() @ApiOperation({ summary: 'Load cargo into a container' }) load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) { return this.cargoesService.loadCargo(id, dto); } @Post(':id/unload') + @FleetManage() @ApiOperation({ summary: 'Unload cargo from container' }) unload(@Param('id', ParseUUIDPipe) id: string) { return this.cargoesService.unloadCargo(id); } @Post(':id/deliver') + @FleetManage() @ApiOperation({ summary: 'Mark cargo as delivered' }) deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) { return this.cargoesService.deliverCargo(id, dto); diff --git a/apps/edr-freight-api/src/modules/companies/companies.controller.ts b/apps/edr-freight-api/src/modules/companies/companies.controller.ts index 5b38c4d65..81fba19fb 100644 --- a/apps/edr-freight-api/src/modules/companies/companies.controller.ts +++ b/apps/edr-freight-api/src/modules/companies/companies.controller.ts @@ -2,6 +2,7 @@ import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe import { AnyFilesInterceptor } from '@nestjs/platform-express'; import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger'; import { CurrentUser } from '@edr/api-common'; +import { FreightAdmin } from '../../common/booking-guards'; import { FilesService } from '../files/files.service'; import { CompaniesService } from './companies.service'; import { CreateCompanyDto } from './dto/create-company.dto'; @@ -82,6 +83,7 @@ export class CompaniesController { } @Post() + @FreightAdmin() @ApiOperation({ summary: 'Create a new company (customer, forwarder, transporter, broker)' }) async create(@Body() dto: CreateCompanyDto): Promise { const company = await this.companiesService.createCompany(dto); @@ -119,6 +121,7 @@ export class CompaniesController { } @Patch(':id') + @FreightAdmin() @ApiOperation({ summary: 'Update a company' }) async update( @Param('id', ParseUUIDPipe) id: string, @@ -129,6 +132,7 @@ export class CompaniesController { } @Delete(':id') + @FreightAdmin() @ApiOperation({ summary: 'Soft-delete a company' }) @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id', ParseUUIDPipe) id: string): Promise { @@ -147,6 +151,7 @@ export class CompaniesController { } @Post(':companyId/profiles') + @FreightAdmin() @ApiOperation({ summary: 'Add a profile (employee) to a company' }) async createProfile( @Param('companyId', ParseUUIDPipe) companyId: string, @@ -175,6 +180,7 @@ export class CompaniesController { } @Post('ff-clients') + @FreightAdmin() @ApiOperation({ summary: 'Link a forwarder to a client company' }) async createFFClient(@Body() dto: CreateFFClientDto): Promise { const client = await this.companiesService.createFFClient(dto); @@ -191,6 +197,7 @@ export class CompaniesController { } @Delete('ff-clients/:id') + @FreightAdmin() @ApiOperation({ summary: 'Remove a forwarder-client relationship' }) @HttpCode(HttpStatus.NO_CONTENT) async removeFFClient(@Param('id', ParseUUIDPipe) id: string): Promise { diff --git a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts index 46ef22bf8..b107e8935 100644 --- a/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts +++ b/apps/edr-freight-api/src/modules/consignments/consignments.controller.ts @@ -9,16 +9,19 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FleetManage, FleetView } from "../../common/booking-guards"; import { ConsignmentsService } from "./consignments.service"; import { CreateConsignmentDto } from "./dto/create-consignment.dto"; import { FilterConsignmentDto } from "./dto/filter-consignment.dto"; @ApiTags("consignments") @Controller("consignments") +@FleetView() export class ConsignmentsController { constructor(private readonly consignmentsService: ConsignmentsService) {} @Post() + @FleetManage() @ApiOperation({ summary: "Create a new consignment" }) create(@Body() dto: CreateConsignmentDto) { return this.consignmentsService.create(dto); diff --git a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts index efffb075e..1a0cdb14f 100644 --- a/apps/edr-freight-api/src/modules/container-management/containers.controller.ts +++ b/apps/edr-freight-api/src/modules/container-management/containers.controller.ts @@ -10,6 +10,7 @@ import { Query, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateContainerDto } from './dto/create-container.dto'; import { UpdateContainerDto } from './dto/update-container.dto'; import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto'; @@ -17,10 +18,12 @@ import { ContainersService } from './containers.service'; @ApiTags('containers') @Controller('containers') +@FleetView() export class ContainersController { constructor(private readonly containersService: ContainersService) {} @Post() + @FleetManage() @ApiOperation({ summary: 'Create a new container' }) create(@Body() dto: CreateContainerDto) { return this.containersService.create(dto); @@ -39,24 +42,28 @@ export class ContainersController { } @Patch(':id') + @FleetManage() @ApiOperation({ summary: 'Update a container' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) { return this.containersService.update(id, dto); } @Delete(':id') + @FleetManage() @ApiOperation({ summary: 'Delete a container' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.remove(id); } @Post(':id/assign-wagon') + @FleetManage() @ApiOperation({ summary: 'Assign container to a wagon' }) assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) { return this.containersService.assignToWagon(id, dto); } @Post(':id/unassign-wagon') + @FleetManage() @ApiOperation({ summary: 'Unassign container from wagon' }) unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) { return this.containersService.unassignFromWagon(id); diff --git a/apps/edr-freight-api/src/modules/customers/customers.controller.ts b/apps/edr-freight-api/src/modules/customers/customers.controller.ts index 404e4d27b..7451bd6b6 100644 --- a/apps/edr-freight-api/src/modules/customers/customers.controller.ts +++ b/apps/edr-freight-api/src/modules/customers/customers.controller.ts @@ -16,12 +16,14 @@ import { import { ApiOperation } from "@nestjs/swagger"; +import { FreightAdmin } from "../../common/booking-guards"; import { CustomersService } from "./customers.service"; import { CreateCustomerDto } from "./dto/create-customer.dto"; import { UpdateCustomerDto } from "./dto/update-customer.dto"; import { Customer } from "./entities/customer.entity"; @Controller("customers") +@FreightAdmin() export class CustomersController { constructor(private readonly customersService: CustomersService) {} diff --git a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts index e8c3fbba0..7a63964d8 100644 --- a/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/dropdown-settings/dropdown-settings.controller.ts @@ -13,6 +13,7 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FreightAdmin } from "../../common/booking-guards"; import { CreateDropdownOptionDto } from "./dto/create-dropdown-option.dto"; import { CreateDropdownSettingDto } from "./dto/create-dropdown-setting.dto"; import { UpdateDropdownOptionDto } from "./dto/update-dropdown-option.dto"; @@ -24,6 +25,9 @@ import { DropdownSettingsService } from "./dropdown-settings.service"; export class DropdownSettingsController { constructor(private readonly service: DropdownSettingsService) {} + // Reads stay open: the customer portal fetches these to render dynamic + // dropdowns (by-code). Only writes are admin-guarded. + @Get() @ApiOperation({ summary: "List all dropdown settings" }) list() { @@ -43,12 +47,14 @@ export class DropdownSettingsController { } @Post() + @FreightAdmin() @ApiOperation({ summary: "Create a new dropdown setting" }) create(@Body() dto: CreateDropdownSettingDto) { return this.service.create(dto); } @Patch(":id") + @FreightAdmin() @ApiOperation({ summary: "Update a dropdown setting's metadata" }) update( @Param("id", ParseUUIDPipe) id: string, @@ -58,6 +64,7 @@ export class DropdownSettingsController { } @Delete(":id") + @FreightAdmin() @ApiOperation({ summary: "Soft-delete a dropdown setting" }) @HttpCode(HttpStatus.NO_CONTENT) remove(@Param("id", ParseUUIDPipe) id: string) { @@ -67,6 +74,7 @@ export class DropdownSettingsController { /* ------------------------- option routes ------------------------- */ @Put(":id/options") + @FreightAdmin() @ApiOperation({ summary: "Replace the full option list for a setting" }) replaceOptions( @Param("id", ParseUUIDPipe) id: string, @@ -76,6 +84,7 @@ export class DropdownSettingsController { } @Post(":id/options") + @FreightAdmin() @ApiOperation({ summary: "Append a single option to a setting" }) addOption( @Param("id", ParseUUIDPipe) id: string, @@ -85,6 +94,7 @@ export class DropdownSettingsController { } @Patch("options/:optionId") + @FreightAdmin() @ApiOperation({ summary: "Update a single option" }) updateOption( @Param("optionId", ParseUUIDPipe) optionId: string, @@ -94,6 +104,7 @@ export class DropdownSettingsController { } @Delete("options/:optionId") + @FreightAdmin() @ApiOperation({ summary: "Soft-delete a single option" }) @HttpCode(HttpStatus.NO_CONTENT) removeOption(@Param("optionId", ParseUUIDPipe) optionId: string) { diff --git a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts index ecdecffc3..661339902 100644 --- a/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts +++ b/apps/edr-freight-api/src/modules/file-upload-settings/file-upload-settings.controller.ts @@ -13,6 +13,7 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FreightAdmin } from "../../common/booking-guards"; import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto"; import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto"; import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto"; @@ -24,6 +25,9 @@ import { FileUploadSettingsService } from "./file-upload-settings.service"; export class FileUploadSettingsController { constructor(private readonly service: FileUploadSettingsService) {} + // Reads stay open: the customer portal fetches these to render dynamic + // upload forms (by-code / by-entity). Only writes are admin-guarded. + @Get() @ApiOperation({ summary: "List all file upload settings" }) list() { @@ -49,12 +53,14 @@ export class FileUploadSettingsController { } @Post() + @FreightAdmin() @ApiOperation({ summary: "Create a new file upload setting" }) create(@Body() dto: CreateFileUploadSettingDto) { return this.service.create(dto); } @Patch(":id") + @FreightAdmin() @ApiOperation({ summary: "Update a file upload setting's metadata" }) update( @Param("id", ParseUUIDPipe) id: string, @@ -64,6 +70,7 @@ export class FileUploadSettingsController { } @Delete(":id") + @FreightAdmin() @ApiOperation({ summary: "Soft-delete a file upload setting" }) @HttpCode(HttpStatus.NO_CONTENT) remove(@Param("id", ParseUUIDPipe) id: string) { @@ -73,6 +80,7 @@ export class FileUploadSettingsController { /* ------------------------- field routes ------------------------- */ @Put(":id/fields") + @FreightAdmin() @ApiOperation({ summary: "Replace the full field list for a setting" }) replaceFields( @Param("id", ParseUUIDPipe) id: string, @@ -82,6 +90,7 @@ export class FileUploadSettingsController { } @Post(":id/fields") + @FreightAdmin() @ApiOperation({ summary: "Append a single field to a setting" }) addField( @Param("id", ParseUUIDPipe) id: string, @@ -91,6 +100,7 @@ export class FileUploadSettingsController { } @Patch("fields/:fieldId") + @FreightAdmin() @ApiOperation({ summary: "Update a single field" }) updateField( @Param("fieldId", ParseUUIDPipe) fieldId: string, @@ -100,6 +110,7 @@ export class FileUploadSettingsController { } @Delete("fields/:fieldId") + @FreightAdmin() @ApiOperation({ summary: "Soft-delete a single field" }) @HttpCode(HttpStatus.NO_CONTENT) removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) { diff --git a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts index f7ccdde1d..c907af717 100644 --- a/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts +++ b/apps/edr-freight-api/src/modules/locomotives/locomotives.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateLocomotiveDto } from './dto/create-locomotive.dto'; import { FilterLocomotivesDto } from './dto/filter-locomotives.dto'; import { UpdateLocomotiveDto } from './dto/update-locomotive.dto'; @@ -9,6 +10,7 @@ import { LocomotivesService } from './locomotives.service'; @ApiTags('locomotives') @ApiBearerAuth() @Controller('locomotives') +@FleetView() export class LocomotivesController { constructor(private readonly locomotivesService: LocomotivesService) {} @@ -25,18 +27,21 @@ export class LocomotivesController { } @Post() + @FleetManage() @ApiOperation({ summary: 'Create a locomotive' }) create(@Body() dto: CreateLocomotiveDto) { return this.locomotivesService.create(dto); } @Patch(':id') + @FleetManage() @ApiOperation({ summary: 'Update a locomotive' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateLocomotiveDto) { return this.locomotivesService.update(id, dto); } @Post(':id/decommission') + @FleetManage() @ApiOperation({ summary: 'Decommission a locomotive' }) decommission(@Param('id', ParseUUIDPipe) id: string) { return this.locomotivesService.decommission(id); diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 736f5d274..384876705 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -17,6 +17,7 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; +import { FreightAdmin } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; import { InitiatePaymentDto, @@ -33,6 +34,7 @@ export class PaymentController { constructor(private readonly paymentService: PaymentService) { } @Get("all") + @FreightAdmin() @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) @ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "status", required: false }) @@ -73,6 +75,7 @@ export class PaymentController { } @Post("refund") + @FreightAdmin() @ApiOperation({ summary: "Refund a paid booking (staff/admin only)" }) refund(@Body() dto: RefundDto) { return this.paymentService.refund(dto); diff --git a/apps/edr-freight-api/src/modules/routes/routes.controller.ts b/apps/edr-freight-api/src/modules/routes/routes.controller.ts index 4af088727..8c25d67b3 100644 --- a/apps/edr-freight-api/src/modules/routes/routes.controller.ts +++ b/apps/edr-freight-api/src/modules/routes/routes.controller.ts @@ -1,6 +1,7 @@ import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateRouteDto } from './dto/create-route.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto'; import { UpdateRouteDto } from './dto/update-route.dto'; @@ -9,6 +10,7 @@ import { RoutesService } from './routes.service'; @ApiTags('routes') @ApiBearerAuth() @Controller('routes') +@FleetView() export class RoutesController { constructor(private readonly routesService: RoutesService) {} @@ -25,18 +27,21 @@ export class RoutesController { } @Post() + @FleetManage() @ApiOperation({ summary: 'Create route' }) create(@Body() dto: CreateRouteDto) { return this.routesService.create(dto); } @Patch(':id') + @FleetManage() @ApiOperation({ summary: 'Update route' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRouteDto) { return this.routesService.update(id, dto); } @Delete(':id') + @FleetManage() @ApiOperation({ summary: 'Deactivate route' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.routesService.deactivate(id); diff --git a/apps/edr-freight-api/src/modules/trains/trains.controller.ts b/apps/edr-freight-api/src/modules/trains/trains.controller.ts index c58fc086e..0217bc161 100644 --- a/apps/edr-freight-api/src/modules/trains/trains.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/trains.controller.ts @@ -11,16 +11,19 @@ import { } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FleetManage, FleetView } from "../../common/booking-guards"; import { CreateTrainDto } from "./dto/create-train.dto"; import { UpdateTrainDto } from "./dto/update-train.dto"; import { TrainsService } from "./trains.service"; @ApiTags("trains") @Controller("trains") +@FleetView() export class TrainsController { constructor(private readonly trainsService: TrainsService) {} @Post() + @FleetManage() @ApiOperation({ summary: "Register a new train" }) create(@Body() dto: CreateTrainDto) { return this.trainsService.create(dto); @@ -39,12 +42,14 @@ export class TrainsController { } @Patch(":id") + @FleetManage() @ApiOperation({ summary: "Update a train" }) update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdateTrainDto) { return this.trainsService.update(id, dto); } @Delete(":id") + @FleetManage() @ApiOperation({ summary: "Delete a train" }) remove(@Param("id", ParseUUIDPipe) id: string) { return this.trainsService.remove(id); diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts index c70208052..ec98a4a4b 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.controller.ts @@ -10,6 +10,7 @@ import { Query, } from '@nestjs/common'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { FleetManage, FleetView } from '../../common/booking-guards'; import { CreateWagonDto } from './dto/create-wagon.dto'; import { ListWagonsQueryDto } from './dto/list-wagons-query.dto'; import { UpdateWagonDto } from './dto/update-wagon.dto'; @@ -19,10 +20,12 @@ import { WagonsService } from './wagons.service'; @ApiTags('wagons') @Controller('wagons') +@FleetView() export class WagonsController { constructor(private readonly wagonsService: WagonsService) {} @Post() + @FleetManage() @ApiOperation({ summary: 'Create a new wagon' }) create(@Body() dto: CreateWagonDto) { return this.wagonsService.create(dto); @@ -41,24 +44,28 @@ export class WagonsController { } @Patch(':id') + @FleetManage() @ApiOperation({ summary: 'Update a wagon' }) update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWagonDto) { return this.wagonsService.update(id, dto); } @Delete(':id') + @FleetManage() @ApiOperation({ summary: 'Delete a wagon' }) remove(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.remove(id); } @Post(':id/assign-train') + @FleetManage() @ApiOperation({ summary: 'Assign wagon to a train' }) assignToTrain(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignWagonToTrainDto) { return this.wagonsService.assignToTrain(id, dto); } @Post(':id/unassign-train') + @FleetManage() @ApiOperation({ summary: 'Unassign wagon from train' }) unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) { return this.wagonsService.unassignFromTrain(id); @@ -67,10 +74,12 @@ export class WagonsController { // Separate controller for train‑specific reorder (registered in module) @Controller('trains/:trainId/reorder-wagons') +@FleetView() export class TrainWagonsReorderController { constructor(private readonly wagonsService: WagonsService) {} @Post() + @FleetManage() @ApiOperation({ summary: 'Reorder wagons of a train' }) reorder(@Param('trainId', ParseUUIDPipe) trainId: string, @Body() dto: ReorderWagonsDto) { return this.wagonsService.reorderWagons(trainId, dto); diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 9d8cd382f..ed0a494ab 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -54,6 +54,9 @@ export const BOOKING_PERMISSIONS: FreightPermissionSeed[] = [ perm('a1000001-0001-4000-8000-00000000000e', 'edr_freight_app:bookings:cancel', 'Cancel booking'), perm('a1000001-0001-4000-8000-00000000000f', 'edr_freight_app:train_scheduling:view', 'View train scheduling'), perm('a1000001-0001-4000-8000-000000000010', 'edr_freight_app:train_scheduling:manage', 'Manage train scheduling'), + perm('a1000001-0001-4000-8000-000000000011', 'edr_freight_app:fleet:view', 'View fleet'), + perm('a1000001-0001-4000-8000-000000000012', 'edr_freight_app:fleet:manage', 'Manage fleet'), + perm('a1000001-0001-4000-8000-000000000013', 'edr_freight_app:admin', 'Freight administration'), ]; const RULE_ENGINE_PERMISSION_IDS: Record = { @@ -109,6 +112,11 @@ export const FREIGHT_PERMS = { view: 'edr_freight_app:train_scheduling:view', manage: 'edr_freight_app:train_scheduling:manage', }, + fleet: { + view: 'edr_freight_app:fleet:view', + manage: 'edr_freight_app:fleet:manage', + }, + admin: 'edr_freight_app:admin', ruleEngine: { view: (slug: RuleEngineResourceSlug) => `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`, @@ -134,12 +142,15 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.bookings.cancel, ...allRuleEngineViewKeys(), ], - // Operations Officer: train scheduling + wagon allocation + transit/complete. + // Operations Officer: train scheduling + wagon allocation + transit/complete + // + fleet management (wagons, trains, locomotives, routes, containers, cargo). operationsOfficer: [ FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.bookings.operations, FREIGHT_PERMS.trainScheduling.view, FREIGHT_PERMS.trainScheduling.manage, + FREIGHT_PERMS.fleet.view, + FREIGHT_PERMS.fleet.manage, ...allRuleEngineViewKeys(), ], director: [ diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 9468fe568..8a1409fbe 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -99,11 +99,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Routes", href: "/dashboard/routes", icon: , + permission: FREIGHT_PERMS.fleet.view, }, { label: "Locomotives", href: "/dashboard/locomotives", icon: , + permission: FREIGHT_PERMS.fleet.view, }, // { // label: "Trains", @@ -119,6 +121,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "Wagons", href: "/dashboard/wagons", icon: , + permission: FREIGHT_PERMS.fleet.view, }, // { // label: "Containers", @@ -139,6 +142,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "User management", href: "/dashboard/user-management", icon: , + permission: FREIGHT_PERMS.admin, children: [ { label: "Users", @@ -166,11 +170,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ label: "File settings", href: "/dashboard/file-settings", icon: , + permission: FREIGHT_PERMS.admin, }, { label: "Dropdown settings", href: "/dashboard/dropdown-settings", icon: , + permission: FREIGHT_PERMS.admin, }, ], }, @@ -325,13 +331,62 @@ const App = () => { } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> {/* iframe-based user management module */} } /> @@ -344,8 +399,22 @@ const App = () => { } /> } /> - } /> - } /> + + + + } + /> + + + + } + /> @@ -82,6 +87,14 @@ export function canManageScheduling(user: AuthUser | null | undefined): boolean return hasPermission(user, FREIGHT_PERMS.trainScheduling.manage); } +export function canViewFleet(user: AuthUser | null | undefined): boolean { + return hasPermission(user, FREIGHT_PERMS.fleet.view); +} + +export function isFreightAdmin(user: AuthUser | null | undefined): boolean { + return hasPermission(user, FREIGHT_PERMS.admin); +} + export function ruleEngineViewKey(slug: RuleEngineResourceSlug): string { return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:view`; } From 2de3f78fb0eb86a5b02d580f260ab5137af546fa Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 17 Jun 2026 04:35:43 +0000 Subject: [PATCH 24/27] Add payments management features including summary and listing, and integrate with the dashboard --- .../src/modules/payment/payment.controller.ts | 13 +- .../src/modules/payment/payment.service.ts | 34 ++ apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../bookings/detail/booking-detail.styles.ts | 1 + .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 5 + .../backoffice/src/hooks/usePayments.ts | 23 ++ .../bookings/BookingRequestDetailPage.tsx | 13 +- .../src/pages/payments/PaymentsPage.tsx | 367 ++++++++++++++++++ .../src/services/payments.service.ts | 84 ++++ 10 files changed, 559 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/hooks/usePayments.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/payments.service.ts diff --git a/apps/edr-freight-api/src/modules/payment/payment.controller.ts b/apps/edr-freight-api/src/modules/payment/payment.controller.ts index 384876705..14308883d 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.controller.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.controller.ts @@ -17,7 +17,7 @@ import { } from "@nestjs/swagger"; import { Response } from "express"; import { Public } from "@edr/api-common"; -import { FreightAdmin } from "../../common/booking-guards"; +import { BookingView, FreightAdmin } from "../../common/booking-guards"; import { PaymentService } from "./payment.service"; import { InitiatePaymentDto, @@ -33,9 +33,16 @@ import { export class PaymentController { constructor(private readonly paymentService: PaymentService) { } + @Get("summary") + @BookingView() + @ApiOperation({ summary: "Payment count/amount summary for dashboard cards" }) + getSummary() { + return this.paymentService.getSummary(); + } + @Get("all") - @FreightAdmin() - @ApiOperation({ summary: "Get all payments with filters (staff/admin only)" }) + @BookingView() + @ApiOperation({ summary: "Get all payments with filters (view-only, any staff)" }) @ApiQuery({ name: "search", required: false }) @ApiQuery({ name: "status", required: false }) @ApiQuery({ name: "method", required: false }) diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 3f90628f0..b24febf91 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -105,6 +105,40 @@ export class PaymentService { }; } + /** Aggregate counts across ALL payments for the dashboard summary cards. */ + async getSummary() { + const rows = await this.paymentRepo + .createQueryBuilder("payment") + .select("payment.status", "status") + .addSelect("COUNT(*)::int", "count") + .groupBy("payment.status") + .getRawMany<{ status: string; count: number }>(); + + const byStatus: Record = {}; + let total = 0; + for (const row of rows) { + byStatus[row.status] = row.count; + total += row.count; + } + + // Sum of successfully collected amounts. + const paidAgg = await this.paymentRepo + .createQueryBuilder("payment") + .select("COALESCE(SUM(payment.amount), 0)", "sum") + .where("payment.status = :status", { status: "success" }) + .getRawOne<{ sum: string }>(); + + return { + total, + success: byStatus["success"] ?? 0, + processing: + (byStatus["processing"] ?? 0) + (byStatus["action-required"] ?? 0), + failed: (byStatus["failed"] ?? 0) + (byStatus["canceled"] ?? 0), + refunded: byStatus["refunded"] ?? 0, + paidAmount: Number(paidAgg?.sum ?? 0), + }; + } + async initiatePayment(dto: InitiatePaymentDto): Promise { const booking = await this.datasource .getRepository(Booking) diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8a1409fbe..a384d5956 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -13,6 +13,7 @@ import { Container, Package, Users, + Wallet, //TrainTrack, } from "lucide-react"; @@ -23,6 +24,7 @@ import LoginPage from "./pages/auth/LoginPage"; import BookingContractPage from "./pages/bookings/BookingContractPage"; import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage"; import BookingRequestsPage from "./pages/bookings/BookingRequestsPage"; +import PaymentsPage from "./pages/payments/PaymentsPage"; import NewBookingPage from "./pages/bookings/NewBookingPage"; import UserManagementHostPage from "./pages/dashboard/user-management/UserManagementHostPage"; import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page"; @@ -72,6 +74,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ href: "/dashboard/booking-requests", icon: , }, + { + label: "Payments", + href: "/dashboard/payments", + icon: , + permission: FREIGHT_PERMS.bookings.view, + }, ...demoItems, ], }, @@ -281,6 +289,14 @@ const App = () => { } /> } /> + + + + } + /> } /> } /> = [ subtitle: "Manage your account and signature", }, }, + { + prefix: "/dashboard/payments", + meta: { + title: "Payments", + subtitle: "View booking payment transactions", + }, + }, { prefix: "/dashboard/operations/train-scheduling-v2/", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 99f817b74..c87cf300c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -124,6 +124,11 @@ export const URL_CONSTANTS = { VERIFY: "/api/otp/verify", }, + PAYMENTS: { + ALL: "/payments/all", + SUMMARY: "/payments/summary", + }, + LOCOMOTIVES: { BASE: "/locomotives", BY_ID: (id: string) => `/locomotives/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts new file mode 100644 index 000000000..e9a09760b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/usePayments.ts @@ -0,0 +1,23 @@ +import { useQuery } from "@tanstack/react-query"; + +import { + paymentsService, + type PaymentListFilter, +} from "@/services/payments.service"; + +export function usePaymentList(filter?: PaymentListFilter, enabled = true) { + return useQuery({ + queryKey: ["payments", "list", filter ?? {}], + queryFn: () => paymentsService.list(filter), + enabled, + }); +} + +export function usePaymentSummary(enabled = true) { + return useQuery({ + queryKey: ["payments", "summary"], + queryFn: () => paymentsService.getSummary(), + staleTime: 30_000, + enabled, + }); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 42bf866b3..73de7ee8f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -35,6 +35,15 @@ import { downloadBookingFile } from "@/services/files.service"; import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings"; import toast from "react-hot-toast"; +// Signature / generated-contract files are surfaced on the contract page, not +// in the booking's Documents list. +const SIGNATURE_FILE_CODES = new Set([ + "signature", + "signature_customer", + "signature_staff", + "contract", +]); + export default function BookingRequestDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -161,7 +170,9 @@ export default function BookingRequestDetailPage() { )} !SIGNATURE_FILE_CODES.has(f.code ?? ""), + )} onDownload={handleDownloadFile} /> diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx new file mode 100644 index 000000000..07e212b76 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -0,0 +1,367 @@ +import { useMemo, useState } from "react"; +import { + ActionIcon, + Box, + Card, + Container, + Group, + Paper, + Select, + Stack, + Tabs, + Text, + TextInput, +} from "@mantine/core"; +import { + CheckCircle2, + CircleDollarSign, + Loader2, + RotateCcw, + Search, + X, + XCircle, + type LucideIcon, +} from "lucide-react"; + +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { usePaymentList, usePaymentSummary } from "@/hooks/usePayments"; +import type { + PaymentMethod, + PaymentRow, +} from "@/services/payments.service"; +import { cn } from "@/lib/utils"; +import { + Badge, + DataTable, + DataTableFooter, + type ColumnDef, + usePagination, +} from "@edr/ui-common"; + +const STATUS_TABS = [ + { key: "all", label: "All", statuses: undefined as string | undefined }, + { key: "success", label: "Success", statuses: "success" }, + { key: "processing", label: "Processing", statuses: "processing,action-required" }, + { key: "failed", label: "Failed", statuses: "failed,canceled" }, + { key: "refunded", label: "Refunded", statuses: "refunded" }, +] as const; + +type StatusTabKey = (typeof STATUS_TABS)[number]["key"]; + +const METHOD_OPTIONS: { value: PaymentMethod; label: string }[] = [ + { value: "telebirr", label: "Telebirr" }, + { value: "waafi", label: "Waafi" }, + { value: "cbe-birr", label: "CBE Birr" }, + { value: "ebirr", label: "E-Birr" }, + { value: "card", label: "Card" }, + { value: "dmoney", label: "D-Money" }, + { value: "cac-bank", label: "CAC Bank" }, +]; + +const STATUS_COLORS: Record = { + success: "green", + processing: "yellow", + "action-required": "yellow", + failed: "red", + canceled: "gray", + refunded: "indigo", +}; + +function StatCard({ + icon: Icon, + label, + value, + accent, +}: { + icon: LucideIcon; + label: string; + value: string | number; + accent: string; +}) { + return ( + + + + + + + + {value} + + + {label} + + + + + ); +} + +function formatAmount(amount: number, currency: string): string { + return `${currency} ${Number(amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + })}`; +} + +function formatDate(iso: string | null): string { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; + +export default function PaymentsPage() { + const { pagination, setPagination } = usePagination({ pageSize: 10 }); + const [query, setQuery] = useState(""); + const [statusTab, setStatusTab] = useState("all"); + const [method, setMethod] = useState(null); + + const statuses = STATUS_TABS.find((t) => t.key === statusTab)?.statuses; + + const filter = useMemo( + () => ({ + search: query.trim() || undefined, + status: statuses, + method: method ?? undefined, + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + }), + [query, statuses, method, pagination.pageIndex, pagination.pageSize], + ); + + const { data, isLoading, isError } = usePaymentList(filter); + const { data: summary, isLoading: summaryLoading } = usePaymentSummary(); + + const rows = data?.items ?? []; + const total = data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); + + const val = (n?: number) => (summaryLoading ? "—" : (n ?? 0)); + + const columns: ColumnDef[] = [ + { + id: "order", + header: () => Order, + cell: ({ row }) => ( +
+

+ {row.original.merchantOrderId ?? row.original.id.slice(0, 8)} +

+

+ Booking {row.original.bookingId?.slice(0, 8) ?? "—"} +

+
+ ), + }, + { + id: "amount", + header: () => Amount, + cell: ({ row }) => ( + + {formatAmount(row.original.amount, row.original.currency)} + + ), + }, + { + id: "method", + header: () => Method, + cell: ({ row }) => ( + + {METHOD_OPTIONS.find((m) => m.value === row.original.method)?.label ?? + row.original.method} + + ), + }, + { + id: "status", + header: () => Status, + cell: ({ row }) => ( + + {row.original.status.replace(/-/g, " ")} + + ), + }, + { + id: "date", + header: () => Date, + cell: ({ row }) => ( + + {formatDate(row.original.paidAt ?? row.original.createdAt)} + + ), + }, + ]; + + return ( +
+ + + + + + + + + + + + + { + setStatusTab((value as StatusTabKey) ?? "all"); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + > + + {STATUS_TABS.map((t) => ( + + {t.label} + + ))} + + + + + + + } + value={query} + onChange={(e) => { + setQuery(e.target.value); + setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); + }} + rightSection={ + query && ( + setQuery("")} + > + + + ) + } + style={{ flex: 1, minWidth: "200px" }} + radius="lg" + /> +