Merge pull request #1445 from Tria-plc/reschedule

Reschedule
This commit is contained in:
Abubeker Yasin
2026-08-28 16:38:08 +03:00
committed by GitHub
39 changed files with 3511 additions and 803 deletions

View File

@@ -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;

View File

@@ -80,6 +80,7 @@ model CoachType {
updatedAt DateTime @updatedAt
coaches Coach[]
seatClasses SeatClass[]
reschedulePolicy ReschedulePolicy?
@@schema("passenger")
}
@@ -568,6 +569,7 @@ model Booking {
foodOrders FoodOrder[]
agentBooking AgentBooking?
modifications BookingModification[]
reschedules BookingReschedule[]
cancellation BookingCancellation?
baggage BaggageBooking[]
excessBaggageCharges ExcessBaggageCharge[]
@@ -1205,6 +1207,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

View File

@@ -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' },

View File

@@ -64,6 +64,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: [
@@ -164,6 +165,7 @@ import { EOtpType } from "@tria-plc/iamapi-common";
TasksModule,
AppReleasesModule,
ConfigurableFareModule,
RescheduleModule,
],
providers: [
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },

View File

@@ -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];

View File

@@ -28,7 +28,7 @@ type EmployeeLike = {
delegatedPositions?: PositionLike[];
};
type MeLikeUser = {
export type MeLikeUser = {
roles?: { key?: string }[];
permissions?: PermissionLike[];
employee?: EmployeeLike | EmployeeLike[] | null;

View File

@@ -1,15 +1,3 @@
/**
* Phone normalisation shared by any lookup that has to match a number a customer typed
* against one already stored. Ethiopian numbers reach us in three interchangeable shapes
* (+2519…, 2519…, 09…) depending on whether they came from IAM, a guest booking form or a
* saved profile, so an exact-string match silently misses.
*/
/**
* Returns all plausible normalised variants of a raw phone string so that the
* DB query matches regardless of how the number was stored (local 09… vs international +251…).
* Returns an empty array when the input is clearly invalid (< 7 digits).
*/
export function normalizePhoneVariants(raw: string): string[] {
// Strip whitespace, dashes, dots, parentheses — keep digits and a leading +
const stripped = raw.replace(/[^\d+]/g, '');
@@ -38,13 +26,6 @@ export function normalizePhoneVariants(raw: string): string[] {
return [...variants];
}
/**
* A sign-in identifier is a single free-text field: the passenger types either an email
* address or a phone number and the server works out which. Phone is the default reading —
* an email must contain an `@` with something either side of it, everything else is treated
* as a number so that malformed emails don't silently fall through to a phone lookup that
* can never match.
*/
export type ResolvedIdentifier = {
kind: 'email' | 'phone';
/** Lower-cased email, or null when the input is a phone number. */
@@ -75,3 +56,27 @@ export function maskPhone(phone: string): string {
const tail = stripped.slice(-3);
return `${head}${'*'.repeat(4)}${tail}`;
}
/**
* Collapses a number to a single canonical E.164 form so two values can be compared directly.
* 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;
}

View File

@@ -26,7 +26,6 @@ import { BookingsService } from "./bookings.service";
import { GuestBookingService } from "./guest-booking.service";
import {
CreateBookingDto,
ModifyBookingDto,
CancelBookingDto,
} from "./bookings.dto";
import {
@@ -692,22 +691,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")

View File

@@ -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;

View File

@@ -5,8 +5,9 @@ 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 { buildPaymentBreakdown } from './payment-breakdown.util';
import { Cron, CronExpression } from '@nestjs/schedule';
import { VerifaydaService } from '../verifayda/verifayda.service';
import { CurrencyService } from '../currency/currency.service';
@@ -1873,7 +1874,7 @@ export class BookingsService {
};
}
private async getBaseFare(
async getBaseFare(
scheduleId: string,
seatClassId: string,
segmentRoute?: string,
@@ -2005,6 +2006,8 @@ export class BookingsService {
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
// Money collected after the original payment (reschedule fees, underpayments).
supplementaryCharges: { orderBy: { createdAt: 'asc' } },
priceTier: { select: { priceMinor: true } },
},
});
@@ -2098,6 +2101,8 @@ export class BookingsService {
returnSchedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
paymentIntent: true, tickets: true,
// Money collected after the original payment (reschedule fees, underpayments).
supplementaryCharges: { orderBy: { createdAt: 'asc' } },
priceTier: { select: { priceMinor: true } },
},
});
@@ -2176,12 +2181,20 @@ export class BookingsService {
},
};
}),
// `amountMinor` stays exactly as it was — the original intent, in the major units that
// column actually stores — so existing callers keep working. Everything collected since
// (reschedule fees and fare differences) lives in `breakdown`, whose `totalPaidMinor` is
// the number to show as "Total paid". See payment-breakdown.util.ts.
payment: (booking as any).paymentIntent
? {
method: (booking as any).paymentIntent.method,
status: (booking as any).paymentIntent.status,
amountMinor: (booking as any).paymentIntent.amountMinor,
currency: (booking as any).paymentIntent.currency,
breakdown: buildPaymentBreakdown(
(booking as any).paymentIntent,
(booking as any).supplementaryCharges ?? [],
),
}
: undefined,
// One ticket per passenger per leg (round trips have a separate ticket — and
@@ -2199,22 +2212,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');

View File

@@ -0,0 +1,101 @@
export type PaymentLineKind = 'BOOKING' | 'SUPPLEMENTARY';
export interface PaymentLine {
kind: PaymentLineKind;
/** Human label — "Original booking", "Reschedule", "Excess baggage". */
label: string;
/** Raw reason for supplementary lines (RESCHEDULE, UNDERPAYMENT, …); null for the booking line. */
reason: string | null;
/** Always true minor units, whatever the source column stored. */
amountMinor: number;
currency: string;
status: string;
paidAt: string | null;
/** Whether this line represents money actually collected. */
settled: boolean;
}
export interface PaymentBreakdown {
lines: PaymentLine[];
/** Sum of settled lines, or null when they are not all in one currency. */
totalPaidMinor: number | null;
totalPaidCurrency: string | null;
/** True when something is still owed (a charge raised but not yet paid). */
hasOutstanding: boolean;
outstandingMinor: number;
}
/** `PaymentIntent.amountMinor` is a Float in major units — bring it onto the minor-unit scale. */
export function intentAmountToMinor(amount: number | null | undefined): number {
if (amount == null) return 0;
return Math.round(amount * 100);
}
const SUPPLEMENTARY_LABELS: Record<string, string> = {
RESCHEDULE: 'Reschedule',
UNDERPAYMENT: 'Underpayment',
FARE_CORRECTION: 'Fare correction',
EXCESS_BAGGAGE: 'Excess baggage',
};
function labelFor(reason: string): string {
return (
SUPPLEMENTARY_LABELS[reason] ??
// "SOME_OTHER_REASON" → "Some other reason"
reason.charAt(0).toUpperCase() + reason.slice(1).toLowerCase().replace(/_/g, ' ')
);
}
export function buildPaymentBreakdown(
paymentIntent: { status?: string | null; amountMinor?: number | null; currency?: string | null } | null | undefined,
supplementaryCharges: Array<{
reason: string;
amountMinor: number;
currency: string;
status: string;
paidAt: Date | string | null;
}> = [],
): PaymentBreakdown {
const lines: PaymentLine[] = [];
if (paymentIntent) {
lines.push({
kind: 'BOOKING',
label: 'Original booking',
reason: null,
amountMinor: intentAmountToMinor(paymentIntent.amountMinor),
currency: paymentIntent.currency ?? 'ETB',
status: paymentIntent.status ?? 'UNKNOWN',
paidAt: null,
settled: paymentIntent.status === 'SUCCEEDED',
});
}
for (const charge of supplementaryCharges) {
if (charge.status === 'WAIVED' || charge.status === 'EXPIRED') continue;
lines.push({
kind: 'SUPPLEMENTARY',
label: labelFor(charge.reason),
reason: charge.reason,
amountMinor: charge.amountMinor,
currency: charge.currency ?? 'ETB',
status: charge.status,
paidAt: charge.paidAt ? new Date(charge.paidAt).toISOString() : null,
settled: charge.status === 'PAID',
});
}
const settled = lines.filter((l) => l.settled);
const currencies = new Set(settled.map((l) => l.currency));
const singleCurrency = currencies.size === 1 ? [...currencies][0] : null;
const outstanding = lines.filter((l) => l.status === 'PENDING');
return {
lines,
totalPaidMinor: singleCurrency ? settled.reduce((sum, l) => sum + l.amountMinor, 0) : null,
totalPaidCurrency: singleCurrency,
hasOutstanding: outstanding.length > 0,
outstandingMinor: outstanding.reduce((sum, l) => sum + l.amountMinor, 0),
};
}

View File

@@ -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;

View File

@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
PaymentSyncService,
ServiceAuthGuard,
],
exports: [PaymentClientService, PaymentsService],
exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
})
export class PaymentsModule {}

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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: {
@@ -55,6 +57,8 @@ export class SupplementaryChargesService {
contactPhone?: string;
contactEmail?: 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 },
@@ -66,7 +70,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,
@@ -172,6 +176,7 @@ export class SupplementaryChargesService {
providerTxnId: providerTxnId ?? null,
},
});
this.eventEmitter.emit('supplementary-charge.paid', { chargeId: id });
}
return updated!;

View File

@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
{} as any,
paymentClient as any,
new CurrencyService(prisma as any),
{ emit: jest.fn() } as any,
);
};

View File

@@ -0,0 +1,82 @@
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 {
CreateReschedulePolicyDto,
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: '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')
@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);
}
@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')
@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);
}
}

View File

@@ -0,0 +1,77 @@
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;
}
/** 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)
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;
}

View File

@@ -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 {}

View File

@@ -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');
});
});

View File

@@ -0,0 +1,643 @@
import {
BadRequestException,
ConflictException,
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 { 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';
import { PaymentsService } from '../payments/payments.service';
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
import { CurrencyService } from '../currency/currency.service';
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;
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; phoneNumber?: 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 ─────────────────────────────────────────────────────────
/** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */
async listPolicies() {
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' },
});
}
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) {
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<number> {
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 ────────────────────────────────────────────────────────────
/**
* 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 (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<string | null> {
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) =>
(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;
}
}

View File

@@ -739,9 +739,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() {

View File

@@ -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();

View File

@@ -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,

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../dashboard/layout';
export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) {
return <DashboardLayout>{children}</DashboardLayout>;
}

View File

@@ -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 (
<PermissionGuard permission={PERMS.bookings.view}>
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Reschedule Policies</h1>
<p className="text-muted-foreground mt-1">
Rules that decide whether a booked journey can be moved, and what the change costs
</p>
</div>
<ReschedulePolicyManager />
</div>
</PermissionGuard>
);
}

View File

@@ -111,6 +111,7 @@ export default function SettingsPage() {
</div>
)}
{activeTab === 'configurations' && (
<div className="card space-y-6">
<h3 className="text-lg font-semibold text-foreground">Rate Limiting (requests / minute / IP)</h3>

View File

@@ -28,6 +28,7 @@ import {
FileText,
Briefcase,
Calendar,
CalendarClock,
Utensils,
Package,
Moon,
@@ -90,6 +91,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 },
]
},
{

View File

@@ -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<ReschedulePolicyRow[]>([]);
const [available, setAvailable] = useState<ReschedulePolicyCoachType[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [showModal, setShowModal] = useState(false);
const [editing, setEditing] = useState<ReschedulePolicyRow | null>(null);
const [coachTypeId, setCoachTypeId] = useState('');
const [form, setForm] = useState<ReschedulePolicyValues>(EMPTY_POLICY);
const [saving, setSaving] = useState(false);
const [formError, setFormError] = useState('');
const [deleting, setDeleting] = useState<ReschedulePolicyRow | null>(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<ReschedulePolicyValues>) => 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) => (
<div>
<span className="font-semibold text-foreground">{row.coachType?.code}</span>
<span className="text-muted-foreground"> - {row.coachType?.name}</span>
</div>
),
},
{
key: 'fee',
label: 'Change fee',
render: (row: ReschedulePolicyRow) => (
<span className="text-sm">{feeLabel(row.feePercent, row.feeMinMinor)}</span>
),
},
{
key: 'routeChangeAllowed',
label: 'Route change',
render: (row: ReschedulePolicyRow) => (
<span className={`edr-badge ${row.routeChangeAllowed ? 'edr-badge-success' : 'edr-badge-danger'}`}>
{row.routeChangeAllowed ? 'Allowed' : 'Not permitted'}
</span>
),
},
{
key: 'sameDay',
label: 'Same-day change',
render: (row: ReschedulePolicyRow) =>
row.sameDayAllowed ? (
<span className="text-sm">{feeLabel(row.sameDayFeePercent, row.sameDayFeeMinMinor)}</span>
) : (
<span className="edr-badge edr-badge-danger">Not permitted</span>
),
},
{
key: 'cutoffMinutes',
label: 'Cutoff',
render: (row: ReschedulePolicyRow) => (
<span className="font-mono text-sm">{row.cutoffMinutes} min</span>
),
},
{
key: 'isActive',
label: 'Status',
render: (row: ReschedulePolicyRow) => (
<span className={`edr-badge ${row.isActive ? 'edr-badge-success' : 'edr-badge-warning'}`}>
{row.isActive ? 'Active' : 'Disabled'}
</span>
),
},
];
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 (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4">
{/* The page supplies the title; this is the rule-of-thumb the table's numbers mean. */}
<p className="text-xs text-muted-foreground max-w-3xl">
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.
</p>
<ActionButton icon={Plus} onClick={openCreate} disabled={available.length === 0}>
Add Reschedule Policy
</ActionButton>
</div>
{!loading && available.length === 0 && (
<p className="text-xs text-muted-foreground">Every fare class already has a policy.</p>
)}
{message && <p className="text-sm text-muted-foreground">{message}</p>}
<DataTable
data={rows}
columns={columns}
actions={actions}
loading={loading}
emptyMessage="No reschedule policies yet - add one to allow rescheduling."
/>
<Modal
isOpen={showModal}
onClose={() => setShowModal(false)}
title={editing ? `Edit Reschedule Policy - ${editing.coachType?.code}` : 'Add Reschedule Policy'}
size="lg"
>
<div className="space-y-4">
<div className="space-y-1">
<label className="label">Fare class</label>
{editing ? (
<>
<input
className="input"
value={`${editing.coachType?.code} - ${editing.coachType?.name}`}
disabled
/>
{/* One policy per fare class, so editing never re-points a row at another class. */}
<p className="text-xs text-muted-foreground">A policy stays attached to its fare class.</p>
</>
) : (
<select className="input" value={coachTypeId} onChange={(e) => setCoachTypeId(e.target.value)}>
<option value="">Select a fare class...</option>
{available.map((ct) => (
<option key={ct.id} value={ct.id}>
{ct.code} - {ct.name}
</option>
))}
</select>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="label">Fee (% of fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
value={form.feePercent}
onChange={(e) => setField({ feePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
value={etb(form.feeMinMinor)}
onChange={(e) => setField({ feeMinMinor: toMinor(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Cutoff before departure (min)</label>
<input
type="number"
min="0"
className="input"
value={form.cutoffMinutes}
onChange={(e) => setField({ cutoffMinutes: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Route change</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.routeChangeAllowed}
onChange={(e) => setField({ routeChangeAllowed: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Same-day change</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.sameDayAllowed}
onChange={(e) => setField({ sameDayAllowed: e.target.checked })}
/>
Allowed
</label>
</div>
<div className="space-y-1">
<label className="label">Status</label>
<label className="flex items-center gap-2 text-sm h-10">
<input
type="checkbox"
checked={form.isActive}
onChange={(e) => setField({ isActive: e.target.checked })}
/>
Active
</label>
</div>
<div className="space-y-1">
<label className="label">Same-day fee (% of fare)</label>
<input
type="number"
min="0"
max="100"
className="input"
disabled={!form.sameDayAllowed}
value={form.sameDayFeePercent}
onChange={(e) => setField({ sameDayFeePercent: Number(e.target.value) })}
/>
</div>
<div className="space-y-1">
<label className="label">Same-day minimum fee (ETB)</label>
<input
type="number"
min="0"
step="0.01"
className="input"
disabled={!form.sameDayAllowed}
value={etb(form.sameDayFeeMinMinor)}
onChange={(e) => setField({ sameDayFeeMinMinor: toMinor(e.target.value) })}
/>
</div>
</div>
{formError && <p className="text-sm text-red-600 dark:text-red-400">{formError}</p>}
<div className="flex justify-end gap-2 pt-2">
<ActionButton variant="secondary" onClick={() => setShowModal(false)}>
Cancel
</ActionButton>
<ActionButton icon={Save} onClick={submit} loading={saving}>
{editing ? 'Update Policy' : 'Create Policy'}
</ActionButton>
</div>
</div>
</Modal>
<ConfirmDialog
isOpen={!!deleting}
onClose={() => 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}
/>
</div>
);
}

View File

@@ -542,6 +542,40 @@ export const systemConfigApi = {
update: (data: Record<string, string>) => apiClient.patch<Record<string, string>>('/config', data),
};
// 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;
type: string;
}
export interface ReschedulePolicyRow extends ReschedulePolicyValues {
id: string;
coachTypeId: string;
coachType: ReschedulePolicyCoachType;
}
export const reschedulePolicyApi = {
list: () => apiClient.get<ReschedulePolicyRow[]>('/reschedule/policies'),
availableCoachTypes: () =>
apiClient.get<ReschedulePolicyCoachType[]>('/reschedule/policies/available-coach-types'),
create: (data: ReschedulePolicyValues & { coachTypeId: string }) =>
apiClient.post<ReschedulePolicyRow>('/reschedule/policies', data),
update: (coachTypeId: string, data: Partial<ReschedulePolicyValues>) =>
apiClient.patch<ReschedulePolicyRow>(`/reschedule/policies/${coachTypeId}`, data),
remove: (coachTypeId: string) => apiClient.delete<any>(`/reschedule/policies/${coachTypeId}`),
};
// App Releases API
export const appReleasesApi = {
getAll: async () => {

View File

@@ -4,6 +4,8 @@ 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 { samePhone } from "@/utils/phone";
import { resolvePaymentRedirectUrl } from "@/lib/payment-redirect";
import { useEffect, useState } from "react";
import {
@@ -78,6 +80,13 @@ 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);
const currentUserPhone = useAuthStore((s) => s.user?.phone);
// 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<string | null>(null);
@@ -329,6 +338,20 @@ 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 isBooker = samePhone(currentUserPhone, booking.contactPhone);
const canReschedule = isAuthenticated && (isBooker || !booking.contactPhone);
const reschedulePath = `/booking/reschedule?ref=${booking.bookingRef}`;
const StatusBadge = () => {
const statusConfig = {
PENDING_PAYMENT: {
@@ -1022,6 +1045,31 @@ function BookingDetailContent() {
</>
)}
</button>
{/* 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 && (!isAuthInitialized || !isAuthenticated || canReschedule) && (
<button
disabled={!isAuthInitialized}
onClick={() =>
router.push(
isAuthenticated
? reschedulePath
: `/login?redirect=${encodeURIComponent(reschedulePath)}`,
)
}
title={
isAuthenticated
? "Change the date, train or seats on this booking"
: "Rescheduling needs an account — sign in to continue"
}
className="px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
<Clock className="w-4 h-4" />
{isAuthInitialized && !isAuthenticated ? "Sign in to reschedule" : "Reschedule"}
</button>
)}
</>
)}
</div>

View File

@@ -0,0 +1,772 @@
"use client";
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";
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,
readRecentStationIds,
} from "@/components/StationDropdown";
import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap";
import { formatTime } from "@/utils/format";
import { Station } from "@/types";
// 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;
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)}`;
/** "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();
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<Date | undefined>(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<any | null>(null);
// 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<Record<number, string>>({});
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [done, setDone] = useState<{ status: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const [dateNotice, setDateNotice] = useState<string | null>(null);
const [recentStationIds, setRecentStationIds] = useState<string[]>(
readRecentStationIds,
);
const saveRecent = (id: string) =>
setRecentStationIds((prev) => pushRecentStation(id, prev));
const resetSeats = () => {
setPassengerSeatMap({});
setActivePassengerIndex(0);
setSelectedCoach(null);
};
const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery<Options>({
queryKey: ["reschedule-options", ref],
queryFn: () => apiClient.get<Options>(`/bookings/${ref}/reschedule`),
// Never fire before the session is known — an unauthenticated call only 401s.
enabled: !!ref && isAuthInitialized && isAuthenticated,
retry: false,
});
const { data: stations = [] } = useQuery<Station[]>({
queryKey: ["stations"],
queryFn: () => apiClient.get<Station[]>("/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);
resetSeats();
setSearched(null);
}, [leg?.leg, leg?.scheduleId]);
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<AvailableDatesResponse>({
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<string>();
// 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);
resetSeats();
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);
resetSeats();
}
}, [noRouteForPair, date]);
const { data: schedules = [], isFetching: searching } = useQuery<any[]>({
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<any>({
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 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<string | null>(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<Quote>({
queryKey: ["reschedule-quote", ref, quoteBody],
queryFn: () => apiClient.post<Quote>(`/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: orderedSeatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })),
});
return apiClient.post<any>(`/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 stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—";
if (!ref) return <Shell><p className="text-gray-600">Missing booking reference.</p></Shell>;
// Hold the spinner through the redirect rather than flashing the booking's error state.
if (!isAuthInitialized || needsLogin) {
return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
}
if (loadingOptions) return <Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>;
if (optionsError || !options || !leg) {
return <Shell><p className="text-red-600">{(optionsError as any)?.response?.data?.message || "This booking cannot be rescheduled."}</p></Shell>;
}
if (done) {
return (
<Shell>
<div className="text-center space-y-4">
<CheckCircle2 className="w-14 h-14 text-green-600 mx-auto" />
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Booking rescheduled</h1>
<p className="text-gray-600 dark:text-gray-400">New tickets have been issued for booking {ref}.</p>
<button className="btn-primary" onClick={() => router.push(`/booking/detail?ref=${ref}`)}>View booking</button>
</div>
</Shell>
);
}
if (options.pending) {
return (
<Shell>
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Reschedule awaiting payment</h1>
<p className="text-gray-600 dark:text-gray-400">
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.
</p>
{options.pending.paymentToken && (
<button className="btn-primary" onClick={() => router.push(`/pay-balance/${options.pending!.paymentToken}`)}>Pay now</button>
)}
</div>
</Shell>
);
}
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 = () => (
<div className="card space-y-3">
<h2 className="text-base font-bold text-gray-900 dark:text-gray-100 pb-2 border-b border-gray-100 dark:border-gray-800">
Change summary
</h2>
<div>
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Currently</div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatDepartureDay(leg.departureAt)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{stationName(leg.originStationId)} {stationName(leg.destinationStationId)}
</div>
</div>
{schedule && (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<div className="text-[10px] font-semibold uppercase tracking-wide text-gray-400 mb-1">Changing to</div>
{/* Same date+time line as "Currently" above, so the two are read side by side. */}
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
{formatDepartureDay(schedule.departureAt) ?? formatTime(schedule.departureAt)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{schedule.trainName || schedule.trainNumber} · arrives {formatTime(schedule.arrivalAt)}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{stationName(originId)} {stationName(destinationId)}
</div>
</div>
)}
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-1">
{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 (
<div key={`sum-${name}-${i}`} className="flex justify-between text-sm">
<span className="text-gray-700 dark:text-gray-300 truncate max-w-[60%]">{name}</span>
<span className={seat ? "font-semibold text-gray-900 dark:text-gray-100" : "text-gray-400"}>
{seat ? `Seat ${buildSeatLabel(seat)}` : "—"}
</span>
</div>
);
})}
</div>
{!quoteBody ? (
<p className="text-xs text-gray-500 dark:text-gray-400 pt-3 border-t border-gray-100 dark:border-gray-800">
Pick a new train and a seat for {leg.seatCount > 1 ? "every passenger" : "the passenger"} to see what this change costs.
</p>
) : quoting ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
) : quote ? (
<div className="pt-3 border-t border-gray-100 dark:border-gray-800 space-y-2 text-sm">
<Row label="Original fare" value={etb(quote.oldFareMinor)} />
<Row label="New fare" value={etb(quote.newFareMinor)} />
<Row
label={quote.fareDifferenceMinor >= 0 ? "Fare difference" : "Fare difference (not refunded)"}
value={etb(Math.max(0, quote.fareDifferenceMinor))}
/>
<Row label={`Change fee${quote.isSameDay ? " (same-day)" : ""}`} value={etb(quote.feeMinor)} />
<div className="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<span className="font-bold text-gray-900 dark:text-gray-100">Total due now</span>
<span className="text-xl font-bold text-primary">{etb(quote.amountDueMinor)}</span>
</div>
{quote.blockers.length > 0 && (
<div className="text-red-600 flex gap-2 text-xs">
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
<div>{quote.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{error && <div className="text-red-600 text-xs">{error}</div>}
<button
className="btn-primary w-full mt-1"
disabled={!quote.allowed || confirm.isPending}
onClick={() => { setError(null); confirm.mutate(); }}
>
{confirm.isPending
? "Processing..."
: quote.amountDueMinor > 0
? `Continue to payment · ${etb(quote.amountDueMinor)}`
: "Confirm reschedule"}
</button>
</div>
) : null}
</div>
);
return (
<Shell wide>
<button onClick={() => router.push(`/booking/detail?ref=${ref}`)} className="flex items-center gap-1 text-sm text-gray-500 mb-4">
<ChevronLeft className="w-4 h-4" /> Back to booking
</button>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-1">Reschedule {ref}</h1>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
{leg.passengerNames.join(", ")} · currently {format(new Date(leg.departureAt), "EEE dd MMM, HH:mm")} · {stationName(leg.originStationId)} {stationName(leg.destinationStationId)}
</p>
{options.legs.length > 1 && (
<div className="flex gap-2 mb-6">
{options.legs.map((l) => (
<button key={l.leg} onClick={() => setLegNo(l.leg)} className={`px-4 py-2 rounded-lg border text-sm ${legNo === l.leg ? "border-primary text-primary" : "border-gray-200 text-gray-600"}`}>
{l.leg === 1 ? "Outbound" : "Return"} · {format(new Date(l.departureAt), "dd MMM")}
</button>
))}
</div>
)}
{/* 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. */}
<div className="lg:grid lg:grid-cols-3 lg:gap-6">
{/* Left column — the choices */}
<div className="lg:col-span-2 space-y-4">
<div className="bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-100 dark:border-gray-700 shadow-sm">
{fareRules.length > 0 && (
<div className="rounded-xl bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 p-4 mb-6">
<div className="font-semibold text-sm text-gray-900 dark:text-white mb-2.5">Your fare rules</div>
<ul className="grid sm:grid-cols-2 gap-x-6 gap-y-2">
{fareRules.map((rule) => (
<li key={rule.label} className="flex items-start gap-2">
<span className="mt-1.5 w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
<span className="text-sm leading-snug text-gray-600 dark:text-gray-400">
{rule.label}:{" "}
<span className="font-medium text-gray-900 dark:text-white">{rule.value}</span>
</span>
</li>
))}
</ul>
</div>
)}
{!leg.canReschedule && (
<div className="rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 p-4 text-sm text-red-700 mb-6 flex gap-2">
<AlertCircle className="w-5 h-5 shrink-0" />
<div>{leg.blockers.map((b) => <div key={b}>{b}</div>)}</div>
</div>
)}
{leg.canReschedule && (
<>
{/* Step 1: route + date */}
<div className="grid md:grid-cols-4 gap-3 mb-4">
<StationDropdown
stations={stations}
value={originId}
excludeId={destinationId}
placeholder="From"
recentIds={recentStationIds}
disabled={routeLocked}
onSelect={(s) => {
setOriginId(s.id);
if (s.id) saveRecent(s.id);
setDateNotice(null);
setSchedule(null);
resetSeats();
setSearched(null);
}}
/>
<StationDropdown
stations={stations}
value={destinationId}
excludeId={originId}
placeholder="To"
recentIds={recentStationIds}
disabled={routeLocked}
onSelect={(s) => {
setDestinationId(s.id);
if (s.id) saveRecent(s.id);
setDateNotice(null);
setSchedule(null);
resetSeats();
setSearched(null);
}}
/>
<ModernDatePicker
value={date}
onChange={(d) => {
setDateNotice(null);
setDate(d);
}}
minDate={new Date()}
maxDate={originId && destinationId ? maxSearchDate : undefined}
disabledDates={disabledDates}
disabled={noRouteForPair}
placeholder="New date"
/>
<button className="btn-primary" disabled={!canSearch || searching} onClick={() => { setSchedule(null); resetSeats(); setSearched({ originId, destinationId, date: format(date!, "yyyy-MM-dd") }); }}>
{searching ? "Searching..." : "Find trains"}
</button>
</div>
{routeLocked && (
<p className="text-xs text-gray-500 dark:text-gray-400 -mt-2 mb-4">
Your fare class does not permit changing stations only the date and train.
</p>
)}
{noRouteForPair && (
<p className="text-xs text-amber-600 dark:text-amber-400 -mt-2 mb-4">
No route connects these stations pick a different destination.
</p>
)}
{dateNotice && !noRouteForPair && (
<p className="text-xs text-amber-600 dark:text-amber-400 -mt-2 mb-4">{dateNotice}</p>
)}
{/* Step 2: schedule */}
{searched && !searching && schedules.length === 0 && <p className="text-sm text-gray-500 mb-4">No trains on that day.</p>}
{schedules.length > 0 && (
<div className="space-y-2 mb-6">
{schedules.map((s: any) => {
const id = s.scheduleId || s.id;
const selected = scheduleId === id;
return (
<button key={id} disabled={s.hasAvailability === false} onClick={() => { setSchedule(s); resetSeats(); }}
className={`w-full text-left rounded-xl border p-4 flex items-center justify-between ${selected ? "border-primary bg-primary/5" : "border-gray-200 dark:border-gray-700"} disabled:opacity-50`}>
<div>
<div className="font-semibold text-gray-900 dark:text-white">{s.trainName || s.trainNumber}</div>
<div className="text-sm text-gray-500">{formatTime(s.departureAt)} <ArrowRight className="inline w-3 h-3" /> {formatTime(s.arrivalAt)}</div>
</div>
<div className="text-xs text-gray-500">{s.hasAvailability === false ? "Sold out" : "Select"}</div>
</button>
);
})}
</div>
)}
{/* Step 3: seats (same class as booked) */}
{scheduleId && (
<div className="mb-6">
<h2 className="font-semibold text-gray-900 dark:text-white mb-1">
Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({orderedSeatIds.length}/{leg.seatCount})
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
{allSeatsChosen
? "Every passenger has a seat."
: `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`}
</p>
{/* Who gets which seat. The API pairs seats to passengers by position, so this
mapping is the payload — not a display convenience. */}
<div className="mb-4 rounded-xl border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-800">
{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 (
<button
key={`${name}-${i}`}
type="button"
onClick={() => isClickable && setActivePassengerIndex(i)}
disabled={!isClickable}
className={`w-full flex items-center justify-between py-2 px-3 text-left transition-all ${
isActive ? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10" : ""
} ${
isClickable
? "cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/60"
: "cursor-not-allowed opacity-50"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
assignedSeat
? "bg-[rgb(20,113,76)] text-white"
: isActive
? "bg-[rgb(20,113,76)]/20 text-[rgb(20,113,76)] ring-2 ring-[rgb(20,113,76)]"
: "bg-gray-200 dark:bg-gray-700 text-gray-500"
}`}
>
{i + 1}
</div>
<div className="min-w-0">
<span className="text-sm text-gray-700 dark:text-gray-300 truncate block max-w-[160px]">
{name}
</span>
{isActive && !assignedSeat && (
<span className="text-[10px] font-semibold text-[rgb(20,113,76)] uppercase tracking-wide">
Now selecting
</span>
)}
</div>
</div>
<span
className={`text-sm font-semibold flex-shrink-0 ${
assignedSeat ? "text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
{assignedSeat ? `Seat ${buildSeatLabel(assignedSeat)}` : "Not Assigned"}
</span>
</button>
);
})}
</div>
{loadingSeats ? (
<Loader2 className="w-5 h-5 animate-spin text-primary" />
) : (
<SeatMap
coaches={coaches}
selectedCoachId={selectedCoach}
onSelectCoach={setSelectedCoach}
isSeatSelected={isSeatSelected}
isSeatAssignedToOther={isSeatAssignedToOther}
onSeatToggle={handleSeatToggle}
/>
)}
</div>
)}
</>
)}
</div>{/* end left card */}
{/* Mobile: the summary sits under the choices instead of beside them */}
<div className="lg:hidden">
<ChangeSummary />
</div>
</div>{/* end left column */}
{/* Right column — sticky change summary (desktop only) */}
<div className="hidden lg:block">
{/* 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. */}
<div className="sticky top-6 max-h-[calc(100vh-3rem)] overflow-y-auto">
<ChangeSummary />
</div>
</div>
</div>
</Shell>
);
}
function Row({ label, value }: { label: string; value: string }) {
return <div className="flex justify-between text-gray-700 dark:text-gray-300"><span>{label}</span><span>{value}</span></div>;
}
// `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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 py-6">
<div className="container mx-auto px-4">
{wide ? (
<div className="max-w-6xl mx-auto">{children}</div>
) : (
<div className="max-w-3xl mx-auto bg-white dark:bg-gray-800 rounded-2xl p-6 border border-gray-200 dark:border-gray-700">
{children}
</div>
)}
</div>
</div>
);
}
export default function ReschedulePage() {
return (
<Suspense fallback={<Shell><Loader2 className="w-6 h-6 animate-spin text-primary" /></Shell>}>
<ReschedulePageContent />
</Suspense>
);
}

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={ref} className="relative">
<div
className={`relative flex items-center border-2 rounded-xl transition-all duration-200 bg-white dark:bg-gray-800 ${
error
? "border-red-400"
: open
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<MapPin className="absolute left-3.5 w-4 h-4 text-primary flex-shrink-0" />
<input
ref={inputRef}
value={displayValue}
onChange={(e) => {
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 && (
<button
type="button"
onClick={() => {
onSelect({ id: "", name: "", code: "", country: "" });
setQuery("");
}}
className="absolute right-3 p-0.5"
>
<X className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{open && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-96 overflow-y-auto overflow-x-hidden scrollbar-hide">
{!query && recentIds.length > 0 && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
Recent
</p>
{recentIds
.map((id) => stations.find((s) => s.id === id))
.filter(Boolean)
.map((s) => (
<button
key={s!.id}
type="button"
onMouseDown={() => {
onSelect(s!);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
>
<Clock className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<span className="text-sm text-gray-800 dark:text-gray-200">
{s!.name}
</span>
</button>
))}
<div className="border-t border-gray-100 dark:border-gray-700 mt-1 mb-1" />
</div>
)}
{filtered.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">
No stations found
</p>
) : (
filtered.map((s) => (
<button
key={s.id}
type="button"
onMouseDown={() => {
onSelect(s);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
>
<MapPin className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{s.name}
</span>
{s.code && (
<span className="text-xs text-gray-400 ml-1.5">
{s.code}
</span>
)}
</div>
</button>
))
)}
</div>
)}
</div>
);
}
// ─── 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<string[]>(() => {
try {
return JSON.parse(localStorage.getItem("edr_recent_stations") || "[]");
} catch {
return [];
}
});
const [recentStationIds, setRecentStationIds] = useState<string[]>(
readRecentStationIds,
);
const passengerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef<HTMLDivElement>(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 = () => {

View File

@@ -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<string, string> = { 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 (
<button
onClick={() => onToggle(bed.id)}
disabled={isDisabled}
title={
isAssignedToOther
? `Bed ${seatLabel} - already assigned to another passenger`
: `${bedType} Berth ${seatLabel} - ${bed.status}`
}
className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
} ${
isSelected
? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
: isAssignedToOther
? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
: bed.status === "AVAILABLE"
? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
: bed.status === "BOOKED" || bed.status === "BLOCKED"
? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
: "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
}`}
>
{/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
berth lies horizontally, matching the direction beds actually run in the coach. */}
<div className="w-9 h-6 flex items-center justify-center overflow-visible">
<Image src="/bed.png" alt="bed" width={22} height={36} className="object-contain rotate-90" />
</div>
<div className="text-xs font-bold text-gray-900 dark:text-white">
{seatLabel}
</div>
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400">
{bedType}
</div>
</button>
);
});
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(() => (
<div
className="flex flex-col items-center justify-center flex-shrink-0 self-stretch w-6 sm:w-7 py-1.5"
title="Ladder to the middle & upper berths"
aria-hidden="true"
>
<svg width="100%" height="100%" viewBox="0 0 24 90" preserveAspectRatio="none" className="text-gray-400 dark:text-gray-500 drop-shadow-sm">
{/* Side rails */}
<rect x="2" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
<rect x="18.5" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
{/* Rungs, evenly spaced top (upper) to bottom (lower) */}
<rect x="2" y="6" width="20" height="4" rx="2" fill="currentColor" />
<rect x="2" y="30" width="20" height="4" rx="2" fill="currentColor" />
<rect x="2" y="54" width="20" height="4" rx="2" fill="currentColor" />
<rect x="2" y="78" width="20" height="4" rx="2" fill="currentColor" />
</svg>
</div>
));
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 (
<div className="flex flex-col items-center">
<button
onClick={() => onToggle(seat.id)}
disabled={isDisabled}
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
isSelected
? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
: isAssignedToOther
? "bg-purple-400 text-white cursor-not-allowed opacity-75"
: seat.status === "AVAILABLE"
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
: seat.status === "HELD"
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
}`}
title={
isAssignedToOther
? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
: `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
}
style={
isBedCoach
? seat.row % 2 === 1
? { transform: "scaleY(-1)" }
: undefined
: seat.row % 2 === 0
? { transform: "scaleY(-1)" }
: undefined
}
>
{isBedCoach ? (
<Bed className="w-7 h-7" />
) : (
<Armchair className="w-7 h-7" />
)}
</button>
</div>
);
},
);
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 <div className="text-xs text-muted-foreground">No seats</div>;
}
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) => (
<div className="flex items-stretch gap-2">
{beds.map((bed: any) => (
<BedCard
key={bed.id}
bed={bed}
isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={handleSeatClick}
/>
))}
{beds.length > 1 && <LadderConnector key={`${keyPrefix}-ladder`} />}
</div>
);
// 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) => (
<div
key={key}
className="bg-gray-50 dark:bg-gray-800/40 rounded-2xl p-4 border border-gray-200 dark:border-gray-700 shadow-sm"
>
<div className="flex flex-col items-center gap-3">
{leftBay.length > 0 && (
<div className="flex justify-center">{renderBerthBay(leftBay, `${key}-left`)}</div>
)}
{leftBay.length > 0 && rightBay.length > 0 && (
<div className="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600" />
)}
{rightBay.length > 0 && (
<div className="flex justify-center">{renderBerthBay(rightBay, `${key}-right`)}</div>
)}
</div>
</div>
);
// 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<string, string>) => {
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 (
<div className="space-y-6">
{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 (
<div
key={room.room_id}
className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border-2 border-gray-200 dark:border-gray-700"
>
{/* Room Header */}
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-300 dark:border-gray-600">
<div>
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
Room {room.roomNumber}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{room.category === "VIP_BED"
? "VIP BED"
: room.category === "ECONOMY_BED"
? "ECONOMY BED"
: room.category}
</p>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{room.totalBeds} beds
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2 mb-4 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-green-50 border border-green-300 rounded" />
<span className="text-gray-600 dark:text-gray-400">
Available
</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-red-50 border border-red-300 rounded" />
<span className="text-gray-600 dark:text-gray-400">
Booked
</span>
</div>
</div>
{/* 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<string, string> = { 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`);
})()}
</div>
);
})}
</div>
);
}
// 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<string, any[]>();
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 (
<div className="space-y-4">
{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}`);
})}
</div>
);
}
// Regular seats with row/column arrangement
const rowMap = new Map<number, any[]>();
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 (
<div className="space-y-0">
{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 (
<div key={`row-${rowNumber}-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && (
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
{groups.map((group, gIdx) => (
<div
key={`num-before-group-${gIdx}`}
className="flex gap-0.5"
>
{group.map((seat: any) => {
const seatLabel =
seat.label || seat.number || seat.seatNumber || "";
return (
<div
key={`num-${seat.id}`}
className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground"
>
{seatLabel}
</div>
);
})}
</div>
))}
</div>
)}
<div className="flex gap-3 justify-start">
{groups.map((group, gIdx) => (
<div key={`group-${gIdx}`} className="flex gap-0.5">
{group.map((seat: any) => (
<SeatButton
key={seat.id}
seat={seat}
isSelected={isSeatSelected(seat.id)}
isAssignedToOther={isSeatAssignedToOther(seat.id)}
onToggle={handleSeatClick}
isBedCoach={false}
bedLabel=""
coachSeatClass={seatClassStr}
/>
))}
</div>
))}
</div>
{!shouldFlipArmchair && (
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
{groups.map((group, gIdx) => (
<div
key={`num-after-group-${gIdx}`}
className="flex gap-0.5"
>
{group.map((seat: any) => {
const seatLabel =
seat.label || seat.number || seat.seatNumber || "";
return (
<div
key={`num-${seat.id}`}
className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground"
>
{seatLabel}
</div>
);
})}
</div>
))}
</div>
)}
{showSpacing && (
<div className="h-3 border-b border-gray-200 dark:border-gray-700" />
)}
</div>
);
})}
</div>
);
};
if (
isRoundTrip
? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length
@@ -2414,7 +1865,14 @@ export default function SeatsPage() {
No seats in this coach
</p>
) : (
renderCoachSeats(selectedCoachData, isBedCoach)
<CoachSeatLayout
coach={selectedCoachData}
isBedCoach={isBedCoach}
seats={validSeats}
isSeatSelected={isSeatSelected}
isSeatAssignedToOther={isSeatAssignedToOther}
onSeatToggle={handleSeatClick}
/>
)}
</div>
</div>

View File

@@ -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<string, string> = {
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 (
<button
onClick={() => onToggle(bed.id)}
disabled={isDisabled}
title={
isAssignedToOther
? `Bed ${seatLabel} - already assigned to another passenger`
: `${bedType} Berth ${seatLabel} - ${bed.status}`
}
className={`relative flex flex-col items-center justify-center gap-0.5 w-16 sm:w-[4.5rem] py-2.5 rounded-xl border shadow-sm transition-all duration-150 ${
isDisabled ? "" : "hover:shadow-md hover:-translate-y-0.5 active:translate-y-0 active:scale-95"
} ${
isSelected
? "bg-blue-50 border-2 border-blue-500 shadow-blue-200/60 dark:bg-blue-900/30 dark:border-blue-400 dark:shadow-none scale-[1.03]"
: isAssignedToOther
? "bg-purple-50 border-purple-300 cursor-not-allowed dark:bg-purple-900/20 dark:border-purple-700"
: bed.status === "AVAILABLE"
? "bg-green-50 border-green-300 hover:bg-green-100 hover:border-green-400 dark:bg-green-900/20 dark:border-green-700"
: bed.status === "BOOKED" || bed.status === "BLOCKED"
? "bg-red-50 border-red-300 cursor-not-allowed dark:bg-red-900/20 dark:border-red-700"
: "bg-gray-100 border-gray-300 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
}`}
>
{/* bed.png is a portrait (headboard-to-footboard) silhouette; rotate it so the
berth lies horizontally, matching the direction beds actually run in the coach. */}
<div className="w-9 h-6 flex items-center justify-center overflow-visible">
<Image src="/bed.png" alt="bed" width={22} height={36} className="object-contain rotate-90" />
</div>
<div className="text-xs font-bold text-gray-900 dark:text-white">{seatLabel}</div>
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400">{bedType}</div>
</button>
);
});
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(() => (
<div
className="flex flex-col items-center justify-center flex-shrink-0 self-stretch w-6 sm:w-7 py-1.5"
title="Ladder to the middle & upper berths"
aria-hidden="true"
>
<svg width="100%" height="100%" viewBox="0 0 24 90" preserveAspectRatio="none" className="text-gray-400 dark:text-gray-500 drop-shadow-sm">
{/* Side rails */}
<rect x="2" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
<rect x="18.5" y="0" width="3.5" height="90" rx="1.75" fill="currentColor" />
{/* Rungs, evenly spaced top (upper) to bottom (lower) */}
<rect x="2" y="6" width="20" height="4" rx="2" fill="currentColor" />
<rect x="2" y="30" width="20" height="4" rx="2" fill="currentColor" />
<rect x="2" y="54" width="20" height="4" rx="2" fill="currentColor" />
<rect x="2" y="78" width="20" height="4" rx="2" fill="currentColor" />
</svg>
</div>
));
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 (
<div className="flex flex-col items-center">
<button
onClick={() => onToggle(seat.id)}
disabled={isDisabled}
className={`${width} h-11 rounded flex items-center justify-center transition-all ${
isSelected
? "bg-[rgb(20_113_76)] text-white shadow-md scale-105"
: isAssignedToOther
? "bg-purple-400 text-white cursor-not-allowed opacity-75"
: seat.status === "AVAILABLE"
? "bg-green-500 hover:bg-green-600 text-white cursor-pointer hover:shadow-md"
: seat.status === "HELD"
? "bg-yellow-500 text-white cursor-not-allowed opacity-75"
: "bg-gray-500 text-white cursor-not-allowed opacity-60"
}`}
title={
isAssignedToOther
? `Seat ${seatLabel}${bedLabel} - already assigned to another passenger`
: `Seat ${seatLabel}${bedLabel} - ${seat.status} - ${coachSeatClass}`
}
style={
isBedCoach
? seat.row % 2 === 1
? { transform: "scaleY(-1)" }
: undefined
: seat.row % 2 === 0
? { transform: "scaleY(-1)" }
: undefined
}
>
{isBedCoach ? <Bed className="w-7 h-7" /> : <Armchair className="w-7 h-7" />}
</button>
</div>
);
},
);
SeatButton.displayName = "SeatButton";
/** Available / Selected / Booked swatches, shown above every expanded coach. */
export function SeatLegend() {
return (
<div className="flex flex-wrap gap-3 mb-4">
{[
{ 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 }) => (
<div key={label} className="flex items-center gap-1.5">
<div className={`w-4 h-4 ${color} rounded`} />
<span className="text-xs text-gray-600 dark:text-gray-400">{label}</span>
</div>
))}
</div>
);
}
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 <div className="text-xs text-muted-foreground">No seats</div>;
}
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) => (
<div className="flex items-stretch gap-2">
{beds.map((bed: any) => (
<BedCard
key={bed.id}
bed={bed}
isSelected={isSeatSelected(bed.id)}
isAssignedToOther={isSeatAssignedToOther(bed.id)}
onToggle={onSeatToggle}
/>
))}
{beds.length > 1 && <LadderConnector key={`${keyPrefix}-ladder`} />}
</div>
);
// 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) => (
<div
key={key}
className="bg-gray-50 dark:bg-gray-800/40 rounded-2xl p-4 border border-gray-200 dark:border-gray-700 shadow-sm"
>
<div className="flex flex-col items-center gap-3">
{leftBay.length > 0 && (
<div className="flex justify-center">{renderBerthBay(leftBay, `${key}-left`)}</div>
)}
{leftBay.length > 0 && rightBay.length > 0 && (
<div className="w-full border-t-2 border-dashed border-gray-300 dark:border-gray-600" />
)}
{rightBay.length > 0 && (
<div className="flex justify-center">{renderBerthBay(rightBay, `${key}-right`)}</div>
)}
</div>
</div>
);
// 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<string, string>) => {
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 (
<div className="space-y-6">
{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 (
<div
key={room.room_id}
className="bg-gray-50 dark:bg-gray-800/50 rounded-xl p-4 border-2 border-gray-200 dark:border-gray-700"
>
{/* Room Header */}
<div className="flex items-center justify-between mb-4 pb-2 border-b border-gray-300 dark:border-gray-600">
<div>
<h4 className="text-sm font-bold text-gray-900 dark:text-white">
Room {room.roomNumber}
</h4>
<p className="text-xs text-gray-500 dark:text-gray-400">
{room.category === "VIP_BED"
? "VIP BED"
: room.category === "ECONOMY_BED"
? "ECONOMY BED"
: room.category}
</p>
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
{room.totalBeds} beds
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap gap-2 mb-4 text-[10px]">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-green-50 border border-green-300 rounded" />
<span className="text-gray-600 dark:text-gray-400">Available</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-red-50 border border-red-300 rounded" />
<span className="text-gray-600 dark:text-gray-400">Booked</span>
</div>
</div>
{/* 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<string, string> = { 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`);
})()}
</div>
);
})}
</div>
);
}
// 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<string, any[]>();
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 (
<div className="space-y-4">
{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}`);
})}
</div>
);
}
// Regular seats with row/column arrangement
const rowMap = new Map<number, any[]>();
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) => (
<div className="flex gap-3 justify-start text-xs text-muted-foreground mb-1">
{groups.map((group, gIdx) => (
<div key={`${keyPrefix}-group-${gIdx}`} className="flex gap-0.5">
{group.map((seat: any) => (
<div
key={`num-${seat.id}`}
className="w-10 h-4 flex items-center justify-center text-xs font-bold leading-3 text-foreground"
>
{seat.label || seat.number || seat.seatNumber || ""}
</div>
))}
</div>
))}
</div>
);
return (
<div className="space-y-0">
{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 (
<div key={`row-${rowNumber}-${rowSeats[0]?.id}`}>
{shouldFlipArmchair && renderSeatNumberStrip(groups, `before-${rowNumber}`)}
<div className="flex gap-3 justify-start">
{groups.map((group, gIdx) => (
<div key={`group-${gIdx}`} className="flex gap-0.5">
{group.map((seat: any) => (
<SeatButton
key={seat.id}
seat={seat}
isSelected={isSeatSelected(seat.id)}
isAssignedToOther={isSeatAssignedToOther(seat.id)}
onToggle={onSeatToggle}
isBedCoach={false}
bedLabel=""
coachSeatClass={seatClassStr}
/>
))}
</div>
))}
</div>
{!shouldFlipArmchair && renderSeatNumberStrip(groups, `after-${rowNumber}`)}
{showSpacing && <div className="h-3 border-b border-gray-200 dark:border-gray-700" />}
</div>
);
})}
</div>
);
}
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 <p className="text-sm text-gray-500 dark:text-gray-400">{emptyLabel}</p>;
}
return (
<div className="py-1">
{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 (
<div key={coach.id}>
{/* Coupling joint */}
<div className="flex justify-center py-0.5">
<div className="flex flex-col items-center gap-px">
<div className="w-7 h-2 bg-gray-300 dark:bg-gray-600 rounded-sm" />
<div className="w-3 h-3 bg-gray-400 dark:bg-gray-500 rounded-sm" />
<div className="w-7 h-2 bg-gray-300 dark:bg-gray-600 rounded-sm" />
</div>
</div>
{/* Coach car */}
<div
className={`border-2 overflow-hidden transition-all duration-200 ${
isExpanded
? "border-[rgb(20,113,76)] shadow-lg shadow-[rgb(20,113,76)]/10"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
{/* Top colour stripe — brand rail */}
<div
className={`h-1.5 transition-colors duration-200 ${
isExpanded ? "bg-[rgb(20,113,76)]" : "bg-gray-200 dark:bg-gray-700"
}`}
/>
<button
type="button"
onClick={() => onSelectCoach(isExpanded ? null : coach.id)}
className={`w-full flex items-center justify-between px-4 py-3 transition-colors ${
isExpanded
? "bg-[rgb(20,113,76)]/5 dark:bg-[rgb(20,113,76)]/10"
: "bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-900/30"
}`}
>
<div className="flex items-center gap-3">
<div
className={`w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0 font-bold text-sm transition-colors ${
isExpanded
? "bg-[rgb(20,113,76)] text-white"
: "bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300"
}`}
>
{index + 1}
</div>
<div className="text-left">
<div
className={`font-semibold text-sm ${
isExpanded ? "text-[rgb(20,113,76)]" : "text-gray-900 dark:text-white"
}`}
>
{coachLabel}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{available} of {total} seats available
</div>
</div>
</div>
<div className="flex items-center gap-3">
{/* Mini availability bars */}
<div className="hidden sm:flex items-end gap-0.5 h-5">
{Array.from({ length: Math.min(total, 12) }).map((_, i) => (
<div
key={i}
className={`w-1 rounded-sm transition-colors ${
i < Math.round((available / Math.max(total, 1)) * Math.min(total, 12))
? "h-full bg-green-400"
: "h-3 bg-gray-200 dark:bg-gray-600"
}`}
/>
))}
</div>
<ChevronDownIcon isExpanded={isExpanded} />
</div>
</button>
{/* Expanded seat map */}
{isExpanded && (
<div className="border-t border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-800 p-4">
<SeatLegend />
<div className="overflow-x-auto">
<div className="inline-block bg-gray-50 dark:bg-gray-700/30 rounded-xl p-4 border border-gray-200 dark:border-gray-700">
{coachSeats.length === 0 ? (
<p className="text-sm text-gray-400 py-4">No seats in this coach</p>
) : (
<CoachSeatLayout
coach={coach}
isBedCoach={isBedCoach}
seats={coachSeats}
isSeatSelected={isSeatSelected}
isSeatAssignedToOther={isSeatAssignedToOther}
onSeatToggle={onSeatToggle}
/>
)}
</div>
</div>
</div>
)}
{/* Bottom colour stripe */}
<div
className={`h-1.5 transition-colors duration-200 ${
isExpanded ? "bg-[rgb(20,113,76)]" : "bg-gray-200 dark:bg-gray-700"
}`}
/>
</div>
</div>
);
})}
</div>
);
}
function ChevronDownIcon({ isExpanded }: { isExpanded: boolean }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={`w-4 h-4 transition-transform duration-200 flex-shrink-0 ${
isExpanded ? "rotate-180 text-[rgb(20,113,76)]" : "text-gray-400"
}`}
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}

View File

@@ -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<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<div ref={ref} className="relative">
<div
className={`relative flex items-center border-2 rounded-xl transition-all duration-200 ${
disabled
? "bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700"
: "bg-white dark:bg-gray-800"
} ${
error
? "border-red-400"
: disabled
? ""
: open
? "border-primary ring-2 ring-primary/20"
: "border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600"
}`}
>
<MapPin
className={`absolute left-3.5 w-4 h-4 flex-shrink-0 ${disabled ? "text-gray-400" : "text-primary"}`}
/>
<input
ref={inputRef}
value={displayValue}
readOnly={disabled}
disabled={disabled}
onChange={(e) => {
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 && (
<button
type="button"
onClick={() => {
onSelect({ id: "", name: "", code: "", country: "" });
setQuery("");
}}
className="absolute right-3 p-0.5"
>
<X className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{open && !disabled && (
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl z-[200] max-h-96 overflow-y-auto overflow-x-hidden scrollbar-hide">
{!query && recentIds.length > 0 && (
<div className="px-3 pt-2 pb-1">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wide mb-1">
Recent
</p>
{recentIds
.map((id) => stations.find((s) => s.id === id))
.filter(Boolean)
.map((s) => (
<button
key={s!.id}
type="button"
onMouseDown={() => {
onSelect(s!);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-2 py-2 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-left"
>
<Clock className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<span className="text-sm text-gray-800 dark:text-gray-200">
{s!.name}
</span>
</button>
))}
<div className="border-t border-gray-100 dark:border-gray-700 mt-1 mb-1" />
</div>
)}
{filtered.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">
No stations found
</p>
) : (
filtered.map((s) => (
<button
key={s.id}
type="button"
onMouseDown={() => {
onSelect(s);
setOpen(false);
setQuery("");
}}
className="w-full flex items-center gap-2 px-3 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-700 text-left transition-colors"
>
<MapPin className="w-3.5 h-3.5 text-primary flex-shrink-0" />
<div>
<span className="text-sm font-medium text-gray-900 dark:text-white">
{s.name}
</span>
{s.code && (
<span className="text-xs text-gray-400 ml-1.5">
{s.code}
</span>
)}
</div>
</button>
))
)}
</div>
)}
</div>
);
}

View File

@@ -40,6 +40,8 @@ interface PassengerVoucherData {
// payment.amountMinor straight from the API) and must NOT be divided by 100 — as
// opposed to the normal case where fareMinor is genuine minor units (cents).
fareIsMajorUnits?: boolean;
/** Itemises the total when a booking was paid more than once (e.g. after a reschedule). */
paymentLines?: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>;
}
// ─── palette ───────────────────────────────────────────────────────────────
@@ -365,6 +367,43 @@ function drawFareSummary(doc: jsPDF, fareMinor: number, currency: string, y: num
return y + cardH + 6;
}
function drawPaymentBreakdown(
doc: jsPDF,
lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>,
y: number,
margin: number,
pageWidth: number,
): number {
if (lines.length < 2) return y;
const rowH = 6;
const padX = 7;
const headerH = 7;
const cardH = headerH + lines.length * rowH + 3;
doc.setDrawColor(...HAIRLINE);
doc.setLineWidth(0.2);
doc.roundedRect(margin, y, pageWidth - margin * 2, cardH, 3, 3, 'S');
doc.setFontSize(7.5); doc.setFont('helvetica', 'bold'); doc.setTextColor(...MUTED);
doc.text('PAYMENT BREAKDOWN', margin + padX, y + 5, { charSpace: 0.2 });
lines.forEach((line, i) => {
const rowY = y + headerH + i * rowH;
doc.setFontSize(8.5); doc.setFont('helvetica', 'normal'); doc.setTextColor(...BODY);
// No status suffix: the caller passes settled lines only, so every row here is paid.
doc.text(line.label, margin + padX, rowY + 4);
doc.setFont('helvetica', 'bold'); doc.setTextColor(...INK);
doc.text(
`${line.currency} ${(line.amountMinor / 100).toFixed(2)}`,
pageWidth - margin - padX,
rowY + 4,
{ align: 'right' },
);
});
return y + cardH + 6;
}
// ─── instructions ──────────────────────────────────────────────────────────
@@ -425,6 +464,7 @@ async function drawPassengerVoucherPage(doc: jsPDF, data: PassengerVoucherData):
y = drawPassengerDetails(doc, data, y, margin, pageW);
y = drawFareSummary(doc, data.fareMinor, data.currency, y, margin, pageW, data.fareIsMajorUnits);
y = drawPaymentBreakdown(doc, data.paymentLines ?? [], y, margin, pageW);
y = drawInstructions(doc, y, margin, pageW);
drawFooter(doc, data.createdAt, y);
}
@@ -474,15 +514,32 @@ interface VoucherData {
tickets?: Array<{ passengerName?: string; leg?: number; barcodePayload?: string }>;
// The actual settled amount/currency for this booking's payment — preferred over the
// ETB booking total once available, since it reflects what was really charged.
payment?: { amountMinor?: number; currency?: string };
payment?: {
amountMinor?: number;
currency?: string;
/** Every line collected for this booking — see the API's payment-breakdown.util.ts. */
breakdown?: {
lines: Array<{ label: string; amountMinor: number; currency: string; status: string; settled: boolean }>;
totalPaidMinor: number | null;
totalPaidCurrency: string | null;
};
};
}
export const generateVoucherPDF = async (booking: VoucherData): Promise<void> => {
// Currency always comes straight from the booking/payment data, never hardcoded — the
// settled payment currency when a payment has settled, otherwise the booking's own
// display currency (falling back to the internal ETB currency field).
const settledAmountMinor = booking.payment?.amountMinor;
const settledCurrency = booking.payment?.currency;
// `payment.amountMinor` is the ORIGINAL intent only — it never moves when a booking is
// rescheduled, so a voucher built from it reports the pre-reschedule amount forever (a 51.85
// booking moved to a 1752.34 journey still printed 51.85). `payment.breakdown` counts every
// line collected, including the reschedule fee and fare difference. It arrives in true minor
// units, so it is divided down to the major-unit basis the rest of this function expects.
const breakdown = booking.payment?.breakdown;
const settledAmountMinor =
breakdown?.totalPaidMinor != null ? breakdown.totalPaidMinor / 100 : booking.payment?.amountMinor;
const settledCurrency =
(breakdown?.totalPaidMinor != null ? breakdown.totalPaidCurrency : booking.payment?.currency) ?? undefined;
const useSettledAmount = settledAmountMinor != null && !!settledCurrency;
// Prefer displayCurrency (passenger's home currency) over the internal ETB currency field.
const voucherCurrency = useSettledAmount ? settledCurrency! : (booking.displayCurrency || booking.currency || 'ETB');
@@ -541,7 +598,10 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
// This passenger's own fare, scaled onto the same currency/amount basis as the rest of
// the voucher — not the booking's overall total, and not an equal share of it.
const passengerFareMinor = Math.round(p.fareMinor * fareScaleFactor);
const scaledFare = p.fareMinor * fareScaleFactor;
const passengerFareMinor = useSettledAmount
? Math.round(scaledFare * 100) / 100
: Math.round(scaledFare);
await generatePassengerVoucherPDF({
bookingRef: booking.bookingRef,
@@ -560,6 +620,7 @@ export const generateVoucherPDF = async (booking: VoucherData): Promise<void> =>
fareMinor: passengerFareMinor,
currency: voucherCurrency,
fareIsMajorUnits: useSettledAmount,
paymentLines: grouped.size === 1 ? breakdown?.lines.filter((l) => l.settled) : undefined,
createdAt: booking.createdAt,
});
}

View File

@@ -0,0 +1,16 @@
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)}`;
if (digits.length === 9) return `+251${digits}`;
return `+${digits}`;
}
export function samePhone(a?: string | null, b?: string | null): boolean {
const left = normalizePhone(a);
const right = normalizePhone(b);
return !!left && !!right && left === right;
}