mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -28,7 +28,7 @@ type EmployeeLike = {
|
||||
delegatedPositions?: PositionLike[];
|
||||
};
|
||||
|
||||
type MeLikeUser = {
|
||||
export type MeLikeUser = {
|
||||
roles?: { key?: string }[];
|
||||
permissions?: PermissionLike[];
|
||||
employee?: EmployeeLike | EmployeeLike[] | null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -74,6 +74,6 @@ function rabbitMQImport(): DynamicModule[] {
|
||||
PaymentSyncService,
|
||||
ServiceAuthGuard,
|
||||
],
|
||||
exports: [PaymentClientService, PaymentsService],
|
||||
exports: [PaymentClientService, PaymentsService, SupplementaryChargesService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -69,6 +69,7 @@ describe('SupplementaryChargesService — payment methods', () => {
|
||||
{} as any,
|
||||
paymentClient as any,
|
||||
new CurrencyService(prisma as any),
|
||||
{ emit: jest.fn() } as any,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../dashboard/layout';
|
||||
|
||||
export default function ReschedulePoliciesLayout({ children }: { children: React.ReactNode }) {
|
||||
return <DashboardLayout>{children}</DashboardLayout>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
706
apps/edr-passenger-web/portal/src/components/SeatMap.tsx
Normal file
706
apps/edr-passenger-web/portal/src/components/SeatMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
apps/edr-passenger-web/portal/src/components/StationDropdown.tsx
Normal file
215
apps/edr-passenger-web/portal/src/components/StationDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
16
apps/edr-passenger-web/portal/src/utils/phone.ts
Normal file
16
apps/edr-passenger-web/portal/src/utils/phone.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user