From 80a7a2f7021faf389c9f26f1f2a705b33bf22420 Mon Sep 17 00:00:00 2001 From: Abubeker Date: Fri, 21 Aug 2026 08:18:03 +0000 Subject: [PATCH 01/28] feat: ( reschedule ) implement booking reschedule --- .../migration.sql | 82 +++ apps/edr-passenger-api/prisma/schema.prisma | 57 ++ apps/edr-passenger-api/prisma/seed.ts | 1 + apps/edr-passenger-api/src/app.module.ts | 2 + .../src/common/audit.actions.ts | 2 + .../src/common/passenger-permission.util.ts | 2 +- .../modules/bookings/bookings.controller.ts | 17 - .../src/modules/bookings/bookings.dto.ts | 7 - .../src/modules/bookings/bookings.service.ts | 20 +- .../notifications/notifications.service.ts | 18 + .../src/modules/payments/payments.module.ts | 2 +- .../src/modules/payments/payments.service.ts | 3 +- .../supplementary-charges-audit.spec.ts | 3 +- .../payments/supplementary-charges.service.ts | 7 +- .../payments/supplementary-charges.spec.ts | 1 + .../reschedule/reschedule.controller.ts | 53 ++ .../src/modules/reschedule/reschedule.dto.ts | 70 +++ .../modules/reschedule/reschedule.module.ts | 40 ++ .../reschedule/reschedule.service.spec.ts | 37 ++ .../modules/reschedule/reschedule.service.ts | 541 ++++++++++++++++++ .../src/modules/seats/seats.service.ts | 6 +- .../src/modules/tasks/tasks.service.ts | 16 + .../seed/passenger-permissions.registry.ts | 3 + .../backoffice/src/app/settings/page.tsx | 129 ++++- .../backoffice/src/lib/api/index.ts | 22 + .../portal/src/app/booking/detail/page.tsx | 9 + .../src/app/booking/reschedule/page.tsx | 351 ++++++++++++ 27 files changed, 1450 insertions(+), 51 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql create mode 100644 apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts create mode 100644 apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts create mode 100644 apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts create mode 100644 apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts create mode 100644 apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts create mode 100644 apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx diff --git a/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql new file mode 100644 index 000000000..0d98e6d0e --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260821000001_add_reschedule/migration.sql @@ -0,0 +1,82 @@ +-- Rescheduling (passenger policy §3). One policy row per fare class — fare classes map 1:1 onto +-- coach types (HSC = Standard, HBC = Flex, SBC = Premium) — plus a per-leg request table. +-- Additive and idempotent; the seed below only inserts for coach types that exist. + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."ReschedulePolicy" ( + "id" TEXT NOT NULL, + "coachTypeId" TEXT NOT NULL, + "feePercent" INTEGER NOT NULL DEFAULT 0, + "feeMinMinor" INTEGER NOT NULL DEFAULT 0, + "routeChangeAllowed" BOOLEAN NOT NULL DEFAULT true, + "sameDayAllowed" BOOLEAN NOT NULL DEFAULT true, + "sameDayFeePercent" INTEGER NOT NULL DEFAULT 0, + "sameDayFeeMinMinor" INTEGER NOT NULL DEFAULT 0, + "cutoffMinutes" INTEGER NOT NULL DEFAULT 60, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ReschedulePolicy_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "ReschedulePolicy_coachTypeId_key" ON "passenger"."ReschedulePolicy"("coachTypeId"); + +DO $$ BEGIN + ALTER TABLE "passenger"."ReschedulePolicy" + ADD CONSTRAINT "ReschedulePolicy_coachTypeId_fkey" FOREIGN KEY ("coachTypeId") + REFERENCES "passenger"."CoachType"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- CreateTable +CREATE TABLE IF NOT EXISTS "passenger"."BookingReschedule" ( + "id" TEXT NOT NULL, + "bookingId" TEXT NOT NULL, + "leg" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL DEFAULT 'PENDING_PAYMENT', + "requestedBy" TEXT NOT NULL, + "oldScheduleId" TEXT NOT NULL, + "newScheduleId" TEXT NOT NULL, + "oldOriginStationId" TEXT, + "oldDestinationStationId" TEXT, + "newOriginStationId" TEXT NOT NULL, + "newDestinationStationId" TEXT NOT NULL, + "oldSeatIds" TEXT[], + "newSeatIds" TEXT[], + "holdId" TEXT, + "oldFareMinor" INTEGER NOT NULL, + "newFareMinor" INTEGER NOT NULL, + "fareDifferenceMinor" INTEGER NOT NULL, + "feeMinor" INTEGER NOT NULL, + "amountDueMinor" INTEGER NOT NULL, + "isSameDay" BOOLEAN NOT NULL DEFAULT false, + "isRouteChange" BOOLEAN NOT NULL DEFAULT false, + "supplementaryChargeId" TEXT, + "expiresAt" TIMESTAMP(3), + "appliedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BookingReschedule_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX IF NOT EXISTS "BookingReschedule_supplementaryChargeId_key" ON "passenger"."BookingReschedule"("supplementaryChargeId"); +CREATE INDEX IF NOT EXISTS "BookingReschedule_bookingId_status_idx" ON "passenger"."BookingReschedule"("bookingId", "status"); + +DO $$ BEGIN + ALTER TABLE "passenger"."BookingReschedule" + ADD CONSTRAINT "BookingReschedule_bookingId_fkey" FOREIGN KEY ("bookingId") + REFERENCES "passenger"."Booking"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- Seed the policy doc's §3 values. Same-day fee is stored as an absolute rule per class: +-- Flex's "50% of the Standard fee" (30% / min 500 ETB) is 15% / min 250 ETB. +INSERT INTO "passenger"."ReschedulePolicy" + ("id", "coachTypeId", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes", "updatedAt") +SELECT gen_random_uuid()::text, ct."id", v."feePercent", v."feeMinMinor", v."routeChangeAllowed", v."sameDayAllowed", v."sameDayFeePercent", v."sameDayFeeMinMinor", v."cutoffMinutes", CURRENT_TIMESTAMP +FROM (VALUES + ('HSC', 30, 50000, false, false, 0, 0, 120), + ('HBC', 0, 0, true, true, 15, 25000, 60), + ('SBC', 0, 0, true, true, 0, 0, 60) +) AS v("code", "feePercent", "feeMinMinor", "routeChangeAllowed", "sameDayAllowed", "sameDayFeePercent", "sameDayFeeMinMinor", "cutoffMinutes") +JOIN "passenger"."CoachType" ct ON ct."code" = v."code" +ON CONFLICT ("coachTypeId") DO NOTHING; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 26f93bd43..0c3d8f73a 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -80,6 +80,7 @@ model CoachType { updatedAt DateTime @updatedAt coaches Coach[] seatClasses SeatClass[] + reschedulePolicy ReschedulePolicy? @@schema("passenger") } @@ -567,6 +568,7 @@ model Booking { foodOrders FoodOrder[] agentBooking AgentBooking? modifications BookingModification[] + reschedules BookingReschedule[] cancellation BookingCancellation? baggage BaggageBooking[] excessBaggageCharges ExcessBaggageCharge[] @@ -1204,6 +1206,61 @@ model AgentCommission { @@schema("passenger") } +/// Rescheduling rule per fare class. Fare families from the passenger policy map 1:1 onto +/// coach types (HSC = Standard, HBC = Flex, SBC = Premium). Seeded by migration from the policy +/// doc; edited in backoffice Settings → Reschedule Policy. +model ReschedulePolicy { + id String @id @default(uuid()) + coachTypeId String @unique + feePercent Int @default(0) // % of the leg's original fare + feeMinMinor Int @default(0) // fee floor, ETB minor units + routeChangeAllowed Boolean @default(true) + sameDayAllowed Boolean @default(true) + sameDayFeePercent Int @default(0) // same-day change: % of the leg's original fare (replaces feePercent) + sameDayFeeMinMinor Int @default(0) // same-day change: fee floor, ETB minor units + cutoffMinutes Int @default(60) // reject when departure - now < this + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + coachType CoachType @relation(fields: [coachTypeId], references: [id]) + + @@schema("passenger") +} + +/// One reschedule request for one leg of a booking. PENDING_PAYMENT while the supplementary +/// charge is unpaid; APPLIED once the booking was moved; EXPIRED when the payment deadline passed. +model BookingReschedule { + id String @id @default(uuid()) + bookingId String + leg Int @default(1) // 1 = outbound, 2 = return + status String @default("PENDING_PAYMENT") // PENDING_PAYMENT | APPLIED | EXPIRED + requestedBy String // passengerId or IAM user id + oldScheduleId String + newScheduleId String + oldOriginStationId String? + oldDestinationStationId String? + newOriginStationId String + newDestinationStationId String + oldSeatIds String[] + newSeatIds String[] + holdId String? + oldFareMinor Int + newFareMinor Int + fareDifferenceMinor Int // new - old; negative = forfeited for now + feeMinor Int + amountDueMinor Int // fee + max(0, difference) + isSameDay Boolean @default(false) + isRouteChange Boolean @default(false) + supplementaryChargeId String? @unique + expiresAt DateTime? + appliedAt DateTime? + createdAt DateTime @default(now()) + booking Booking @relation(fields: [bookingId], references: [id]) + + @@index([bookingId, status]) + @@schema("passenger") +} + model BookingModification { id String @id @default(uuid()) bookingId String diff --git a/apps/edr-passenger-api/prisma/seed.ts b/apps/edr-passenger-api/prisma/seed.ts index 313ce34b0..8768ba899 100644 --- a/apps/edr-passenger-api/prisma/seed.ts +++ b/apps/edr-passenger-api/prisma/seed.ts @@ -657,6 +657,7 @@ async function seedNotificationTemplates() { { id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' }, { id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' }, { id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' }, + { id: uuidv4(), code: 'booking.rescheduled', channel: 'EMAIL', subject: 'Booking Rescheduled', bodyTemplate: 'Your {{leg}} journey on booking {{bookingRef}} has been rescheduled. New tickets have been issued. Change fee: {{feeAmount}} {{currency}}.' }, // Templates below are not wired to handlers yet (Phase 2 — full event coverage). { id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' }, { id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' }, diff --git a/apps/edr-passenger-api/src/app.module.ts b/apps/edr-passenger-api/src/app.module.ts index bd8372b29..1b80d253b 100644 --- a/apps/edr-passenger-api/src/app.module.ts +++ b/apps/edr-passenger-api/src/app.module.ts @@ -63,6 +63,7 @@ import { ConfigurableFareModule } from "./modules/configurable-fare/configurable import { SegmentFareSeeder } from "./seed/segment-fare.seeder"; import { EOtpType } from "@tria-plc/iamapi-common"; +import { RescheduleModule } from './modules/reschedule/reschedule.module'; @Module({ imports: [ @@ -149,6 +150,7 @@ import { EOtpType } from "@tria-plc/iamapi-common"; TasksModule, AppReleasesModule, ConfigurableFareModule, + RescheduleModule, ], providers: [ { provide: APP_FILTER, useClass: DeleteExceptionFilter }, diff --git a/apps/edr-passenger-api/src/common/audit.actions.ts b/apps/edr-passenger-api/src/common/audit.actions.ts index f9ee389db..8ac818e48 100644 --- a/apps/edr-passenger-api/src/common/audit.actions.ts +++ b/apps/edr-passenger-api/src/common/audit.actions.ts @@ -86,6 +86,8 @@ export const AUDIT_ENTITIES = { // Operations Ticket: 'Ticket', Booking: 'Booking', + BookingReschedule: 'BookingReschedule', + ReschedulePolicy: 'ReschedulePolicy', } as const; export type AuditEntity = (typeof AUDIT_ENTITIES)[keyof typeof AUDIT_ENTITIES]; diff --git a/apps/edr-passenger-api/src/common/passenger-permission.util.ts b/apps/edr-passenger-api/src/common/passenger-permission.util.ts index cc94b0e0c..628d39dfe 100644 --- a/apps/edr-passenger-api/src/common/passenger-permission.util.ts +++ b/apps/edr-passenger-api/src/common/passenger-permission.util.ts @@ -28,7 +28,7 @@ type EmployeeLike = { delegatedPositions?: PositionLike[]; }; -type MeLikeUser = { +export type MeLikeUser = { roles?: { key?: string }[]; permissions?: PermissionLike[]; employee?: EmployeeLike | EmployeeLike[] | null; 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 482c34268..29d69c94d 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.controller.ts @@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service"; import { GuestBookingService } from "./guest-booking.service"; import { CreateBookingDto, - ModifyBookingDto, CancelBookingDto, } from "./bookings.dto"; import { @@ -663,22 +662,6 @@ Results are ordered most-recent first. Use the returned \`bookingRef\` to open b return this.service.getByRef(ref); } - @Patch(":bookingRef/modify") - @UseGuards(JwtGuard) - @ApiBearerAuth("JWT-auth") - @ApiOperation({ - summary: "Modify booking seats or trip", - description: "Allows modification of confirmed bookings before departure", - }) - @ApiResponse({ status: 200, description: "Booking modified successfully" }) - @ApiResponse({ - status: 400, - description: "Cannot modify cancelled or past bookings", - }) - modify(@Req() req: any, @Body() dto: ModifyBookingDto) { - return this.service.modify(dto, req.user?.id); - } - @Delete(":id") @PassengerAdmin() @ApiBearerAuth("IAM-auth") 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 454339516..5e9d98d11 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.dto.ts @@ -214,13 +214,6 @@ export class CreateBookingDto { @IsOptional() @IsString() returnLeg2SeatClassId?: string; } -export class ModifyBookingDto { - @ApiProperty() @IsString() bookingRef: string; - @ApiProperty({ example: 'schedule-uuid' }) @IsString() newScheduleId: string; - @ApiProperty({ type: [String] }) @IsArray() newSeatIds: string[]; - @ApiPropertyOptional() @IsOptional() @IsString() reason?: string; -} - export class CancelBookingDto { @ApiProperty() @IsString() bookingRef: string; @ApiPropertyOptional() @IsOptional() @IsString() reason?: string; 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 fc958be70..fc3328605 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -5,7 +5,7 @@ import { PrismaService } from '../../common/prisma.service'; import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; -import { CreateBookingDto, ModifyBookingDto } from './bookings.dto'; +import { CreateBookingDto } from './bookings.dto'; import { assertIdentitiesNotAlreadyBooked, resolveIdentityRef } from './booking-identity.util'; import { Cron, CronExpression } from '@nestjs/schedule'; import { VerifaydaService } from '../verifayda/verifayda.service'; @@ -1905,7 +1905,7 @@ export class BookingsService { }; } - private async getBaseFare( + async getBaseFare( scheduleId: string, seatClassId: string, segmentRoute?: string, @@ -2231,22 +2231,6 @@ export class BookingsService { }; } - async modify(dto: ModifyBookingDto, iamUserId?: string) { - const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, include: { seats: true, schedule: true } }); - if (!booking) throw new NotFoundException('Booking not found'); - if (booking.status !== 'CONFIRMED') throw new BadRequestException('Only confirmed bookings can be modified'); - if (booking.schedule.departureAt < new Date()) throw new BadRequestException('Cannot modify past bookings'); - - const oldSeats = booking.seats.map(s => s.seatId); - await this.prisma.bookingModification.create({ - data: { bookingId: booking.id, modifiedBy: booking.passengerId, modificationType: 'SEAT_CHANGE', oldData: { scheduleId: booking.scheduleId, seatIds: oldSeats }, newData: { scheduleId: dto.newScheduleId, seatIds: dto.newSeatIds }, fareAdjustment: 0, reason: dto.reason }, - }); - await this.seatsService.releaseSeats(booking.id); - await this.seatsService.confirmSeats(dto.newSeatIds); - await this.auditService.log({ userId: iamUserId ?? booking.passengerId, action: 'UPDATE', entityType: 'Booking', entityId: booking.id, oldData: { seatIds: oldSeats }, newData: { seatIds: dto.newSeatIds, reason: dto.reason } }); - return { modified: true, bookingRef: dto.bookingRef }; - } - async cancel(bookingRef: string, reason?: string, iamUserId?: string) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: { seats: true, paymentIntent: true } }); if (!booking) throw new NotFoundException('Booking not found'); diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 3b7e7be2f..44ba03dea 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -731,6 +731,24 @@ export class NotificationsService { ); } + @OnEvent('booking.rescheduled') + async onBookingRescheduled(payload: any) { + const { booking, reschedule } = payload; + await this.send( + 'booking.rescheduled', + booking.passengerId, + { + bookingRef: booking.bookingRef, + leg: reschedule?.leg === 2 ? 'return' : 'outbound', + feeAmount: ((reschedule?.feeMinor ?? 0) / 100).toFixed(2), + currency: 'ETB', + category: 'BOOKING', + deepLink: `edr://bookings/${booking.bookingRef}`, + }, + ['IN_APP', 'EMAIL', 'SMS'], + ); + } + @OnEvent('booking.cancelled') async onBookingCancelled(payload: any) { const booking = payload.booking; diff --git a/apps/edr-passenger-api/src/modules/payments/payments.module.ts b/apps/edr-passenger-api/src/modules/payments/payments.module.ts index 07f452e43..970983cb4 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.module.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.module.ts @@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] { PaymentSyncService, ServiceAuthGuard, ], - exports: [PaymentClientService, PaymentsService], + exports: [PaymentClientService, PaymentsService, SupplementaryChargesService], }) export class PaymentsModule {} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 7192c7fc7..ced758d98 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -1619,6 +1619,7 @@ export class PaymentsService { settledCurrency: event.currency, }, }); + this.eventEmitter.emit("supplementary-charge.paid", { chargeId: charge.id }); } return { processed: true, alreadyFinalized: count === 0 }; } @@ -1996,7 +1997,7 @@ export class PaymentsService { }); } - private async createJourneySegments( + async createJourneySegments( booking: Prisma.BookingGetPayload<{ include: { seats: true } }>, ) { const b = booking as any; diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts index 28188f70f..24e112548 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges-audit.spec.ts @@ -52,7 +52,7 @@ describe('SupplementaryChargesService — audit', () => { }; audit = { log: jest.fn().mockResolvedValue(undefined) }; - // Constructor order: prisma, audit, sms, email, paymentClient, currency. + // Constructor order: prisma, audit, sms, email, paymentClient, currency, eventEmitter. service = new SupplementaryChargesService( prisma as any, audit as any, @@ -60,6 +60,7 @@ describe('SupplementaryChargesService — audit', () => { { sendEmail: jest.fn() } as any, {} as any, {} as any, + { emit: jest.fn() } as any, ); return row; }; diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts index 714b02170..534f6df08 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.service.ts @@ -14,6 +14,7 @@ import { } from '@edr/types'; import { PaymentPlatformDto } from './payments.dto'; import { PaymentMethodType } from '@prisma/client'; +import { EventEmitter2 } from '@nestjs/event-emitter'; const CHARGE_TTL_MS = 72 * 60 * 60 * 1000; // 72 hours @@ -45,6 +46,7 @@ export class SupplementaryChargesService { private emailClient: EmailClientService, private paymentClient: PaymentClientService, private currencyService: CurrencyService, + private eventEmitter: EventEmitter2, ) {} async create(dto: { @@ -53,6 +55,8 @@ export class SupplementaryChargesService { reason: string; notes?: string; createdBy: string; + /** Overrides the default 72h link lifetime (a reschedule charge must die with its seat hold). */ + expiresAt?: Date; }) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef: dto.bookingRef }, @@ -64,7 +68,7 @@ export class SupplementaryChargesService { } if (dto.amountMinor <= 0) throw new BadRequestException('Amount must be positive'); - const expiresAt = new Date(Date.now() + CHARGE_TTL_MS); + const expiresAt = dto.expiresAt ?? new Date(Date.now() + CHARGE_TTL_MS); const charge = await this.prisma.supplementaryCharge.create({ data: { bookingId: booking.id, @@ -170,6 +174,7 @@ export class SupplementaryChargesService { providerTxnId: providerTxnId ?? null, }, }); + this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id }); } return updated!; diff --git a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts index 043ec919c..bd72b649b 100644 --- a/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts +++ b/apps/edr-passenger-api/src/modules/payments/supplementary-charges.spec.ts @@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => { {} as any, paymentClient as any, new CurrencyService(prisma as any), + { emit: jest.fn() } as any, ); }; diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts new file mode 100644 index 000000000..8024556a9 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -0,0 +1,53 @@ +import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { JwtGuard } from '../../common/jwt.guard'; +import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { RescheduleService } from './reschedule.service'; +import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto'; + +@ApiTags('Reschedule') +@Controller() +export class RescheduleController { + constructor(private service: RescheduleService) {} + + @Get('reschedule/policies') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reschedule policy per coach type (fare class)' }) + listPolicies() { + return this.service.listPolicies(); + } + + @Patch('reschedule/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Update the reschedule policy of a coach type (admin)' }) + updatePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string, @Body() dto: UpdateReschedulePolicyDto) { + return this.service.updatePolicy(coachTypeId, dto, req.user?.id); + } + + @Get('bookings/:bookingRef/reschedule') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reschedule eligibility per leg, pending request, history' }) + options(@Req() req: any, @Param('bookingRef') bookingRef: string) { + return this.service.getOptions(bookingRef, req.user); + } + + @Post('bookings/:bookingRef/reschedule/quote') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Itemised quote (fee, fare difference, amount due) for a proposed change' }) + quote(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: RescheduleQuoteDto) { + return this.service.quote(bookingRef, dto, req.user); + } + + @Post('bookings/:bookingRef/reschedule') + @UseGuards(JwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Reschedule a leg. Applies immediately when nothing is due, otherwise returns a payment token' }) + create(@Req() req: any, @Param('bookingRef') bookingRef: string, @Body() dto: CreateRescheduleDto) { + return this.service.create(bookingRef, dto, req.user); + } +} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts new file mode 100644 index 000000000..d41fbc9a8 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts @@ -0,0 +1,70 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class UpdateReschedulePolicyDto { + @ApiPropertyOptional({ example: 30, description: '% of the leg fare charged as a change fee' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) + feePercent?: number; + + @ApiPropertyOptional({ example: 50000, description: 'Fee floor in ETB minor units (500 ETB = 50000)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + feeMinMinor?: number; + + @ApiPropertyOptional({ example: false }) + @IsOptional() @IsBoolean() + routeChangeAllowed?: boolean; + + @ApiPropertyOptional({ example: false }) + @IsOptional() @IsBoolean() + sameDayAllowed?: boolean; + + @ApiPropertyOptional({ example: 15, description: 'Same-day change: % of the leg fare (replaces feePercent)' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) @Max(100) + sameDayFeePercent?: number; + + @ApiPropertyOptional({ example: 25000, description: 'Same-day change: fee floor in ETB minor units' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + sameDayFeeMinMinor?: number; + + @ApiPropertyOptional({ example: 120, description: 'Changes are refused this many minutes before departure' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(0) + cutoffMinutes?: number; + + @ApiPropertyOptional({ example: true }) + @IsOptional() @IsBoolean() + isActive?: boolean; +} + +export class RescheduleQuoteDto { + @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) + leg?: number; + + @ApiProperty({ example: 'schedule-uuid' }) + @IsString() newScheduleId: string; + + @ApiProperty({ example: 'station-uuid' }) + @IsString() newOriginStationId: string; + + @ApiProperty({ example: 'station-uuid' }) + @IsString() newDestinationStationId: string; + + @ApiProperty({ type: [String], description: 'One seat per seated passenger of the leg, in BookingSeat order' }) + @IsArray() @ArrayMinSize(1) @IsString({ each: true }) + newSeatIds: string[]; +} + +export class CreateRescheduleDto extends RescheduleQuoteDto { + @ApiProperty({ example: 'hold-uuid', description: 'SeatHold on the new schedule covering newSeatIds (POST /seats/hold)' }) + @IsString() holdId: string; +} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts new file mode 100644 index 000000000..ffb2c23bc --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.module.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger, Module } from '@nestjs/common'; +import { ModuleRef } from '@nestjs/core'; +import { OnEvent } from '@nestjs/event-emitter'; +import { AuditModule } from '../../common/audit.module'; +import { BookingsModule } from '../bookings/bookings.module'; +import { SeatsModule } from '../seats/seats.module'; +import { TicketsModule } from '../tickets/tickets.module'; +import { PaymentsModule } from '../payments/payments.module'; +import { CurrencyModule } from '../currency/currency.module'; +import { RescheduleController } from './reschedule.controller'; +import { RescheduleService, SUPPLEMENTARY_CHARGE_PAID_EVENT } from './reschedule.service'; + +/** + * RescheduleService is request-scoped by transitivity (AuditService injects REQUEST), and Nest + * never fires @OnEvent on request-scoped providers — so the listener lives on this singleton and + * resolves the service per event, the same way TasksService reaches PaymentsService. + */ +@Injectable() +export class RescheduleEventsListener { + private readonly logger = new Logger(RescheduleEventsListener.name); + constructor(private readonly moduleRef: ModuleRef) {} + + @OnEvent(SUPPLEMENTARY_CHARGE_PAID_EVENT, { async: true }) + async onChargePaid(payload: { chargeId: string }) { + try { + const service = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false }); + await service.applyForCharge(payload.chargeId); + } catch (err) { + this.logger.error(`Failed to apply reschedule for charge ${payload.chargeId}: ${err instanceof Error ? err.message : err}`); + } + } +} + +@Module({ + imports: [AuditModule, BookingsModule, SeatsModule, TicketsModule, PaymentsModule, CurrencyModule], + controllers: [RescheduleController], + providers: [RescheduleService, RescheduleEventsListener], + exports: [RescheduleService], +}) +export class RescheduleModule {} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts new file mode 100644 index 000000000..d9dca0157 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.spec.ts @@ -0,0 +1,37 @@ +import { addisDay, computeRescheduleAmounts } from './reschedule.service'; + +const standard = { feePercent: 30, feeMinMinor: 50000, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 }; +const flex = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 15, sameDayFeeMinMinor: 25000 }; +const premium = { feePercent: 0, feeMinMinor: 0, sameDayFeePercent: 0, sameDayFeeMinMinor: 0 }; + +describe('computeRescheduleAmounts (policy §3)', () => { + it('Standard: 30% of fare, floored at 500 ETB, plus positive fare difference', () => { + // 1000 ETB fare → 30% = 300 < 500 floor + expect(computeRescheduleAmounts(standard, 100000, 120000, false)).toEqual({ feeMinor: 50000, fareDifferenceMinor: 20000, amountDueMinor: 70000 }); + // 3000 ETB fare → 30% = 900 > floor + expect(computeRescheduleAmounts(standard, 300000, 300000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: 0, amountDueMinor: 90000 }); + }); + + it('negative fare difference is recorded but never paid out', () => { + expect(computeRescheduleAmounts(standard, 300000, 200000, false)).toEqual({ feeMinor: 90000, fareDifferenceMinor: -100000, amountDueMinor: 90000 }); + expect(computeRescheduleAmounts(flex, 300000, 200000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: -100000, amountDueMinor: 0 }); + }); + + it('Flex: free, fare difference only; same-day is 15% min 250 ETB', () => { + expect(computeRescheduleAmounts(flex, 100000, 150000, false)).toEqual({ feeMinor: 0, fareDifferenceMinor: 50000, amountDueMinor: 50000 }); + expect(computeRescheduleAmounts(flex, 100000, 100000, true)).toEqual({ feeMinor: 25000, fareDifferenceMinor: 0, amountDueMinor: 25000 }); + expect(computeRescheduleAmounts(flex, 400000, 400000, true)).toEqual({ feeMinor: 60000, fareDifferenceMinor: 0, amountDueMinor: 60000 }); + }); + + it('Premium: always free, even same-day', () => { + expect(computeRescheduleAmounts(premium, 500000, 500000, true).amountDueMinor).toBe(0); + expect(computeRescheduleAmounts(premium, 500000, 560000, true).amountDueMinor).toBe(60000); + }); +}); + +describe('addisDay', () => { + it('compares calendar days in Africa/Addis_Ababa (UTC+3), not UTC', () => { + expect(addisDay(new Date('2026-09-01T21:30:00Z'))).toBe('2026-09-02'); + expect(addisDay(new Date('2026-09-01T20:30:00Z'))).toBe('2026-09-01'); + }); +}); diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts new file mode 100644 index 000000000..4b2019938 --- /dev/null +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -0,0 +1,541 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma.service'; +import { AuditService } from '../../common/audit.service'; +import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; +import { hasPassengerPermission, MeLikeUser } from '../../common/passenger-permission.util'; +import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { BookingsService } from '../bookings/bookings.service'; +import { SeatsService } from '../seats/seats.service'; +import { TicketsService } from '../tickets/tickets.service'; +import { PaymentsService } from '../payments/payments.service'; +import { SupplementaryChargesService } from '../payments/supplementary-charges.service'; +import { CurrencyService } from '../currency/currency.service'; +import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto'; + +export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE'; +export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid'; + +type PolicyNumbers = { + feePercent: number; + feeMinMinor: number; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; +}; + +/** + * Pure fee arithmetic — policy §3. Negative fare differences are recorded but NOT paid out + * (credit/refund handling is a later step), so amountDue never goes below the fee. + */ +export function computeRescheduleAmounts( + policy: PolicyNumbers, + oldFareMinor: number, + newFareMinor: number, + isSameDay: boolean, +): { feeMinor: number; fareDifferenceMinor: number; amountDueMinor: number } { + const pct = isSameDay ? policy.sameDayFeePercent : policy.feePercent; + const min = isSameDay ? policy.sameDayFeeMinMinor : policy.feeMinMinor; + const feeMinor = pct > 0 || min > 0 ? Math.max(Math.round((oldFareMinor * pct) / 100), min) : 0; + const fareDifferenceMinor = newFareMinor - oldFareMinor; + return { feeMinor, fareDifferenceMinor, amountDueMinor: feeMinor + Math.max(0, fareDifferenceMinor) }; +} + +export function addisDay(d: Date): string { + return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); +} + +type ActingUser = MeLikeUser & { id?: string; sub?: string }; + +type LegView = { + leg: number; + scheduleId: string; + originStationId: string | null; + destinationStationId: string | null; + departureAt: Date; + seats: Array<{ id: string; seatId: string; passengerName: string; fareMinor: number | null; passengerCategory: string }>; + coachTypeId: string; +}; + +// Seats are ordered by passenger name so getOptions(), quote() and create() all see the same +// sequence — the client submits newSeatIds in that order (BookingSeat has no creation order). +const bookingInclude: Prisma.BookingInclude = { + schedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } }, + returnSchedule: { select: { id: true, departureAt: true, arrivalAt: true, originStationId: true, destinationStationId: true } }, + seats: { include: { seat: { include: { coach: { select: { coachTypeId: true } } } } }, orderBy: [{ passengerName: 'asc' }, { id: 'asc' }] }, +}; + +@Injectable() +export class RescheduleService { + private readonly logger = new Logger(RescheduleService.name); + + constructor( + private prisma: PrismaService, + private bookingsService: BookingsService, + private seatsService: SeatsService, + private ticketsService: TicketsService, + private paymentsService: PaymentsService, + private supplementaryCharges: SupplementaryChargesService, + private currencyService: CurrencyService, + private auditService: AuditService, + private eventEmitter: EventEmitter2, + ) {} + + // ── Policy admin ───────────────────────────────────────────────────────── + + async listPolicies() { + const coachTypes = await this.prisma.coachType.findMany({ + where: { type: { notIn: ['dining', 'baggage'] } }, + include: { reschedulePolicy: true }, + orderBy: { code: 'asc' }, + }); + return coachTypes.map((ct) => ({ + coachTypeId: ct.id, + code: ct.code, + name: ct.name, + policy: ct.reschedulePolicy, + })); + } + + async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) { + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + const before = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } }); + const policy = await this.prisma.reschedulePolicy.upsert({ + where: { coachTypeId }, + update: dto, + create: { coachTypeId, ...dto }, + }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + oldData: before ?? undefined, + newData: { coachTypeCode: coachType.code, ...dto }, + }); + return policy; + } + + // ── Reads ──────────────────────────────────────────────────────────────── + + /** What the portal needs before picking a new schedule: per-leg eligibility + the rule set. */ + async getOptions(bookingRef: string, user: ActingUser) { + const booking = await this.loadOwnedBooking(bookingRef, user); + const legs = this.legsOf(booking); + const out = []; + for (const leg of legs) { + const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } }); + const blockers = this.legBlockers(booking, leg, policy); + out.push({ + leg: leg.leg, + scheduleId: leg.scheduleId, + originStationId: leg.originStationId, + destinationStationId: leg.destinationStationId, + departureAt: leg.departureAt, + coachTypeId: leg.coachTypeId, + seatCount: leg.seats.length, + passengerNames: leg.seats.map((s) => s.passengerName), + oldFareMinor: this.legFare(booking, leg), + policy: policy && { + feePercent: policy.feePercent, + feeMinMinor: policy.feeMinMinor, + routeChangeAllowed: policy.routeChangeAllowed, + sameDayAllowed: policy.sameDayAllowed, + sameDayFeePercent: policy.sameDayFeePercent, + sameDayFeeMinMinor: policy.sameDayFeeMinMinor, + cutoffMinutes: policy.cutoffMinutes, + }, + canReschedule: blockers.length === 0, + blockers, + }); + } + const reschedules = await this.prisma.bookingReschedule.findMany({ + where: { bookingId: booking.id }, + orderBy: { createdAt: 'desc' }, + }); + const pending = reschedules.find((r) => r.status === 'PENDING_PAYMENT'); + const charge = pending?.supplementaryChargeId + ? await this.prisma.supplementaryCharge.findUnique({ + where: { id: pending.supplementaryChargeId }, + select: { paymentToken: true, status: true, expiresAt: true }, + }) + : null; + return { + bookingRef: booking.bookingRef, + bookingType: booking.bookingType, + legs: out, + pending: pending ? { ...pending, paymentToken: charge?.paymentToken ?? null } : null, + history: reschedules.filter((r) => r.status !== 'PENDING_PAYMENT'), + }; + } + + // ── Quote / create / apply ─────────────────────────────────────────────── + + async quote(bookingRef: string, dto: RescheduleQuoteDto, user: ActingUser) { + const booking = await this.loadOwnedBooking(bookingRef, user); + return this.buildQuote(booking, dto); + } + + async create(bookingRef: string, dto: CreateRescheduleDto, user: ActingUser) { + const booking = await this.loadOwnedBooking(bookingRef, user); + const q = await this.buildQuote(booking, dto); + if (!q.allowed) throw new BadRequestException(q.blockers.join(' ')); + + const hold = await this.prisma.seatHold.findUnique({ where: { id: dto.holdId } }); + if (!hold || hold.expiresAt < new Date()) throw new BadRequestException('Seat hold expired'); + if (hold.scheduleId !== dto.newScheduleId) throw new BadRequestException('Seat hold is for a different schedule'); + const held = new Set(hold.seatIds); + if (!dto.newSeatIds.every((id) => held.has(id))) throw new BadRequestException('Selected seats are not covered by the hold'); + + // Availability was enforced when the hold was taken (holdSeats checks holds + booked + // segments for the leg); tickets.generate() re-checks at apply time. + + const requestedBy = user.id ?? user.sub ?? booking.passengerId; + const newDeparture = q.newDepartureAt; + const expiresAt = computePaymentDeadline(new Date(), newDeparture); + + const reschedule = await this.prisma.bookingReschedule.create({ + data: { + bookingId: booking.id, + leg: q.leg, + status: 'PENDING_PAYMENT', + requestedBy, + oldScheduleId: q.oldScheduleId, + newScheduleId: dto.newScheduleId, + oldOriginStationId: q.oldOriginStationId, + oldDestinationStationId: q.oldDestinationStationId, + newOriginStationId: dto.newOriginStationId, + newDestinationStationId: dto.newDestinationStationId, + oldSeatIds: q.oldSeatIds, + newSeatIds: dto.newSeatIds, + holdId: dto.holdId, + oldFareMinor: q.oldFareMinor, + newFareMinor: q.newFareMinor, + fareDifferenceMinor: q.fareDifferenceMinor, + feeMinor: q.feeMinor, + amountDueMinor: q.amountDueMinor, + isSameDay: q.isSameDay, + isRouteChange: q.isRouteChange, + expiresAt: q.amountDueMinor > 0 ? expiresAt : null, + }, + }); + + if (q.amountDueMinor === 0) { + await this.apply(reschedule.id); + return { rescheduleId: reschedule.id, status: 'APPLIED', amountDueMinor: 0, paymentToken: null, quote: q }; + } + + // Money owed: raise a supplementary charge (pay page /pay-balance/:token, SMS+email link) and + // keep the new seats held until the same deadline the charge carries. + const charge = await this.supplementaryCharges.create({ + bookingRef: booking.bookingRef, + amountMinor: q.amountDueMinor, + reason: RESCHEDULE_CHARGE_REASON, + notes: `Reschedule leg ${q.leg} → schedule ${dto.newScheduleId}`, + createdBy: requestedBy, + expiresAt, + }); + await this.prisma.bookingReschedule.update({ + where: { id: reschedule.id }, + data: { supplementaryChargeId: charge.id }, + }); + await this.seatsService.confirmSeats(dto.newSeatIds); + + await this.auditService.log({ + userId: requestedBy, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.BookingReschedule, + entityId: reschedule.id, + newData: { bookingRef: booking.bookingRef, leg: q.leg, amountDueMinor: q.amountDueMinor, chargeId: charge.id }, + }); + return { rescheduleId: reschedule.id, status: 'PENDING_PAYMENT', amountDueMinor: q.amountDueMinor, paymentToken: charge.paymentToken, expiresAt, quote: q }; + } + + /** Entry point for the paid-charge event. Idempotent: only a PENDING_PAYMENT row is applied. */ + async applyForCharge(supplementaryChargeId: string) { + const r = await this.prisma.bookingReschedule.findUnique({ where: { supplementaryChargeId } }); + if (!r || r.status !== 'PENDING_PAYMENT') return; + await this.apply(r.id); + } + + /** Moves the booking leg: booking fields, seats, journey segments, tickets. */ + async apply(rescheduleId: string) { + const r = await this.prisma.bookingReschedule.findUnique({ where: { id: rescheduleId } }); + if (!r) throw new NotFoundException('Reschedule not found'); + if (r.status !== 'PENDING_PAYMENT') return r; + + const booking = await this.prisma.booking.findUnique({ where: { id: r.bookingId }, include: bookingInclude }); + if (!booking) throw new NotFoundException('Booking not found'); + const leg = this.legsOf(booking).find((l) => l.leg === r.leg); + if (!leg) throw new BadRequestException('Leg no longer exists on booking'); + if (leg.seats.length !== r.newSeatIds.length) throw new BadRequestException('Seat count changed since quote'); + + const newTotal = Math.max(0, booking.totalMinor + r.fareDifferenceMinor); + const displayTotal = + booking.displayCurrency && booking.displayCurrency !== 'ETB' + ? await this.currencyService.convertAmount(newTotal, 'ETB' as any, booking.displayCurrency as any) + : newTotal; + const perSeatNew = this.splitFare(r.newFareMinor, leg.seats); + + await this.prisma.$transaction(async (tx) => { + await tx.booking.update({ + where: { id: booking.id }, + data: { + ...(r.leg === 1 + ? { scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId } + : { returnScheduleId: r.newScheduleId, returnOriginStationId: r.newOriginStationId, returnDestinationStationId: r.newDestinationStationId }), + totalMinor: newTotal, + displayTotalMinor: displayTotal, + }, + }); + // Two passes so the (scheduleId, seatId) unique key never collides mid-update when a + // passenger takes a seat another passenger of the same booking is leaving. + for (const s of leg.seats) { + await tx.bookingSeat.update({ where: { id: s.id }, data: { scheduleId: `moving-${s.id}` } }); + } + for (let i = 0; i < leg.seats.length; i++) { + await tx.bookingSeat.update({ + where: { id: leg.seats[i].id }, + data: { seatId: r.newSeatIds[i], scheduleId: r.newScheduleId, fareMinor: perSeatNew[i], seatLabelSnapshot: null }, + }); + } + await tx.bookingModification.create({ + data: { + bookingId: booking.id, + modifiedBy: r.requestedBy, + modificationType: 'RESCHEDULE', + oldData: { leg: r.leg, scheduleId: r.oldScheduleId, originStationId: r.oldOriginStationId, destinationStationId: r.oldDestinationStationId, seatIds: r.oldSeatIds, fareMinor: r.oldFareMinor }, + newData: { leg: r.leg, scheduleId: r.newScheduleId, originStationId: r.newOriginStationId, destinationStationId: r.newDestinationStationId, seatIds: r.newSeatIds, fareMinor: r.newFareMinor, feeMinor: r.feeMinor }, + fareAdjustment: r.fareDifferenceMinor, + }, + }); + await tx.bookingReschedule.update({ where: { id: r.id }, data: { status: 'APPLIED', appliedAt: new Date() } }); + }); + + // Occupancy + tickets are rebuilt from the (now updated) booking, outside the transaction. + const fresh = await this.prisma.booking.findUnique({ where: { id: booking.id }, include: { seats: true, tickets: { select: { id: true } } } }); + if (fresh) { + try { + await this.seatsService.releaseSeats(fresh.id); + await this.paymentsService.createJourneySegments(fresh as any); + } catch (err) { + this.logger.error(`Reschedule ${r.id}: journey segments failed: ${err instanceof Error ? err.message : err}`); + } + // Old tickets' SYSTEM seat blocks reference ticket ids that generate() is about to delete. + for (const t of fresh.tickets) { + await this.prisma.seatBlock.deleteMany({ where: { reason: { contains: t.id }, blockedBy: 'SYSTEM' } }); + } + try { await this.ticketsService.generate(fresh.id); } catch (err) { + this.logger.error(`Reschedule ${r.id}: ticket generation failed: ${err instanceof Error ? err.message : err}`); + } + } + // The new seats are owned by the journey now; the old seats' own booking-time hold (holds + // outlive confirmation until the payment deadline) would otherwise keep them HELD on the old + // schedule. Nobody else can hold a booked seat, so any hold there is this booking's. + await this.prisma.seatHold.deleteMany({ + where: { OR: [{ id: r.holdId ?? '' }, { scheduleId: r.oldScheduleId, seatIds: { hasSome: r.oldSeatIds } }] }, + }); + + await this.auditService.log({ + userId: r.requestedBy, + action: AUDIT_ACTIONS.UPDATE, + entityType: AUDIT_ENTITIES.Booking, + entityId: booking.id, + oldData: { leg: r.leg, scheduleId: r.oldScheduleId, seatIds: r.oldSeatIds }, + newData: { leg: r.leg, scheduleId: r.newScheduleId, seatIds: r.newSeatIds, feeMinor: r.feeMinor, fareDifferenceMinor: r.fareDifferenceMinor, rescheduleId: r.id }, + }); + this.eventEmitter.emit('booking.rescheduled', { booking: fresh ?? booking, reschedule: r }); + return { ...r, status: 'APPLIED' }; + } + + /** Cron hook: unpaid reschedules past their payment deadline. The seat hold lapses by itself. */ + async expireStale(now = new Date()): Promise { + const stale = await this.prisma.bookingReschedule.findMany({ + where: { status: 'PENDING_PAYMENT', expiresAt: { lt: now } }, + select: { id: true, supplementaryChargeId: true }, + }); + for (const r of stale) { + await this.prisma.bookingReschedule.update({ where: { id: r.id }, data: { status: 'EXPIRED' } }); + if (r.supplementaryChargeId) { + await this.prisma.supplementaryCharge.updateMany({ + where: { id: r.supplementaryChargeId, status: 'PENDING' }, + data: { status: 'EXPIRED' }, + }); + } + } + return stale.length; + } + + // ── Internals ──────────────────────────────────────────────────────────── + + private async loadOwnedBooking(bookingRef: string, user: ActingUser) { + const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude }); + if (!booking) throw new NotFoundException('Booking not found'); + const iamUserId = user.id ?? user.sub; + if (!iamUserId) throw new ForbiddenException(); + if (hasPassengerPermission(user, PASSENGER_PERMS.bookings.reschedule)) return booking; + const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); + if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking'); + return booking; + } + + private legsOf(booking: any): LegView[] { + const legs: LegView[] = []; + const seatsOf = (n: number) => + (booking.seats as any[]) + .filter((s) => (s.leg ?? 1) === n) + .map((s) => ({ id: s.id, seatId: s.seatId, passengerName: s.passengerName, fareMinor: s.fareMinor, passengerCategory: s.passengerCategory, coachTypeId: s.seat?.coach?.coachTypeId })); + const l1 = seatsOf(1); + if (l1.length && booking.schedule) { + legs.push({ leg: 1, scheduleId: booking.scheduleId, originStationId: booking.originStationId, destinationStationId: booking.destinationStationId, departureAt: booking.schedule.departureAt, seats: l1, coachTypeId: l1[0].coachTypeId }); + } + const l2 = seatsOf(2); + if (booking.bookingType === 'ROUND_TRIP' && l2.length && booking.returnSchedule) { + legs.push({ leg: 2, scheduleId: booking.returnScheduleId, originStationId: booking.returnOriginStationId, destinationStationId: booking.returnDestinationStationId, departureAt: booking.returnSchedule.departureAt, seats: l2, coachTypeId: l2[0].coachTypeId }); + } + return legs; + } + + /** The leg's original fare: per-seat amounts when recorded, else the whole booking (one-way). */ + private legFare(booking: any, leg: LegView): number { + const recorded = leg.seats.reduce((sum, s) => sum + (s.fareMinor ?? 0), 0); + if (recorded > 0) return recorded; + return booking.bookingType === 'ONE_WAY' ? booking.totalMinor : Math.round(booking.totalMinor / 2); + } + + private legBlockers(booking: any, leg: LegView, policy: any, now = new Date()): string[] { + const blockers: string[] = []; + if (!['ONE_WAY', 'ROUND_TRIP'].includes(booking.bookingType)) blockers.push('Only one-way and round-trip bookings can be rescheduled.'); + if (booking.status !== 'CONFIRMED') blockers.push('Only confirmed bookings can be rescheduled.'); + // ponytail: a boarded leg can't be moved and tickets.generate() rebuilds every leg, so a + // round trip whose outbound was already used can't change its return yet — needs leg-scoped + // ticket regeneration. + if (booking.outboundBoardedAt || booking.returnBoardedAt) blockers.push('This booking has already been used for travel.'); + if (!policy || !policy.isActive) blockers.push('Rescheduling is not available for this fare class.'); + else if (leg.departureAt.getTime() - now.getTime() < policy.cutoffMinutes * 60_000) { + blockers.push(`Changes must be made at least ${policy.cutoffMinutes} minutes before departure.`); + } + return blockers; + } + + private async buildQuote(booking: any, dto: RescheduleQuoteDto) { + const legNo = dto.leg ?? 1; + const leg = this.legsOf(booking).find((l) => l.leg === legNo); + if (!leg) throw new BadRequestException(`Booking has no leg ${legNo}`); + const policy = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId: leg.coachTypeId } }); + const blockers = this.legBlockers(booking, leg, policy); + + const pending = await this.prisma.bookingReschedule.findFirst({ where: { bookingId: booking.id, status: 'PENDING_PAYMENT' } }); + if (pending) blockers.push('A reschedule is already awaiting payment for this booking.'); + if (dto.newSeatIds.length !== leg.seats.length) blockers.push(`Select exactly ${leg.seats.length} seat(s).`); + if (new Set(dto.newSeatIds).size !== dto.newSeatIds.length) blockers.push('Duplicate seats selected.'); + + const schedule = await this.prisma.trainSchedule.findUnique({ + where: { id: dto.newScheduleId }, + include: { stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } } }, + }); + if (!schedule) throw new NotFoundException('New schedule not found'); + const now = new Date(); + if (schedule.departureAt <= now || schedule.status !== 'SCHEDULED') blockers.push('The selected departure is no longer bookable.'); + if (schedule.id === leg.scheduleId && dto.newOriginStationId === leg.originStationId && dto.newDestinationStationId === leg.destinationStationId) { + blockers.push('Pick a different departure, route or date.'); + } + const originStop = schedule.stopTimes.find((s) => s.stationId === dto.newOriginStationId); + const destStop = schedule.stopTimes.find((s) => s.stationId === dto.newDestinationStationId); + if (!originStop || !destStop || originStop.sequence >= destStop.sequence) blockers.push('Origin/destination are not valid for this schedule.'); + + const isRouteChange = dto.newOriginStationId !== leg.originStationId || dto.newDestinationStationId !== leg.destinationStationId; + if (isRouteChange && policy && !policy.routeChangeAllowed) blockers.push('Route changes are not permitted for this fare class.'); + const isSameDay = addisDay(schedule.departureAt) === addisDay(leg.departureAt); + if (isSameDay && policy && !policy.sameDayAllowed) blockers.push('Same-day changes are not permitted for this fare class.'); + + // Keep the round trip chronologically sane. + if (booking.bookingType === 'ROUND_TRIP') { + if (legNo === 1 && booking.returnSchedule && schedule.arrivalAt >= booking.returnSchedule.departureAt) blockers.push('New outbound must arrive before the return departs.'); + if (legNo === 2 && booking.schedule && schedule.departureAt <= booking.schedule.arrivalAt) blockers.push('New return must depart after the outbound arrives.'); + } + + // New seats: same coach type as booked (no class change in this step), priced per seat. + const seats = await this.prisma.seat.findMany({ + where: { id: { in: dto.newSeatIds } }, + include: { coach: { include: { coachType: { include: { seatClasses: { where: { isActive: true } } } } } } }, + }); + const seatById = new Map(seats.map((s) => [s.id, s])); + let newFareMinor = 0; + if (originStop && destStop && seats.length === dto.newSeatIds.length) { + const nationalityType = booking.displayCurrency === 'USD' ? 'INTERNATIONAL' : 'LOCAL'; + // ponytail: passenger nationality isn't stored on the booking; currency is the proxy the + // search/fare code already uses (ETB/DJF = local, USD = international). + const nationality = booking.displayCurrency === 'DJF' ? 'Djiboutian' : booking.displayCurrency === 'ETB' ? 'Ethiopian' : undefined; + const segmentRoute = `${originStop.station.code}-${destStop.station.code}`; + for (let i = 0; i < dto.newSeatIds.length; i++) { + const seat = seatById.get(dto.newSeatIds[i])!; + if (seat.coach.coachTypeId !== leg.coachTypeId) { blockers.push('New seats must be in the same class as the original booking.'); break; } + const oldSeat = leg.seats[i]; + if (oldSeat.fareMinor === 0) continue; // free child keeps riding free + const seatClass = this.pickSeatClass(seat.coach.coachType.seatClasses, seat.bedPosition, nationalityType); + if (!seatClass) { blockers.push('No fare is configured for the selected seat.'); break; } + newFareMinor += await this.bookingsService.getBaseFare( + schedule.id, seatClass.id, segmentRoute, undefined, nationality, + originStop.sequence, destStop.sequence, originStop.stationId, destStop.stationId, + ); + } + } else if (seats.length !== dto.newSeatIds.length) { + blockers.push('One or more selected seats do not exist.'); + } + + const oldFareMinor = this.legFare(booking, leg); + const amounts = policy + ? computeRescheduleAmounts(policy, oldFareMinor, newFareMinor, isSameDay) + : { feeMinor: 0, fareDifferenceMinor: newFareMinor - oldFareMinor, amountDueMinor: 0 }; + + return { + allowed: blockers.length === 0, + blockers: Array.from(new Set(blockers)), + leg: legNo, + oldScheduleId: leg.scheduleId, + oldOriginStationId: leg.originStationId, + oldDestinationStationId: leg.destinationStationId, + oldSeatIds: leg.seats.map((s) => s.seatId), + newScheduleId: schedule.id, + newDepartureAt: schedule.departureAt, + isSameDay, + isRouteChange, + oldFareMinor, + newFareMinor, + ...amounts, + currency: 'ETB', + cutoffAt: policy ? new Date(leg.departureAt.getTime() - policy.cutoffMinutes * 60_000) : null, + policy: policy && { feePercent: policy.feePercent, feeMinMinor: policy.feeMinMinor, sameDayFeePercent: policy.sameDayFeePercent, sameDayFeeMinMinor: policy.sameDayFeeMinMinor, routeChangeAllowed: policy.routeChangeAllowed, sameDayAllowed: policy.sameDayAllowed, cutoffMinutes: policy.cutoffMinutes }, + }; + } + + /** Mirrors SearchService's class matching: nationality filter, then bed position. */ + private pickSeatClass(classes: any[], bedPosition: string | null, nationalityType: string) { + const byNat = classes.filter((c) => !c.nationalityType || c.nationalityType === nationalityType); + const pool = byNat.length ? byNat : classes; + const bed = bedPosition?.toLowerCase() ?? null; + const exact = pool.find((c) => (c.bedPosition?.toLowerCase() ?? null) === bed); + return exact ?? pool.find((c) => !c.bedPosition) ?? pool[0] ?? null; + } + + /** Distributes the leg fare over seats, free children (fare 0) stay 0; rounding lands on the last paid seat. */ + private splitFare(total: number, seats: LegView['seats']): number[] { + const paid = seats.map((s) => s.fareMinor !== 0); + const n = paid.filter(Boolean).length || 1; + const each = Math.floor(total / n); + let remaining = total; + let lastPaid = -1; + const out = seats.map((_, i) => { if (!paid[i]) return 0; lastPaid = i; remaining -= each; return each; }); + if (lastPaid >= 0) out[lastPaid] += remaining; + return out; + } +} diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 00aff331b..0e0bbb0fc 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -738,9 +738,11 @@ export class SeatsService { } } - // Delete the Journey (and its JourneySegments) scoped to this booking. + // Delete the Journey (and its JourneySegments) scoped to this booking. The segment FK has no + // ON DELETE CASCADE, so segments go first or the journey delete fails on a ticketed booking. async releaseSeats(bookingId: string) { - await this.prisma.journey.deleteMany({ where: { bookingId } as any }); + await this.prisma.journeySegment.deleteMany({ where: { journey: { bookingId } } }); + await this.prisma.journey.deleteMany({ where: { bookingId } }); } async getBlockedSeats() { diff --git a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts index fe4207a50..27a94f4e9 100644 --- a/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts +++ b/apps/edr-passenger-api/src/modules/tasks/tasks.service.ts @@ -5,6 +5,7 @@ import { PrismaService } from '../../common/prisma.service'; import { SmsClientService } from '../notifications/sms-client.service'; import { CurrencyService } from '../currency/currency.service'; import { PaymentsService } from '../payments/payments.service'; +import { RescheduleService } from '../reschedule/reschedule.service'; import { MAX_PAYMENT_HOURS, CUTOFF_MINUTES, computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; // Retention windows @@ -510,6 +511,21 @@ export class TasksService { // ───────────────────────────────────────────────────────────────────────── // Daily at 02:00 EAT: purge expired/stale records to enforce data retention. // ───────────────────────────────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────────────── + // Every 1 min: reschedule requests whose payment deadline passed → EXPIRED + // (their supplementary charge too). The new-seat hold lapses on its own. + // ───────────────────────────────────────────────────────────────────────── + @Cron('*/1 * * * *') + async expireStaleReschedules() { + try { + const reschedule = await this.moduleRef.resolve(RescheduleService, undefined, { strict: false }); + const n = await reschedule.expireStale(); + if (n > 0) this.logger.log(`Expired ${n} unpaid reschedule request(s)`); + } catch (err) { + this.logger.error(`expireStaleReschedules failed: ${err instanceof Error ? err.message : err}`); + } + } + @Cron('0 2 * * *') async purgeExpiredData() { const now = new Date(); diff --git a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts index 82cc62ef6..d30f37d94 100644 --- a/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts +++ b/apps/edr-passenger-api/src/seed/passenger-permissions.registry.ts @@ -18,6 +18,7 @@ export const PASSENGER_PERMISSIONS: PassengerPermissionSeed[] = [ perm('40f1b49c-c33d-4563-a6bb-9373eabbde9b', 'edr_passenger_app:bookings:view', 'View bookings'), perm('62810ae5-315e-4ae5-8ed1-33cead51b95a', 'edr_passenger_app:bookings:manage', 'Manage bookings'), perm('b593adf3-2060-48b0-b35d-ff9ff5d72bc4', 'edr_passenger_app:bookings:cancel', 'Cancel bookings'), + perm('0c5e7a2b-9d41-4f7e-8b36-2a1c6d9e4f50', 'edr_passenger_app:bookings:reschedule', 'Reschedule bookings'), perm('566c968f-71f1-462d-9824-4b7cd33cecbb', 'edr_passenger_app:passengers:view', 'View passengers'), perm('ff5d33a0-0fe7-427f-a065-46dd14ac1da0', 'edr_passenger_app:passengers:manage', 'Manage passengers'), perm('326ec767-1da8-4c7e-b557-d4d2f9dd6d2c', 'edr_passenger_app:tickets:view', 'View tickets'), @@ -77,6 +78,7 @@ export const PASSENGER_PERMS = { view: 'edr_passenger_app:bookings:view', manage: 'edr_passenger_app:bookings:manage', cancel: 'edr_passenger_app:bookings:cancel', + reschedule: 'edr_passenger_app:bookings:reschedule', }, passengers: { view: 'edr_passenger_app:passengers:view', @@ -181,6 +183,7 @@ export const ROLE_PERMISSION_PRESETS = { stationMaster: [ PASSENGER_PERMS.bookings.view, PASSENGER_PERMS.bookings.manage, + PASSENGER_PERMS.bookings.reschedule, PASSENGER_PERMS.tickets.view, PASSENGER_PERMS.tickets.manage, PASSENGER_PERMS.tickets.generate, diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index 873e81578..a97254898 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'; import { Save } from 'lucide-react'; -import { systemConfigApi } from '@/lib/api'; +import { systemConfigApi, reschedulePolicyApi, type ReschedulePolicyRow } from '@/lib/api'; -type Tab = 'general' | 'payment' | 'integrations' | 'configurations'; +type Tab = 'general' | 'payment' | 'integrations' | 'configurations' | 'reschedule'; export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); @@ -57,6 +57,7 @@ export default function SettingsPage() { const tabs: { id: Tab; label: string }[] = [ { id: 'general', label: 'General' }, { id: 'configurations', label: 'Configurations' }, + { id: 'reschedule', label: 'Reschedule Policy' }, ]; return ( @@ -111,6 +112,8 @@ export default function SettingsPage() { )} + {activeTab === 'reschedule' && } + {activeTab === 'configurations' && (

Rate Limiting (requests / minute / IP)

@@ -224,3 +227,125 @@ export default function SettingsPage() {
); } + +type PolicyForm = NonNullable; + +const EMPTY_POLICY: PolicyForm = { + feePercent: 0, feeMinMinor: 0, routeChangeAllowed: true, sameDayAllowed: true, + sameDayFeePercent: 0, sameDayFeeMinMinor: 0, cutoffMinutes: 60, isActive: true, +}; + +/** Policy §3 — one editable row per fare class (HSC = Standard, HBC = Flex, SBC = Premium). Money is entered in ETB, stored in minor units. */ +function ReschedulePolicyTab() { + const [rows, setRows] = useState([]); + const [forms, setForms] = useState>({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(null); + const [message, setMessage] = useState(''); + + useEffect(() => { + reschedulePolicyApi.list() + .then((data) => { + const list = Array.isArray(data) ? data : []; + setRows(list); + setForms(Object.fromEntries(list.map((r) => [r.coachTypeId, { ...EMPTY_POLICY, ...(r.policy ?? {}) }]))); + }) + .catch(() => setMessage('Failed to load policies.')) + .finally(() => setLoading(false)); + }, []); + + const setField = (id: string, patch: Partial) => + setForms((f) => ({ ...f, [id]: { ...f[id], ...patch } })); + + const save = async (id: string) => { + setSaving(id); + setMessage(''); + try { + await reschedulePolicyApi.update(id, forms[id]); + setMessage('Saved.'); + } catch { + setMessage('Failed to save.'); + } finally { + setSaving(null); + } + }; + + const etb = (minor: number) => String(minor / 100); + const minor = (etbValue: string) => Math.round(Number(etbValue || 0) * 100); + + if (loading) return

Loading...

; + + return ( +
+
+

Rescheduling rules per fare class

+

+ Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is not refunded. + Same-day = new departure on the same calendar day as the original. +

+
+ {rows.map((r) => { + const f = forms[r.coachTypeId]; + return ( +
+
+
+ {r.code} + — {r.name} + {!r.policy && no policy yet (rescheduling disabled)} +
+ +
+
+
+ + setField(r.coachTypeId, { feePercent: Number(e.target.value) })} /> +
+
+ + setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} /> +
+
+ + setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} /> +
+
+ + +
+
+ + +
+
+ + setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} /> +
+
+ + setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} /> +
+
+ +
+
+
+ ); + })} + {rows.length === 0 &&

No passenger coach types found.

} + {message && {message}} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index 7350928b3..b9b7d41f1 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -527,6 +527,28 @@ export const systemConfigApi = { update: (data: Record) => apiClient.patch>('/config', data), }; +// Reschedule Policy API (one row per coach type = fare class) +export interface ReschedulePolicyRow { + coachTypeId: string; + code: string; + name: string; + policy: { + feePercent: number; + feeMinMinor: number; + routeChangeAllowed: boolean; + sameDayAllowed: boolean; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; + cutoffMinutes: number; + isActive: boolean; + } | null; +} +export const reschedulePolicyApi = { + list: () => apiClient.get('/reschedule/policies'), + update: (coachTypeId: string, data: Partial>) => + apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), +}; + // App Releases API export const appReleasesApi = { getAll: async () => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index d42ab0dc3..34f5b59ec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -1022,6 +1022,15 @@ function BookingDetailContent() { )} + {!booking.isPackageBooking && ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && !booking.outboundBoardedAt && ( + + )} )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx new file mode 100644 index 000000000..85cdc5786 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { Suspense, useEffect, useMemo, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { format } from "date-fns"; +import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; +import { apiClient } from "@/lib/api-client"; +import ModernDatePicker from "@/components/ModernDatePicker"; +import { formatTime } from "@/utils/format"; + +type Station = { id: string; name: string; code?: string }; + +type LegOption = { + leg: number; + scheduleId: string; + originStationId: string | null; + destinationStationId: string | null; + departureAt: string; + coachTypeId: string; + seatCount: number; + passengerNames: string[]; + oldFareMinor: number; + policy: { + feePercent: number; + feeMinMinor: number; + routeChangeAllowed: boolean; + sameDayAllowed: boolean; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; + cutoffMinutes: number; + } | null; + canReschedule: boolean; + blockers: string[]; +}; + +type Options = { + bookingRef: string; + bookingType: string; + legs: LegOption[]; + pending: { id: string; amountDueMinor: number; paymentToken: string | null; expiresAt: string | null } | null; +}; + +type Quote = { + allowed: boolean; + blockers: string[]; + oldFareMinor: number; + newFareMinor: number; + feeMinor: number; + fareDifferenceMinor: number; + amountDueMinor: number; + isSameDay: boolean; + isRouteChange: boolean; +}; + +const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; + +function ReschedulePageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const ref = searchParams.get("ref") || ""; + + const [legNo, setLegNo] = useState(1); + const [date, setDate] = useState(undefined); + const [originId, setOriginId] = useState(""); + const [destinationId, setDestinationId] = useState(""); + const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null); + const [schedule, setSchedule] = useState(null); + const [seatIds, setSeatIds] = useState([]); + const [done, setDone] = useState<{ status: string } | null>(null); + const [error, setError] = useState(null); + + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ + queryKey: ["reschedule-options", ref], + queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), + enabled: !!ref, + retry: false, + }); + const { data: stations = [] } = useQuery({ + queryKey: ["stations"], + queryFn: () => apiClient.get("/stations"), + }); + + const leg = useMemo(() => options?.legs.find((l) => l.leg === legNo) ?? options?.legs[0], [options, legNo]); + + // Prefill route from the leg being changed. + useEffect(() => { + if (!leg) return; + setOriginId(leg.originStationId ?? ""); + setDestinationId(leg.destinationStationId ?? ""); + setSchedule(null); + setSeatIds([]); + setSearched(null); + }, [leg?.leg, leg?.scheduleId]); + + const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY"; + + const { data: schedules = [], isFetching: searching } = useQuery({ + queryKey: ["reschedule-search", searched], + queryFn: async () => { + const res: any = await apiClient.post("/search", { + originStationId: searched!.originId, + destinationStationId: searched!.destinationId, + date: searched!.date, + adultCount: leg?.seatCount ?? 1, + childCount: 0, + journeyType: "ONE_WAY", + }); + const list = Array.isArray(res) ? res : res?.outbound ?? res?.data ?? []; + return list.filter((s: any) => (s.scheduleId || s.id) !== leg?.scheduleId || searched!.originId !== leg?.originStationId || searched!.destinationId !== leg?.destinationStationId); + }, + enabled: !!searched && !!leg, + }); + + const scheduleId = schedule ? schedule.scheduleId || schedule.id : null; + const { data: seatMap, isLoading: loadingSeats } = useQuery({ + queryKey: ["reschedule-seatmap", scheduleId, leg?.coachTypeId, originId, destinationId], + queryFn: async () => { + const res: any = await apiClient.get( + `/seats/seatmap/${scheduleId}?coachTypeId=${leg!.coachTypeId}&journeyDirection=${journeyDirection}&originStationId=${originId}&destinationStationId=${destinationId}`, + ); + return res?.data || res; + }, + enabled: !!scheduleId && !!leg, + }); + + const quoteBody = leg && scheduleId && seatIds.length === leg.seatCount + ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds } + : null; + const { data: quote, isFetching: quoting } = useQuery({ + queryKey: ["reschedule-quote", ref, quoteBody], + queryFn: () => apiClient.post(`/bookings/${ref}/reschedule/quote`, quoteBody), + enabled: !!quoteBody, + }); + + const confirm = useMutation({ + mutationFn: async () => { + const hold: any = await apiClient.post("/seats/hold", { + scheduleId, + originStationId: originId, + destinationStationId: destinationId, + journeyDirection, + passengers: seatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })), + }); + return apiClient.post(`/bookings/${ref}/reschedule`, { ...quoteBody, holdId: hold.holdId || hold.id }); + }, + onSuccess: (res) => { + if (res.paymentToken) router.push(`/pay-balance/${res.paymentToken}`); + else setDone({ status: res.status }); + }, + onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not reschedule"), + }); + + const toggleSeat = (id: string) => { + setSeatIds((prev) => { + if (prev.includes(id)) return prev.filter((s) => s !== id); + if (prev.length >= (leg?.seatCount ?? 1)) return [...prev.slice(1), id]; + return [...prev, id]; + }); + }; + + const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—"; + + if (!ref) return

Missing booking reference.

; + if (loadingOptions) return ; + if (optionsError || !options || !leg) { + return

{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}

; + } + + if (done) { + return ( + +
+ +

Booking rescheduled

+

New tickets have been issued for booking {ref}.

+ +
+
+ ); + } + + if (options.pending) { + return ( + +
+

Reschedule awaiting payment

+

+ A change of {etb(options.pending.amountDueMinor)} is waiting to be paid + {options.pending.expiresAt ? ` before ${format(new Date(options.pending.expiresAt), "dd MMM HH:mm")}` : ""}. + Your new seats are held until then. +

+ {options.pending.paymentToken && ( + + )} +
+
+ ); + } + + const routeLocked = !leg.policy?.routeChangeAllowed; + const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId; + + return ( + + +

Reschedule {ref}

+

+ {leg.passengerNames.join(", ")} · currently {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} · {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)} +

+ + {options.legs.length > 1 && ( +
+ {options.legs.map((l) => ( + + ))} +
+ )} + + {leg.policy && ( +
+
Your fare rules
+
Change fee: {leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` : "Free"}. A higher new fare is payable; a lower one is not refunded.
+
Route change: {leg.policy.routeChangeAllowed ? "allowed" : "not permitted"}. Same-day change: {leg.policy.sameDayAllowed ? (leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` : "free") : "not permitted"}.
+
Changes close {leg.policy.cutoffMinutes} minutes before departure.
+
+ )} + + {!leg.canReschedule && ( +
+ +
{leg.blockers.map((b) =>
{b}
)}
+
+ )} + + {leg.canReschedule && ( + <> + {/* Step 1: route + date */} +
+ + + + +
+ + {/* Step 2: schedule */} + {searched && !searching && schedules.length === 0 &&

No trains on that day.

} + {schedules.length > 0 && ( +
+ {schedules.map((s: any) => { + const id = s.scheduleId || s.id; + const selected = scheduleId === id; + return ( + + ); + })} +
+ )} + + {/* Step 3: seats (same class as booked) */} + {scheduleId && ( +
+

Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({seatIds.length}/{leg.seatCount})

+ {loadingSeats && } + {(seatMap?.coaches ?? []).map((coach: any) => ( +
+
{coach.name} · {coach.coachTypeName}
+
+ {(coach.seats ?? []).map((seat: any) => { + const picked = seatIds.includes(seat.id); + const free = seat.status === "AVAILABLE"; + return ( + + ); + })} +
+
+ ))} + {seatMap && (seatMap.coaches ?? []).length === 0 &&

No coach of your class on this train.

} +
+ )} + + {/* Step 4: quote + confirm */} + {quoteBody && ( +
+ {quoting && } + {quote && ( + <> + + + = 0 ? "Fare difference" : "Fare difference (not refunded)"} value={etb(Math.max(0, quote.fareDifferenceMinor))} /> + +
Total due now{etb(quote.amountDueMinor)}
+ {quote.blockers.length > 0 && ( +
{quote.blockers.map((b) =>
{b}
)}
+ )} + {error &&
{error}
} + + + )} +
+ )} + + )} +
+ ); +} + +function Row({ label, value }: { label: string; value: string }) { + return
{label}{value}
; +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+
{children}
+
+
+ ); +} + +export default function ReschedulePage() { + return ( + }> + + + ); +} From 9843b64989532e04740c28d78efb2943a7af0eb0 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Fri, 21 Aug 2026 15:30:50 +0300 Subject: [PATCH 02/28] feat: (reschedule) use the home-page station dropdown and available-date calendar in the portal reschedule form --- .../src/app/booking/reschedule/page.tsx | 153 ++++++++++++- .../portal/src/app/booking/search/page.tsx | 177 +------------- .../portal/src/components/StationDropdown.tsx | 215 ++++++++++++++++++ 3 files changed, 367 insertions(+), 178 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/components/StationDropdown.tsx diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 85cdc5786..392791806 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -7,9 +7,24 @@ import { format } from "date-fns"; import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api-client"; import ModernDatePicker from "@/components/ModernDatePicker"; +import StationDropdown, { + pushRecentStation, + readRecentStationIds, +} from "@/components/StationDropdown"; import { formatTime } from "@/utils/format"; +import { Station } from "@/types"; -type Station = { id: string; name: string; code?: string }; +// Same horizon the search widget uses — /search/available-dates is server-clamped to 90 days, +// so the picker's maxDate has to match or unchecked future months render as pickable again. +const AVAILABLE_DATES_RANGE_DAYS = 90; + +const toDateStr = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; + +interface AvailableDatesResponse { + routeExists: boolean; + dates: { date: string; available: boolean }[]; +} type LegOption = { leg: number; @@ -69,6 +84,12 @@ function ReschedulePageContent() { const [seatIds, setSeatIds] = useState([]); const [done, setDone] = useState<{ status: string } | null>(null); const [error, setError] = useState(null); + const [dateNotice, setDateNotice] = useState(null); + const [recentStationIds, setRecentStationIds] = useState( + readRecentStationIds, + ); + const saveRecent = (id: string) => + setRecentStationIds((prev) => pushRecentStation(id, prev)); const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ queryKey: ["reschedule-options", ref], @@ -95,6 +116,69 @@ function ReschedulePageContent() { const journeyDirection = leg?.leg === 2 ? "RETURN" : options?.bookingType === "ROUND_TRIP" ? "OUTBOUND" : "ONE_WAY"; + // Which dates actually have a bookable train for the chosen From/To. Same endpoint and same + // shape the home-page search widget uses, so the reschedule calendar greys out the same days + // rather than letting someone pick a date that can only come back empty. + const { data: availableDates } = useQuery({ + queryKey: ["available-dates", originId, destinationId], + queryFn: async () => { + const from = new Date(); + const to = new Date(); + to.setDate(to.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return (await apiClient.get("/search/available-dates", { + params: { + originStationId: originId, + destinationStationId: destinationId, + from: toDateStr(from), + to: toDateStr(to), + }, + })) as AvailableDatesResponse; + }, + enabled: !!originId && !!destinationId && originId !== destinationId, + staleTime: 5 * 60 * 1000, + }); + + const disabledDates = useMemo(() => { + const set = new Set(); + // routeExists === false is handled by disabling the control outright (noRouteForPair): + // the server returns an empty `dates` array in that case anyway. + if (!availableDates?.routeExists) return set; + for (const d of availableDates.dates) if (!d.available) set.add(d.date); + return set; + }, [availableDates]); + + const noRouteForPair = + !!originId && !!destinationId && availableDates?.routeExists === false; + + const maxSearchDate = useMemo(() => { + const d = new Date(); + d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS); + return d; + }, []); + + // If the picked date turns out to have no train (stations changed, or the availability query + // just resolved), drop it and say why — rather than letting "Find trains" return nothing. + useEffect(() => { + if (date && disabledDates.has(toDateStr(date))) { + setDate(undefined); + setSearched(null); + setSchedule(null); + setSeatIds([]); + setDateNotice("No trains run this route on that date — please pick another."); + } + }, [date, disabledDates]); + + // Losing the route invalidates any date already chosen, so nothing stale can be submitted + // from behind a now-disabled control. + useEffect(() => { + if (noRouteForPair && date) { + setDate(undefined); + setSearched(null); + setSchedule(null); + setSeatIds([]); + } + }, [noRouteForPair, date]); + const { data: schedules = [], isFetching: searching } = useQuery({ queryKey: ["reschedule-search", searched], queryFn: async () => { @@ -199,7 +283,8 @@ function ReschedulePageContent() { } const routeLocked = !leg.policy?.routeChangeAllowed; - const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId; + const canSearch = + !!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair; return ( @@ -241,17 +326,67 @@ function ReschedulePageContent() { <> {/* Step 1: route + date */}
- - - + { + setOriginId(s.id); + if (s.id) saveRecent(s.id); + setDateNotice(null); + setSchedule(null); + setSeatIds([]); + setSearched(null); + }} + /> + { + setDestinationId(s.id); + if (s.id) saveRecent(s.id); + setDateNotice(null); + setSchedule(null); + setSeatIds([]); + setSearched(null); + }} + /> + { + setDateNotice(null); + setDate(d); + }} + minDate={new Date()} + maxDate={originId && destinationId ? maxSearchDate : undefined} + disabledDates={disabledDates} + disabled={noRouteForPair} + placeholder="New date" + />
+ {routeLocked && ( +

+ Your fare class does not permit changing stations — only the date and train. +

+ )} + {noRouteForPair && ( +

+ No route connects these stations — pick a different destination. +

+ )} + {dateNotice && !noRouteForPair && ( +

{dateNotice}

+ )} {/* Step 2: schedule */} {searched && !searching && schedules.length === 0 &&

No trains on that day.

} 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 bb4e2cf12..3b6a17bb8 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 @@ -23,6 +23,10 @@ import { } from "lucide-react"; import { useEffect, useRef, useState, useCallback, useMemo } from "react"; import ModernDatePicker from "@/components/ModernDatePicker"; +import StationDropdown, { + pushRecentStation, + readRecentStationIds, +} from "@/components/StationDropdown"; const AVAILABLE_DATES_RANGE_DAYS = 90; @@ -393,163 +397,6 @@ function PassengerModal({ ); } -// ─── Station Autocomplete (Desktop dropdown) ────────────────────────────────── -function StationDropdown({ - stations, - value, - excludeId, - placeholder, - onSelect, - error, - recentIds, - onOpen, -}: { - stations: Station[]; - value: string; - excludeId?: string; - placeholder: string; - onSelect: (s: Station) => void; - error?: string; - recentIds: string[]; - onOpen?: () => void; -}) { - const [query, setQuery] = useState(""); - const [open, setOpen] = useState(false); - const ref = useRef(null); - const inputRef = useRef(null); - const selectedStation = stations.find((s) => s.id === value); - - useEffect(() => { - if (selectedStation && !open) setQuery(""); - }, [selectedStation, open]); - - useEffect(() => { - const handler = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, []); - - const filtered = query.trim() - ? stations.filter( - (s) => - s.id !== excludeId && - (s.name.toLowerCase().includes(query.toLowerCase()) || - s.code?.toLowerCase().includes(query.toLowerCase())), - ) - : stations.filter((s) => s.id !== excludeId).slice(0, 20); - - const displayValue = open ? query : (selectedStation?.name ?? ""); - - return ( -
-
- - { - setQuery(e.target.value); - setOpen(true); - }} - onFocus={() => { - setQuery(""); - setOpen(true); - onOpen?.(); - }} - placeholder={placeholder} - className="w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm text-gray-900 dark:text-white placeholder-gray-400" - /> - {value && ( - - )} -
- - {open && ( -
- {!query && recentIds.length > 0 && ( -
-

- Recent -

- {recentIds - .map((id) => stations.find((s) => s.id === id)) - .filter(Boolean) - .map((s) => ( - - ))} -
-
- )} - {filtered.length === 0 ? ( -

- No stations found -

- ) : ( - filtered.map((s) => ( - - )) - )} -
- )} -
- ); -} - // ─── Main Page ──────────────────────────────────────────────────────────────── export default function SearchPage() { const router = useRouter(); @@ -581,13 +428,9 @@ export default function SearchPage() { useEffect(() => { router.prefetch("/booking/results"); }, [router]); - const [recentStationIds, setRecentStationIds] = useState(() => { - try { - return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]"); - } catch { - return []; - } - }); + const [recentStationIds, setRecentStationIds] = useState( + readRecentStationIds, + ); const passengerRef = useRef(null); const widgetRef = useRef(null); @@ -847,11 +690,7 @@ export default function SearchPage() { }, [noReturnRouteForPair, returnDate, setValue, clearErrors]); const saveRecent = useCallback((id: string) => { - setRecentStationIds((prev) => { - const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5); - localStorage.setItem("edr_recent_stations", JSON.stringify(next)); - return next; - }); + setRecentStationIds((prev) => pushRecentStation(id, prev)); }, []); const handleSwap = () => { diff --git a/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx new file mode 100644 index 000000000..8b0fb0073 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/StationDropdown.tsx @@ -0,0 +1,215 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Clock, MapPin, X } from "lucide-react"; +import { Station } from "@/types"; + +// Shared by the search widget (home page) and the reschedule flow so both pick stations the +// same way. Recents live under one localStorage key, so a station picked on the home page is +// still offered as "Recent" when rescheduling. +export const RECENT_STATIONS_KEY = "edr_recent_stations"; +export const MAX_RECENT_STATIONS = 5; + +export function readRecentStationIds(): string[] { + try { + const raw = JSON.parse(localStorage.getItem(RECENT_STATIONS_KEY) || "[]"); + return Array.isArray(raw) ? raw : []; + } catch { + return []; + } +} + +/** Prepends `id`, de-duplicates, caps the list, persists it, and returns the new list. */ +export function pushRecentStation(id: string, prev: string[]): string[] { + const next = [id, ...prev.filter((x) => x !== id)].slice(0, MAX_RECENT_STATIONS); + try { + localStorage.setItem(RECENT_STATIONS_KEY, JSON.stringify(next)); + } catch { + /* private mode / storage disabled — recents are a convenience, never a requirement */ + } + return next; +} + +// ─── Station Autocomplete ───────────────────────────────────────────────────── +export default function StationDropdown({ + stations, + value, + excludeId, + placeholder, + onSelect, + error, + recentIds, + onOpen, + disabled = false, +}: { + stations: Station[]; + value: string; + excludeId?: string; + placeholder: string; + onSelect: (s: Station) => void; + error?: string; + recentIds: string[]; + onOpen?: () => void; + /** + * Read-only: shows the selection but refuses to open. Used where the route is fixed — + * a fare class whose policy sets `routeChangeAllowed: false` cannot change stations, and + * a dropdown that opens only to reject the pick is worse than one that plainly can't. + */ + disabled?: boolean; +}) { + const [query, setQuery] = useState(""); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const inputRef = useRef(null); + const selectedStation = stations.find((s) => s.id === value); + + useEffect(() => { + if (selectedStation && !open) setQuery(""); + }, [selectedStation, open]); + + // Close if the control is disabled while open (e.g. switching to a route-locked leg). + useEffect(() => { + if (disabled) setOpen(false); + }, [disabled]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) + setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const filtered = query.trim() + ? stations.filter( + (s) => + s.id !== excludeId && + (s.name.toLowerCase().includes(query.toLowerCase()) || + s.code?.toLowerCase().includes(query.toLowerCase())), + ) + : stations.filter((s) => s.id !== excludeId).slice(0, 20); + + const displayValue = open ? query : (selectedStation?.name ?? ""); + + return ( +
+
+ + { + setQuery(e.target.value); + setOpen(true); + }} + onFocus={() => { + if (disabled) return; + setQuery(""); + setOpen(true); + onOpen?.(); + }} + placeholder={placeholder} + className={`w-full pl-10 pr-8 py-3.5 bg-transparent rounded-xl focus:outline-none text-sm placeholder-gray-400 ${ + disabled + ? "text-gray-500 dark:text-gray-400 cursor-not-allowed" + : "text-gray-900 dark:text-white" + }`} + /> + {value && !disabled && ( + + )} +
+ + {open && !disabled && ( +
+ {!query && recentIds.length > 0 && ( +
+

+ Recent +

+ {recentIds + .map((id) => stations.find((s) => s.id === id)) + .filter(Boolean) + .map((s) => ( + + ))} +
+
+ )} + {filtered.length === 0 ? ( +

+ No stations found +

+ ) : ( + filtered.map((s) => ( + + )) + )} +
+ )} +
+ ); +} From 0fd6a1d7d78869e778381a1a35df4ace194faa9e Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Sat, 22 Aug 2026 09:36:00 +0300 Subject: [PATCH 03/28] feat: (reschedule) share the booking flow's seat map and add a sticky change summary --- .../src/app/booking/reschedule/page.tsx | 400 ++++++++-- .../portal/src/app/booking/seats/page.tsx | 582 +-------------- .../portal/src/components/SeatMap.tsx | 706 ++++++++++++++++++ 3 files changed, 1056 insertions(+), 632 deletions(-) create mode 100644 apps/edr-passenger-web/portal/src/components/SeatMap.tsx diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 392791806..350df552a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; @@ -11,6 +11,7 @@ import StationDropdown, { pushRecentStation, readRecentStationIds, } from "@/components/StationDropdown"; +import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap"; import { formatTime } from "@/utils/format"; import { Station } from "@/types"; @@ -70,6 +71,13 @@ type Quote = { const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; +/** "Sat 29 Aug, 21:00" — the summary's departure line. Null when the value isn't a real date. */ +const formatDepartureDay = (value: string | Date | undefined | null): string | null => { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value); + return isNaN(d.getTime()) ? null : format(d, "EEE dd MMM, HH:mm"); +}; + function ReschedulePageContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -81,7 +89,12 @@ function ReschedulePageContent() { const [destinationId, setDestinationId] = useState(""); const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null); const [schedule, setSchedule] = useState(null); - const [seatIds, setSeatIds] = useState([]); + // Seat per passenger, keyed by index into leg.passengerNames. The API pairs newSeatIds[i] + // with the i-th BookingSeat in that same order, so an explicit map is the only way the + // right passenger keeps the right fare (a free child must not inherit an adult's seat). + const [passengerSeatMap, setPassengerSeatMap] = useState>({}); + const [activePassengerIndex, setActivePassengerIndex] = useState(0); + const [selectedCoach, setSelectedCoach] = useState(null); const [done, setDone] = useState<{ status: string } | null>(null); const [error, setError] = useState(null); const [dateNotice, setDateNotice] = useState(null); @@ -91,6 +104,12 @@ function ReschedulePageContent() { const saveRecent = (id: string) => setRecentStationIds((prev) => pushRecentStation(id, prev)); + const resetSeats = () => { + setPassengerSeatMap({}); + setActivePassengerIndex(0); + setSelectedCoach(null); + }; + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ queryKey: ["reschedule-options", ref], queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), @@ -110,7 +129,7 @@ function ReschedulePageContent() { setOriginId(leg.originStationId ?? ""); setDestinationId(leg.destinationStationId ?? ""); setSchedule(null); - setSeatIds([]); + resetSeats(); setSearched(null); }, [leg?.leg, leg?.scheduleId]); @@ -163,7 +182,7 @@ function ReschedulePageContent() { setDate(undefined); setSearched(null); setSchedule(null); - setSeatIds([]); + resetSeats(); setDateNotice("No trains run this route on that date — please pick another."); } }, [date, disabledDates]); @@ -175,7 +194,7 @@ function ReschedulePageContent() { setDate(undefined); setSearched(null); setSchedule(null); - setSeatIds([]); + resetSeats(); } }, [noRouteForPair, date]); @@ -208,8 +227,62 @@ function ReschedulePageContent() { enabled: !!scheduleId && !!leg, }); - const quoteBody = leg && scheduleId && seatIds.length === leg.seatCount - ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds } + const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]); + + // Expand the first coach automatically — with one coach of the booked class on most trains, + // making the user open it before any seat is visible is a click for nothing. Fires once per + // schedule: keying it on `selectedCoach` instead would re-open the coach the moment the user + // collapsed it, since collapsing sets selectedCoach back to null. + const autoExpandedFor = useRef(null); + useEffect(() => { + if (!scheduleId || coaches.length === 0) return; + if (autoExpandedFor.current === scheduleId) return; + autoExpandedFor.current = scheduleId; + setSelectedCoach(coaches[0].id); + }, [scheduleId, coaches]); + + // newSeatIds must line up with the leg's BookingSeats, which the API orders by passenger + // name — the same order `passengerNames` arrives in. Indexing by passenger builds that + // order by construction, so there is nothing for a click sequence to get wrong. + const orderedSeatIds = useMemo( + () => Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => passengerSeatMap[i]).filter(Boolean) as string[], + [passengerSeatMap, leg?.seatCount], + ); + const allSeatsChosen = !!leg && orderedSeatIds.length === leg.seatCount; + + const isSeatSelected = (seatId: string) => passengerSeatMap[activePassengerIndex] === seatId; + const isSeatAssignedToOther = (seatId: string) => + Object.entries(passengerSeatMap).some( + ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, + ); + + const handleSeatToggle = (seatId: string) => { + if (isSeatAssignedToOther(seatId)) return; + setPassengerSeatMap((prev) => { + const next = { ...prev }; + if (next[activePassengerIndex] === seatId) { + delete next[activePassengerIndex]; + return next; + } + next[activePassengerIndex] = seatId; + // Move to the next passenger still without a seat so a multi-passenger leg can be + // filled by clicking straight down the coach. + const total = leg?.seatCount ?? 1; + const nextUnassigned = Array.from({ length: total }, (_, i) => i).find((i) => !next[i]); + if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned); + return next; + }); + }; + + // No skipping ahead of a passenger who still needs a seat — same rule as /booking/seats. + const firstUnassignedIndex = Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => i).find( + (i) => !passengerSeatMap[i], + ); + const maxSelectableIndex = + firstUnassignedIndex === undefined ? (leg?.seatCount ?? 1) - 1 : firstUnassignedIndex; + + const quoteBody = leg && scheduleId && allSeatsChosen + ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: orderedSeatIds } : null; const { data: quote, isFetching: quoting } = useQuery({ queryKey: ["reschedule-quote", ref, quoteBody], @@ -224,7 +297,7 @@ function ReschedulePageContent() { originStationId: originId, destinationStationId: destinationId, journeyDirection, - passengers: seatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })), + passengers: orderedSeatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })), }); return apiClient.post(`/bookings/${ref}/reschedule`, { ...quoteBody, holdId: hold.holdId || hold.id }); }, @@ -235,14 +308,6 @@ function ReschedulePageContent() { onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not reschedule"), }); - const toggleSeat = (id: string) => { - setSeatIds((prev) => { - if (prev.includes(id)) return prev.filter((s) => s !== id); - if (prev.length >= (leg?.seatCount ?? 1)) return [...prev.slice(1), id]; - return [...prev, id]; - }); - }; - const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—"; if (!ref) return

Missing booking reference.

; @@ -283,11 +348,133 @@ function ReschedulePageContent() { } const routeLocked = !leg.policy?.routeChangeAllowed; + + // Short label/value pairs rather than prose — the rules are scanned, not read. + const fareRules = leg.policy + ? [ + { + label: "Change fee", + value: + leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 + ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` + : "Free", + }, + { label: "Route change", value: leg.policy.routeChangeAllowed ? "Allowed" : "Not permitted" }, + { + label: "Same-day change", + value: !leg.policy.sameDayAllowed + ? "Not permitted" + : leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 + ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` + : "Free", + }, + { label: "Changes close", value: `${leg.policy.cutoffMinutes} min before departure` }, + { label: "Higher new fare", value: "Payable" }, + { label: "Lower new fare", value: "Not refunded" }, + ] + : []; + const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair; + // Mirrors the booking flow's FareSidebar: a sticky money card that is present from the + // start and fills in as choices are made, rather than a total that appears at the end. + // Rendered twice — inline under the content on mobile, sticky beside it on desktop. + const ChangeSummary = () => ( +
+

+ Change summary +

+ +
+
Currently
+
+ {formatDepartureDay(leg.departureAt)} +
+
+ {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)} +
+
+ + {schedule && ( +
+
Changing to
+ {/* Same date+time line as "Currently" above, so the two are read side by side. */} +
+ {formatDepartureDay(schedule.departureAt) ?? formatTime(schedule.departureAt)} +
+
+ {schedule.trainName || schedule.trainNumber} · arrives {formatTime(schedule.arrivalAt)} +
+
+ {stationName(originId)} → {stationName(destinationId)} +
+
+ )} + +
+ {leg.passengerNames.map((name, i) => { + const seatId = passengerSeatMap[i]; + const seat = seatId + ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId) + : null; + return ( +
+ {name} + + {seat ? `Seat ${buildSeatLabel(seat)}` : "—"} + +
+ ); + })} +
+ + {!quoteBody ? ( +

+ Pick a new train and a seat for {leg.seatCount > 1 ? "every passenger" : "the passenger"} to see what this change costs. +

+ ) : quoting ? ( +
+ +
+ ) : quote ? ( +
+ + + = 0 ? "Fare difference" : "Fare difference (not refunded)"} + value={etb(Math.max(0, quote.fareDifferenceMinor))} + /> + +
+ Total due now + {etb(quote.amountDueMinor)} +
+ {quote.blockers.length > 0 && ( +
+ +
{quote.blockers.map((b) =>
{b}
)}
+
+ )} + {error &&
{error}
} + +
+ ) : null} +
+ ); + return ( - + @@ -306,12 +493,24 @@ function ReschedulePageContent() {
)} - {leg.policy && ( -
-
Your fare rules
-
Change fee: {leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` : "Free"}. A higher new fare is payable; a lower one is not refunded.
-
Route change: {leg.policy.routeChangeAllowed ? "allowed" : "not permitted"}. Same-day change: {leg.policy.sameDayAllowed ? (leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` : "free") : "not permitted"}.
-
Changes close {leg.policy.cutoffMinutes} minutes before departure.
+
+ {/* Left column — the choices */} +
+
+ {fareRules.length > 0 && ( +
+
Your fare rules
+
    + {fareRules.map((rule) => ( +
  • + + + {rule.label}:{" "} + {rule.value} + +
  • + ))} +
)} @@ -338,7 +537,7 @@ function ReschedulePageContent() { if (s.id) saveRecent(s.id); setDateNotice(null); setSchedule(null); - setSeatIds([]); + resetSeats(); setSearched(null); }} /> @@ -354,7 +553,7 @@ function ReschedulePageContent() { if (s.id) saveRecent(s.id); setDateNotice(null); setSchedule(null); - setSeatIds([]); + resetSeats(); setSearched(null); }} /> @@ -370,7 +569,7 @@ function ReschedulePageContent() { disabled={noRouteForPair} placeholder="New date" /> -
@@ -396,7 +595,7 @@ function ReschedulePageContent() { const id = s.scheduleId || s.id; const selected = scheduleId === id; return ( - - ); - })} -
-
- ))} - {seatMap && (seatMap.coaches ?? []).length === 0 &&

No coach of your class on this train.

} -
- )} +

+ Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({orderedSeatIds.length}/{leg.seatCount}) +

+

+ {allSeatsChosen + ? "Every passenger has a seat." + : `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`} +

- {/* Step 4: quote + confirm */} - {quoteBody && ( -
- {quoting && } - {quote && ( - <> - - - = 0 ? "Fare difference" : "Fare difference (not refunded)"} value={etb(Math.max(0, quote.fareDifferenceMinor))} /> - -
Total due now{etb(quote.amountDueMinor)}
- {quote.blockers.length > 0 && ( -
{quote.blockers.map((b) =>
{b}
)}
- )} - {error &&
{error}
} - - + {/* Who gets which seat. The API pairs seats to passengers by position, so this + mapping is the payload — not a display convenience. */} +
+ {leg.passengerNames.map((name, i) => { + const assignedSeatId = passengerSeatMap[i]; + const assignedSeat = assignedSeatId + ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === assignedSeatId) + : null; + const isActive = i === activePassengerIndex; + const isClickable = i <= maxSelectableIndex; + return ( + + ); + })} +
+ + {loadingSeats ? ( + + ) : ( + )}
)} + )} +
{/* end left card */} + + {/* Mobile: the summary sits under the choices instead of beside them */} +
+ +
+ {/* end left column */} + + {/* Right column — sticky change summary (desktop only) */} +
+
+ +
+
+
); } @@ -467,11 +719,19 @@ function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } -function Shell({ children }: { children: React.ReactNode }) { +// `wide` switches to the booking flow's two-column width and hands card styling to the +// columns themselves; the narrow single-card form still carries the loading/error states. +function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) { return (
-
{children}
+ {wide ? ( +
{children}
+ ) : ( +
+ {children} +
+ )}
); 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 fa1a86524..e457c7d62 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 @@ -6,157 +6,18 @@ import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; -import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; -import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; -import Image from "next/image"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; import CustomModal from "@/components/CustomModal"; import { Skeleton } from "@/components/Skeleton"; import { isChild } from "@/utils/fare-utils"; - -const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; - -const buildSeatLabel = (seat: any): string => { - const base: string = seat.number || seat.label || seat.seatNumber || ''; - if (!base) return ''; - const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : ''; - return suffix ? `${base}${suffix}` : base; -}; - -const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => { - const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; - const bedPosition = bed.bedPosition || ""; - const bedType = - bedPosition === "upper" - ? "Upper" - : bedPosition === "middle" - ? "Middle" - : "Lower"; - const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther; - - return ( - - ); -}); - -BedCard.displayName = "BedCard"; - -// A real berth ladder is a single fixed rail mounted at the end of the bay that a -// passenger climbs to reach every level — not a separate rung floating between each -// pair of beds. So this renders once per bay, right after the last berth card, with -// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. -const LadderConnector = memo(() => ( - -)); - -LadderConnector.displayName = "LadderConnector"; - -const SeatButton = memo( - ({ - seat, - isSelected, - isAssignedToOther, - onToggle, - isBedCoach, - bedLabel, - coachSeatClass, - }: any) => { - const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; - const bedWidth = "w-24"; - const width = isBedCoach ? bedWidth : "w-10"; - const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther; - - return ( -
- -
- ); - }, -); - -SeatButton.displayName = "SeatButton"; +import { + buildSeatLabel, + CoachSeatLayout, + getBedPosition, + getValidSeatsForCoach as getValidSeatsForCoachData, +} from "@/components/SeatMap"; export default function SeatsPage() { const router = useRouter(); @@ -701,72 +562,11 @@ export default function SeatsPage() { [filteredCoaches, selectedCoach], ); - const getBedPosition = (selectedClass: string): string | null => { - const lowerClass = selectedClass.toLowerCase(); - if (lowerClass.includes("upper")) return "upper"; - if (lowerClass.includes("middle")) return "middle"; - if (lowerClass.includes("lower")) return "lower"; - return null; - }; - - // Extracted so it can be applied to ANY coach, not just the one currently expanded — - // Auto Assign needs to look across every coach of this type, not just selectedCoachData. + // Berth-class narrowing lives in the shared SeatMap module so the booking and reschedule + // flows filter beds identically; this wrapper just binds the current leg's fare class. const getValidSeatsForCoach = useCallback( - (coachData: any): any[] => { - if (!coachData) return []; - - // If coach has rooms, extract all beds from rooms - if (coachData.rooms?.length > 0) { - const allBeds: any[] = []; - coachData.rooms.forEach((room: any) => { - if (room.beds) { - allBeds.push(...room.beds); - } - }); - - let beds = allBeds.filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ""; - return seatLabel && !seatLabel.startsWith("-"); - }); - - const isBedCoach = - coachData.seatClass?.toLowerCase().includes("bed") || - coachData.mode?.toLowerCase().includes("bed"); - - if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition( - currentSchedule.selectedSeatClass, - ); - if (selectedBedPosition) { - beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); - } - } - - return beds; - } - - // Fallback to old seat structure - let seats = (coachData.seats || []).filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ""; - return seatLabel && !seatLabel.startsWith("-"); - }); - const isBedCoach = - coachData.isBedCoach === true || - seats.some((s: any) => s.bedPosition) || - coachData.seatClass?.toLowerCase().includes("bed") || - coachData.mode?.toLowerCase().includes("bed"); - - if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition( - currentSchedule.selectedSeatClass, - ); - if (selectedBedPosition) { - seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); - } - } - - return seats; - }, + (coachData: any): any[] => + getValidSeatsForCoachData(coachData, currentSchedule?.selectedSeatClass), [currentSchedule?.selectedSeatClass], ); @@ -1369,355 +1169,6 @@ export default function SeatsPage() { } }, [seatEligibility, seatEligibleIndices, activePassengerIndex]); - const parseSeatArrangement = ( - arrangement: string | null, - seatClasses?: string[], - ): number[] => { - if (!arrangement) return [2, 2]; - - // Check if this is a bed coach based on seat classes - const isBedCoach = seatClasses?.some((sc) => - sc?.toLowerCase().includes("bed"), - ); - - if (isBedCoach) { - // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically - // We want to render them as single column, so return [1] - const parts = arrangement - .split("+") - .map((p) => parseInt(p.trim())) - .filter((n) => !isNaN(n) && n > 0); - return parts.length > 0 ? [Math.max(...parts)] : [3]; - } - - // For regular seats, parse normally (e.g., "3+2" -> [3, 2]) - const parts = arrangement - .split("+") - .map((p) => parseInt(p.trim())) - .filter((n) => !isNaN(n) && n > 0); - return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; - }; - - const renderCoachSeats = (coach: any, isBedCoach: boolean) => { - const arrangement = parseSeatArrangement( - coach.seatArrangement, - coach.seatClasses || [coach.seatClass], - ); - - if (validSeats.length === 0) { - return
No seats
; - } - - const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - const seatClassStr = - typeof selectedCoachData?.seatClass === "string" - ? selectedCoachData.seatClass - : selectedCoachData?.seatClass?.name || ""; - - // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with - // the single ladder that actually serves the whole bay shown once at the end. - const renderBerthBay = (beds: any[], keyPrefix: string) => ( -
- {beds.map((bed: any) => ( - - ))} - {beds.length > 1 && } -
- ); - - // Two-side compartment: the left bay and right bay each get their own row (berths - // still laid out horizontally within a row), stacked one above the other and split - // by a dashed aisle divider — instead of squeezing both sides into a single row. - const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( -
-
- {leftBay.length > 0 && ( -
{renderBerthBay(leftBay, `${key}-left`)}
- )} - {leftBay.length > 0 && rightBay.length > 0 && ( -
- )} - {rightBay.length > 0 && ( -
{renderBerthBay(rightBay, `${key}-right`)}
- )} -
-
- ); - - // Bay position ordering + left/right side detection shared by both bed layouts below. - const BERTH_ORDER = ["lower", "middle", "upper"]; - const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { - if (bed.position === "LEFT") return true; - if (bed.position === "RIGHT") return false; - const leftCol = leftColByPosition[bed.bedPosition]; - return leftCol ? bed.col === leftCol : true; - }; - - // Bed coach with bed positions (Upper, Middle, Lower) - if (isBedCoach && hasBedPositionData) { - // Check if this is VIP_BED or ECONOMY_BED based on room data - const rooms = (coach as any).rooms || []; - const hasRooms = rooms.length > 0; - - if (hasRooms) { - // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) - return ( -
- {rooms.map((room: any) => { - const isVipBed = - room.category === "VIP_BED" || room.totalBeds === 4; - const isEconomyBed = - room.category === "ECONOMY_BED" || room.totalBeds === 6; - - // Sort beds by position and column - const sortedBeds = [...(room.beds || [])].sort((a, b) => { - const posOrder = { upper: 3, middle: 2, lower: 1 }; - const posA = - posOrder[a.bedPosition as keyof typeof posOrder] || 0; - const posB = - posOrder[b.bedPosition as keyof typeof posOrder] || 0; - if (posA !== posB) return posA - posB; - return (a.col || "").localeCompare(b.col || ""); - }); - - return ( -
- {/* Room Header */} -
-
-

- Room {room.roomNumber} -

-

- {room.category === "VIP_BED" - ? "VIP BED" - : room.category === "ECONOMY_BED" - ? "ECONOMY BED" - : room.category} -

-
-
- {room.totalBeds} beds -
-
- - {/* Legend */} -
-
-
- - Available - -
-
-
- - Booked - -
-
- - {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} - {isVipBed && (() => { - const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); - const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); - const isLeft = (bed: any, idx: number) => - bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; - - const leftBay = [lowerBeds, upperBeds] - .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) - .filter(Boolean); - const rightBay = [lowerBeds, upperBeds] - .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) - .filter(Boolean); - - return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); - })()} - - {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} - {isEconomyBed && (() => { - const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; - const leftBay = BERTH_ORDER - .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) - .filter(Boolean); - const rightBay = BERTH_ORDER - .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) - .filter(Boolean); - - return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); - })()} -
- ); - })} -
- ); - } - - // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), - // then pair adjacent bays into two-side compartments, same as the room-based layouts. - const seatGroups = new Map(); - - for (const seat of validSeats) { - const baseNumber = seat.seatNumber || seat.number || seat.label || ""; - if (!seatGroups.has(baseNumber)) { - seatGroups.set(baseNumber, []); - } - seatGroups.get(baseNumber)!.push(seat); - } - - const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { - const numA = parseInt(a) || 0; - const numB = parseInt(b) || 0; - return numA - numB; - }); - - const bays = sortedGroups - .map(([, beds]) => - BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), - ) - .filter((bay) => bay.length > 0); - - return ( -
- {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { - const leftBay = bays[i * 2] || []; - const rightBay = bays[i * 2 + 1] || []; - return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); - })} -
- ); - } - - // Regular seats with row/column arrangement - const rowMap = new Map(); - for (const seat of validSeats) { - if (!rowMap.has(seat.row)) { - rowMap.set(seat.row, []); - } - rowMap.get(seat.row)!.push(seat); - } - - const rows = Array.from(rowMap.entries()) - .sort(([a], [b]) => a - b) - .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); - - return ( -
- {rows.map((rowSeats: any[], rowIdx: number) => { - const groups: any[][] = []; - - // Split seats into groups based on arrangement - if (arrangement.length === 1) { - // Single group (all seats together) - groups.push(rowSeats); - } else { - // Multiple groups with aisle separation - arrangement.forEach((_groupSize, groupIdx) => { - const startIdx = arrangement - .slice(0, groupIdx) - .reduce((sum, size) => sum + size, 0); - const endIdx = arrangement - .slice(0, groupIdx + 1) - .reduce((sum, size) => sum + size, 0); - const currentGroup = rowSeats.slice(startIdx, endIdx); - if (currentGroup.length > 0) groups.push(currentGroup); - }); - } - - const rowNumber = rowSeats[0]?.row || 1; - const shouldFlipArmchair = rowNumber % 2 === 0; - const showSpacing = rowIdx % 2 === 1; - - return ( -
- {shouldFlipArmchair && ( -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => { - const seatLabel = - seat.label || seat.number || seat.seatNumber || ""; - return ( -
- {seatLabel} -
- ); - })} -
- ))} -
- )} -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => ( - - ))} -
- ))} -
- - {!shouldFlipArmchair && ( -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => { - const seatLabel = - seat.label || seat.number || seat.seatNumber || ""; - return ( -
- {seatLabel} -
- ); - })} -
- ))} -
- )} - - {showSpacing && ( -
- )} -
- ); - })} -
- ); - }; - if ( isRoundTrip ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length @@ -2414,7 +1865,14 @@ export default function SeatsPage() { No seats in this coach

) : ( - renderCoachSeats(selectedCoachData, isBedCoach) + )}
diff --git a/apps/edr-passenger-web/portal/src/components/SeatMap.tsx b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx new file mode 100644 index 000000000..94fbe7c98 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx @@ -0,0 +1,706 @@ +"use client"; + +import { memo } from "react"; +import Image from "next/image"; +import { Armchair, Bed } from "lucide-react"; + +/** + * The seat map shared by the booking flow (`/booking/seats`) and the reschedule flow + * (`/booking/reschedule`). Everything here is presentational: it takes a coach from + * `GET /seats/seatmap/:scheduleId` and three callbacks, and knows nothing about bookings, + * passengers, holds or fares. Both pages must render seats identically, so this is the one + * copy — extend it rather than forking a second layout. + */ + +export const BED_POSITION_SUFFIX: Record = { + lower: "L", + middle: "M", + upper: "U", +}; + +export const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ""; + if (!base) return ""; + const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? "") : ""; + return suffix ? `${base}${suffix}` : base; +}; + +/** "Economy Bed - Upper" → "upper". Null when the class names no berth level. */ +export const getBedPosition = (selectedClass: string): string | null => { + const lowerClass = selectedClass.toLowerCase(); + if (lowerClass.includes("upper")) return "upper"; + if (lowerClass.includes("middle")) return "middle"; + if (lowerClass.includes("lower")) return "lower"; + return null; +}; + +export const isBedCoachData = (coachData: any): boolean => + coachData?.isBedCoach === true || + coachData?.rooms?.length > 0 || + (coachData?.seats || []).some((s: any) => s.bedPosition) || + coachData?.seatClass?.toLowerCase().includes("bed") || + coachData?.mode?.toLowerCase().includes("bed"); + +/** + * Flattens a coach into the seats that are actually selectable: beds out of `rooms` when the + * coach has them, otherwise `seats`. Placeholder rows (labels starting "-") are dropped, and + * on a bed coach a berth-specific fare class narrows the list to that level. + */ +export const getValidSeatsForCoach = ( + coachData: any, + selectedSeatClass?: string | null, +): any[] => { + if (!coachData) return []; + + if (coachData.rooms?.length > 0) { + const allBeds: any[] = []; + coachData.rooms.forEach((room: any) => { + if (room.beds) allBeds.push(...room.beds); + }); + + let beds = allBeds.filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + if (isBedCoachData(coachData) && selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSeatClass); + if (selectedBedPosition) { + beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + return beds; + } + + let seats = (coachData.seats || []).filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + if (isBedCoachData(coachData) && selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSeatClass); + if (selectedBedPosition) { + seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + return seats; +}; + +/** "3+2" → [3, 2] so the aisle gap lands between the groups. Bed coaches collapse to one column. */ +export const parseSeatArrangement = ( + arrangement: string | null, + seatClasses?: (string | undefined)[], +): number[] => { + if (!arrangement) return [2, 2]; + + const isBedCoach = seatClasses?.some((sc) => sc?.toLowerCase().includes("bed")); + + if (isBedCoach) { + // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically — + // render them as a single column. + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); + return parts.length > 0 ? [Math.max(...parts)] : [3]; + } + + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); + return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; +}; + +export const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => { + const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; + const bedPosition = bed.bedPosition || ""; + const bedType = + bedPosition === "upper" ? "Upper" : bedPosition === "middle" ? "Middle" : "Lower"; + const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther; + + return ( + + ); +}); + +BedCard.displayName = "BedCard"; + +// A real berth ladder is a single fixed rail mounted at the end of the bay that a +// passenger climbs to reach every level — not a separate rung floating between each +// pair of beds. So this renders once per bay, right after the last berth card, with +// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. +export const LadderConnector = memo(() => ( + +)); + +LadderConnector.displayName = "LadderConnector"; + +export const SeatButton = memo( + ({ seat, isSelected, isAssignedToOther, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; + const bedWidth = "w-24"; + const width = isBedCoach ? bedWidth : "w-10"; + const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther; + + return ( +
+ +
+ ); + }, +); + +SeatButton.displayName = "SeatButton"; + +/** Available / Selected / Booked swatches, shown above every expanded coach. */ +export function SeatLegend() { + return ( +
+ {[ + { color: "bg-green-50 border border-green-300", label: "Available" }, + { color: "bg-blue-50 border-2 border-blue-500", label: "Selected" }, + { color: "bg-red-50 border border-red-300", label: "Booked" }, + ].map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+ ); +} + +export interface CoachSeatLayoutProps { + coach: any; + isBedCoach: boolean; + /** Already filtered by `getValidSeatsForCoach` — the caller owns berth-class narrowing. */ + seats: any[]; + isSeatSelected: (seatId: string) => boolean; + isSeatAssignedToOther: (seatId: string) => boolean; + onSeatToggle: (seatId: string) => void; +} + +/** + * The seat grid for one coach. Four layouts, picked off the coach's own shape: + * room-based VIP (4 berths), room-based Economy (6 berths), room-less berth bays, and + * regular rows with the aisle gap from `seatArrangement`. + */ +export function CoachSeatLayout({ + coach, + isBedCoach, + seats: validSeats, + isSeatSelected, + isSeatAssignedToOther, + onSeatToggle, +}: CoachSeatLayoutProps) { + const arrangement = parseSeatArrangement( + coach?.seatArrangement, + coach?.seatClasses || [coach?.seatClass], + ); + + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const seatClassStr = + typeof coach?.seatClass === "string" ? coach.seatClass : coach?.seatClass?.name || ""; + + // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with + // the single ladder that actually serves the whole bay shown once at the end. + const renderBerthBay = (beds: any[], keyPrefix: string) => ( +
+ {beds.map((bed: any) => ( + + ))} + {beds.length > 1 && } +
+ ); + + // Two-side compartment: the left bay and right bay each get their own row (berths + // still laid out horizontally within a row), stacked one above the other and split + // by a dashed aisle divider — instead of squeezing both sides into a single row. + const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( +
+
+ {leftBay.length > 0 && ( +
{renderBerthBay(leftBay, `${key}-left`)}
+ )} + {leftBay.length > 0 && rightBay.length > 0 && ( +
+ )} + {rightBay.length > 0 && ( +
{renderBerthBay(rightBay, `${key}-right`)}
+ )} +
+
+ ); + + // Bay position ordering + left/right side detection shared by both bed layouts below. + const BERTH_ORDER = ["lower", "middle", "upper"]; + const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { + if (bed.position === "LEFT") return true; + if (bed.position === "RIGHT") return false; + const leftCol = leftColByPosition[bed.bedPosition]; + return leftCol ? bed.col === leftCol : true; + }; + + if (isBedCoach && hasBedPositionData) { + const rooms = (coach as any)?.rooms || []; + + if (rooms.length > 0) { + // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) + return ( +
+ {rooms.map((room: any) => { + const isVipBed = room.category === "VIP_BED" || room.totalBeds === 4; + const isEconomyBed = room.category === "ECONOMY_BED" || room.totalBeds === 6; + + const sortedBeds = [...(room.beds || [])].sort((a, b) => { + const posOrder = { upper: 3, middle: 2, lower: 1 }; + const posA = posOrder[a.bedPosition as keyof typeof posOrder] || 0; + const posB = posOrder[b.bedPosition as keyof typeof posOrder] || 0; + if (posA !== posB) return posA - posB; + return (a.col || "").localeCompare(b.col || ""); + }); + + return ( +
+ {/* Room Header */} +
+
+

+ Room {room.roomNumber} +

+

+ {room.category === "VIP_BED" + ? "VIP BED" + : room.category === "ECONOMY_BED" + ? "ECONOMY BED" + : room.category} +

+
+
+ {room.totalBeds} beds +
+
+ + {/* Legend */} +
+
+
+ Available +
+
+
+ Booked +
+
+ + {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} + {isVipBed && (() => { + const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); + const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); + const isLeft = (bed: any, idx: number) => + bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; + + const leftBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) + .filter(Boolean); + const rightBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) + .filter(Boolean); + + return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); + })()} + + {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} + {isEconomyBed && (() => { + const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; + const leftBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + const rightBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + + return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); + })()} +
+ ); + })} +
+ ); + } + + // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), + // then pair adjacent bays into two-side compartments, same as the room-based layouts. + const seatGroups = new Map(); + for (const seat of validSeats) { + const baseNumber = seat.seatNumber || seat.number || seat.label || ""; + if (!seatGroups.has(baseNumber)) seatGroups.set(baseNumber, []); + seatGroups.get(baseNumber)!.push(seat); + } + + const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { + const numA = parseInt(a) || 0; + const numB = parseInt(b) || 0; + return numA - numB; + }); + + const bays = sortedGroups + .map(([, beds]) => + BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), + ) + .filter((bay) => bay.length > 0); + + return ( +
+ {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { + const leftBay = bays[i * 2] || []; + const rightBay = bays[i * 2 + 1] || []; + return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); + })} +
+ ); + } + + // Regular seats with row/column arrangement + const rowMap = new Map(); + for (const seat of validSeats) { + if (!rowMap.has(seat.row)) rowMap.set(seat.row, []); + rowMap.get(seat.row)!.push(seat); + } + + const rows = Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); + + const renderSeatNumberStrip = (groups: any[][], keyPrefix: string) => ( +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( +
+ {seat.label || seat.number || seat.seatNumber || ""} +
+ ))} +
+ ))} +
+ ); + + return ( +
+ {rows.map((rowSeats: any[], rowIdx: number) => { + const groups: any[][] = []; + + if (arrangement.length === 1) { + groups.push(rowSeats); + } else { + arrangement.forEach((_groupSize, groupIdx) => { + const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0); + const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0); + const currentGroup = rowSeats.slice(startIdx, endIdx); + if (currentGroup.length > 0) groups.push(currentGroup); + }); + } + + const rowNumber = rowSeats[0]?.row || 1; + const shouldFlipArmchair = rowNumber % 2 === 0; + const showSpacing = rowIdx % 2 === 1; + + return ( +
+ {shouldFlipArmchair && renderSeatNumberStrip(groups, `before-${rowNumber}`)} + +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( + + ))} +
+ ))} +
+ + {!shouldFlipArmchair && renderSeatNumberStrip(groups, `after-${rowNumber}`)} + + {showSpacing &&
} +
+ ); + })} +
+ ); +} + +export interface SeatMapProps { + /** Coaches straight off `GET /seats/seatmap/:scheduleId`. */ + coaches: any[]; + selectedCoachId: string | null; + onSelectCoach: (coachId: string | null) => void; + /** Berth-level fare class, e.g. "Economy Bed - Upper". Narrows a bed coach to one level. */ + selectedSeatClass?: string | null; + isSeatSelected: (seatId: string) => boolean; + isSeatAssignedToOther: (seatId: string) => boolean; + onSeatToggle: (seatId: string) => void; + emptyLabel?: string; +} + +/** + * Coach accordion + legend + seat grid, styled as the train itself: coupling joints between + * cars, a brand stripe top and bottom, and per-coach availability bars. + */ +export default function SeatMap({ + coaches, + selectedCoachId, + onSelectCoach, + selectedSeatClass, + isSeatSelected, + isSeatAssignedToOther, + onSeatToggle, + emptyLabel = "No coach of your class on this train.", +}: SeatMapProps) { + if (!coaches || coaches.length === 0) { + return

{emptyLabel}

; + } + + return ( +
+ {coaches.map((coach: any, index: number) => { + const coachSeats = getValidSeatsForCoach(coach, selectedSeatClass); + const available = coachSeats.filter((s: any) => s.status === "AVAILABLE").length; + const total = coachSeats.length; + const isExpanded = selectedCoachId === coach.id; + const isBedCoach = isBedCoachData(coach); + const coachLabel = coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`; + + return ( +
+ {/* Coupling joint */} +
+
+
+
+
+
+
+ + {/* Coach car */} +
+ {/* Top colour stripe — brand rail */} +
+ + + + {/* Expanded seat map */} + {isExpanded && ( +
+ +
+
+ {coachSeats.length === 0 ? ( +

No seats in this coach

+ ) : ( + + )} +
+
+
+ )} + + {/* Bottom colour stripe */} +
+
+
+ ); + })} +
+ ); +} + +function ChevronDownIcon({ isExpanded }: { isExpanded: boolean }) { + return ( + + + + ); +} From 2c9453f87d647517ded02e9c724a1c8cfb77334f Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 24 Aug 2026 10:44:28 +0300 Subject: [PATCH 04/28] feat: ( reschedule ) manage reschedule policies from a Master Data page with full CRUD --- .../reschedule/reschedule.controller.ts | 35 +- .../src/modules/reschedule/reschedule.dto.ts | 7 + .../modules/reschedule/reschedule.service.ts | 87 +++- .../src/app/reschedule-policies/layout.tsx | 5 + .../src/app/reschedule-policies/page.tsx | 26 ++ .../backoffice/src/app/settings/page.tsx | 128 +----- .../src/components/layout/Sidebar.tsx | 2 + .../reschedule/ReschedulePolicyManager.tsx | 376 ++++++++++++++++++ .../backoffice/src/lib/api/index.ts | 42 +- 9 files changed, 554 insertions(+), 154 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts index 8024556a9..0aba110d1 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.controller.ts @@ -1,10 +1,15 @@ -import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { JwtGuard } from '../../common/jwt.guard'; import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards'; import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; import { RescheduleService } from './reschedule.service'; -import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto'; +import { + CreateReschedulePolicyDto, + CreateRescheduleDto, + RescheduleQuoteDto, + UpdateReschedulePolicyDto, +} from './reschedule.dto'; @ApiTags('Reschedule') @Controller() @@ -14,11 +19,27 @@ export class RescheduleController { @Get('reschedule/policies') @PassengerStaff(PASSENGER_PERMS.bookings.view) @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Reschedule policy per coach type (fare class)' }) + @ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' }) listPolicies() { return this.service.listPolicies(); } + @Get('reschedule/policies/available-coach-types') + @PassengerStaff(PASSENGER_PERMS.bookings.view) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' }) + listUnconfiguredCoachTypes() { + return this.service.listUnconfiguredCoachTypes(); + } + + @Post('reschedule/policies') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' }) + createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) { + return this.service.createPolicy(dto, req.user?.id); + } + @Patch('reschedule/policies/:coachTypeId') @PassengerAdmin() @ApiBearerAuth('JWT-auth') @@ -27,6 +48,14 @@ export class RescheduleController { return this.service.updatePolicy(coachTypeId, dto, req.user?.id); } + @Delete('reschedule/policies/:coachTypeId') + @PassengerAdmin() + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' }) + deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) { + return this.service.deletePolicy(coachTypeId, req.user?.id); + } + @Get('bookings/:bookingRef/reschedule') @UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth') diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts index d41fbc9a8..479896adf 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.dto.ts @@ -45,6 +45,13 @@ export class UpdateReschedulePolicyDto { isActive?: boolean; } +/** Same fields as the update DTO, plus the fare class the new policy attaches to. */ +export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto { + @ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' }) + @IsString() + coachTypeId: string; +} + export class RescheduleQuoteDto { @ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2) diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index 4b2019938..a0a8670f6 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ConflictException, ForbiddenException, Injectable, Logger, @@ -19,11 +20,33 @@ import { TicketsService } from '../tickets/tickets.service'; import { PaymentsService } from '../payments/payments.service'; import { SupplementaryChargesService } from '../payments/supplementary-charges.service'; import { CurrencyService } from '../currency/currency.service'; -import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto'; +import { + CreateReschedulePolicyDto, + CreateRescheduleDto, + RescheduleQuoteDto, + UpdateReschedulePolicyDto, +} from './reschedule.dto'; export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE'; export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid'; +/** + * Coaches nobody buys a seat in, so they can never carry a reschedule policy. + * + * Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' | + * 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space + * included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the + * dining coach as a fare class. This mirrors the portal's own test (`/dining|dpc/i`, + * booking/seats/page.tsx) and checks `code` as well as `type`. + */ +const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage']; +const NOT_A_FARE_CLASS = { + NOT: NON_FARE_COACH_TERMS.flatMap((term) => [ + { type: { contains: term, mode: 'insensitive' as const } }, + { code: { contains: term, mode: 'insensitive' as const } }, + ]), +}; + type PolicyNumbers = { feePercent: number; feeMinMinor: number; @@ -90,18 +113,62 @@ export class RescheduleService { // ── Policy admin ───────────────────────────────────────────────────────── + /** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */ async listPolicies() { - const coachTypes = await this.prisma.coachType.findMany({ - where: { type: { notIn: ['dining', 'baggage'] } }, - include: { reschedulePolicy: true }, + return this.prisma.reschedulePolicy.findMany({ + include: { coachType: { select: { id: true, code: true, name: true, type: true } } }, + orderBy: { coachType: { code: 'asc' } }, + }); + } + + /** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */ + async listUnconfiguredCoachTypes() { + return this.prisma.coachType.findMany({ + where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } }, + select: { id: true, code: true, name: true, type: true }, orderBy: { code: 'asc' }, }); - return coachTypes.map((ct) => ({ - coachTypeId: ct.id, - code: ct.code, - name: ct.name, - policy: ct.reschedulePolicy, - })); + } + + async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) { + const { coachTypeId, ...values } = dto; + const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } }); + if (!coachType) throw new NotFoundException('Coach type not found'); + if (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) { + throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`); + } + const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } }); + if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`); + + const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.CREATE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + newData: { coachTypeCode: coachType.code, ...values }, + }); + return policy; + } + + async deletePolicy(coachTypeId: string, actorId?: string) { + const policy = await this.prisma.reschedulePolicy.findUnique({ + where: { coachTypeId }, + include: { coachType: { select: { code: true } } }, + }); + if (!policy) throw new NotFoundException('Reschedule policy not found'); + + await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } }); + await this.auditService.log({ + userId: actorId, + action: AUDIT_ACTIONS.DELETE, + entityType: AUDIT_ENTITIES.ReschedulePolicy, + entityId: policy.id, + oldData: policy, + }); + // Rescheduling for this fare class is now refused outright (legBlockers treats a missing + // policy the same as an inactive one), which is the intended effect of deleting it. + return { deleted: true, coachTypeId }; } async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) { diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx new file mode 100644 index 000000000..8e7a60fce --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../dashboard/layout'; + +export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx new file mode 100644 index 000000000..31f265b6c --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reschedule-policies/page.tsx @@ -0,0 +1,26 @@ +'use client'; + +import ReschedulePolicyManager from '@/components/reschedule/ReschedulePolicyManager'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; + +/** + * Master Data → Reschedule Policies. One policy per fare class (coach type); a class with no + * policy cannot be rescheduled at all. Gated on bookings:view because that is what + * `GET /reschedule/policies` requires; creating, editing and deleting are admin-only server-side. + */ +export default function ReschedulePoliciesPage() { + return ( + +
+
+

Reschedule Policies

+

+ Rules that decide whether a booked journey can be moved, and what the change costs +

+
+ +
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx index a97254898..90725b683 100644 --- a/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/settings/page.tsx @@ -2,9 +2,9 @@ import { useState, useEffect } from 'react'; import { Save } from 'lucide-react'; -import { systemConfigApi, reschedulePolicyApi, type ReschedulePolicyRow } from '@/lib/api'; +import { systemConfigApi } from '@/lib/api'; -type Tab = 'general' | 'payment' | 'integrations' | 'configurations' | 'reschedule'; +type Tab = 'general' | 'payment' | 'integrations' | 'configurations'; export default function SettingsPage() { const [activeTab, setActiveTab] = useState('general'); @@ -57,7 +57,6 @@ export default function SettingsPage() { const tabs: { id: Tab; label: string }[] = [ { id: 'general', label: 'General' }, { id: 'configurations', label: 'Configurations' }, - { id: 'reschedule', label: 'Reschedule Policy' }, ]; return ( @@ -112,7 +111,6 @@ export default function SettingsPage() {
)} - {activeTab === 'reschedule' && } {activeTab === 'configurations' && (
@@ -227,125 +225,3 @@ export default function SettingsPage() {
); } - -type PolicyForm = NonNullable; - -const EMPTY_POLICY: PolicyForm = { - feePercent: 0, feeMinMinor: 0, routeChangeAllowed: true, sameDayAllowed: true, - sameDayFeePercent: 0, sameDayFeeMinMinor: 0, cutoffMinutes: 60, isActive: true, -}; - -/** Policy §3 — one editable row per fare class (HSC = Standard, HBC = Flex, SBC = Premium). Money is entered in ETB, stored in minor units. */ -function ReschedulePolicyTab() { - const [rows, setRows] = useState([]); - const [forms, setForms] = useState>({}); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(null); - const [message, setMessage] = useState(''); - - useEffect(() => { - reschedulePolicyApi.list() - .then((data) => { - const list = Array.isArray(data) ? data : []; - setRows(list); - setForms(Object.fromEntries(list.map((r) => [r.coachTypeId, { ...EMPTY_POLICY, ...(r.policy ?? {}) }]))); - }) - .catch(() => setMessage('Failed to load policies.')) - .finally(() => setLoading(false)); - }, []); - - const setField = (id: string, patch: Partial) => - setForms((f) => ({ ...f, [id]: { ...f[id], ...patch } })); - - const save = async (id: string) => { - setSaving(id); - setMessage(''); - try { - await reschedulePolicyApi.update(id, forms[id]); - setMessage('Saved.'); - } catch { - setMessage('Failed to save.'); - } finally { - setSaving(null); - } - }; - - const etb = (minor: number) => String(minor / 100); - const minor = (etbValue: string) => Math.round(Number(etbValue || 0) * 100); - - if (loading) return

Loading...

; - - return ( -
-
-

Rescheduling rules per fare class

-

- Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is not refunded. - Same-day = new departure on the same calendar day as the original. -

-
- {rows.map((r) => { - const f = forms[r.coachTypeId]; - return ( -
-
-
- {r.code} - — {r.name} - {!r.policy && no policy yet (rescheduling disabled)} -
- -
-
-
- - setField(r.coachTypeId, { feePercent: Number(e.target.value) })} /> -
-
- - setField(r.coachTypeId, { feeMinMinor: minor(e.target.value) })} /> -
-
- - setField(r.coachTypeId, { cutoffMinutes: Number(e.target.value) })} /> -
-
- - -
-
- - -
-
- - setField(r.coachTypeId, { sameDayFeePercent: Number(e.target.value) })} /> -
-
- - setField(r.coachTypeId, { sameDayFeeMinMinor: minor(e.target.value) })} /> -
-
- -
-
-
- ); - })} - {rows.length === 0 &&

No passenger coach types found.

} - {message && {message}} -
- ); -} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index de8619577..3b74eb7dc 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -28,6 +28,7 @@ import { FileText, Briefcase, Calendar, + CalendarClock, Utensils, Package, Moon, @@ -88,6 +89,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, + { name: 'Reschedule Policies', href: '/reschedule-policies', icon: CalendarClock, permission: PERMS.bookings.view }, ] }, { diff --git a/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx new file mode 100644 index 000000000..efc1ca5d2 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/reschedule/ReschedulePolicyManager.tsx @@ -0,0 +1,376 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Edit, Plus, Save, Trash2 } from 'lucide-react'; +import DataTable from '@/components/ui/DataTable'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; +import ConfirmDialog from '@/components/ui/ConfirmDialog'; +import { + reschedulePolicyApi, + type ReschedulePolicyCoachType, + type ReschedulePolicyRow, + type ReschedulePolicyValues, +} from '@/lib/api'; + +const EMPTY_POLICY: ReschedulePolicyValues = { + feePercent: 0, + feeMinMinor: 0, + routeChangeAllowed: true, + sameDayAllowed: true, + sameDayFeePercent: 0, + sameDayFeeMinMinor: 0, + cutoffMinutes: 60, + isActive: true, +}; + +// Money is entered in ETB and stored in minor units. +const etb = (minor: number) => String(minor / 100); +const toMinor = (value: string) => Math.round(Number(value || 0) * 100); +const feeLabel = (percent: number, minMinor: number) => + percent > 0 || minMinor > 0 ? `${percent}% · min ETB ${etb(minMinor)}` : 'Free'; + +/** + * Policy §3 - one policy per fare class (coach type), listed as a table and edited in a dialog, + * the same shape as Coach Management. A fare class with no row here cannot be rescheduled at all. + */ +export default function ReschedulePolicyManager() { + const [rows, setRows] = useState([]); + const [available, setAvailable] = useState([]); + const [loading, setLoading] = useState(true); + const [message, setMessage] = useState(''); + + const [showModal, setShowModal] = useState(false); + const [editing, setEditing] = useState(null); + const [coachTypeId, setCoachTypeId] = useState(''); + const [form, setForm] = useState(EMPTY_POLICY); + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(''); + + const [deleting, setDeleting] = useState(null); + const [deleteBusy, setDeleteBusy] = useState(false); + + const load = async () => { + setLoading(true); + try { + const [policies, coachTypes] = await Promise.all([ + reschedulePolicyApi.list(), + reschedulePolicyApi.availableCoachTypes(), + ]); + setRows(Array.isArray(policies) ? policies : []); + setAvailable(Array.isArray(coachTypes) ? coachTypes : []); + } catch { + setMessage('Failed to load reschedule policies.'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const openCreate = () => { + setEditing(null); + setCoachTypeId(''); + setForm(EMPTY_POLICY); + setFormError(''); + setShowModal(true); + }; + + const openEdit = (row: ReschedulePolicyRow) => { + setEditing(row); + setCoachTypeId(row.coachTypeId); + setForm({ + feePercent: row.feePercent, + feeMinMinor: row.feeMinMinor, + routeChangeAllowed: row.routeChangeAllowed, + sameDayAllowed: row.sameDayAllowed, + sameDayFeePercent: row.sameDayFeePercent, + sameDayFeeMinMinor: row.sameDayFeeMinMinor, + cutoffMinutes: row.cutoffMinutes, + isActive: row.isActive, + }); + setFormError(''); + setShowModal(true); + }; + + const setField = (patch: Partial) => setForm((f) => ({ ...f, ...patch })); + + const submit = async () => { + if (!editing && !coachTypeId) { + setFormError('Pick a fare class.'); + return; + } + setSaving(true); + setFormError(''); + try { + if (editing) await reschedulePolicyApi.update(editing.coachTypeId, form); + else await reschedulePolicyApi.create({ coachTypeId, ...form }); + setShowModal(false); + setMessage(editing ? 'Policy updated.' : 'Policy created.'); + await load(); + } catch (err: any) { + setFormError(err?.response?.data?.message || err?.message || 'Failed to save the policy.'); + } finally { + setSaving(false); + } + }; + + const confirmDelete = async () => { + if (!deleting) return; + setDeleteBusy(true); + try { + await reschedulePolicyApi.remove(deleting.coachTypeId); + setDeleting(null); + setMessage('Policy deleted.'); + await load(); + } catch { + setMessage('Failed to delete the policy.'); + } finally { + setDeleteBusy(false); + } + }; + + const columns = [ + { + key: 'coachType', + label: 'Fare class', + render: (row: ReschedulePolicyRow) => ( +
+ {row.coachType?.code} + - {row.coachType?.name} +
+ ), + }, + { + key: 'fee', + label: 'Change fee', + render: (row: ReschedulePolicyRow) => ( + {feeLabel(row.feePercent, row.feeMinMinor)} + ), + }, + { + key: 'routeChangeAllowed', + label: 'Route change', + render: (row: ReschedulePolicyRow) => ( + + {row.routeChangeAllowed ? 'Allowed' : 'Not permitted'} + + ), + }, + { + key: 'sameDay', + label: 'Same-day change', + render: (row: ReschedulePolicyRow) => + row.sameDayAllowed ? ( + {feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)} + ) : ( + Not permitted + ), + }, + { + key: 'cutoffMinutes', + label: 'Cutoff', + render: (row: ReschedulePolicyRow) => ( + {row.cutoffMinutes} min + ), + }, + { + key: 'isActive', + label: 'Status', + render: (row: ReschedulePolicyRow) => ( + + {row.isActive ? 'Active' : 'Disabled'} + + ), + }, + ]; + + const actions = [ + { label: 'Edit', onClick: openEdit, variant: 'secondary' as const, icon: Edit }, + { + label: 'Delete', + onClick: (row: ReschedulePolicyRow) => setDeleting(row), + variant: 'danger' as const, + icon: Trash2, + }, + ]; + + return ( +
+
+ {/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */} +

+ Fee = max(fee % × original leg fare, minimum). A higher new fare is always charged on top; a lower one is + not refunded. Same-day = new departure on the same calendar day as the original. A fare class with no + policy here cannot be rescheduled at all. +

+ + Add Reschedule Policy + +
+ + {!loading && available.length === 0 && ( +

Every fare class already has a policy.

+ )} + {message &&

{message}

} + + + + setShowModal(false)} + title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'} + size="lg" + > +
+
+ + {editing ? ( + <> + + {/* One policy per fare class, so editing never re-points a row at another class. */} +

A policy stays attached to its fare class.

+ + ) : ( + + )} +
+ +
+
+ + setField({ feePercent: Number(e.target.value) })} + /> +
+
+ + setField({ feeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + setField({ cutoffMinutes: Number(e.target.value) })} + /> +
+
+ + +
+
+ + +
+
+ + +
+
+ + setField({ sameDayFeePercent: Number(e.target.value) })} + /> +
+
+ + setField({ sameDayFeeMinMinor: toMinor(e.target.value) })} + /> +
+
+ + {formError &&

{formError}

} + +
+ setShowModal(false)}> + Cancel + + + {editing ? 'Update Policy' : 'Create Policy'} + +
+
+
+ + setDeleting(null)} + onConfirm={confirmDelete} + title="Delete reschedule policy" + message={`Delete the reschedule policy for ${deleting?.coachType?.code ?? ''}?`} + warning="Passengers on this fare class will no longer be able to reschedule. Bookings already rescheduled are unaffected." + confirmText="Delete" + isDanger + isLoading={deleteBusy} + /> +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts index b9b7d41f1..a5baf0071 100644 --- a/apps/edr-passenger-web/backoffice/src/lib/api/index.ts +++ b/apps/edr-passenger-web/backoffice/src/lib/api/index.ts @@ -527,26 +527,38 @@ export const systemConfigApi = { update: (data: Record) => apiClient.patch>('/config', data), }; -// Reschedule Policy API (one row per coach type = fare class) -export interface ReschedulePolicyRow { - coachTypeId: string; +// Reschedule Policy API — one policy per coach type (fare class). A coach type with no policy +// simply has no row, and rescheduling is refused for it. +export interface ReschedulePolicyValues { + feePercent: number; + feeMinMinor: number; + routeChangeAllowed: boolean; + sameDayAllowed: boolean; + sameDayFeePercent: number; + sameDayFeeMinMinor: number; + cutoffMinutes: number; + isActive: boolean; +} +export interface ReschedulePolicyCoachType { + id: string; code: string; name: string; - policy: { - feePercent: number; - feeMinMinor: number; - routeChangeAllowed: boolean; - sameDayAllowed: boolean; - sameDayFeePercent: number; - sameDayFeeMinMinor: number; - cutoffMinutes: number; - isActive: boolean; - } | null; + type: string; +} +export interface ReschedulePolicyRow extends ReschedulePolicyValues { + id: string; + coachTypeId: string; + coachType: ReschedulePolicyCoachType; } export const reschedulePolicyApi = { list: () => apiClient.get('/reschedule/policies'), - update: (coachTypeId: string, data: Partial>) => - apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), + availableCoachTypes: () => + apiClient.get('/reschedule/policies/available-coach-types'), + create: (data: ReschedulePolicyValues & { coachTypeId: string }) => + apiClient.post('/reschedule/policies', data), + update: (coachTypeId: string, data: Partial) => + apiClient.patch(`/reschedule/policies/${coachTypeId}`, data), + remove: (coachTypeId: string) => apiClient.delete(`/reschedule/policies/${coachTypeId}`), }; // App Releases API From 46fa17b34114e65981c8c131e7ff30a89da07490 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 26 Aug 2026 09:19:53 +0300 Subject: [PATCH 05/28] feat: (reschedule) drop the staff override so only the booker can reschedule --- .../src/common/utils/phone.utils.ts | 25 +++++++++++ .../modules/reschedule/reschedule.service.ts | 43 +++++++++++++++++-- .../portal/src/app/booking/detail/page.tsx | 41 ++++++++++++++++-- .../src/app/booking/reschedule/page.tsx | 22 +++++++++- 4 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/utils/phone.utils.ts diff --git a/apps/edr-passenger-api/src/common/utils/phone.utils.ts b/apps/edr-passenger-api/src/common/utils/phone.utils.ts new file mode 100644 index 000000000..808f8df22 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/phone.utils.ts @@ -0,0 +1,25 @@ +/** + * Phone numbers reach us in every shape the UI allows — `+251912345678`, `0912345678`, + * `912345678`, and the same again with spaces or dashes. Comparing two of them as raw strings + * is a coin flip, so anything that decides access on a phone number must normalise first. + * + * Mirrors `PassengerAuthService.standardizePhone`, plus the bare-9-digit case the passenger + * form produces (its input sits behind a fixed `+251` prefix control). + */ +export function normalizePhone(phone?: string | null): string | null { + if (!phone) return null; + const digits = phone.replace(/\D/g, ''); + if (!digits) return null; + if (digits.startsWith('251')) return `+${digits}`; + if (digits.startsWith('0')) return `+251${digits.slice(1)}`; + // A bare local subscriber number, e.g. "912345678" from the +251-prefixed input. + if (digits.length === 9) return `+251${digits}`; + return `+${digits}`; +} + +/** True only when both numbers are present and resolve to the same E.164 form. */ +export function samePhone(a?: string | null, b?: string | null): boolean { + const left = normalizePhone(a); + const right = normalizePhone(b); + return !!left && !!right && left === right; +} diff --git a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts index a0a8670f6..fdf10ca0a 100644 --- a/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts +++ b/apps/edr-passenger-api/src/modules/reschedule/reschedule.service.ts @@ -11,9 +11,9 @@ import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma.service'; import { AuditService } from '../../common/audit.service'; import { AUDIT_ACTIONS, AUDIT_ENTITIES } from '../../common/audit.actions'; -import { hasPassengerPermission, MeLikeUser } from '../../common/passenger-permission.util'; -import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry'; +import { MeLikeUser } from '../../common/passenger-permission.util'; import { computePaymentDeadline } from '../../common/utils/payment-deadline.utils'; +import { normalizePhone, samePhone } from '../../common/utils/phone.utils'; import { BookingsService } from '../bookings/bookings.service'; import { SeatsService } from '../seats/seats.service'; import { TicketsService } from '../tickets/tickets.service'; @@ -75,7 +75,7 @@ export function addisDay(d: Date): string { return d.toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' }); } -type ActingUser = MeLikeUser & { id?: string; sub?: string }; +type ActingUser = MeLikeUser & { id?: string; sub?: string; phoneNumber?: string }; type LegView = { leg: number; @@ -443,17 +443,52 @@ export class RescheduleService { // ── Internals ──────────────────────────────────────────────────────────── + /** + * Who may act on this booking: only the person who made it, proven by their account's phone + * number matching the booking's `contactPhone`. Being merely *named* on the booking is not + * enough — a passenger travelling on someone else's booking cannot move it. + * + * There is deliberately no staff override. The `bookings:reschedule` permission still exists in + * the registry (and on the stationMaster preset) but is not honoured here, so a station master + * cannot reschedule on a customer's behalf yet. To restore it, re-import + * `hasPassengerPermission` / `PASSENGER_PERMS` and return the booking early when the caller + * holds `PASSENGER_PERMS.bookings.reschedule`. + */ private async loadOwnedBooking(bookingRef: string, user: ActingUser) { const booking = await this.prisma.booking.findUnique({ where: { bookingRef }, include: bookingInclude }); if (!booking) throw new NotFoundException('Booking not found'); const iamUserId = user.id ?? user.sub; if (!iamUserId) throw new ForbiddenException(); - if (hasPassengerPermission(user, PASSENGER_PERMS.bookings.reschedule)) return booking; + + if (booking.contactPhone) { + const callerPhone = await this.resolveUserPhone(iamUserId, user); + if (samePhone(callerPhone, booking.contactPhone)) return booking; + throw new ForbiddenException( + 'Only the person who made this booking can reschedule it. Sign in with the phone number used to book.', + ); + } + + // ~0.3% of bookings (72 of 24.7k on dev) carry no contactPhone at all, so there is nothing to + // match against. Fall back to the account link rather than locking their owner out entirely. const passenger = await this.prisma.passenger.findUnique({ where: { iamUserId }, select: { id: true } }); if (!passenger || passenger.id !== booking.passengerId) throw new ForbiddenException('Not your booking'); return booking; } + /** + * The signed-in user's phone. The session snapshot (`userInfo.phoneNumber`) is frequently an + * empty string, so `iam.users` is the source of truth — and reading it live also means a user + * who changed their number does not have to sign out before the new one counts. + */ + private async resolveUserPhone(iamUserId: string, user: ActingUser): Promise { + const fromSession = normalizePhone(user.phoneNumber); + if (fromSession) return fromSession; + const rows = await this.prisma.$queryRaw<{ phone_number: string | null }[]>` + SELECT phone_number FROM iam.users WHERE id = ${iamUserId}::uuid LIMIT 1 + `; + return normalizePhone(rows[0]?.phone_number); + } + private legsOf(booking: any): LegView[] { const legs: LegView[] = []; const seatsOf = (n: number) => diff --git a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx index 34f5b59ec..58372ca60 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/detail/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect"; import { useEffect, useState } from "react"; import { @@ -78,6 +79,12 @@ function BookingDetailContent() { searchParams.get("bookingRef") || searchParams.get("pnr"); + // `isInitialized` gates on the auth store having read localStorage. Without it a signed-in + // user watches the Reschedule button appear a beat after the page, because the store starts + // every render as logged-out. AppSidebar calls initialize() from the root layout. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + // Mirrors /booking/payment's state shape: selectedMethod is the PaymentMethod `type` // (used both for lookup and to decide provider-specific redirect handling), not the id. const [selectedMethod, setSelectedMethod] = useState(null); @@ -329,6 +336,16 @@ function BookingDetailContent() { const isExpired = booking.status === "EXPIRED"; const isCancelled = booking.status === "CANCELLED"; + // Whether this booking is the kind that can be rescheduled at all. The per-leg rules + // (fare-class policy, cutoff, already-boarded) are the API's call and are shown on the + // reschedule page itself; this is only the coarse shape test. + const bookingSupportsReschedule = + !booking.isPackageBooking && + ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && + !booking.outboundBoardedAt; + + const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`; + const StatusBadge = () => { const statusConfig = { PENDING_PAYMENT: { @@ -1022,13 +1039,29 @@ function BookingDetailContent() { )} - {!booking.isPackageBooking && ["ONE_WAY", "ROUND_TRIP"].includes(booking.bookingType) && !booking.outboundBoardedAt && ( + {/* Rescheduling is account-only: every /bookings/:ref/reschedule route sits behind + JwtGuard and resolves ownership from the signed-in IAM user. A guest who got + here through booking lookup (ref + phone) has no session, so instead of hiding + the option we name the blocker and send them somewhere that fixes it. */} + {bookingSupportsReschedule && ( )} diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 350df552a..8e58b8585 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -6,6 +6,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; import { AlertCircle, ArrowRight, CheckCircle2, ChevronLeft, Loader2 } from "lucide-react"; import { apiClient } from "@/lib/api-client"; +import { useAuthStore } from "@/lib/auth-store"; import ModernDatePicker from "@/components/ModernDatePicker"; import StationDropdown, { pushRecentStation, @@ -83,6 +84,20 @@ function ReschedulePageContent() { const searchParams = useSearchParams(); const ref = searchParams.get("ref") || ""; + // Every endpoint this page calls is behind JwtGuard, so a guest who deep-links here would + // otherwise watch the options request 401 and land on "This booking cannot be rescheduled" — + // which blames the booking for what is really a missing session. Send them to sign in and + // bring them straight back instead. Waits for isInitialized: the store starts logged-out. + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isAuthInitialized = useAuthStore((s) => s.isInitialized); + const needsLogin = isAuthInitialized && !isAuthenticated; + + useEffect(() => { + if (!needsLogin) return; + const back = ref ? `/booking/reschedule?ref=${ref}` : "/booking/lookup"; + router.replace(`/login?redirect=${encodeURIComponent(back)}`); + }, [needsLogin, ref, router]); + const [legNo, setLegNo] = useState(1); const [date, setDate] = useState(undefined); const [originId, setOriginId] = useState(""); @@ -113,7 +128,8 @@ function ReschedulePageContent() { const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ queryKey: ["reschedule-options", ref], queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), - enabled: !!ref, + // Never fire before the session is known — an unauthenticated call only 401s. + enabled: !!ref && isAuthInitialized && isAuthenticated, retry: false, }); const { data: stations = [] } = useQuery({ @@ -311,6 +327,10 @@ function ReschedulePageContent() { const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—"; if (!ref) return

Missing booking reference.

; + // Hold the spinner through the redirect rather than flashing the booking's error state. + if (!isAuthInitialized || needsLogin) { + return ; + } if (loadingOptions) return ; if (optionsError || !options || !leg) { return

{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}

; From c11704b15ca702d8022e5e4d14f40610001c2f23 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Wed, 26 Aug 2026 10:24:28 +0300 Subject: [PATCH 06/28] fix: ( reschedule ) stick the change summery card --- .../portal/src/app/booking/reschedule/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 8e58b8585..816d2547d 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -513,7 +513,11 @@ function ReschedulePageContent() {
)} -
+ {/* No `items-start` here on purpose: it shrinks each column to its own content height, and a + sticky child can only travel inside its containing block — so the summary would scroll + away like a normal card. The grid default (stretch) gives the right column the full row + height to stick within. */} +
{/* Left column — the choices */}
@@ -726,7 +730,9 @@ function ReschedulePageContent() { {/* Right column — sticky change summary (desktop only) */}
-
+ {/* Caps at the viewport and scrolls inside itself, so a long passenger list can't push + the total and the confirm button off the bottom of the screen. */} +
From 78c6f8be0a960b851d42bc607590c148cc45cd7f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:17:35 +0000 Subject: [PATCH 07/28] feat(bookings): show the invoice number in Pricing & payment The booking's freight invoice is looked up through the existing invoice list endpoint (source=booking, sourceId matched by search), newest first so a re-issue supersedes the old number. --- .../bookings/BookingPricingSummary.tsx | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx index 814fb9a95..0cf206350 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingPricingSummary.tsx @@ -1,12 +1,32 @@ import { Banknote, Receipt } from "lucide-react"; import { Divider, Group, Paper, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/services/api"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./detail/SectionCard"; import { detailStyles } from "./detail/booking-detail.styles"; export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + // The booking's own freight invoice: source `booking`, sourceId = booking id + // (which `search` matches). Newest first — a re-issue supersedes the old one. + const invoiceQuery = useQuery( + api.invoices.list.queryOptions({ + input: { + filter: { + page: 1, + pageSize: 1, + sources: "booking", + search: booking.id, + sortBy: "createdAt", + sortOrder: "DESC", + }, + }, + }), + ); + const invoiceNumber = invoiceQuery.data?.items[0]?.invoiceNumber ?? null; + const computed = Number(booking.totalAmount); // The booking price is computed from the contract and is NOT staff-editable. // A historical `adjustedTotalAmount` (from before adjustments were removed) @@ -49,6 +69,7 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { + {invoiceNumber && } {booking.pnrCode && } {lineItems.length > 0 && ( From a6b2519136b2dfe90fe0702337ce3b87db14f0a4 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:17:41 +0000 Subject: [PATCH 08/28] feat(billing): filter invoices and manual payments by invoice type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a free-form `types` CSV filter to the invoice list DTO and query (same treatment as `paymentMethods` — each billing source mints its own type string, so an IsIn would drop real values), carries it into the invoices export dataset, and surfaces a Type column plus filter pill on both the Invoices and Manual Payments tables. --- .../src/modules/billing/billing.service.ts | 5 ++++ .../billing/dto/filter-invoice.dto.spec.ts | 2 ++ .../modules/billing/dto/filter-invoice.dto.ts | 12 +++++++++ .../exports/datasets/invoices.dataset.ts | 3 +++ .../src/pages/invoices/InvoicesPage.tsx | 14 +++++++++- .../src/pages/invoices/UsdPaymentsPage.tsx | 20 +++++++++++++- .../backoffice/src/types/invoice.ts | 27 +++++++++++++++++++ 7 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index cf41998ba..1dd4c5656 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -116,6 +116,8 @@ export interface InvoiceListFilters { status?: Freight.InvoiceStatus; statuses?: Freight.InvoiceStatus[]; sources?: string[]; + /** What the invoice bills for (`PREPAID`, `DEMURRAGE`, …) — free-form per source. */ + types?: string[]; eimsStatuses?: string[]; /** Settled payment method, normalised UPPER_SNAKE — see `invoicePaymentMethodExpr`. */ paymentMethods?: string[]; @@ -306,6 +308,9 @@ export class BillingService { sources: filter.sources, }); } + if (filter.types?.length) { + qb.andWhere("invoice.type IN (:...types)", { types: filter.types }); + } if (filter.eimsStatuses?.length) { qb.andWhere("invoice.eimsStatus IN (:...eimsStatuses)", { eimsStatuses: filter.eimsStatuses, diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts index 55e6b19d2..45c7acb51 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.spec.ts @@ -22,6 +22,7 @@ describe("FilterInvoiceDto", () => { search: "INV-2026", statuses: "PENDING,OVERDUE", sources: "booking,warehouse", + types: "PREPAID,WAGON_CANCEL_FEE", eimsStatuses: "NOT_SUBMITTED", currency: "etb", issuedFrom: "2026-08-01T00:00:00.000Z", @@ -39,6 +40,7 @@ describe("FilterInvoiceDto", () => { expect(errors).toEqual([]); expect(dto.statuses).toEqual(["PENDING", "OVERDUE"]); expect(dto.sources).toEqual(["booking", "warehouse"]); + expect(dto.types).toEqual(["PREPAID", "WAGON_CANCEL_FEE"]); expect(dto.currency).toBe("ETB"); expect(dto.minAmount).toBe(100); expect(dto.hasBalance).toBe(true); diff --git a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts index a98ad06c1..fa00fb521 100644 --- a/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts +++ b/apps/edr-freight-api/src/modules/billing/dto/filter-invoice.dto.ts @@ -89,6 +89,18 @@ export class FilterInvoiceDto { @IsIn(Object.values(Freight.InvoiceSource), { each: true }) sources?: Freight.InvoiceSource[]; + /** + * What the invoice bills for (`?types=PREPAID,WAGON_CANCEL_FEE`). Free-form + * like `paymentMethods`: every billing source mints its own `type` string, so + * an `IsIn` here would silently drop a real value. + */ + @ApiPropertyOptional({ isArray: true, example: ["PREPAID"] }) + @IsOptional() + @Transform(csv) + @IsArray() + @IsString({ each: true }) + types?: string[]; + /** MoR filing state — Finance's "what still needs registering" cut. */ @ApiPropertyOptional({ isArray: true, enum: EimsInvoiceStatus }) @IsOptional() diff --git a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts index 56ab3b74e..d4d635f6f 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/invoices.dataset.ts @@ -123,6 +123,7 @@ export const invoicesDataset: ExportDataset = { // on-screen filter actually carries into the export. { key: 'status', label: 'Status (single)', type: 'text' }, { key: 'sources', label: 'Source', type: 'multiselect' }, + { key: 'types', label: 'Type', type: 'multiselect' }, { key: 'eimsStatuses', label: 'EIMS status', type: 'multiselect' }, { key: 'paymentMethods', label: 'Payment method', type: 'multiselect' }, { key: 'currency', label: 'Currency', type: 'select', options: [ @@ -151,6 +152,8 @@ export const invoicesDataset: ExportDataset = { if (params.status) qb.andWhere('i.status = :status', { status: params.status }); const sources = params.sources as string[] | null; if (sources?.length) qb.andWhere('i.source IN (:...sources)', { sources }); + const types = params.types as string[] | null; + if (types?.length) qb.andWhere('i.type IN (:...types)', { types }); const eimsStatuses = params.eimsStatuses as string[] | null; if (eimsStatuses?.length) qb.andWhere('i.eims_status IN (:...eimsStatuses)', { eimsStatuses }); const paymentMethods = params.paymentMethods as string[] | null; diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index 8b28fd71a..2b319de30 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -19,8 +19,10 @@ import { ExportButton } from "@/components/export/ExportButton"; import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; import { + INVOICE_TYPE_OPTIONS, PAYMENT_METHOD_OPTIONS, invoicePaymentMethod, + invoiceTypeLabel, paymentMethodLabel, type Invoice, type InvoiceListFilter, @@ -55,6 +57,7 @@ const EIMS_STATUS_OPTIONS = [ const INVOICE_FILTER_DEFS: FilterDef[] = [ { key: "statuses", label: "Status", type: "enum", options: STATUS_OPTIONS }, { key: "sources", label: "Source", type: "enum", options: SOURCE_OPTIONS }, + { key: "types", label: "Type", type: "enum", options: INVOICE_TYPE_OPTIONS }, { key: "currency", label: "Currency", @@ -261,6 +264,15 @@ export default function InvoicesPanel() { size: 220, cell: ({ row }) => , }, + { + id: "type", + header: "Type", + cell: ({ row }) => ( + + {invoiceTypeLabel(row.original.type)} + + ), + }, { id: "status", header: "Status", @@ -389,7 +401,7 @@ export default function InvoicesPanel() { - + ( + + {invoiceTypeLabel(row.original.type)} + + ), + }, { id: "status", header: "Status", @@ -523,7 +541,7 @@ export default function UsdPaymentsPanel({ - + PAYMENT_METHOD_LABELS.get(method) ?? method; + +/** + * What an invoice bills for. Every billing source mints its own `type` string, + * so this list is the known vocabulary, not a closed enum — render an unknown + * value rather than treating it as invalid. + */ +export const INVOICE_TYPE_OPTIONS: { value: string; label: string }[] = [ + { value: "PREPAID", label: "Prepaid freight" }, + { value: "WAGON_CANCEL_FEE", label: "Wagon cancellation fee" }, + { value: "GL_FINAL", label: "General contract final" }, + { value: "ADDITIONAL_CHARGE", label: "Additional charge" }, + { value: "PORT_CHARGES", label: "Port charges" }, + { value: "MISCELLANEOUS", label: "Miscellaneous" }, + { value: "DELIVERY_FEE", label: "Delivery fee" }, + { value: "LAST_MILE_ADVANCE", label: "Last-mile advance" }, + { value: "SHIPPING_LINE_CREDIT", label: "Shipping line credit" }, + { value: "STORAGE_FEE", label: "Storage fee" }, + { value: "DEMURRAGE", label: "Demurrage" }, + { value: "MIXED_WAREHOUSE_FEES", label: "Mixed warehouse fees" }, +]; + +/** Label for an invoice `type`, falling back to the humanised raw value. */ +export const invoiceTypeLabel = (type: string): string => + INVOICE_TYPE_OPTIONS.find((o) => o.value === type)?.label ?? + type.replace(/_/g, " "); From 0e9cfbce66802f426b271140f58c33507b595119 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:20:46 +0000 Subject: [PATCH 09/28] fix(reports): count container tonnage in exports and reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every SQL tonnage in the export datasets and report definitions used `COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)`. COALESCE falls through on NULL, never on 0 — and the portal booking wizard stores `cargo_total_weight_vgm = 0` for container freight on purpose, because VGM is captured per container line, not as a booking-level figure. So every portal-created container booking reported as weighing nothing. The backoffice wizard does store a booking-level total, so the same table holds both shapes and the numbers looked erratic rather than uniformly zero. Extract the resolver the TypeScript side already has three copies of (bookingCargoTons, cargoTonsAndItems, totalVgmTons) into one SQL helper: NULLIF both booking-level columns, then fall back to SUM(booking_container.total_vgm_tons). Applied to the bookings and train-schedules export datasets, the cargo-summary, contract-utilization and booking-status-breakdown reports, and the intercity booking list. On dev data this recovers 116 of 154 zero-weight container bookings and raises live booking tonnage from 42,973 t to 61,424 t. --- .../modules/bookings/booking-tons.sql.spec.ts | 30 +++++++++++++++++++ .../src/modules/bookings/booking-tons.sql.ts | 26 ++++++++++++++++ .../exports/datasets/bookings.dataset.ts | 9 +++--- .../datasets/train-schedules.dataset.ts | 3 +- .../booking-status-breakdown.report.ts | 3 +- .../definitions/cargo-summary.report.ts | 3 +- .../contract-utilization.report.ts | 3 +- .../train-scheduling/intercity.service.ts | 3 +- 8 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts new file mode 100644 index 000000000..07067c3ed --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.spec.ts @@ -0,0 +1,30 @@ +import { bookingTonsSql } from './booking-tons.sql'; + +describe('bookingTonsSql', () => { + const sql = bookingTonsSql('b'); + + // The regression this exists for: a plain COALESCE stops at the portal's + // literal 0 for container bookings and reports them as weighing nothing. + it('treats a stored 0 as "no figure" on both booking-level columns', () => { + expect(sql).toContain('NULLIF(b.bulk_total_weight_tons, 0)'); + expect(sql).toContain('NULLIF(b.cargo_total_weight_vgm, 0)'); + }); + + it('falls back to the per-line container VGM, excluding soft-deleted lines', () => { + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).toContain('freight.booking_container bc'); + expect(sql).toContain('bc.booking_id = b.id'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('never returns NULL, so callers may SUM it directly', () => { + expect(sql.trimEnd().endsWith('0)')).toBe(true); + }); + + it('rewrites every reference when embedded under another alias', () => { + const aliased = bookingTonsSql('bk'); + expect(aliased).not.toMatch(/\bb\./); + expect(aliased).toContain('bk.cargo_total_weight_vgm'); + expect(aliased).toContain('bc.booking_id = bk.id'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts new file mode 100644 index 000000000..f3a8590a2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-tons.sql.ts @@ -0,0 +1,26 @@ +/** + * SQL mirror of `bookingCargoTons()` (train-scheduling/train-capacity.util.ts). + * + * Three storage conventions share `bookings.cargo_total_weight_vgm`: + * - BULK PER_TON — the column holds tons. + * - BULK PER_ITEM — the column holds an ITEM COUNT; the tons are in + * `bulk_total_weight_tons`. + * - CONTAINER — the portal wizard captures VGM per line, not per booking, + * and sends 0 (portal NewBookingPage: "containers carry NO weight at the + * wizard"). The tons live in `booking_container.total_vgm_tons`. The + * backoffice wizard does store a booking-level total, so both shapes exist + * in the same table. + * + * Hence NULLIF on both columns: a plain + * `COALESCE(bulk_total_weight_tons, cargo_total_weight_vgm)` stops at the + * portal's 0 — COALESCE falls through on NULL, never on 0 — and every + * portal-created container booking reads as 0 tons in exports and reports. + */ +export function bookingTonsSql(alias = 'b'): string { + return `COALESCE( + NULLIF(${alias}.bulk_total_weight_tons, 0), + NULLIF(${alias}.cargo_total_weight_vgm, 0), + (SELECT SUM(bc.total_vgm_tons) FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), + 0)`; +} diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index c9f2a7d21..eed069764 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -1,4 +1,5 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; import { CompanyProfile } from '../../companies/entities/company-profile.entity'; @@ -14,11 +15,11 @@ import { ExportDataset } from '../export.types'; /** * Domain semantics that the retired `bookings-list` report used to share. - * Kept identical on purpose — for PER_ITEM bulk bookings `cargo_total_weight_vgm` - * holds an item COUNT, not tonnage, and `adjusted_total_amount` silently - * overrides `total_amount`. Getting either wrong misreports money or weight. + * Tonnage is `bookingTonsSql` — the one resolver for the three ways a booking + * stores its weight. `adjusted_total_amount` silently overrides `total_amount`. + * Getting either wrong misreports money or weight. */ -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; const STATUS_OPTIONS = [ diff --git a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts index bb31591da..d380470de 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/train-schedules.dataset.ts @@ -1,4 +1,5 @@ import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Route } from '../../routes/entities/route.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; @@ -86,7 +87,7 @@ export const trainSchedulesDataset: ExportDataset = { }, { key: 'totalWeightTons', label: 'Total weight (t)', type: 'tons', group: 'load', default: true, - select: `(SELECT ROUND(COALESCE(SUM(COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)), 0))::float8 + select: `(SELECT ROUND(COALESCE(SUM(${bookingTonsSql('b')}), 0))::float8 FROM freight.bookings b WHERE b.train_schedule_id = sch.id AND b.deleted_at IS NULL)`, }, diff --git a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts index 449c47181..c70fcb13f 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/booking-status-breakdown.report.ts @@ -1,6 +1,7 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; import { BookingStatus } from '@edr/types'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Yard } from '../../rule-engine/entities/yard.entity'; import { CargoType } from '../../rule-engine/entities/cargo-type.entity'; @@ -9,7 +10,7 @@ import { ReportContext, ReportDefinition } from '../report.types'; // One resolver behind "Booking per status, per port/train/date/cargo/contract // type" — the same breakdown Operation, Marketing, Global Logistics and the // Operation Report each ask for verbatim. Embed once, reuse everywhere. -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; const STATUS_OPTIONS = [...new Set(Object.values(BookingStatus))].map((v) => ({ diff --git a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts index ee3063ee1..2df862f3c 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/cargo-summary.report.ts @@ -1,9 +1,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const NOT_UMBRELLA = "(b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')"; const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; diff --git a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts index 747a14891..64598fa87 100644 --- a/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts +++ b/apps/edr-freight-api/src/modules/reports/definitions/contract-utilization.report.ts @@ -1,10 +1,11 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm'; +import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Company } from '../../companies/entities/company.entity'; import { Contract } from '../../contracts/entities/contract.entity'; import { ReportContext, ReportDefinition } from '../report.types'; -const TONS = 'COALESCE(b.bulk_total_weight_tons, b.cargo_total_weight_vgm)'; +const TONS = bookingTonsSql('b'); const DEAD_STATUSES = ['DRAFT', 'CANCELLED', 'REJECTED', 'EXPIRED']; function baseQuery(ctx: ReportContext): SelectQueryBuilder { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts index 4e2724f49..9dc75335f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/intercity.service.ts @@ -7,6 +7,7 @@ import { import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; +import { bookingTonsSql } from '../bookings/booking-tons.sql'; import { Booking } from '../bookings/entities/booking.entity'; import { RouteMilestone } from '../routes/entities/route-milestone.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -58,7 +59,7 @@ export class IntercityService { b.reference AS "reference", b.status AS "status", b.freight_type AS "freightType", - b.cargo_total_weight_vgm AS "weightTons", + ${bookingTonsSql('b')} AS "weightTons", b.loaded_at AS "loadedAt", b.arrived_at AS "arrivedAt", company.name AS "customer", From 339b8a8682226fc2307f9c9e455e760d4248b2bb Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 09:42:56 +0000 Subject: [PATCH 10/28] feat(bookings): filter and export booking content MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The booking-requests list could filter by freight type but not by what is actually in the booking, and the export's only cargo column showed the commodity name — blank for every container booking, which stores no commodity at all. Adds one resolver, `bookingContentSql`, that answers "what did the customer say is in this booking" per freight type: the container lines they entered ("2 × 40FT, 1 × 20FT") for container freight, since the wizard asks them for no description; the commodity they picked from the cargo tree for bulk, falling back to their free-text description. List filters: - "Content" — a single select flattening the cargo tree the same way the booking wizard presents it (group, then each commodity as "Bulk → Wheat"). Picking a GROUP matches its whole subtree via a recursive walk, so "Bulk" returns all 44 bulk bookings rather than the 0 that carry the group id itself. This makes the existing, previously unexposed `cargoTypeId` param group-aware. - "Content contains" — a contains-search over the description, the commodity name and the container types, so container bookings are reachable by "40FT" even though they carry no words of the customer's own. Both apply through `applyListFilters`, so the list, its summary tiles and its facets agree, and both are declared on the bookings export dataset — the export button already forwards the page's filters verbatim. Export fields: "Content" (default), plus "Cargo description" as its own column. The old `cargo` column is unchanged and still selectable, relabelled "Cargo (commodity)"; it loses only its default tick, so saved presets that name it keep working. --- .../bookings/booking-content.sql.spec.ts | 64 +++++++++++++++++++ .../modules/bookings/booking-content.sql.ts | 58 +++++++++++++++++ .../modules/bookings/bookings.repository.ts | 17 ++++- .../src/modules/bookings/bookings.service.ts | 2 + .../bookings/dto/filter-booking.dto.ts | 16 ++++- .../exports/datasets/bookings.dataset.ts | 50 ++++++++++++++- .../pages/bookings/BookingRequestsPage.tsx | 40 +++++++++++- .../src/services/bookings.service.ts | 8 +++ 8 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts new file mode 100644 index 000000000..39e6da2f2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -0,0 +1,64 @@ +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContentMatchSql, + bookingContentSql, +} from './booking-content.sql'; + +describe('bookingContentSql', () => { + const sql = bookingContentSql('b'); + + it('prefers the container lines, since container bookings carry no description', () => { + expect(sql.indexOf('freight.booking_container')).toBeLessThan( + sql.indexOf('freight.cargo_types'), + ); + expect(sql).toContain('freight.container_types'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); + + it('falls back to commodity, then to the free-text description', () => { + expect(sql.indexOf('cgt.cargo_type_name')).toBeLessThan( + sql.indexOf('b.cargo_free_text'), + ); + }); + + // An empty string is not a missing value to COALESCE — without NULLIF a blank + // description would win over the commodity behind it. + it('treats an empty string as absent at every level', () => { + expect(sql.match(/NULLIF/g)).toHaveLength(3); + }); + + it('rewrites every reference when embedded under another alias', () => { + expect(bookingContentSql('bk')).not.toMatch(/\bb\.(cargo|id)/); + }); +}); + +describe('CARGO_TYPE_SUBTREE_SQL', () => { + // The filter offers groups, not just leaves, so picking "Bulk" has to reach + // commodities at any depth beneath it — two levels today, more tomorrow. + it('walks the tree recursively rather than one level of children', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WITH RECURSIVE'); + expect(CARGO_TYPE_SUBTREE_SQL).toContain('c.parent_group_id = sub.id'); + }); + + it('includes the picked node itself, so a leaf still matches exactly', () => { + expect(CARGO_TYPE_SUBTREE_SQL).toContain('WHERE id = :cargoTypeId'); + }); +}); + +describe('bookingContentMatchSql', () => { + const sql = bookingContentMatchSql('b'); + + it('searches all three places content can live', () => { + expect(sql).toContain('b.cargo_free_text ILIKE :cargoText'); + expect(sql).toContain('cgt.cargo_type_name ILIKE :cargoText'); + expect(sql).toContain('cnt.code ILIKE :cargoText'); + }); + + // Anything but OR would make the text box match nothing for whole freight + // types — a container booking has no commodity, a bulk one has no container. + it('ORs them, and stays one parenthesised term for andWhere', () => { + expect(sql).not.toContain(' AND :cargoText'); + expect(sql.startsWith('(')).toBe(true); + expect(sql.trimEnd().endsWith(')')).toBe(true); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts new file mode 100644 index 000000000..0896e6be0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -0,0 +1,58 @@ +/** + * What the customer said is IN the booking, per freight type — the list + * filter, the summary and the export all read this one expression so the + * column, the pill and the sheet can never disagree. + * + * BULK the commodity picked from the cargo tree (`cargo_types`), falling + * back to the free-text description for a bare group or a legacy row + * that has no commodity. + * CONTAINER the wizard asks for no description at all — VGM and contents are + * captured later in operations — so the closest thing to the + * customer's own words is the container lines they entered: + * "2 × 40FT, 1 × 20FT". + * + * Containers are checked FIRST: a container booking has no `cargo_type_id` + * (the API rejects one), so the order only matters for a mixed legacy row, + * where the physical lines are the better answer. + */ +export function bookingContentSql(alias = 'b'): string { + return `COALESCE( + NULLIF((SELECT string_agg(bc.quantity || ' × ' || COALESCE(cnt.label, cnt.code), ', ' + ORDER BY cnt.size_ft DESC NULLS LAST, cnt.code) + FROM freight.booking_container bc + JOIN freight.container_types cnt ON cnt.id = bc.container_type_id + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL), ''), + NULLIF((SELECT cgt.cargo_type_name FROM freight.cargo_types cgt + WHERE cgt.id = ${alias}.cargo_type_id), ''), + NULLIF(${alias}.cargo_free_text, ''))`; +} + +/** + * Cargo types at or under `:cargoTypeId`, so picking a GROUP in the filter + * matches every commodity beneath it — the same group→commodity drill-down the + * booking wizard offers, read back. Recursive because `cargo_types` is an + * arbitrary-depth tree (Bulk → Steel Billet → S1 → …), not two levels. + */ +export const CARGO_TYPE_SUBTREE_SQL = `( + WITH RECURSIVE sub AS ( + SELECT id FROM freight.cargo_types WHERE id = :cargoTypeId + UNION ALL + SELECT c.id FROM freight.cargo_types c JOIN sub ON c.parent_group_id = sub.id + ) + SELECT id FROM sub)`; + +/** + * Contains-match over every part of the content a customer can type or pick: + * their own description, the commodity's name, and the container types on the + * booking. Bind `:cargoText` already wrapped in `%`. + */ +export function bookingContentMatchSql(alias = 'b'): string { + return `(${alias}.cargo_free_text ILIKE :cargoText + OR EXISTS (SELECT 1 FROM freight.cargo_types cgt + WHERE cgt.id = ${alias}.cargo_type_id + AND cgt.cargo_type_name ILIKE :cargoText) + OR EXISTS (SELECT 1 FROM freight.booking_container bc + JOIN freight.container_types cnt ON cnt.id = bc.container_type_id + WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL + AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 885486031..f0ca9cfa5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -21,6 +21,10 @@ import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-co import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContentMatchSql, +} from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { BookingDocumentReview, @@ -65,7 +69,10 @@ export interface BookingListFilterOptions { contractId?: string; contractType?: string; serviceTypeId?: string; + /** Cargo type OR cargo group — a group matches every commodity beneath it. */ cargoTypeId?: string; + /** Contains-search over content: description, commodity name, container types. */ + cargoText?: string; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1176,11 +1183,19 @@ export class BookingsRepository extends BaseRepository { serviceTypeId: options.serviceTypeId, }); } + // A group is selectable in the filter, not just a leaf commodity, so this + // matches the whole subtree — picking "Bulk" must return every commodity + // under it, the same drill-down the booking wizard offers, read back. if (options.cargoTypeId) { - qb.andWhere('booking.cargo_type_id = :cargoTypeId', { + qb.andWhere(`booking.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: options.cargoTypeId, }); } + if (options.cargoText) { + qb.andWhere(bookingContentMatchSql('booking'), { + cargoText: `%${options.cargoText}%`, + }); + } if (omit !== 'freightType' && options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index b6047633a..8d54e5e86 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1845,6 +1845,7 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2072,6 +2073,7 @@ export class BookingsService { contractType: filter.contractType, serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, + cargoText: filter.cargoText, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index a404c005e..bfa80b453 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -62,11 +62,25 @@ export class FilterBookingDto { @IsUUID() serviceTypeId?: string; - @ApiPropertyOptional({ format: 'uuid' }) + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Cargo type OR cargo group — a group matches every commodity beneath it', + }) @IsOptional() @IsUUID() cargoTypeId?: string; + @ApiPropertyOptional({ + description: + 'Contains-search over booking content: cargo description, commodity name, container types', + }) + @IsOptional() + @Transform(({ value }) => + typeof value === 'string' && value.trim() ? value.trim() : undefined, + ) + cargoText?: string; + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) @IsOptional() @IsIn([...FREIGHT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index eed069764..ab3ba6118 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -1,4 +1,11 @@ +import { DataSource } from 'typeorm'; + import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; +import { + CARGO_TYPE_SUBTREE_SQL, + bookingContentMatchSql, + bookingContentSql, +} from '../../bookings/booking-content.sql'; import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; import { Company } from '../../companies/entities/company.entity'; @@ -11,6 +18,7 @@ import { Yard } from '../../rule-engine/entities/yard.entity'; import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity'; import { Train } from '../../trains/entities/train.entity'; import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; +import { ExportFilterOption } from '../export-filter.util'; import { ExportDataset } from '../export.types'; /** @@ -22,12 +30,43 @@ import { ExportDataset } from '../export.types'; const TONS = bookingTonsSql('b'); const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; +/** What the customer described as the booking's contents — see the helper. */ +const CONTENT = bookingContentSql('b'); + const STATUS_OPTIONS = [ 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', 'CANCELLED', 'EXPIRED', 'SCHEDULED', 'LOADED', 'IN_TRANSIT', 'ARRIVED', 'DELIVERED', 'COMPLETED', ].map((v) => ({ value: v, label: v.replace(/_/g, ' ') })); +/** + * Cargo tree flattened for a single select: groups and every commodity beneath + * them, each labelled by its full path ("Bulk → Wheat") the way the booking + * wizard shows a deep leaf. Picking a group row filters its whole subtree. + * + * Recursive because `cargo_types` is arbitrary-depth, not two levels. + */ +async function cargoTypeOptions(ds: DataSource): Promise { + return ds.query(` + WITH RECURSIVE t AS ( + SELECT id, display_order, 0 AS depth, + ARRAY[display_order]::int[] AS ord, + ARRAY[cargo_type_name]::text[] AS path + FROM freight.cargo_types + WHERE parent_group_id IS NULL AND deleted_at IS NULL AND is_active + UNION ALL + SELECT c.id, c.display_order, t.depth + 1, + t.ord || c.display_order, + t.path || c.cargo_type_name + FROM freight.cargo_types c + JOIN t ON c.parent_group_id = t.id + WHERE c.deleted_at IS NULL AND c.is_active + ) + SELECT id AS value, array_to_string(path, ' → ') AS label + FROM t ORDER BY ord, path + `) as Promise; +} + export const bookingsDataset: ExportDataset = { key: 'bookings', title: 'Bookings', @@ -113,7 +152,11 @@ export const bookingsDataset: ExportDataset = { { key: 'serviceType', label: 'Service type', type: 'string', group: 'route', requires: ['st'], select: 'st.service_name' }, // ---- Cargo ----------------------------------------------------------- - { key: 'cargo', label: 'Cargo', type: 'string', group: 'cargo', default: true, requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + // What the customer said is in the booking. `cargo` below is the narrower + // commodity-only view, kept for saved presets that already tick it. + { key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT }, + { key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, + { key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' }, { key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' }, { key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS }, { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' }, @@ -191,6 +234,8 @@ export const bookingsDataset: ExportDataset = { { value: 'PAID', label: 'Paid' }, { value: 'FAILED', label: 'Failed' }, ] }, + { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions }, + { key: 'cargoText', label: 'Content contains', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -211,6 +256,9 @@ export const bookingsDataset: ExportDataset = { if (params.tradeDirection) qb.andWhere('b.trade_direction = :tradeDirection', { tradeDirection: params.tradeDirection }); if (params.freightType) qb.andWhere('b.freight_type = :freightType', { freightType: params.freightType }); + // Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL). + if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId }); + if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` }); if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus }); if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId }); if (params.search) { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 758f8a7bb..9a7010b05 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -140,6 +140,25 @@ export default function BookingRequestsPage() { [refData], ); + // Content options mirror the booking wizard's cargo picker: the group itself + // — which the server expands to every commodity beneath it — then each + // commodity, labelled by its full path so a generically-named leaf still + // reads unambiguously. A group with no descendants is emitted by the + // reference-data tree as its own single child; drop that duplicate. + const cargoTypeOptions = useMemo( + () => + (refData?.cargo_type ?? []).flatMap((group) => [ + { value: group.id, label: group.name }, + ...(group.children ?? []) + .filter((child) => child.id !== group.id) + .map((child) => ({ + value: child.id, + label: `${group.name} → ${child.name}`, + })), + ]), + [refData], + ); + // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — // the header's document-review alarm opens exactly the undecided requests // it is counting down for. No sync effect needed any more: controls.values @@ -183,6 +202,25 @@ export default function BookingRequestsPage() { multiple: false, options: FREIGHT_TYPE_OPTIONS, }, + { + // Cargo group or commodity. The group row matches its whole subtree + // server-side, so "Bulk" returns every bulk commodity under it. + key: "cargoTypeId", + label: "Content", + type: "enum", + multiple: false, + options: cargoTypeOptions, + }, + { + // Containers carry no customer-written description, so this is also how + // they are reached: it matches container types ("40FT") as well as the + // commodity name and the bulk cargo description. + key: "cargoText", + label: "Content contains", + type: "text", + secondary: true, + placeholder: "Commodity, description or container type", + }, { key: "serviceTypeId", label: "Service", @@ -244,7 +282,7 @@ export default function BookingRequestsPage() { toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, ], - [filterOptions, yardOptions, serviceTypeOptions], + [filterOptions, yardOptions, serviceTypeOptions, cargoTypeOptions], ); const controls = useFilters(bookingFilterDefs, { diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 28c700529..c97d14b68 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -73,6 +73,10 @@ export interface BookingListFilter { freightType?: string; /** Service type (rule-engine service_types.id). */ serviceTypeId?: string; + /** Cargo type OR cargo group — a group matches every commodity beneath it. */ + cargoTypeId?: string; + /** Contains-search over content: cargo description, commodity, container types. */ + cargoText?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; /** 'true' → customs bookings, 'false' → self-clearance (non-customs). */ @@ -199,6 +203,8 @@ export const bookingsService = { if (filter.companyId) params.companyId = filter.companyId; if (filter.freightType) params.freightType = filter.freightType; if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; + if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; + if (filter.cargoText) params.cargoText = filter.cargoText; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) @@ -238,6 +244,8 @@ export const bookingsService = { if (filter.contractId) params.contractId = filter.contractId; if (filter.freightType) params.freightType = filter.freightType; if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; + if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; + if (filter.cargoText) params.cargoText = filter.cargoText; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) From 74fb05207c08f79d811b6ad7f50ac7d684b3eea7 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Fri, 28 Aug 2026 10:02:01 +0000 Subject: [PATCH 11/28] feat(bookings): filter by container count, export per-type quantities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container filters on the booking-requests list: - "Container type" — bookings carrying that type. - "Containers" — a count of BOXES (booking_container is one row per line with a quantity, so this sums quantity rather than counting rows), as an exact value or a range. It reads the container-type filter when one is set, so the one control answers both "10 containers in total" and "10 forty-footers". Export gains a column per container type ("20FT containers", "40FT containers"), plus the total "Containers" column and the two filters. Container types are reference rows, not a constant, so `ExportDataset` gains an optional `dynamicFields` resolver — DB-driven columns appended to the static list and cached for the process, mirroring the existing `ExportFilterDef.optionsQuery`. Adding a 45ft container type adds its column with no code change. The type id is interpolated into raw SQL (ExportField.select has no parameter bag), so the resolver drops any id that is not a uuid. Also repoints the export's "Container VGM" column at the per-line sum. It was projecting bookings.cargo_total_weight_vgm, which the portal wizard leaves at 0 for container freight — the same trap the tonnage fix addressed — so the column read 0 for every portal-created container booking. Non-zero on dev data goes from 54 to 170 of 208 container bookings. --- .../bookings/booking-content.sql.spec.ts | 44 +++++++++++ .../modules/bookings/booking-content.sql.ts | 42 +++++++++++ .../modules/bookings/bookings.repository.ts | 29 ++++++++ .../src/modules/bookings/bookings.service.ts | 6 ++ .../bookings/dto/filter-booking.dto.ts | 22 ++++++ .../exports/datasets/bookings.dataset.ts | 74 ++++++++++++++++++- .../src/modules/exports/export-filter.util.ts | 22 ++++++ .../src/modules/exports/export.types.ts | 9 +++ .../src/modules/exports/exports.controller.ts | 26 ++++--- .../pages/bookings/BookingRequestsPage.tsx | 38 +++++++++- .../src/services/bookings.service.ts | 11 +++ 11 files changed, 310 insertions(+), 13 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts index 39e6da2f2..8580c7774 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.spec.ts @@ -1,7 +1,10 @@ import { CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, + bookingContainerVgmSql, bookingContentMatchSql, bookingContentSql, + bookingHasContainerTypeSql, } from './booking-content.sql'; describe('bookingContentSql', () => { @@ -62,3 +65,44 @@ describe('bookingContentMatchSql', () => { expect(sql.trimEnd().endsWith(')')).toBe(true); }); }); + +describe('bookingContainerCountSql', () => { + // booking_container is one row per LINE carrying a quantity, so counting rows + // would report a 54-container booking as 1. + it('sums the line quantities rather than counting lines', () => { + expect(bookingContainerCountSql('b')).toContain('SUM(bc.quantity)'); + expect(bookingContainerCountSql('b')).not.toContain('COUNT('); + }); + + it('counts every type by default and one type when scoped', () => { + expect(bookingContainerCountSql('b')).not.toContain('container_type_id'); + expect(bookingContainerCountSql('b', true)).toContain( + 'bc.container_type_id = :containerTypeId', + ); + }); + + it('is 0, never NULL, so a bound comparison still decides', () => { + expect(bookingContainerCountSql('b')).toContain('COALESCE(SUM(bc.quantity), 0)'); + }); + + it('ignores soft-deleted lines', () => { + expect(bookingContainerCountSql('b')).toContain('bc.deleted_at IS NULL'); + expect(bookingHasContainerTypeSql('b')).toContain('bc.deleted_at IS NULL'); + }); + + it('rewrites the booking reference under another alias', () => { + expect(bookingContainerCountSql('bk')).toContain('bc.booking_id = bk.id'); + expect(bookingHasContainerTypeSql('bk')).toContain('bc.booking_id = bk.id'); + }); +}); + +describe('bookingContainerVgmSql', () => { + // The whole point: b.cargo_total_weight_vgm is 0 for portal container + // bookings, so the weight has to come off the lines. + it('reads the lines, never the booking-level column', () => { + const sql = bookingContainerVgmSql('b'); + expect(sql).toContain('SUM(bc.total_vgm_tons)'); + expect(sql).not.toContain('cargo_total_weight_vgm'); + expect(sql).toContain('bc.deleted_at IS NULL'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts index 0896e6be0..813e833fc 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-content.sql.ts @@ -56,3 +56,45 @@ export function bookingContentMatchSql(alias = 'b'): string { WHERE bc.booking_id = ${alias}.id AND bc.deleted_at IS NULL AND (cnt.label ILIKE :cargoText OR cnt.code ILIKE :cargoText)))`; } + +/** + * Containers on a booking, as a count of physical boxes — `booking_container` + * is one row PER LINE with a `quantity`, not one row per box, so this sums the + * quantity rather than counting rows. + * + * `scopedToType` narrows the sum to `:containerTypeId`, which is what makes one + * number filter answer both "10 containers in total" and "10 forty-footers": + * the count filter reads the container-type filter when one is set, and counts + * every type when it is not. + */ +export function bookingContainerCountSql(alias = 'b', scopedToType = false): string { + return `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL${ + scopedToType ? '\n AND bc.container_type_id = :containerTypeId' : '' + })`; +} + +/** Bookings carrying at least one line of `:containerTypeId`. */ +export function bookingHasContainerTypeSql(alias = 'b'): string { + return `EXISTS (SELECT 1 FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = :containerTypeId)`; +} + +/** + * Container VGM on a booking, in tons — the sum of the per-line totals. + * + * NOT `bookings.cargo_total_weight_vgm`: the portal wizard leaves that at 0 for + * container freight (VGM is captured per container, later, in operations), so + * reading the booking-level column showed every portal container booking as + * weighing nothing. Same reason `bookingTonsSql` falls through to these lines. + */ +export function bookingContainerVgmSql(alias = 'b'): string { + return `(SELECT COALESCE(SUM(bc.total_vgm_tons), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = ${alias}.id + AND bc.deleted_at IS NULL)`; +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index f0ca9cfa5..a2d2fb53f 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -23,7 +23,9 @@ import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; import { CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, bookingContentMatchSql, + bookingHasContainerTypeSql, } from './booking-content.sql'; import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity'; import { @@ -73,6 +75,10 @@ export interface BookingListFilterOptions { cargoTypeId?: string; /** Contains-search over content: description, commodity name, container types. */ cargoText?: string; + /** Bookings carrying this container type; also scopes the container count. */ + containerTypeId?: string; + containersMin?: number; + containersMax?: number; freightType?: string; bookingType?: string; tradeDirection?: string; @@ -1196,6 +1202,29 @@ export class BookingsRepository extends BaseRepository { cargoText: `%${options.cargoText}%`, }); } + if (options.containerTypeId) { + qb.andWhere(bookingHasContainerTypeSql('booking'), { + containerTypeId: options.containerTypeId, + }); + } + // One count filter, two questions: with a container type picked it counts + // that type, without one it counts every box on the booking. + if (options.containersMin != null || options.containersMax != null) { + const count = bookingContainerCountSql( + 'booking', + Boolean(options.containerTypeId), + ); + if (options.containersMin != null) { + qb.andWhere(`${count} >= :containersMin`, { + containersMin: options.containersMin, + }); + } + if (options.containersMax != null) { + qb.andWhere(`${count} <= :containersMax`, { + containersMax: options.containersMax, + }); + } + } if (omit !== 'freightType' && options.freightType) { qb.andWhere('booking.freight_type = :freightType', { freightType: options.freightType, diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 8d54e5e86..bbadfda55 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1846,6 +1846,9 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, @@ -2074,6 +2077,9 @@ export class BookingsService { serviceTypeId: filter.serviceTypeId, cargoTypeId: filter.cargoTypeId, cargoText: filter.cargoText, + containerTypeId: filter.containerTypeId, + containersMin: filter.containersMin, + containersMax: filter.containersMax, freightType: filter.freightType, bookingType: filter.bookingType, tradeDirection: filter.tradeDirection, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index bfa80b453..3eb579b86 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -81,6 +81,28 @@ export class FilterBookingDto { ) cargoText?: string; + @ApiPropertyOptional({ + format: 'uuid', + description: + 'Bookings carrying this container type. Also scopes containersMin/Max to it.', + }) + @IsOptional() + @IsUUID() + containerTypeId?: string; + + @ApiPropertyOptional({ + description: + 'Minimum container count — of containerTypeId when set, else of all types', + }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMin?: number; + + @ApiPropertyOptional({ description: 'Maximum container count — see containersMin' }) + @IsOptional() + @Transform(({ value }) => (value === '' || value == null ? undefined : Number(value))) + containersMax?: number; + @ApiPropertyOptional({ enum: FREIGHT_TYPES }) @IsOptional() @IsIn([...FREIGHT_TYPES]) diff --git a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts index ab3ba6118..1ed7883c9 100644 --- a/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts +++ b/apps/edr-freight-api/src/modules/exports/datasets/bookings.dataset.ts @@ -3,8 +3,11 @@ import { DataSource } from 'typeorm'; import { FREIGHT_PERMS } from '../../../seed/freight-permissions.registry'; import { CARGO_TYPE_SUBTREE_SQL, + bookingContainerCountSql, bookingContentMatchSql, + bookingContainerVgmSql, bookingContentSql, + bookingHasContainerTypeSql, } from '../../bookings/booking-content.sql'; import { bookingTonsSql } from '../../bookings/booking-tons.sql'; import { Booking } from '../../bookings/entities/booking.entity'; @@ -19,7 +22,7 @@ import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line import { Train } from '../../trains/entities/train.entity'; import { applyDirectionScope } from '../../user-trade-access/trade-scope.util'; import { ExportFilterOption } from '../export-filter.util'; -import { ExportDataset } from '../export.types'; +import { ExportDataset, ExportField } from '../export.types'; /** * Domain semantics that the retired `bookings-list` report used to share. @@ -32,6 +35,7 @@ const REVENUE = 'COALESCE(b.adjusted_total_amount, b.total_amount)'; /** What the customer described as the booking's contents — see the helper. */ const CONTENT = bookingContentSql('b'); +const CONTAINER_COUNT = bookingContainerCountSql('b'); const STATUS_OPTIONS = [ 'DRAFT', 'SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED', @@ -67,6 +71,53 @@ async function cargoTypeOptions(ds: DataSource): Promise { `) as Promise; } +/** Container types are 2 rows that change about never. */ +async function containerTypeOptions(ds: DataSource): Promise { + return ds.query(` + SELECT id AS value, COALESCE(label, code) AS label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `) as Promise; +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * One column per container type ("20FT", "40FT", …), each the box count of + * that type on the booking. Resolved from `container_types` rather than + * hardcoded, so adding a 45ft adds its column without a deploy of this file. + * + * The type id is INTERPOLATED, not bound — `ExportField.select` is a raw SQL + * string with no parameter bag — so ids that are not uuids are dropped rather + * than spliced. They come from our own table; the guard is for the day someone + * changes that column's type. + */ +async function containerTypeFields(ds: DataSource): Promise { + const rows: Array<{ id: string; code: string; label: string | null }> = await ds.query(` + SELECT id, code, label + FROM freight.container_types + WHERE deleted_at IS NULL AND is_active + ORDER BY display_order, code + `); + return rows + .filter((r) => UUID_RE.test(r.id)) + .map((r) => { + const name = r.label || r.code; + return { + key: `containers${r.code.replace(/[^A-Za-z0-9]/g, '')}`, + label: `${name} containers`, + type: 'number' as const, + group: 'cargo', + select: `(SELECT COALESCE(SUM(bc.quantity), 0) + FROM freight.booking_container bc + WHERE bc.booking_id = b.id + AND bc.deleted_at IS NULL + AND bc.container_type_id = '${r.id}')::int`, + }; + }); +} + export const bookingsDataset: ExportDataset = { key: 'bookings', title: 'Bookings', @@ -157,9 +208,13 @@ export const bookingsDataset: ExportDataset = { { key: 'content', label: 'Content', type: 'string', group: 'cargo', default: true, select: CONTENT, sortExpr: CONTENT }, { key: 'cargo', label: 'Cargo (commodity)', type: 'string', group: 'cargo', requires: ['cty'], select: 'COALESCE(cty.cargo_type_name, b.cargo_free_text)' }, { key: 'cargoDescription', label: 'Cargo description', type: 'string', group: 'cargo', select: 'b.cargo_free_text' }, + // Boxes, not lines: booking_container is one row per LINE with a quantity. + { key: 'containerCount', label: 'Containers', type: 'number', group: 'cargo', default: true, select: `${CONTAINER_COUNT}::int`, sortExpr: CONTAINER_COUNT }, { key: 'freightType', label: 'Freight type', type: 'string', group: 'cargo', default: true, select: 'b.freight_type' }, { key: 'tons', label: 'Tonnage', type: 'tons', group: 'cargo', default: true, select: `ROUND(${TONS})::float8`, sortExpr: TONS }, - { key: 'containerWeightVgm', label: 'Container VGM', type: 'number', group: 'cargo', select: 'b.cargo_total_weight_vgm' }, + // The per-line sum, NOT b.cargo_total_weight_vgm — the portal leaves that + // column at 0 for container freight, so it read 0 for every such booking. + { key: 'containerWeightVgm', label: 'Container VGM (t)', type: 'tons', group: 'cargo', select: `${bookingContainerVgmSql('b')}::float8`, sortExpr: bookingContainerVgmSql('b') }, { key: 'bulkWeightTons', label: 'Bulk weight (t)', type: 'tons', group: 'cargo', select: 'b.bulk_total_weight_tons' }, { key: 'isHazardous', label: 'Hazardous', type: 'boolean', group: 'cargo', select: 'b.is_hazardous' }, { key: 'isReefer', label: 'Reefer', type: 'boolean', group: 'cargo', select: 'b.is_reefer' }, @@ -216,6 +271,8 @@ export const bookingsDataset: ExportDataset = { { key: 'doubleHandling', label: 'Double handling', type: 'boolean', group: 'clearance', select: 'b.double_handling' }, ], + dynamicFields: containerTypeFields, + filters: [ { key: 'created', label: 'Created', type: 'daterange' }, { key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS }, @@ -236,6 +293,9 @@ export const bookingsDataset: ExportDataset = { ] }, { key: 'cargoTypeId', label: 'Content (cargo type)', type: 'select', optionsQuery: cargoTypeOptions }, { key: 'cargoText', label: 'Content contains', type: 'text' }, + { key: 'containerTypeId', label: 'Container type', type: 'select', optionsQuery: containerTypeOptions }, + { key: 'containersMin', label: 'Containers (min)', type: 'text' }, + { key: 'containersMax', label: 'Containers (max)', type: 'text' }, { key: 'companyId', label: 'Customer', type: 'text' }, { key: 'search', label: 'Search reference or customer', type: 'text' }, ], @@ -259,6 +319,16 @@ export const bookingsDataset: ExportDataset = { // Group or leaf — a group matches its whole subtree (see CARGO_TYPE_SUBTREE_SQL). if (params.cargoTypeId) qb.andWhere(`b.cargo_type_id IN ${CARGO_TYPE_SUBTREE_SQL}`, { cargoTypeId: params.cargoTypeId }); if (params.cargoText) qb.andWhere(bookingContentMatchSql('b'), { cargoText: `%${params.cargoText as string}%` }); + if (params.containerTypeId) qb.andWhere(bookingHasContainerTypeSql('b'), { containerTypeId: params.containerTypeId }); + // With a container type picked the count is of THAT type, else of every box. + const containerCount = bookingContainerCountSql('b', Boolean(params.containerTypeId)); + // coerceFilterParams yields null (not undefined) for an unset filter, and + // Number(null) is 0 — which would silently apply ">= 0" to every export. + const num = (v: unknown) => (v == null || v === '' ? NaN : Number(v)); + const min = num(params.containersMin); + const max = num(params.containersMax); + if (Number.isFinite(min)) qb.andWhere(`${containerCount} >= :containersMin`, { containersMin: min }); + if (Number.isFinite(max)) qb.andWhere(`${containerCount} <= :containersMax`, { containersMax: max }); if (params.paymentStatus) qb.andWhere('b.payment_status = :paymentStatus', { paymentStatus: params.paymentStatus }); if (params.companyId) qb.andWhere('b.company_id = :companyId', { companyId: params.companyId }); if (params.search) { diff --git a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts index c302d9d3b..40c8e6980 100644 --- a/apps/edr-freight-api/src/modules/exports/export-filter.util.ts +++ b/apps/edr-freight-api/src/modules/exports/export-filter.util.ts @@ -1,5 +1,7 @@ import { DataSource } from 'typeorm'; +import type { ExportField } from './export.types'; + const DAY_MS = 24 * 60 * 60 * 1000; export type ExportFilterType = 'daterange' | 'date' | 'select' | 'multiselect' | 'text'; @@ -64,6 +66,26 @@ export function coerceFilterParams( */ const optionsCache = new Map(); +/** Process-lifetime cache for `dynamicFields`, keyed by dataset. */ +const fieldsCache = new Map(); + +/** + * A dataset's full field list: its static fields plus whatever `dynamicFields` + * resolves from the DB. Every read of `dataset.fields` goes through this, so + * the catalog and the download agree on which keys exist. + */ +export async function resolveDatasetFields( + dataset: { key: string; fields: ExportField[]; dynamicFields?: (ds: DataSource) => Promise }, + ds: DataSource, +): Promise { + if (!dataset.dynamicFields) return dataset.fields; + const cached = fieldsCache.get(dataset.key); + if (cached) return cached; + const resolved = [...dataset.fields, ...(await dataset.dynamicFields(ds))]; + fieldsCache.set(dataset.key, resolved); + return resolved; +} + export async function resolveFilterOptions( filters: ExportFilterDef[], ds: DataSource, diff --git a/apps/edr-freight-api/src/modules/exports/export.types.ts b/apps/edr-freight-api/src/modules/exports/export.types.ts index db12f0211..71abdad88 100644 --- a/apps/edr-freight-api/src/modules/exports/export.types.ts +++ b/apps/edr-freight-api/src/modules/exports/export.types.ts @@ -103,6 +103,15 @@ export interface ExportDataset { alwaysJoin?: string[]; groups: ExportGroup[]; fields: ExportField[]; + /** + * Extra fields resolved from reference data and appended to `fields` — one + * column per row of some small, rarely-changing table (a column per container + * type, say). Cached for the process, like `ExportFilterDef.optionsQuery`. + * + * The SQL these build is interpolated, not bound, so a resolver MUST validate + * anything it splices in; see `bookingsDataset` for the uuid guard. + */ + dynamicFields?: (ds: DataSource) => Promise; filters: ExportFilterDef[]; /** Must name a field whose `sortExpr` references only the base alias. */ defaultSort?: { key: string; dir: 'ASC' | 'DESC' }; diff --git a/apps/edr-freight-api/src/modules/exports/exports.controller.ts b/apps/edr-freight-api/src/modules/exports/exports.controller.ts index aea35a9bc..46c6ebad2 100644 --- a/apps/edr-freight-api/src/modules/exports/exports.controller.ts +++ b/apps/edr-freight-api/src/modules/exports/exports.controller.ts @@ -9,7 +9,7 @@ import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/curre import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util'; import { UserTradeAccessService } from '../user-trade-access/user-trade-access.service'; -import { resolveFilterOptions } from './export-filter.util'; +import { resolveDatasetFields, resolveFilterOptions } from './export-filter.util'; import { EXPORT_MIME, formatRowCap, @@ -31,13 +31,16 @@ const CAPS = { csv: CSV_ROW_CAP, xlsx: XLSX_ROW_CAP, pdf: PDF_ROW_CAP }; * Metadata only. `select` / `requires` / `sortExpr` are raw SQL and a map of * the schema — they never leave the server. */ -const toCatalogEntry = (dataset: ExportDataset): ExportCatalogEntry => ({ +const toCatalogEntry = ( + dataset: ExportDataset, + fields: ExportField[], +): ExportCatalogEntry => ({ key: dataset.key, title: dataset.title, description: dataset.description, group: dataset.group, groups: dataset.groups, - fields: dataset.fields.map(({ key, label, type, group, default: isDefault }) => ({ + fields: fields.map(({ key, label, type, group, default: isDefault }) => ({ key, label, type, @@ -72,7 +75,7 @@ export class ExportsController { const allowed = DATASETS.filter((d) => hasFreightPermission(user, d.permission)); return Promise.all( allowed.map(async (d) => ({ - ...toCatalogEntry(d), + ...toCatalogEntry(d, await resolveDatasetFields(d, this.dataSource)), filters: await resolveFilterOptions(d.filters, this.dataSource), })), ); @@ -102,7 +105,10 @@ export class ExportsController { const dataset = this.resolve(key, user); const directions = await this.userTradeAccessService.resolveAllowedDirections(user); const format = resolveExportFormat(query.format); - const fields = this.resolveFields(dataset, query.fields); + const fields = ExportsController.pickFields( + await resolveDatasetFields(dataset, this.dataSource), + query.fields, + ); const rows = await this.runner.run(dataset, fields, query, directions, { cap: formatRowCap(format), @@ -134,15 +140,15 @@ export class ExportsController { * DEFAULT set, not everything — a booking export has ~70 fields and dumping * all of them on an unparameterised call is nobody's intent. */ - private resolveFields(dataset: ExportDataset, raw: string | undefined): ExportField[] { + private static pickFields(all: ExportField[], raw: string | undefined): ExportField[] { if (raw?.trim()) { - const picked = pickByKey(dataset.fields, raw); + const picked = pickByKey(all, raw); // pickByKey falls back to everything when nothing matched; for a dataset // the safer read of "all keys unknown" is still the default set. - if (picked.length !== dataset.fields.length) return picked; + if (picked.length !== all.length) return picked; } - const defaults = dataset.fields.filter((f) => f.default); - return defaults.length ? defaults : dataset.fields; + const defaults = all.filter((f) => f.default); + return defaults.length ? defaults : all; } private resolve(key: string, user: TCurrentUser): ExportDataset { diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 9a7010b05..28edee8b8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -166,6 +166,15 @@ export default function BookingRequestsPage() { // already mounted just works, and every filter — direction included — // auto-pins its own pill the moment it has a value (FilterBar's `secondary` // split), so a deep link can never land behind "More filters" unseen. + // Container types, flattened out of the reference data's size groups. + const containerTypeOptions = useMemo( + () => + (refData?.containers ?? []).flatMap((group) => + group.types.map((t) => ({ value: t.id, label: t.name || t.code })), + ), + [refData], + ); + const bookingFilterDefs: FilterDef[] = useMemo( () => [ { @@ -221,6 +230,27 @@ export default function BookingRequestsPage() { secondary: true, placeholder: "Commodity, description or container type", }, + { + key: "containerTypeId", + label: "Container type", + type: "enum", + multiple: false, + options: containerTypeOptions, + secondary: true, + }, + { + // Counts boxes. Scoped to the container-type filter when one is set, so + // this one control answers "10 containers" and "10 forty-footers" both. + key: "containers", + label: "Containers", + type: "number", + secondary: true, + operators: ["is", "between"], + toParams: (v) => + v.op === "between" + ? { containersMin: v.v[0], containersMax: v.v[1] } + : { containersMin: v.v[0], containersMax: v.v[0] }, + }, { key: "serviceTypeId", label: "Service", @@ -282,7 +312,13 @@ export default function BookingRequestsPage() { toParams: dateRangeParams("scheduledFrom", "scheduledTo"), }, ], - [filterOptions, yardOptions, serviceTypeOptions, cargoTypeOptions], + [ + filterOptions, + yardOptions, + serviceTypeOptions, + cargoTypeOptions, + containerTypeOptions, + ], ); const controls = useFilters(bookingFilterDefs, { diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index c97d14b68..3acd3bd05 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -77,6 +77,11 @@ export interface BookingListFilter { cargoTypeId?: string; /** Contains-search over content: cargo description, commodity, container types. */ cargoText?: string; + /** Bookings carrying this container type; also scopes containersMin/Max to it. */ + containerTypeId?: string; + /** Container count bounds — of containerTypeId when set, else of all types. */ + containersMin?: string; + containersMax?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; /** 'true' → customs bookings, 'false' → self-clearance (non-customs). */ @@ -205,6 +210,9 @@ export const bookingsService = { if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; if (filter.cargoText) params.cargoText = filter.cargoText; + if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId; + if (filter.containersMin) params.containersMin = filter.containersMin; + if (filter.containersMax) params.containersMax = filter.containersMax; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) @@ -246,6 +254,9 @@ export const bookingsService = { if (filter.serviceTypeId) params.serviceTypeId = filter.serviceTypeId; if (filter.cargoTypeId) params.cargoTypeId = filter.cargoTypeId; if (filter.cargoText) params.cargoText = filter.cargoText; + if (filter.containerTypeId) params.containerTypeId = filter.containerTypeId; + if (filter.containersMin) params.containersMin = filter.containersMin; + if (filter.containersMax) params.containersMax = filter.containersMax; if (filter.bookingType) params.bookingType = filter.bookingType; if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection; if (filter.paymentCurrency) From 3015de75089cec40ecc1914dad6ea08f03f430f5 Mon Sep 17 00:00:00 2001 From: Marshal Date: Fri, 28 Aug 2026 10:34:02 +0000 Subject: [PATCH 12/28] fix issues --- ...780000000000-ScheduleCancellationReason.ts | 28 ++++ .../booking-wagon-cancellation.service.ts | 10 +- .../entities/train-schedule.entity.ts | 14 ++ .../booking-batch.service.spec.ts | 26 +++- .../train-scheduling/booking-batch.service.ts | 76 ++++++--- .../train-scheduling.controller.ts | 17 +- .../dispatch-partial-load-gate.spec.ts | 145 ++++++++++++++++++ .../dto/cancel-train-schedule.dto.ts | 14 ++ .../services/train-scheduling.service.ts | 76 +++++++-- .../TrainScheduleV2DetailPage.tsx | 12 ++ .../TrainScheduleV2ListPage.tsx | 47 +++++- .../backoffice/src/services/api.ts | 10 +- .../src/services/trainScheduling.service.ts | 3 +- .../backoffice/src/types/trainScheduling.ts | 6 + 14 files changed, 435 insertions(+), 49 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts diff --git a/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts new file mode 100644 index 000000000..dbb68fe61 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3780000000000-ScheduleCancellationReason.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Why a train schedule was cancelled, captured at cancel time. Staff pick a + * reason in the cancel dialog and every view of the cancelled schedule reads it + * back — a cancelled train on the board used to say nothing about why it died. + */ +export class ScheduleCancellationReason3780000000000 implements MigrationInterface { + name = 'ScheduleCancellationReason3780000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."train_schedules" + ADD COLUMN IF NOT EXISTS "cancellation_reason" varchar(500), + ADD COLUMN IF NOT EXISTS "cancelled_at" timestamptz, + ADD COLUMN IF NOT EXISTS "cancelled_by_user_id" uuid + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "freight"."train_schedules" + DROP COLUMN IF EXISTS "cancellation_reason", + DROP COLUMN IF EXISTS "cancelled_at", + DROP COLUMN IF EXISTS "cancelled_by_user_id" + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index ac6fb5aa3..64ccb1929 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -836,15 +836,15 @@ export class BookingWagonCancellationService { ) .where('alloc.booking_id = :bookingId', { bookingId }) .getMany(); - const loaded = allocations.filter( - (a) => a.status === 'LOADED' || a.status === 'DEPARTED', - ); const remaining = allocations.filter( (a) => a.status !== 'LOADED' && a.status !== 'DEPARTED', ); - if (!loaded.length) { + // A booking whose cargo never showed up at all (0 loaded) is cancelled the + // same way — the gate that holds the train does not care whether loading + // started, only that nothing is left unresolved. + if (!allocations.length) { throw new BadRequestException( - 'Loading has not started for this booking — use the normal wagon cancellation flow.', + 'This booking has no wagons on this schedule — use the normal wagon cancellation flow.', ); } if (!remaining.length) { diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index a1f927a77..5f24359a2 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -273,6 +273,20 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'wagon_allocation_snapshot', type: 'jsonb', nullable: true }) wagonAllocationSnapshot?: WagonAllocationSnapshot | null; + /** + * Why this schedule was cancelled — required at cancel time and shown on every + * view of the cancelled train. NULL on live schedules and on rows cancelled + * before the reason was captured. + */ + @Column({ name: 'cancellation_reason', type: 'varchar', length: 500, nullable: true }) + cancellationReason?: string | null; + + @Column({ name: 'cancelled_at', type: 'timestamptz', nullable: true }) + cancelledAt?: Date | null; + + @Column({ name: 'cancelled_by_user_id', type: 'uuid', nullable: true }) + cancelledByUserId?: string | null; + @OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule) scheduleBookings?: TrainScheduleBooking[]; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts index 9051bb816..8599a96bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.spec.ts @@ -1379,6 +1379,8 @@ describe('BookingBatchService — built-train wagon capacity', () => { maxWagons?: number; routeStops?: string[]; yardCountries?: Record; + maxPullWeightTons?: number; + maxTrainLengthMeters?: number; }) => { const schedule = { id: scheduleId, @@ -1390,8 +1392,11 @@ describe('BookingBatchService — built-train wagon capacity', () => { scheduleBookings: [], trainSet: { locomotive: { - maxPullWeightTons: 1, - maxTrainLengthMeters: 1, + // Roomy on purpose: these cases exercise the SLOT axis, so the pull + // budget must not be what closes the train. Weight-bound behaviour + // has its own cases below. + maxPullWeightTons: opts.maxPullWeightTons ?? 100000, + maxTrainLengthMeters: opts.maxTrainLengthMeters ?? 100000, overageToleranceTons: 0, overageToleranceMeters: 0, }, @@ -1459,15 +1464,28 @@ describe('BookingBatchService — built-train wagon capacity', () => { await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); }); - it('is NOT full while physical wagons remain, ignoring weight/length limits', async () => { + it('is NOT full while physical wagons remain and the loco can still haul them', async () => { const { service } = buildService({ physicalWagons: 3, reserved: [reservedBooking('b1'), reservedBooking('b2')], }); - // 1T pull cap would have been exhausted long ago under the old math. await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false); }); + it('is FULL when the locomotive cannot pull another wagon, though slots are free', async () => { + // The consist has a spare slot, but every wagon spends its TARE out of the + // same pull limit the cargo needs — so a slot-free train can still be + // weight-full. This is what let a 44-wagon booking plan 4065T gross onto a + // 3500T train while the board advertised free wagons. + const { service } = buildService({ + physicalWagons: 3, + reserved: [reservedBooking('b1'), reservedBooking('b2')], + maxPullWeightTons: 1, + maxTrainLengthMeters: 1, + }); + await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true); + }); + it('is NOT full when only a middle leg is sold and other edges run free (domestic route)', async () => { // Leg-aware allocation (planWagonsWithStock legs) made mid-leg wagons real // capacity on the edges they don't ride: a domestic corridor with cargo diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index d787f737d..bf9c0aa5f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -658,8 +658,36 @@ export class BookingBatchService implements OnModuleInit { } } - const linked = + let linked = await this.trainScheduleBookingsRepository.existsForBooking(bookingId); + // A link row can outlive the booking's own pointer (cleared on one path + // while the row survives on another). The booking then looks "linked" here, + // so the branch below calls tryAutoWagonAllocation(null) and the paid + // booking silently never gets wagons — no error, just no allocation. + if (linked && !booking.trainScheduleId) { + // The link row still names the train it belongs to — restore the pointer + // from it rather than dropping the link, so the booking keeps the train + // it was placed on and the allocation below has a schedule to run against. + const [link] = await this.trainScheduleBookingsRepository.findByBookingIds([ + bookingId, + ]); + if (link?.trainScheduleId) { + await this.dataSource + .getRepository(Booking) + .update(bookingId, { trainScheduleId: link.trainScheduleId } as never); + booking.trainScheduleId = link.trainScheduleId; + this.logger.warn( + `[BATCH] ${booking.reference ?? bookingId} was linked to schedule ${link.trainScheduleId} ` + + `with no train_schedule_id of its own — pointer restored so it can allocate`, + ); + } else { + await this.trainScheduleBookingsRepository.deleteByScheduleAndBooking( + link?.trainScheduleId ?? '', + bookingId, + ); + linked = false; + } + } // Intercity is allocated MANUALLY: payment secures the ride, staff then // place it on whichever same-route train suits (intercity panel). Unpin // from the train it reserved against — that train may be the wrong one by @@ -5376,10 +5404,13 @@ export class BookingBatchService implements OnModuleInit { * Dire→Djibouti leaves the Addis→Dire edges untouched. * * Two capacity regimes, decided by the schedule's train: - * - Built train (Train Builder consist with physical wagons): the consist IS - * the capacity. Wagon slots = physical wagon count; weight and length are - * NOT re-checked here — the builder and adjust-consist already enforced the - * locomotive's pull/length limits when the consist was assembled. + * - Built train (Train Builder consist with physical wagons): wagon slots = + * physical wagon count, but the locomotive's weight/length budgets STILL + * apply. The builder only proves the EMPTY consist can be pulled; every + * wagon then spends its tare out of the same pull limit the cargo needs, so + * a 54-wagon consist can be slot-free and still weight-full. Treating the + * consist as unlimited tonnage is what let a 44-wagon booking plan 4065T + * gross onto a 3500T train. * - No built train (legacy schedules): the locomotive's length-derived slot * count plus its weight/length budgets, as before — yard staff attach the * missing wagons manually before wagon assignment. @@ -5392,13 +5423,16 @@ export class BookingBatchService implements OnModuleInit { ): Promise { const physicalWagons = await this.builtTrainWagonCount(schedule); if (physicalWagons != null) { + // The consist fixes the SLOT count (never the locomotive's length-derived + // estimate), but weight and length stay on the locomotive's real budget — + // including its overage tolerance, which `fits` may spend on a whole unit. limits = { base: { wagons: physicalWagons, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, + weightTons: limits.base.weightTons, + lengthMeters: limits.base.lengthMeters, }, - tolerance: { weightTons: 0, lengthMeters: 0 }, + tolerance: limits.tolerance, }; } // Built trains keep the leg-aware multi-edge corridor too: the wagon @@ -5631,19 +5665,23 @@ export class BookingBatchService implements OnModuleInit { const wagonDims = await this.loadWagonDims(); const physicalWagons = await this.builtTrainWagonCount(schedule); let limits: TrainLimits; + const locomotive = trainSetLocomotiveLimits(schedule.trainSet); if (physicalWagons != null) { - // The consist is the capacity; weight/length were settled at build time. - // remainingBudget swaps in the physical wagon count per edge itself. - limits = { - base: { - wagons: physicalWagons, - weightTons: Number.POSITIVE_INFINITY, - lengthMeters: Number.POSITIVE_INFINITY, - }, - tolerance: { weightTons: 0, lengthMeters: 0 }, - }; + // The consist fixes the slot count, but the locomotive's pull/length + // budget still binds: 54 empty slots are worthless once the tare of the + // wagons already loaded has spent the pull limit. Without a locomotive + // there is nothing to weigh against, so the slot axis is all that is left. + limits = locomotive + ? await this.capacityLimits(locomotive) + : { + base: { + wagons: physicalWagons, + weightTons: Number.POSITIVE_INFINITY, + lengthMeters: Number.POSITIVE_INFINITY, + }, + tolerance: { weightTons: 0, lengthMeters: 0 }, + }; } else { - const locomotive = trainSetLocomotiveLimits(schedule.trainSet); // No loco, no built train: only the slot axis exists to bind against. if (!locomotive) return (await this.remainingWagons(schedule)) <= 0; limits = await this.capacityLimits(locomotive); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index fa32b601b..fbf555444 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -32,6 +32,7 @@ import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; import { StationWorkDto } from "../dto/station-work.dto"; import { AssignBookingsDto } from "../dto/assign-bookings.dto"; +import { CancelTrainScheduleDto } from "../dto/cancel-train-schedule.dto"; import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto"; @@ -1184,14 +1185,22 @@ export class TrainSchedulingController { @Post("container/schedules/:id/cancel") @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel container train schedule" }) - cancelTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.cancelTrainSchedule(id); + cancelTrainSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelTrainScheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id); } @Post('bulk/schedules/:id/cancel') @TrainSchedulingCancel() @ApiOperation({ summary: "Cancel bulk train schedule" }) - cancelBulkTrainSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.cancelTrainSchedule(id); + cancelBulkTrainSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: CancelTrainScheduleDto, + @CurrentUser() user: AuthUserPayload, + ) { + return this.trainSchedulingService.cancelTrainSchedule(id, dto, user?.id); } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts new file mode 100644 index 000000000..ef98e0887 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dispatch-partial-load-gate.spec.ts @@ -0,0 +1,145 @@ +import { BadRequestException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * Per-wagon loading dispatch gate. A booking half-loaded at the DEPARTURE yard + * blocks the train; a booking that boards further down the corridor + * (A→B→C→D carrying a B→C load) never does — its wagons are not due until its + * own yard, so the SQL is scoped by `b.origin_yard_id = `. + * The scoping lives in the query, so this checks the parameters that carry it + * plus the throw/pass decision on the rows it returns. + */ +describe('TrainSchedulingService.assertNoPartiallyLoadedBookings', () => { + const ORIGIN = 'yard-a'; + const SET = 'set-1'; + + const makeService = (rows: Array<{ reference: string; loaded: string; total: string }>) => { + const calls: Array<{ sql: string; params: unknown[] }> = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { query: (sql: string, params: unknown[]) => Promise }; + assertNoPartiallyLoadedBookings( + schedule: unknown, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise; + assertPassedYardsFullyLoaded( + schedule: unknown, + stations: Array<{ sequenceNo: number; yardId: string; label: string }>, + sequenceNo: number, + ): Promise; + }; + svc.dataSource = { + query: async (sql: string, params: unknown[]) => { + calls.push({ sql, params }); + return rows; + }, + }; + return { svc, calls }; + }; + const schedule = { trainSetId: SET, originStationId: ORIGIN }; + + it('scopes the scan to bookings boarding at this departure yard', async () => { + const { svc, calls } = makeService([]); + await svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }); + expect(calls).toHaveLength(1); + // The origin filter is what keeps a mid-corridor booking from holding the + // train — without it, one early-loaded B→C wagon blocks dispatch at A. + expect(calls[0].sql).toContain('b.origin_yard_id = $2'); + expect(calls[0].params).toEqual([SET, ORIGIN]); + }); + + it('lets the train go when nothing at this yard is half-loaded', async () => { + const { svc } = makeService([]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).resolves.toBeUndefined(); + }); + + it('blocks a booking half-loaded at this yard, naming its progress', async () => { + const { svc } = makeService([{ reference: 'BK-2026-000220', loaded: '4', total: '5' }]); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).rejects.toThrow(/BK-2026-000220 \(4\/5 wagons loaded\)/); + await expect( + svc.assertNoPartiallyLoadedBookings(schedule, ORIGIN, { action: 'dispatch' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('skips the scan entirely for a schedule with no train set', async () => { + const { svc, calls } = makeService([{ reference: 'X', loaded: '1', total: '2' }]); + await expect( + svc.assertNoPartiallyLoadedBookings({ trainSetId: null }, ORIGIN, { + action: 'dispatch', + }), + ).resolves.toBeUndefined(); + expect(calls).toHaveLength(0); + }); +}); + +/** + * Mid-corridor twin: logging a checkpoint at station N means the train left + * every earlier stop, so each of those yards is checked for its OWN + * half-loaded bookings. The origin is excluded (dispatch gated it) and the + * yard being arrived at is excluded (its loading has not happened yet). + */ +describe('TrainSchedulingService.assertPassedYardsFullyLoaded', () => { + const STATIONS = [ + { sequenceNo: 0, yardId: 'mojo', label: 'Mojo' }, + { sequenceNo: 1, yardId: 'adama', label: 'Adama' }, + { sequenceNo: 2, yardId: 'dire', label: 'Dire Dawa' }, + { sequenceNo: 3, yardId: 'djibouti', label: 'Djibouti' }, + ]; + + const makeService = (rowsByYard: Record>>) => { + const scanned: string[] = []; + const svc = Object.create(TrainSchedulingService.prototype) as { + dataSource: { query: (sql: string, params: unknown[]) => Promise }; + assertPassedYardsFullyLoaded( + schedule: unknown, + stations: typeof STATIONS, + sequenceNo: number, + ): Promise; + }; + svc.dataSource = { + query: async (_sql: string, params: unknown[]) => { + const yardId = params[1] as string; + scanned.push(yardId); + return rowsByYard[yardId] ?? []; + }, + }; + return { svc, scanned }; + }; + const schedule = { trainSetId: 'set-1', originStationId: 'mojo' }; + + it('checks the stops already departed, never the origin or the yard being reached', async () => { + const { svc, scanned } = makeService({}); + await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 3); + // Mojo is dispatch's job; Djibouti has not been loaded at yet. + expect(scanned).toEqual(['adama', 'dire']); + }); + + it('blocks the checkpoint when a passed yard left a booking half-loaded', async () => { + const { svc } = makeService({ + adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], + }); + await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( + /Adama.*BK-200 \(5\/8 wagons loaded\)/s, + ); + }); + + it('names the resolution the operator has: load the rest, or cancel it', async () => { + const { svc } = makeService({ + adama: [{ reference: 'BK-200', loaded: '5', total: '8' }], + }); + await expect(svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 2)).rejects.toThrow( + /customer fault: cancellation fee; EDR fault: no fee, rebookable/, + ); + }); + + it('scans nothing at the first checkpoint after the origin', async () => { + const { svc, scanned } = makeService({}); + await svc.assertPassedYardsFullyLoaded(schedule, STATIONS, 1); + expect(scanned).toEqual([]); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts new file mode 100644 index 000000000..b3e2c7678 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/cancel-train-schedule.dto.ts @@ -0,0 +1,14 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class CancelTrainScheduleDto { + @ApiProperty({ + description: + 'Why this train is being cancelled. Shown on the schedule from then on, and to the staff who have to re-place its bookings.', + maxLength: 500, + }) + @IsString() + @IsNotEmpty() + @MaxLength(500) + reason!: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 37ba657e2..5b724849c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2949,7 +2949,9 @@ export class TrainSchedulingService { // Per-wagon loading: a booking mid-load is neither ridable nor removable — // every wagon must be LOADED, or the never-loaded remainder cancelled // (at-loading cancellation), before the train departs. - await this.assertNoPartiallyLoadedBookings(schedule); + await this.assertNoPartiallyLoadedBookings(schedule, schedule.originStationId, { + action: 'dispatch', + }); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); @@ -3149,13 +3151,21 @@ export class TrainSchedulingService { * (their charge sits on the credit ledger) yet ride from accept. */ /** - * Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and - * SOME still PLANNED/RESERVED must resolve before departure — load the rest - * or cancel it (which shrinks the booking to its loaded wagons). Blocking - * here beats silently unassigning: unassign would delete LOADED allocations - * and strand cargo that is physically on the train. + * Per-wagon loading gate: a booking with SOME wagons LOADED and SOME still + * PLANNED/RESERVED must resolve before the train leaves the yard it boards + * at — load the rest, or cancel the remainder (which shrinks the booking to + * its loaded wagons). Blocking beats silently unassigning: unassign would + * delete LOADED allocations and strand cargo physically on the train. + * + * Scoped to bookings BOARDING AT `boardingYardId`, so each yard answers only + * for its own cargo: a mid-corridor booking (A→B→C→D carrying a B→C load) is + * not due at A and must never hold the train there. */ - private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise { + private async assertNoPartiallyLoadedBookings( + schedule: TrainSchedule, + boardingYardId: string, + context: { action: string; yardLabel?: string }, + ): Promise { if (!schedule.trainSetId) return; const rows: Array<{ reference: string; loaded: string; total: string }> = await this.dataSource.query( @@ -3166,24 +3176,51 @@ export class TrainSchedulingService { JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id JOIN freight.bookings b ON b.id = a.booking_id WHERE tsw.train_set_id = $1 + AND b.origin_yard_id = $2 AND a.deleted_at IS NULL AND tsw.deleted_at IS NULL AND b.deleted_at IS NULL GROUP BY b.id, b.reference HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0 AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`, - [schedule.trainSetId], + [schedule.trainSetId, boardingYardId], ); if (rows.length) { const detail = rows .map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`) .join(', '); + const where = context.yardLabel ? ` at ${context.yardLabel}` : ''; throw new BadRequestException( - `Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`, + `Cannot ${context.action}: booking(s) partially loaded${where} — load every wagon ` + + `or cancel the remainder (customer fault: cancellation fee; EDR fault: no fee, ` + + `rebookable) first: ${detail}`, ); } } + /** + * Mid-corridor twin of the dispatch gate. Logging a checkpoint at station N + * asserts the train has left every earlier stop, so each of those yards must + * have no half-loaded booking of its own left behind. The origin (seq 0) is + * skipped — dispatch already gated it — and the final station is included: + * arriving there still means the train left the stop before it. + */ + private async assertPassedYardsFullyLoaded( + schedule: TrainSchedule, + stations: Array<{ sequenceNo: number; yardId: string; label: string }>, + sequenceNo: number, + ): Promise { + const departed = stations.filter( + (st) => st.sequenceNo > 0 && st.sequenceNo < sequenceNo, + ); + for (const st of departed) { + await this.assertNoPartiallyLoadedBookings(schedule, st.yardId, { + action: 'record this checkpoint', + yardLabel: st.label, + }); + } + } + private async unloadedOriginBoarderIds( scheduleId: string, originYardId: string, @@ -4652,6 +4689,12 @@ export class TrainSchedulingService { : TrainCheckpointKind.Passed); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt); + // Per-wagon loading, mid-corridor: recording THIS station means the train + // left the previous one, so every booking that boarded back there must be + // fully loaded or its remainder cancelled. The origin is covered by + // dispatch; here we answer for the stops between it and this one, so a + // skipped checkpoint log cannot smuggle an unresolved yard past the gate. + await this.assertPassedYardsFullyLoaded(schedule, stations, dto.sequenceNo); // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. const [existing] = await this.trainCheckpointEventsRepository.findAll({ @@ -5404,7 +5447,11 @@ export class TrainSchedulingService { return this.getTrainScheduleById(id); } - async cancelTrainSchedule(id: string) { + async cancelTrainSchedule( + id: string, + dto?: { reason?: string }, + userId?: string, + ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); if (!schedule) { throw new NotFoundException(`Train schedule ${id} not found`); @@ -5435,6 +5482,11 @@ export class TrainSchedulingService { TrainScheduleStatusEnum.Cancelled, now, ), + // Why the train died — read back by every view of the cancelled + // schedule, and by the staff who have to re-place its bookings. + cancellationReason: dto?.reason?.trim() || null, + cancelledAt: now, + cancelledByUserId: userId ?? null, }, manager, ); @@ -7928,6 +7980,8 @@ export class TrainSchedulingService { freightType: this.resolveScheduleFreightType(schedule), status: schedule.status, bookingWindowStatus: schedule.bookingWindowStatus ?? 'OPEN', + cancellationReason: schedule.cancellationReason ?? null, + cancelledAt: schedule.cancelledAt ?? null, maxWagons: schedule.maxWagons ?? 0, remainingWagons: Math.max( 0, @@ -9886,6 +9940,8 @@ export class TrainSchedulingService { id: schedule.id, reference: schedule.reference ?? null, status: schedule.status, + cancellationReason: schedule.cancellationReason ?? null, + cancelledAt: schedule.cancelledAt ?? null, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, voyageNumber: schedule.voyageNumber ?? null, diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index 94da4837e..17fdbb6e4 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -1159,6 +1159,18 @@ export default function TrainScheduleV2DetailPage() { } /> + {schedule.status === "CANCELLED" && schedule.cancellationReason ? ( + } + title="This schedule was cancelled" + > + {schedule.cancellationReason} + + ) : null} + {/* Ops signage: Train No. / Voyage No. / Direction read at a glance from across the room, so these stay large rather than folding into the numeric KpiStrip below. */} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index 8041ad3ad..f9857fa5b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -17,6 +17,7 @@ import { Stack, Switch, Text, + Textarea, TextInput, ThemeIcon, } from "@mantine/core"; @@ -150,6 +151,9 @@ export default function TrainScheduleV2ListPage() { const [dispatchAt, setDispatchAt] = useState(null); // Cancelling is likewise irreversible — confirmed before the mutation fires. const [cancelTarget, setCancelTarget] = useState(null); + // Required: the reason is stored on the schedule and shown wherever the + // cancelled train appears, so staff downstream know why it died. + const [cancelReason, setCancelReason] = useState(""); const [editDateSchedule, setEditDateSchedule] = useState(null); const [routeId, setRouteId] = useState(""); const [scheduleDate, setScheduleDate] = useState(""); @@ -823,7 +827,10 @@ export default function TrainScheduleV2ListPage() { confirmed here rather than firing straight from the row menu. */} setCancelTarget(null)} + onClose={() => { + setCancelTarget(null); + setCancelReason(""); + }} title="Cancel this schedule?" centered radius="md" @@ -842,23 +849,43 @@ export default function TrainScheduleV2ListPage() { another schedule. ) : null} +