Added Checkin duration configuration

This commit is contained in:
Roba Boru
2026-07-17 22:12:54 +03:00
parent 0c584cce88
commit 738b7df5cf
14 changed files with 297 additions and 132 deletions

View File

@@ -0,0 +1,12 @@
-- Rename StopStatus enum values to reflect segment-level booking lifecycle.
-- UPCOMING → OPEN (segment is bookable)
-- APPROACHING → CHECKIN_CLOSED (within check-in cutoff, no new bookings)
-- CURRENT → BOARDED (train has departed this stop)
-- COMPLETED stays as-is
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'UPCOMING' TO 'OPEN';
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'APPROACHING' TO 'CHECKIN_CLOSED';
ALTER TYPE "passenger"."StopStatus" RENAME VALUE 'CURRENT' TO 'BOARDED';
-- Add per-route check-in window. Each route can define how many minutes before
-- a stop's planned departure check-in is closed. Defaults to 30 minutes.
ALTER TABLE "passenger"."Route" ADD COLUMN "checkinMinutesBefore" INTEGER NOT NULL DEFAULT 30;

View File

@@ -0,0 +1 @@
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "checkinMinutesBefore" INTEGER;

View File

@@ -180,10 +180,10 @@ enum NotificationCategory {
}
enum StopStatus {
OPEN
CHECKIN_CLOSED
BOARDED
COMPLETED
APPROACHING
CURRENT
UPCOMING
@@schema("passenger")
}
@@ -397,7 +397,7 @@ model TripStopTime {
plannedArrivalAt DateTime?
plannedDepartureAt DateTime?
actualArrivalAt DateTime?
status StopStatus @default(UPCOMING)
status StopStatus @default(OPEN)
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
station Station @relation(fields: [stationId], references: [id])
@@ -1026,14 +1026,15 @@ model PasswordResetToken {
}
model Route {
id String @id @default(uuid())
code String @unique
name String
description String?
effectiveFrom DateTime
effectiveUntil DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
id String @id @default(uuid())
code String @unique
name String
description String?
effectiveFrom DateTime
effectiveUntil DateTime?
active Boolean @default(true)
checkinMinutesBefore Int @default(30)
createdAt DateTime @default(now())
stops RouteStop[]
fareRules RouteFareRule[]
segmentFares SegmentFareRule[]
@@ -1043,13 +1044,14 @@ model Route {
}
model RouteStop {
id String @id @default(uuid())
routeId String
stationId String
sequence Int
distanceKm Float?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
id String @id @default(uuid())
routeId String
stationId String
sequence Int
distanceKm Float?
checkinMinutesBefore Int?
createdAt DateTime @default(now())
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
@@unique([routeId, sequence])
@@index([routeId, stationId])

View File

@@ -13,10 +13,17 @@ export const MAX_PAYMENT_HOURS = 2;
export const CUTOFF_MINUTES = 30;
/**
* payment_deadline = MIN(booking_time + 2h, departure_time - 30min)
* payment_deadline = MIN(booking_time + MAX_PAYMENT_HOURS, segment_departure - checkinMinutes)
*
* checkinMinutes defaults to CUTOFF_MINUTES but callers should pass the route-level
* checkinMinutesBefore so that each route's own window is respected.
*/
export function computePaymentDeadline(createdAt: Date, departureAt: Date): Date {
export function computePaymentDeadline(
createdAt: Date,
departureAt: Date,
checkinMinutes: number = CUTOFF_MINUTES,
): Date {
const maxDeadline = new Date(createdAt.getTime() + MAX_PAYMENT_HOURS * 60 * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - CUTOFF_MINUTES * 60 * 1000);
const cutoffDeadline = new Date(departureAt.getTime() - checkinMinutes * 60 * 1000);
return maxDeadline < cutoffDeadline ? maxDeadline : cutoffDeadline;
}

View File

@@ -12,7 +12,7 @@ export class LiveService {
});
if (!schedule) throw new NotFoundException('Schedule not found');
const live = schedule.liveStatus;
const nextStop = schedule.stopTimes.find((s) => s.status === 'UPCOMING' || s.status === 'APPROACHING');
const nextStop = schedule.stopTimes.find((s) => s.status === 'OPEN' || s.status === 'CHECKIN_CLOSED');
return {
scheduleId: schedule.id, trainName: schedule.train.name,
fromStationName: schedule.originStation.name, toStationName: schedule.destinationStation.name,

View File

@@ -6,6 +6,7 @@ export class RouteStopInputDto {
@ApiProperty({ example: 'station-uuid', description: 'Station UUID' }) @IsString() stationId: string;
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 120.5, description: 'Distance in km from previous stop' }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
}
export class CreateRouteDto {
@@ -35,6 +36,7 @@ export class AddRouteStopDto {
@ApiProperty({ example: 'station-uuid' }) @IsString() stationId: string;
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: number;
@ApiPropertyOptional({ example: 45, description: 'Override check-in cutoff (minutes) for this stop. Falls back to route-level checkinMinutesBefore if omitted.' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
}
export class UpdateRouteDto {
@@ -42,6 +44,7 @@ export class UpdateRouteDto {
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() active?: boolean;
@ApiPropertyOptional({ example: '2027-12-31T23:59:59Z' }) @IsOptional() @IsDateString() effectiveUntil?: string;
@ApiPropertyOptional({ example: 30, description: 'Minutes before departure to close check-in for this route' }) @IsOptional() @IsInt() @Min(1) checkinMinutesBefore?: number;
@ApiPropertyOptional({ type: [RouteStopInputDto] }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => RouteStopInputDto) stops?: RouteStopInputDto[];
}

View File

@@ -36,6 +36,7 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
})),
},
},
@@ -92,6 +93,7 @@ export class RoutesService {
description: dto.description,
active: dto.active,
effectiveUntil: dto.effectiveUntil ? new Date(dto.effectiveUntil) : undefined,
...(dto.checkinMinutesBefore != null ? { checkinMinutesBefore: dto.checkinMinutesBefore } : {}),
},
});
@@ -103,6 +105,7 @@ export class RoutesService {
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
})),
});
}
@@ -221,6 +224,7 @@ export class RoutesService {
stationId: dto.stationId,
sequence: dto.sequence,
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
},
});
}

View File

@@ -12,10 +12,10 @@ export enum TripStatus {
}
export enum StopStatus {
OPEN = 'OPEN',
CHECKIN_CLOSED = 'CHECKIN_CLOSED',
BOARDED = 'BOARDED',
COMPLETED = 'COMPLETED',
APPROACHING = 'APPROACHING',
CURRENT = 'CURRENT',
UPCOMING = 'UPCOMING',
}
export enum PassengerCategory {
@@ -65,7 +65,7 @@ export class UpdateScheduleDto {
export class UpdateStopTimeDto {
@ApiPropertyOptional({ example: '2026-06-15T09:30:00Z' }) @IsOptional() @IsDateString() plannedArrivalAt?: string;
@ApiPropertyOptional({ example: '2026-06-15T09:45:00Z' }) @IsOptional() @IsDateString() plannedDepartureAt?: string;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.UPCOMING }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
@ApiPropertyOptional({ enum: StopStatus, example: StopStatus.OPEN }) @IsOptional() @IsEnum(StopStatus) status?: StopStatus;
}
export class CreateFareRuleDto {

View File

@@ -4,10 +4,9 @@ import { SearchService } from './search.service';
import { CurrencyModule } from '../currency/currency.module';
import { FareEngineModule } from '../fare-engine/fare-engine.module';
import { SegmentsModule } from '../segments/segments.module';
import { SystemConfigModule } from '../system-config/system-config.module';
@Module({
imports: [CurrencyModule, FareEngineModule, SegmentsModule, SystemConfigModule],
imports: [CurrencyModule, FareEngineModule, SegmentsModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],

View File

@@ -6,7 +6,6 @@ import { FareEngineService } from '../fare-engine/fare-engine.service';
import { SegmentsService } from '../segments/segments.service';
import { resolveCurrencyFromNationality } from '../fare-engine/fare-engine.dto';
import { Currency } from '@prisma/client';
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
const POINTS_TO_MINOR = 10;
@@ -20,6 +19,7 @@ type ScheduleWithIncludes = {
train: any;
originStation: any;
destinationStation: any;
route: { checkinMinutesBefore: number; stops: Array<{ stationId: string; checkinMinutesBefore: number | null }> } | null;
stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>;
coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>;
};
@@ -28,6 +28,7 @@ const SCHEDULE_INCLUDE = {
train: true,
originStation: true,
destinationStation: true,
route: { select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } } },
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
coachAssignments: {
include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } },
@@ -41,13 +42,8 @@ export class SearchService {
private currencyService: CurrencyService,
private fareEngine: FareEngineService,
private segmentsService: SegmentsService,
private systemConfig: SystemConfigService,
) {}
private async getCutoffHours(): Promise<number> {
return this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE);
}
async searchTrips(dto: SearchTripsDto) {
const [direct, transit] = await Promise.all([
this.searchSchedules(
@@ -218,10 +214,11 @@ export class SearchService {
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(now.getTime() + cutoffHours * 60 * 60 * 1000);
// Use now as the lower bound for today so we don't fetch schedules that have
// already fully departed. The per-segment cutoff check in buildScheduleResult
// handles the exact check using each stop's own plannedDepartureAt.
const isToday = now.getFullYear() === y && now.getMonth() === m - 1 && now.getDate() === d;
const earliest = isToday ? cutoffThreshold : date;
const earliest = isToday ? now : date;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
@@ -284,12 +281,9 @@ export class SearchService {
}),
]);
const cutoffHours = await this.getCutoffHours();
const cutoffThreshold = new Date(Date.now() + cutoffHours * 60 * 60 * 1000);
const results: any[] = [];
for (const leg1 of (leg1Schedules as ScheduleWithIncludes[]).filter(s => new Date(s.departureAt) > cutoffThreshold)) {
for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) {
const originStop = leg1.stopTimes.find(s => s.stationId === originStationId);
if (!originStop) continue;
@@ -376,6 +370,16 @@ export class SearchService {
const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId);
if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null;
// Segment-level cutoff: use the origin stop's planned departure, not the
// schedule's overall departureAt (which is station A's time). This lets
// B→D remain bookable even after A→D closes.
// Cutoff resolution: stop-level override → route default → 30 min fallback.
const now = new Date();
const segmentDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const routeStop = schedule.route?.stops?.find(s => s.stationId === originStationId);
const checkinMinutes = routeStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
if (segmentDepartureAt.getTime() - now.getTime() <= checkinMinutes * 60 * 1000) return null;
// Collect all valid seat IDs upfront for a single batch availability check
const allValidSeatIds = schedule.coachAssignments.flatMap(a =>
a.coach.seats

View File

@@ -268,23 +268,38 @@ export class SeatsService {
if (new Set(seatIds).size !== seatIds.length)
throw new BadRequestException('Duplicate seatId in passengers list');
const [holdMinutes, cutoffHours] = await Promise.all([
this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES),
this.systemConfig.getNumber(CONFIG_KEYS.HOLD_CUTOFF_HOURS_BEFORE_DEPARTURE),
]);
const holdMinutes = await this.systemConfig.getNumber(CONFIG_KEYS.SEAT_HOLD_DURATION_MINUTES);
const expiresAt = new Date(Date.now() + holdMinutes * 60 * 1000);
const schedule = await this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: { departureAt: true },
});
const [schedule, originStopTime, originRouteStop] = await Promise.all([
this.prisma.trainSchedule.findUnique({
where: { id: dto.scheduleId },
select: {
departureAt: true,
route: { select: { checkinMinutesBefore: true } },
},
}),
this.prisma.tripStopTime.findFirst({
where: { scheduleId: dto.scheduleId, stationId: dto.originStationId },
select: { plannedDepartureAt: true },
}),
this.prisma.routeStop.findFirst({
where: {
route: { schedules: { some: { id: dto.scheduleId } } },
stationId: dto.originStationId,
},
select: { checkinMinutesBefore: true },
}),
]);
if (!schedule) throw new NotFoundException('Schedule not found');
const msUntilDeparture = schedule.departureAt.getTime() - Date.now();
const cutoffMs = cutoffHours * 60 * 60 * 1000;
if (msUntilDeparture <= cutoffMs) {
// Stop-level override wins; falls back to route-level; then to 30 min.
const checkinMinutes = originRouteStop?.checkinMinutesBefore ?? schedule.route?.checkinMinutesBefore ?? 30;
const segmentDepartureAt = originStopTime?.plannedDepartureAt ?? schedule.departureAt;
const msUntilDeparture = segmentDepartureAt.getTime() - Date.now();
if (msUntilDeparture <= checkinMinutes * 60 * 1000) {
throw new BadRequestException(
`Seats cannot be held within ${cutoffHours} hour${cutoffHours !== 1 ? 's' : ''} of departure`,
`Seats cannot be held within ${checkinMinutes} minute${checkinMinutes !== 1 ? 's' : ''} of departure`,
);
}

View File

@@ -42,6 +42,7 @@ export class TasksService {
const now = new Date();
const thirtyMinFromNow = new Date(now.getTime() + CUTOFF_MINUTES * 60 * 1000);
// ── Schedule-level transitions (operational display) ───────────────────
const [boarding, departed, arrived] = await Promise.all([
this.prisma.trainSchedule.updateMany({
where: { status: 'SCHEDULED', departureAt: { lte: thirtyMinFromNow } },
@@ -57,9 +58,71 @@ export class TasksService {
}),
]);
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0) {
// ── Per-stop transitions (segment-level status) ────────────────────────
// OPEN → CHECKIN_CLOSED: each RouteStop carries its own checkinMinutesBefore
// override; falls back to the Route-level value when null.
// Group by effective cutoff → one updateMany per (effectiveMins, routeId) pair.
const routeStops = await this.prisma.routeStop.findMany({
select: {
routeId: true,
stationId: true,
checkinMinutesBefore: true,
route: { select: { checkinMinutesBefore: true } },
},
});
// Map: effectiveMins → Map<routeId, stationId[]>
const byMins = new Map<number, Map<string, string[]>>();
for (const stop of routeStops) {
const mins = stop.checkinMinutesBefore ?? stop.route.checkinMinutesBefore;
if (!byMins.has(mins)) byMins.set(mins, new Map());
const byRoute = byMins.get(mins)!;
if (!byRoute.has(stop.routeId)) byRoute.set(stop.routeId, []);
byRoute.get(stop.routeId)!.push(stop.stationId);
}
let reopenedCount = 0;
let checkinClosedCount = 0;
for (const [mins, byRoute] of byMins) {
const cutoffAt = new Date(now.getTime() + mins * 60 * 1000);
for (const [routeId, stationIds] of byRoute) {
// Revert first: if the cutoff was reduced, stops that were prematurely closed
// should reopen (departure is still beyond the new cutoff window).
const reverted = await this.prisma.tripStopTime.updateMany({
where: {
status: 'CHECKIN_CLOSED',
plannedDepartureAt: { gt: cutoffAt },
stationId: { in: stationIds },
schedule: { routeId },
},
data: { status: 'OPEN' },
});
reopenedCount += reverted.count;
// Forward: close stops now within the cutoff window.
const closed = await this.prisma.tripStopTime.updateMany({
where: {
status: 'OPEN',
plannedDepartureAt: { lte: cutoffAt },
stationId: { in: stationIds },
schedule: { routeId },
},
data: { status: 'CHECKIN_CLOSED' },
});
checkinClosedCount += closed.count;
}
}
const boardedStops = await this.prisma.tripStopTime.updateMany({
where: { status: 'CHECKIN_CLOSED', plannedDepartureAt: { lte: now } },
data: { status: 'BOARDED' },
});
if (boarding.count > 0 || departed.count > 0 || arrived.count > 0 ||
reopenedCount > 0 || checkinClosedCount > 0 || boardedStops.count > 0) {
this.logger.log(
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED`,
`Schedule sync: ${boarding.count} → BOARDING, ${departed.count} → EN_ROUTE, ${arrived.count} → ARRIVED | ` +
`Stops: ${reopenedCount} → OPEN (reverted), ${checkinClosedCount} → CHECKIN_CLOSED, ${boardedStops.count} → BOARDED`,
);
}
}
@@ -96,13 +159,17 @@ export class TasksService {
status: 'PENDING_PAYMENT',
paymentReminderSentAt: null,
createdAt: { gte: threeHoursAgo },
schedule: { departureAt: { gte: now } },
} as any,
// Do NOT filter by schedule.departureAt here: for multi-stop routes the
// passenger's segment may depart well after the schedule's first stop, and
// that first-stop time could already be in the past even though B→C is still open.
},
include: {
schedule: {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
},
},
},
@@ -110,9 +177,15 @@ export class TasksService {
for (const booking of bookings) {
try {
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
const createdAt = booking.createdAt as Date;
// Use the booking's origin-segment departure and the route's own check-in window.
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const checkinMinutes = (booking.schedule as any).route?.checkinMinutesBefore ?? 30;
if (dep <= now) continue; // segment has already departed; cancel job handles clean-up
const paymentDeadline = computePaymentDeadline(createdAt, dep, checkinMinutes);
const totalWindowMs = paymentDeadline.getTime() - createdAt.getTime();
// Skip degenerate windows (< 2 min) — the cancel job will handle these immediately
@@ -176,6 +249,8 @@ export class TasksService {
include: {
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
stopTimes: { select: { stationId: true, plannedDepartureAt: true } },
route: { select: { checkinMinutesBefore: true } },
},
},
paymentIntent: { select: { method: true } },
@@ -187,9 +262,14 @@ export class TasksService {
for (const booking of expiredBookings) {
try {
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation
const createdAt = booking.createdAt as Date;
const dep = booking.schedule.departureAt as Date;
// Re-verify exact deadline to avoid racing with a concurrent payment confirmation.
// Use the booking's origin-segment departure for the deadline so that a B→C booking
// on an A→B→C→D schedule gets the correct payment window anchored to B, not A.
const createdAt = booking.createdAt as Date;
const originStop = (booking.schedule as any).stopTimes?.find(
(s: any) => s.stationId === (booking as any).originStationId,
);
const dep = (originStop?.plannedDepartureAt ?? booking.schedule.departureAt) as Date;
const paymentDeadline = computePaymentDeadline(createdAt, dep);
if (now < paymentDeadline) continue;
@@ -201,7 +281,7 @@ export class TasksService {
// this they'd otherwise keep the seat locked for up to MAX_PAYMENT_HOURS even
// though the booking is now cancelled. Scoped to this booking's own schedule,
// since the same physical Seat row is reused across other recurring dates.
const seatIds = booking.seats.map(s => s.seatId);
const seatIds = booking.seats.map((s: any) => s.seatId);
if (seatIds.length > 0) {
await this.prisma.seatHold.deleteMany({
where: { scheduleId: booking.scheduleId, seatIds: { hasSome: seatIds } },

View File

@@ -16,6 +16,7 @@ interface RouteStop {
sequence: number;
distanceKm?: number;
distanceFromOrigin?: number;
checkinMinutesBefore?: number;
}
type Tab = 'routes' | 'coaches';
@@ -167,10 +168,13 @@ export default function RoutesPage() {
const [activeTab, setActiveTab] = useState<Tab>('routes');
const [showModal, setShowModal] = useState(false);
const [editingRoute, setEditingRoute] = useState<any>(null);
const [editLoading, setEditLoading] = useState(false);
const [stops, setStops] = useState<RouteStop[]>([]);
const [originStationId, setOriginStationId] = useState('');
const [destinationStationId, setDestinationStationId] = useState('');
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = useState<number | undefined>(undefined);
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
@@ -241,19 +245,22 @@ export default function RoutesPage() {
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
const stopsArray = [
{ stationId: originStationId, sequence: 1, distanceKm: 0 },
{ stationId: originStationId, sequence: 1, distanceKm: 0, checkinMinutesBefore: originCheckinMinutes ?? undefined },
...sortedMiddleStops.map((stop, idx) => ({
stationId: stop.stationId,
sequence: idx + 2,
distanceKm: stop.distanceFromOrigin || 0,
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
})),
{
stationId: destinationStationId,
sequence: sortedMiddleStops.length + 2,
distanceKm: destinationDistance || 0,
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
},
];
const checkinRaw = formData.get('checkinMinutesBefore') as string;
const routeData = {
code: formData.get('code') as string,
name: formData.get('name') as string,
@@ -261,6 +268,7 @@ export default function RoutesPage() {
active: !editingRoute ? (formData.get('active') !== 'false') : undefined,
effectiveFrom: formData.get('effectiveFrom') as string,
effectiveUntil: formData.get('effectiveUntil') as string || undefined,
checkinMinutesBefore: checkinRaw ? parseInt(checkinRaw) : undefined,
stops: stopsArray,
};
@@ -341,6 +349,11 @@ export default function RoutesPage() {
{ key: 'code', label: 'Route Code', sortable: true },
{ key: 'name', label: 'Route Name', sortable: true },
{ key: 'description', label: 'Description', render: (route: any) => route.description || 'N/A' },
{
key: 'checkinMinutesBefore',
label: 'Check-in Cutoff',
render: (route: any) => `${route.checkinMinutesBefore ?? 30} min`,
},
{
key: 'active',
label: 'Status',
@@ -363,29 +376,38 @@ export default function RoutesPage() {
);
});
const openEditModal = async (route: any) => {
setEditLoading(true);
try {
const full = await routesApi.getById(route.id) as any;
const routeStops: any[] = full?.stops || [];
setEditingRoute(full ?? route);
if (routeStops.length >= 2) {
const originStop = routeStops[0];
const destStop = routeStops[routeStops.length - 1];
setOriginStationId(originStop.stationId);
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
setDestinationStationId(destStop.stationId);
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
setDestinationDistance(destStop.distanceKm || 0);
setStops(routeStops.slice(1, -1).map((s: any) => ({
stationId: s.stationId,
sequence: s.sequence,
distanceKm: s.distanceKm,
distanceFromOrigin: s.distanceKm || 0,
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
})));
}
setShowModal(true);
} finally {
setEditLoading(false);
}
};
const routeActions = [
{
label: 'Edit',
onClick: (route: any) => {
setEditingRoute(route);
const routeStops = route.stops || [];
if (routeStops.length >= 2) {
setOriginStationId(routeStops[0].stationId);
setDestinationStationId(routeStops[routeStops.length - 1].stationId);
// distanceKm is cumulative from origin — read directly
setDestinationDistance(routeStops[routeStops.length - 1].distanceKm || 0);
const middleStops = routeStops.slice(1, -1).map((stop: any) => ({
stationId: stop.stationId,
sequence: stop.sequence,
distanceKm: stop.distanceKm,
distanceFromOrigin: stop.distanceKm || 0,
}));
setStops(middleStops);
}
setShowModal(true);
},
onClick: openEditModal,
variant: 'secondary' as const,
icon: Edit,
},
@@ -410,10 +432,11 @@ export default function RoutesPage() {
onClick={() => {
setEditingRoute(null);
setOriginStationId('');
setOriginCheckinMinutes(undefined);
setDestinationStationId('');
setDestinationCheckinMinutes(undefined);
setDestinationDistance(undefined);
setStops([]);
setSearch('');
setShowModal(true);
}}
>
@@ -456,7 +479,7 @@ export default function RoutesPage() {
data={displayedRoutes}
columns={routeColumns}
actions={routeActions}
loading={routesLoading}
loading={routesLoading || editLoading}
emptyMessage={search ? 'No routes match your search' : 'No routes found'}
/>
</>
@@ -488,7 +511,9 @@ export default function RoutesPage() {
setShowModal(false);
setEditingRoute(null);
setOriginStationId('');
setOriginCheckinMinutes(undefined);
setDestinationStationId('');
setDestinationCheckinMinutes(undefined);
setDestinationDistance(undefined);
setStops([]);
setSearch('');
@@ -588,6 +613,23 @@ export default function RoutesPage() {
/>
</div>
<div>
<label className="label">Check-in Cutoff (minutes before departure)</label>
<input
type="number"
name="checkinMinutesBefore"
className="input"
defaultValue={editingRoute?.checkinMinutesBefore ?? 30}
min={1}
max={480}
step={1}
required
/>
<p className="text-xs text-muted-foreground mt-1">
Booking closes and check-in ends this many minutes before each stop's departure. Default: 30.
</p>
</div>
{!editingRoute && (
<div>
<label className="label">Status</label>
@@ -623,7 +665,7 @@ export default function RoutesPage() {
<div className="border-t pt-4">
<div className="flex items-center justify-between mb-3">
<label className="label mb-0">Route Stops</label>
<span className="text-xs text-muted-foreground">Drag to rearrange intermediate stops</span>
<span className="text-xs text-muted-foreground">Drag to rearrange · <span className="font-medium">Cutoff min</span> overrides route check-in window per stop (leave blank to inherit)</span>
</div>
<div className="space-y-2">
@@ -641,9 +683,18 @@ export default function RoutesPage() {
<span className="text-muted-foreground">Select origin station above</span>
)}
</div>
<div className="text-sm text-muted-foreground">
0 km
<div className="w-28 flex-shrink-0">
<input
type="number"
className="input input-sm"
placeholder="cutoff min"
value={originCheckinMinutes ?? ''}
onChange={(e) => setOriginCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
min={1}
title="Check-in cutoff override (minutes) for this stop"
/>
</div>
<div className="text-sm text-muted-foreground w-12 text-right flex-shrink-0">0 km</div>
</div>
{stops.map((stop, index) => (
@@ -678,7 +729,7 @@ export default function RoutesPage() {
))}
</select>
</div>
<div className="w-32">
<div className="w-28">
<input
type="number"
className="input input-sm"
@@ -690,6 +741,17 @@ export default function RoutesPage() {
required
/>
</div>
<div className="w-28">
<input
type="number"
className="input input-sm"
placeholder="cutoff min"
value={stop.checkinMinutesBefore ?? ''}
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
min={1}
title="Check-in cutoff override (minutes) for this stop"
/>
</div>
<button
type="button"
onClick={() => removeStop(index)}
@@ -728,7 +790,20 @@ export default function RoutesPage() {
<span className="text-muted-foreground">Select destination station above</span>
)}
</div>
<div className="w-32">
<div className="w-28">
{destinationStationId && (
<input
type="number"
className="input input-sm"
placeholder="cutoff min"
value={destinationCheckinMinutes ?? ''}
onChange={(e) => setDestinationCheckinMinutes(e.target.value ? parseInt(e.target.value) : undefined)}
min={1}
title="Check-in cutoff override (minutes) for this stop"
/>
)}
</div>
<div className="w-28">
{destinationStationId && (
<input
type="number"
@@ -754,7 +829,9 @@ export default function RoutesPage() {
setShowModal(false);
setEditingRoute(null);
setOriginStationId('');
setOriginCheckinMinutes(undefined);
setDestinationStationId('');
setDestinationCheckinMinutes(undefined);
setDestinationDistance(undefined);
setStops([]);
setSearch('');

View File

@@ -27,7 +27,6 @@ import {
markManageBookingPaymentReturn,
consumeManageBookingPaymentReturn,
} from "@/utils/manage-booking-return";
import QRCode from "qrcode.react";
// Derive display currency from the booking record's own displayCurrency field
// (set at booking creation from the passenger's nationality). Falls back to ETB.
@@ -985,22 +984,6 @@ function BookingDetailContent() {
</button>
</div>
{isConfirmed && (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Total paid:{" "}
<span className="font-semibold text-gray-900 dark:text-gray-100">
{booking?.payment?.amountMinor != null
? `${booking.payment.currency || 'ETB'} ${(booking.payment.amountMinor / 100).toFixed(2)}`
: `ETB ${((booking?.totalMinor ?? 0) / 100).toFixed(2)}`}
</span>
{booking?.payment?.method && (
<span className="text-gray-500 dark:text-gray-400">
{" "}
via {booking.payment.method}
</span>
)}
</div>
)}
<div className="flex flex-wrap gap-3 justify-center mt-6">
{isConfirmed && (
@@ -1076,17 +1059,6 @@ function BookingDetailContent() {
</span>
<SeatDetailsGrid seat={seat} />
</div>
{isConfirmed && (
<div className="flex-shrink-0">
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
<QRCode
value={`TICKET:${booking.bookingRef}-${seat?.id || `${idx}-${legLabel}`}`}
size={72}
level="M"
/>
</div>
</div>
)}
</div>
))}
</div>
@@ -1095,17 +1067,6 @@ function BookingDetailContent() {
<div className="flex-1">
<SeatDetailsGrid seat={passenger.outboundSeat} />
</div>
{isConfirmed && (
<div className="flex-shrink-0">
<div className="bg-white p-3 rounded-lg border-2 border-gray-200">
<QRCode
value={`TICKET:${booking.bookingRef}-${passenger.outboundSeat?.id || idx}`}
size={80}
level="M"
/>
</div>
</div>
)}
</div>
)}
</div>