mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Adding train delay minutes and also disabling no schedule days in the booking selection
This commit is contained in:
@@ -369,6 +369,24 @@ export class BookingsController {
|
|||||||
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
return this.guestService.issueBookingFromReservation(seatId, dto, actingUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Delete("reservations/:seatId")
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.bookings.manage, PASSENGER_PERMS.admin])
|
||||||
|
@ApiBearerAuth("IAM-auth")
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Cancel a seat's pending-payment reservation and release the seat",
|
||||||
|
description:
|
||||||
|
"For a seat with an active PASSENGER-kind reservation (payment link sent, not yet paid): cancels that booking and releases the seat's hold, so it's genuinely free for someone else. The old payment link stops working immediately (the booking is no longer PENDING_PAYMENT).",
|
||||||
|
})
|
||||||
|
@ApiQuery({ name: "scheduleId", required: true, description: "TrainSchedule UUID the reservation was issued on" })
|
||||||
|
cancelReservationForSeat(
|
||||||
|
@Param("seatId") seatId: string,
|
||||||
|
@Query("scheduleId") scheduleId: string,
|
||||||
|
@Req() req: any,
|
||||||
|
) {
|
||||||
|
const actingUserId = req.user?.id ?? req.user?.sub ?? null;
|
||||||
|
return this.service.cancelReservationForSeat(seatId, scheduleId, actingUserId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get("pay/:token")
|
@Get("pay/:token")
|
||||||
@SetMetadata("isPublic", true)
|
@SetMetadata("isPublic", true)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -2138,6 +2138,39 @@ export class BookingsService {
|
|||||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
|
return { cancelled: true, refundAmount: refundAmount / 100, currency: booking.displayCurrency};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Staff releasing a seat that already has an in-flight backoffice reservation must not
|
||||||
|
* leave that booking dangling as PENDING_PAYMENT with a still-payable link — the traveler
|
||||||
|
* could pay for a seat that's since been given away. Finds the active reservation covering
|
||||||
|
* this exact seat+schedule and cancels it via the normal cancel() path (refund=0, since it's
|
||||||
|
* still unpaid), then separately releases the SeatHold issueBookingFromReservation created —
|
||||||
|
* cancel()'s releaseSeats() only deletes Journey/JourneySegment rows, which don't exist yet
|
||||||
|
* for an unpaid reservation, so without this the seat would stay held until the hold's own
|
||||||
|
* expiry. Once status flips to CANCELLED, getByPayToken's existing status check already
|
||||||
|
* rejects the old payToken with "This booking is no longer awaiting payment" — no separate
|
||||||
|
* payToken invalidation needed.
|
||||||
|
*/
|
||||||
|
async cancelReservationForSeat(seatId: string, scheduleId: string, actingUserId: string | null) {
|
||||||
|
const bookingSeat = await this.prisma.bookingSeat.findFirst({
|
||||||
|
where: {
|
||||||
|
seatId,
|
||||||
|
scheduleId,
|
||||||
|
booking: { source: 'BACKOFFICE_RESERVATION', status: 'PENDING_PAYMENT' },
|
||||||
|
},
|
||||||
|
include: { booking: true },
|
||||||
|
});
|
||||||
|
if (!bookingSeat) throw new NotFoundException('No pending reservation found for this seat');
|
||||||
|
|
||||||
|
const { bookingRef } = bookingSeat.booking;
|
||||||
|
const result = await this.cancel(bookingRef, 'Seat released by staff before payment', actingUserId ?? undefined);
|
||||||
|
|
||||||
|
await this.prisma.seatHold.deleteMany({
|
||||||
|
where: { scheduleId, seatIds: { hasSome: [seatId] } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...result, bookingRef };
|
||||||
|
}
|
||||||
|
|
||||||
async update(id: string, dto: any) {
|
async update(id: string, dto: any) {
|
||||||
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
const booking = await this.prisma.booking.findUnique({ where: { id } });
|
||||||
if (!booking) throw new NotFoundException('Booking not found');
|
if (!booking) throw new NotFoundException('Booking not found');
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ import { Module } from '@nestjs/common';
|
|||||||
import { LiveController } from './live.controller';
|
import { LiveController } from './live.controller';
|
||||||
import { LiveService } from './live.service';
|
import { LiveService } from './live.service';
|
||||||
|
|
||||||
@Module({ controllers: [LiveController], providers: [LiveService] })
|
@Module({ controllers: [LiveController], providers: [LiveService], exports: [LiveService] })
|
||||||
export class LiveModule {}
|
export class LiveModule {}
|
||||||
|
|||||||
@@ -312,6 +312,18 @@ export class NotificationsService {
|
|||||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||||
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
|
const passengerId = booking?.passengerId ?? payload.booking.passengerId;
|
||||||
|
|
||||||
|
// A backoffice-issued reservation already sends its own purpose-built message —
|
||||||
|
// GuestBookingService.issueBookingFromReservation texts /reserve/pay/<payToken> for a
|
||||||
|
// PASSENGER-kind booking (the traveler has no portal session, so this generic template's
|
||||||
|
// /booking/detail?ref= link doesn't work), and for STAFF kind the booking is finalized
|
||||||
|
// immediately after this event fires, so onPaymentSucceeded's "ticket ready" message is
|
||||||
|
// the correct one to send, not a redundant/contradictory "awaiting payment" notice.
|
||||||
|
const source = (booking as any)?.source ?? payload.booking?.source;
|
||||||
|
if (source === 'BACKOFFICE_RESERVATION') {
|
||||||
|
this.logger.log(`Skipping generic booking.created notification for ${ref} — reservation flow sends its own`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const template = await this.prisma.notificationTemplate.findUnique({
|
const template = await this.prisma.notificationTemplate.findUnique({
|
||||||
where: { code: 'booking.created' },
|
where: { code: 'booking.created' },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, ParseInt
|
|||||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiResponse } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SchedulesService } from './schedules.service';
|
import { SchedulesService } from './schedules.service';
|
||||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus } from './schedules.dto';
|
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, BulkSchedulesResponseDto, TripStatus, ApplyDelayDto } from './schedules.dto';
|
||||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||||
|
|
||||||
@@ -171,6 +171,22 @@ export class SchedulesController {
|
|||||||
@Body() dto: UpdateStopTimeDto,
|
@Body() dto: UpdateStopTimeDto,
|
||||||
) { return this.service.updateStop(id, sequence, dto); }
|
) { return this.service.updateStop(id, sequence, dto); }
|
||||||
|
|
||||||
|
@Post(':id/delay')
|
||||||
|
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Report a delay — pushes every downstream stop\'s planned times (and check-in cutoffs) back by the same amount',
|
||||||
|
description: `Shifts plannedArrivalAt/plannedDepartureAt on every stop not yet BOARDED/COMPLETED (or from fromSequence
|
||||||
|
onward, if given) by delayMinutes. Since check-in cutoffs are derived directly from these planned
|
||||||
|
times, this is the only action needed for booking closure to reflect the delay — no separate cutoff
|
||||||
|
update. Also shifts the schedule's own departureAt/arrivalAt when the origin stop is included, and
|
||||||
|
records the accumulated delay on the schedule's live status. Does not change schedule/stop status.`,
|
||||||
|
})
|
||||||
|
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||||
|
@ApiResponse({ status: 200, description: 'Schedule with shifted stop times' })
|
||||||
|
applyDelay(@Param('id') id: string, @Body() dto: ApplyDelayDto) {
|
||||||
|
return this.service.applyDelay(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Put(':scheduleId/fares/:seatClassId')
|
@Put(':scheduleId/fares/:seatClassId')
|
||||||
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
@PassengerStaff([PASSENGER_PERMS.schedules.manage, PASSENGER_PERMS.admin]) @ApiBearerAuth('IAM-auth')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -113,6 +113,14 @@ export class UpdateScheduleStatusDto {
|
|||||||
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
@ApiProperty({ enum: TripStatus, example: TripStatus.EN_ROUTE }) @IsEnum(TripStatus) status: TripStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ApplyDelayDto {
|
||||||
|
@ApiProperty({ example: 60, description: 'Minutes to shift downstream stop times by. Negative to correct an over-reported delay.' })
|
||||||
|
@IsInt() delayMinutes: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ example: 3, description: 'Only shift stops from this sequence onward. Omit to default to every stop not yet BOARDED/COMPLETED.' })
|
||||||
|
@IsOptional() @IsInt() @Min(1) fromSequence?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class BulkCreateSchedulesDto {
|
export class BulkCreateSchedulesDto {
|
||||||
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
@ApiProperty({ example: 'train-uuid', description: 'Train UUID' }) @IsString() trainId: string;
|
||||||
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
@ApiProperty({ example: 'route-uuid', description: 'Route UUID' }) @IsString() routeId: string;
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { RoutesController } from './routes.controller';
|
|||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
import { FareEngineModule } from '../fare-engine/fare-engine.module';
|
||||||
import { AuditModule } from '../../common/audit.module';
|
import { AuditModule } from '../../common/audit.module';
|
||||||
|
import { LiveModule } from '../live/live.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [FareEngineModule, AuditModule],
|
imports: [FareEngineModule, AuditModule, LiveModule],
|
||||||
controllers: [RoutesController, SchedulesController],
|
controllers: [RoutesController, SchedulesController],
|
||||||
providers: [RoutesService, SchedulesService],
|
providers: [RoutesService, SchedulesService],
|
||||||
exports: [RoutesService, SchedulesService],
|
exports: [RoutesService, SchedulesService],
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import { Injectable, Logger, NotFoundException, BadRequestException } from '@nes
|
|||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from '../../common/prisma.service';
|
||||||
import { RoutesService } from './routes.service';
|
import { RoutesService } from './routes.service';
|
||||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto, ApplyDelayDto } from './schedules.dto';
|
||||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { LiveService } from '../live/live.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SchedulesService {
|
export class SchedulesService {
|
||||||
@@ -16,6 +17,7 @@ export class SchedulesService {
|
|||||||
private routesService: RoutesService,
|
private routesService: RoutesService,
|
||||||
private fareEngine: FareEngineService,
|
private fareEngine: FareEngineService,
|
||||||
private auditService: AuditService,
|
private auditService: AuditService,
|
||||||
|
private liveService: LiveService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +128,7 @@ export class SchedulesService {
|
|||||||
include: { coach: true },
|
include: { coach: true },
|
||||||
orderBy: { positionNumber: 'asc' },
|
orderBy: { positionNumber: 'asc' },
|
||||||
},
|
},
|
||||||
|
liveStatus: { select: { delayMinutes: true } },
|
||||||
_count: { select: { coachAssignments: true, bookings: true } },
|
_count: { select: { coachAssignments: true, bookings: true } },
|
||||||
},
|
},
|
||||||
orderBy: { departureAt: 'asc' },
|
orderBy: { departureAt: 'asc' },
|
||||||
@@ -246,6 +249,7 @@ export class SchedulesService {
|
|||||||
orderBy: { positionNumber: 'asc' },
|
orderBy: { positionNumber: 'asc' },
|
||||||
},
|
},
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||||
|
liveStatus: { select: { delayMinutes: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
@@ -462,6 +466,70 @@ export class SchedulesService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shifts stored planned times additively rather than reusing updateSchedulePartial's
|
||||||
|
* recompute-from-route-interpolation path — that path also guards `departureAt must be in the
|
||||||
|
* future`, which a delay report for an already-departed/EN_ROUTE train would legitimately
|
||||||
|
* fail. Check-in cutoffs (resolveCheckinCutoff, SeatsService.holdSeats) are both derived
|
||||||
|
* directly from TripStopTime.plannedArrivalAt/plannedDepartureAt at read time, so shifting the
|
||||||
|
* stored values here is the entire fix — neither of those needs to change.
|
||||||
|
*/
|
||||||
|
async applyDelay(scheduleId: string, dto: ApplyDelayDto) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||||
|
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||||
|
|
||||||
|
const stopWhere: any = { scheduleId };
|
||||||
|
if (dto.fromSequence != null) {
|
||||||
|
stopWhere.sequence = { gte: dto.fromSequence };
|
||||||
|
} else {
|
||||||
|
// Default: only stops the train hasn't reached yet — a delay report must not retroactively
|
||||||
|
// move a stop that's already BOARDED/COMPLETED.
|
||||||
|
stopWhere.status = { notIn: ['BOARDED', 'COMPLETED'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopsToShift = await this.prisma.tripStopTime.findMany({ where: stopWhere });
|
||||||
|
const shiftMs = dto.delayMinutes * 60_000;
|
||||||
|
const includesOrigin = stopsToShift.some((s) => s.sequence === 1);
|
||||||
|
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const stop of stopsToShift) {
|
||||||
|
await tx.tripStopTime.update({
|
||||||
|
where: { id: stop.id },
|
||||||
|
data: {
|
||||||
|
plannedArrivalAt: stop.plannedArrivalAt ? new Date(stop.plannedArrivalAt.getTime() + shiftMs) : undefined,
|
||||||
|
plannedDepartureAt: stop.plannedDepartureAt ? new Date(stop.plannedDepartureAt.getTime() + shiftMs) : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin stop shifted → the schedule's own departureAt/arrivalAt drive search's day-window
|
||||||
|
// queries and the displayed departure time, so they must move too (both together, so
|
||||||
|
// durationMinutes stays correct).
|
||||||
|
if (includesOrigin) {
|
||||||
|
await tx.trainSchedule.update({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
data: {
|
||||||
|
departureAt: new Date(schedule.departureAt.getTime() + shiftMs),
|
||||||
|
arrivalAt: new Date(schedule.arrivalAt.getTime() + shiftMs),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentLive = await this.prisma.tripLiveStatus.findUnique({ where: { scheduleId } });
|
||||||
|
const accumulatedDelayMinutes = Math.max(0, (currentLive?.delayMinutes ?? 0) + dto.delayMinutes);
|
||||||
|
await this.liveService.updateLiveStatus(scheduleId, { delayMinutes: accumulatedDelayMinutes });
|
||||||
|
|
||||||
|
await this.auditService.log({
|
||||||
|
action: 'UPDATE',
|
||||||
|
entityType: 'Schedule',
|
||||||
|
entityId: scheduleId,
|
||||||
|
newData: { delayMinutes: dto.delayMinutes, fromSequence: dto.fromSequence, accumulatedDelayMinutes },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.getSchedule(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
async upsertScheduleFare(
|
async upsertScheduleFare(
|
||||||
scheduleId: string,
|
scheduleId: string,
|
||||||
seatClassId: string,
|
seatClassId: string,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Body, Controller, Post, Get, Query } from '@nestjs/common';
|
|||||||
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger';
|
||||||
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator';
|
||||||
import { SearchService } from './search.service';
|
import { SearchService } from './search.service';
|
||||||
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto } from './search.dto';
|
import { SearchTripsDto, FareQuoteDto, FareBreakdownRequestDto, AvailableDatesQueryDto } from './search.dto';
|
||||||
|
|
||||||
@ApiTags('Search')
|
@ApiTags('Search')
|
||||||
@Controller('search')
|
@Controller('search')
|
||||||
@@ -67,6 +67,21 @@ Nationality-Based:
|
|||||||
return this.service.getFareQuote(dto);
|
return this.service.getFareQuote(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('available-dates')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Which dates in a range have a bookable schedule for an origin/destination pair',
|
||||||
|
description: `Used to disable schedule-less dates on the search date picker before the user submits a search.
|
||||||
|
|
||||||
|
For each date in the (server-clamped, max 90-day) range, a date is "available" if at least one
|
||||||
|
schedule exists for the origin→destination pair whose status/package/coach state is bookable and
|
||||||
|
whose check-in cutoff has not yet passed. This does not check seat-level availability — a date
|
||||||
|
can be marked available and still turn out fully booked when actually searched.`,
|
||||||
|
})
|
||||||
|
@ApiResponse({ status: 200, description: 'routeExists flag plus a per-date availability list' })
|
||||||
|
getAvailableDates(@Query() dto: AvailableDatesQueryDto) {
|
||||||
|
return this.service.getAvailableDates(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('fare-breakdown')
|
@Get('fare-breakdown')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Per-passenger fare breakdown for booking review page',
|
summary: 'Per-passenger fare breakdown for booking review page',
|
||||||
|
|||||||
@@ -29,6 +29,20 @@ export class SearchTripsDto {
|
|||||||
@IsOptional() @IsDateString() returnDate?: string;
|
@IsOptional() @IsDateString() returnDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AvailableDatesQueryDto {
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Origin station UUID' })
|
||||||
|
@IsString() originStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID' })
|
||||||
|
@IsString() destinationStationId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-06-15', description: 'Start of the date range (YYYY-MM-DD)' })
|
||||||
|
@IsDateString() from: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '2026-09-13', description: 'End of the date range (YYYY-MM-DD), inclusive — server clamps to a max 90-day span' })
|
||||||
|
@IsDateString() to: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class FareQuoteDto {
|
export class FareQuoteDto {
|
||||||
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
|
@ApiProperty({ example: 'schedule-uuid', description: 'TrainSchedule UUID from search results' })
|
||||||
@IsString() scheduleId: string;
|
@IsString() scheduleId: string;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
FareQuoteDto,
|
FareQuoteDto,
|
||||||
FareBreakdownRequestDto,
|
FareBreakdownRequestDto,
|
||||||
FareBreakdownPassengerDto,
|
FareBreakdownPassengerDto,
|
||||||
|
AvailableDatesQueryDto,
|
||||||
} from "./search.dto";
|
} from "./search.dto";
|
||||||
import { CurrencyService } from "../currency/currency.service";
|
import { CurrencyService } from "../currency/currency.service";
|
||||||
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
import { FareEngineService } from "../fare-engine/fare-engine.service";
|
||||||
@@ -383,16 +384,9 @@ export class SearchService {
|
|||||||
|
|
||||||
// 1. Does any active route connect these two stations, in this direction, at all —
|
// 1. Does any active route connect these two stations, in this direction, at all —
|
||||||
// ignoring date entirely?
|
// ignoring date entirely?
|
||||||
const candidateRoutes = await this.prisma.route.findMany({
|
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||||
where: { active: true, stops: { some: { stationId: originStationId } } },
|
return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
||||||
select: { stops: { select: { stationId: true, sequence: true } } },
|
}
|
||||||
});
|
|
||||||
const routeExists = candidateRoutes.some((r) => {
|
|
||||||
const o = r.stops.find((s) => s.stationId === originStationId);
|
|
||||||
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
|
||||||
return !!o && !!d && o.sequence < d.sequence;
|
|
||||||
});
|
|
||||||
if (!routeExists) return withCode(Passenger.SearchEmptyReasonCode.NoRoute);
|
|
||||||
|
|
||||||
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
// 2. A route exists — is there any schedule at all on the requested date for this pair
|
||||||
// (regardless of status/package/coach/cutoff — those are checked next)?
|
// (regardless of status/package/coach/cutoff — those are checked next)?
|
||||||
@@ -425,12 +419,7 @@ export class SearchService {
|
|||||||
|
|
||||||
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
|
||||||
// (right status, not package-only, has at least one coach assigned).
|
// (right status, not package-only, has at least one coach assigned).
|
||||||
const bookable = sameDayForPair.filter(
|
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
|
||||||
(s) =>
|
|
||||||
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
|
||||||
!s.isPackageOnly &&
|
|
||||||
s.coachAssignments.length > 0,
|
|
||||||
);
|
|
||||||
if (bookable.length === 0) {
|
if (bookable.length === 0) {
|
||||||
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
|
||||||
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
|
||||||
@@ -452,6 +441,110 @@ export class SearchService {
|
|||||||
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
return withCode(Passenger.SearchEmptyReasonCode.FullyBooked);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether any active route connects originStationId → destinationStationId in this
|
||||||
|
* direction, ignoring date/schedule state entirely. Shared by classifyEmptySearch and
|
||||||
|
* getAvailableDates.
|
||||||
|
*/
|
||||||
|
private async routeExistsForPair(originStationId: string, destinationStationId: string): Promise<boolean> {
|
||||||
|
const candidateRoutes = await this.prisma.route.findMany({
|
||||||
|
where: { active: true, stops: { some: { stationId: originStationId } } },
|
||||||
|
select: { stops: { select: { stationId: true, sequence: true } } },
|
||||||
|
});
|
||||||
|
return candidateRoutes.some((r) => {
|
||||||
|
const o = r.stops.find((s) => s.stationId === originStationId);
|
||||||
|
const d = r.stops.find((s) => s.stationId === destinationStationId);
|
||||||
|
return !!o && !!d && o.sequence < d.sequence;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
|
||||||
|
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
|
||||||
|
return (
|
||||||
|
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
|
||||||
|
!s.isPackageOnly &&
|
||||||
|
s.coachAssignments.length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly MAX_AVAILABLE_DATES_SPAN_DAYS = 90;
|
||||||
|
private readonly ADDIS_OFFSET_MS = 3 * 60 * 60 * 1000;
|
||||||
|
private readonly ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/** Converts an absolute instant to its calendar date string in Africa/Addis_Ababa (fixed UTC+3, no DST). */
|
||||||
|
private toAddisDateStr(d: Date): string {
|
||||||
|
return new Date(d.getTime() + this.ADDIS_OFFSET_MS).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For each date in the (server-clamped) range, whether at least one bookable schedule exists
|
||||||
|
* for originStationId → destinationStationId — used to disable schedule-less dates on the
|
||||||
|
* search date picker before the user submits a search. Reuses the same route-existence and
|
||||||
|
* bookability checks as classifyEmptySearch, plus the same check-in cutoff resolution used
|
||||||
|
* throughout this service, but does not compute seat-level availability (see buildScheduleResult)
|
||||||
|
* — a date can be marked available and still turn out fully booked when actually searched.
|
||||||
|
*/
|
||||||
|
async getAvailableDates(dto: AvailableDatesQueryDto) {
|
||||||
|
const { originStationId, destinationStationId } = dto;
|
||||||
|
|
||||||
|
const todayStr = this.toAddisDateStr(new Date());
|
||||||
|
const from = dto.from > todayStr ? dto.from : todayStr;
|
||||||
|
const fromDate = new Date(`${from}T00:00:00+03:00`);
|
||||||
|
|
||||||
|
const maxToDate = new Date(fromDate.getTime() + this.MAX_AVAILABLE_DATES_SPAN_DAYS * this.ONE_DAY_MS);
|
||||||
|
const requestedToDate = new Date(`${dto.to}T00:00:00+03:00`);
|
||||||
|
const toDate = requestedToDate < maxToDate ? requestedToDate : maxToDate;
|
||||||
|
const to = this.toAddisDateStr(toDate);
|
||||||
|
|
||||||
|
if (!(await this.routeExistsForPair(originStationId, destinationStationId))) {
|
||||||
|
return {
|
||||||
|
originStationId,
|
||||||
|
destinationStationId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
routeExists: false,
|
||||||
|
dates: [] as { date: string; available: boolean }[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rangeEnd = new Date(toDate.getTime() + this.ONE_DAY_MS);
|
||||||
|
const schedules = await this.prisma.trainSchedule.findMany({
|
||||||
|
where: {
|
||||||
|
departureAt: { gte: fromDate, lt: rangeEnd },
|
||||||
|
stopTimes: { some: { stationId: originStationId } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
departureAt: true,
|
||||||
|
status: true,
|
||||||
|
isPackageOnly: true,
|
||||||
|
route: {
|
||||||
|
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
|
||||||
|
},
|
||||||
|
stopTimes: { select: { stationId: true, sequence: true, plannedArrivalAt: true, plannedDepartureAt: true } },
|
||||||
|
coachAssignments: { select: { id: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const availableDays = new Set<string>();
|
||||||
|
for (const s of schedules) {
|
||||||
|
const originStop = s.stopTimes.find((st) => st.stationId === originStationId);
|
||||||
|
const destinationStop = s.stopTimes.find((st) => st.stationId === destinationStationId);
|
||||||
|
if (!originStop || !destinationStop || originStop.sequence >= destinationStop.sequence) continue;
|
||||||
|
if (!this.isBookableSchedule(s)) continue;
|
||||||
|
if (now >= resolveCheckinCutoff(s, originStop, originStationId).cutoffAt.getTime()) continue;
|
||||||
|
availableDays.add(this.toAddisDateStr(s.departureAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
const dates: { date: string; available: boolean }[] = [];
|
||||||
|
for (let cursor = fromDate; cursor <= toDate; cursor = new Date(cursor.getTime() + this.ONE_DAY_MS)) {
|
||||||
|
const dateStr = this.toAddisDateStr(cursor);
|
||||||
|
dates.push({ date: dateStr, available: availableDays.has(dateStr) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { originStationId, destinationStationId, from, to, routeExists: true, dates };
|
||||||
|
}
|
||||||
|
|
||||||
// ── Transit search ─────────────────────────────────────────────────────────
|
// ── Transit search ─────────────────────────────────────────────────────────
|
||||||
private readonly MIN_CONNECTION_MINUTES = 30;
|
private readonly MIN_CONNECTION_MINUTES = 30;
|
||||||
private readonly MAX_CONNECTION_MINUTES = 360;
|
private readonly MAX_CONNECTION_MINUTES = 360;
|
||||||
|
|||||||
@@ -45,13 +45,16 @@ export class SeatsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
const [effectiveStatuses, reservations] = await Promise.all([
|
||||||
scheduleId,
|
this.resolveEffectiveStatuses(
|
||||||
allSeatIds,
|
scheduleId,
|
||||||
originStationId ?? schedule.originStationId,
|
allSeatIds,
|
||||||
destinationStationId ?? schedule.destinationStationId,
|
originStationId ?? schedule.originStationId,
|
||||||
journeyDirection
|
destinationStationId ?? schedule.destinationStationId,
|
||||||
);
|
journeyDirection
|
||||||
|
),
|
||||||
|
this.resolveActiveReservations(allSeatIds, scheduleId),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coaches: assignments.map((a) => {
|
coaches: assignments.map((a) => {
|
||||||
@@ -70,6 +73,7 @@ export class SeatsService {
|
|||||||
? this.resolveBedPosition(s.col, s.bedPosition)
|
? this.resolveBedPosition(s.col, s.bedPosition)
|
||||||
: s.bedPosition;
|
: s.bedPosition;
|
||||||
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
|
||||||
|
const reservation = reservations.get(s.id);
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
seatNumber: s.seatNumber,
|
seatNumber: s.seatNumber,
|
||||||
@@ -88,6 +92,15 @@ export class SeatsService {
|
|||||||
position: this.colToPosition(s.col, a.coach.arrangement),
|
position: this.colToPosition(s.col, a.coach.arrangement),
|
||||||
bed_type: this.bedPositionToType(resolvedBedPosition),
|
bed_type: this.bedPositionToType(resolvedBedPosition),
|
||||||
} : {}),
|
} : {}),
|
||||||
|
// Backoffice-issued reservation covering this seat, if any — lets staff see who's
|
||||||
|
// paying/ticketed for a HELD (awaiting payment) or BLOCKED (ticketed) seat without
|
||||||
|
// leaving the seat map. See resolveActiveReservations.
|
||||||
|
...(reservation ? {
|
||||||
|
bookingRef: reservation.bookingRef,
|
||||||
|
reservationStatus: reservation.status,
|
||||||
|
reservationPassengerName: reservation.passengerName,
|
||||||
|
reservationContactPhone: reservation.contactPhone,
|
||||||
|
} : {}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,6 +272,46 @@ export class SeatsService {
|
|||||||
return statusMap;
|
return statusMap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-resolves the backoffice-issued reservation (if any) covering each of these seats on
|
||||||
|
* this schedule — a booking created via GuestBookingService.issueBookingFromReservation
|
||||||
|
* (`source: 'BACKOFFICE_RESERVATION'`), still PENDING_PAYMENT (payment link sent, not yet
|
||||||
|
* paid) or already CONFIRMED (ticketed). Used to surface the booking reference on the
|
||||||
|
* backoffice seat map so staff can see who's paying/ticketed for a given seat without
|
||||||
|
* looking it up separately.
|
||||||
|
*/
|
||||||
|
private async resolveActiveReservations(
|
||||||
|
seatIds: string[],
|
||||||
|
scheduleId: string,
|
||||||
|
): Promise<Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>> {
|
||||||
|
const map = new Map<string, { bookingRef: string; status: string; passengerName: string | null; contactPhone: string | null }>();
|
||||||
|
if (seatIds.length === 0) return map;
|
||||||
|
|
||||||
|
const bookingSeats = await this.prisma.bookingSeat.findMany({
|
||||||
|
where: {
|
||||||
|
seatId: { in: seatIds },
|
||||||
|
scheduleId,
|
||||||
|
booking: { source: 'BACKOFFICE_RESERVATION', status: { in: ['PENDING_PAYMENT', 'CONFIRMED'] } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
seatId: true,
|
||||||
|
passengerName: true,
|
||||||
|
booking: { select: { bookingRef: true, status: true, contactPhone: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const bs of bookingSeats) {
|
||||||
|
if (!bs.seatId) continue;
|
||||||
|
map.set(bs.seatId, {
|
||||||
|
bookingRef: bs.booking.bookingRef,
|
||||||
|
status: bs.booking.status,
|
||||||
|
passengerName: bs.passengerName,
|
||||||
|
contactPhone: bs.booking.contactPhone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
async holdSeats(dto: HoldSeatsDto) {
|
async holdSeats(dto: HoldSeatsDto) {
|
||||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||||
const seatIds = dto.passengers.map(p => p.seatId);
|
const seatIds = dto.passengers.map(p => p.seatId);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { validateSync } from "class-validator";
|
|||||||
import { plainToInstance } from "class-transformer";
|
import { plainToInstance } from "class-transformer";
|
||||||
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
import { SchedulesService } from "../src/modules/schedules/schedules.service";
|
||||||
import { SeatsService } from "../src/modules/seats/seats.service";
|
import { SeatsService } from "../src/modules/seats/seats.service";
|
||||||
|
import { SegmentsService } from "../src/modules/segments/segments.service";
|
||||||
import { TicketsService } from "../src/modules/tickets/tickets.service";
|
import { TicketsService } from "../src/modules/tickets/tickets.service";
|
||||||
import { PaymentsService } from "../src/modules/payments/payments.service";
|
import { PaymentsService } from "../src/modules/payments/payments.service";
|
||||||
import { CurrencyService } from "../src/modules/currency/currency.service";
|
import { CurrencyService } from "../src/modules/currency/currency.service";
|
||||||
@@ -34,6 +35,7 @@ import { SystemConfigService } from "../src/modules/system-config/system-config.
|
|||||||
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
import { BookingsService } from "../src/modules/bookings/bookings.service";
|
||||||
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
import { GuestBookingService } from "../src/modules/bookings/guest-booking.service";
|
||||||
import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto";
|
import { ReservationBookingKind, IssueReservationBookingDto } from "../src/modules/bookings/guest-booking.dto";
|
||||||
|
import { NotificationsService } from "../src/modules/notifications/notifications.service";
|
||||||
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
import { createServiceHarness, ServiceHarness } from "./setup/slim-app";
|
||||||
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
import { IDS, resetAndSeedCore } from "./fixtures/seed-core";
|
||||||
|
|
||||||
@@ -48,7 +50,9 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
let seatsService: SeatsService;
|
let seatsService: SeatsService;
|
||||||
let guestBookingService: GuestBookingService;
|
let guestBookingService: GuestBookingService;
|
||||||
let bookingsService: BookingsService;
|
let bookingsService: BookingsService;
|
||||||
|
let notificationsService: NotificationsService;
|
||||||
let smsClient: { sendSms: jest.Mock };
|
let smsClient: { sendSms: jest.Mock };
|
||||||
|
let emailClient: { sendEmail: jest.Mock };
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
harness = await createServiceHarness();
|
harness = await createServiceHarness();
|
||||||
@@ -57,7 +61,11 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
const fareEngine = harness.moduleRef.get(FareEngineService);
|
const fareEngine = harness.moduleRef.get(FareEngineService);
|
||||||
const systemConfig = new SystemConfigService(harness.prisma as any);
|
const systemConfig = new SystemConfigService(harness.prisma as any);
|
||||||
|
|
||||||
seatsService = new SeatsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
// Real SegmentsService (not asyncStub) — the getSeatMap test below exercises
|
||||||
|
// resolveEffectiveStatuses, which calls segmentsService.getSeatAvailabilityMap and needs
|
||||||
|
// an actual Map back, not asyncStub's `async () => undefined`.
|
||||||
|
const segmentsService = new SegmentsService(harness.prisma as any);
|
||||||
|
seatsService = new SeatsService(harness.prisma as any, segmentsService, systemConfig, asyncStub(), asyncStub());
|
||||||
const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
const ticketsService = new TicketsService(harness.prisma as any, asyncStub(), systemConfig, asyncStub(), asyncStub());
|
||||||
const paymentsService = new PaymentsService(
|
const paymentsService = new PaymentsService(
|
||||||
harness.prisma as any,
|
harness.prisma as any,
|
||||||
@@ -85,12 +93,26 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
harness.prisma as any,
|
harness.prisma as any,
|
||||||
asyncStub(), // dataSource
|
asyncStub(), // dataSource
|
||||||
seatsService,
|
seatsService,
|
||||||
{ emit: () => true } as any,
|
ticketsService,
|
||||||
|
{ emit: () => true } as any, // eventEmitter
|
||||||
asyncStub(), // verifaydaService
|
asyncStub(), // verifaydaService
|
||||||
currencyService,
|
currencyService,
|
||||||
fareEngine,
|
fareEngine,
|
||||||
asyncStub(), // auditService
|
asyncStub(), // auditService
|
||||||
);
|
);
|
||||||
|
|
||||||
|
emailClient = { sendEmail: jest.fn().mockResolvedValue({ queued: true }) };
|
||||||
|
// Same smsClient instance guestBookingService uses — lets the notification-suppression
|
||||||
|
// test assert on ONE shared call count across both services, proving the reservation
|
||||||
|
// flow's own SMS is the only message sent for a BACKOFFICE_RESERVATION booking.
|
||||||
|
notificationsService = new NotificationsService(
|
||||||
|
harness.prisma as any,
|
||||||
|
asyncStub(), // dataSource (TypeORM) — only reached for non-UUID recipients / IAM lookups,
|
||||||
|
// never hit by these guest-passenger-id-keyed test bookings
|
||||||
|
emailClient as any,
|
||||||
|
smsClient as any,
|
||||||
|
asyncStub(), // pushAdapter
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -99,6 +121,7 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
smsClient.sendSms.mockClear();
|
smsClient.sendSms.mockClear();
|
||||||
|
emailClient.sendEmail.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
|
/** Creates a fresh Train + TrainSchedule on the seed-core route, coach assigned at creation. */
|
||||||
@@ -294,6 +317,53 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
expect(byToken.schedule.origin.id).toBe(IDS.stationA);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("NotificationsService.onBookingCreated skips its own message for a BACKOFFICE_RESERVATION booking (issueBookingFromReservation already sent one), but still fires for a normal booking", async () => {
|
||||||
|
// Regression for: the customer got TWO conflicting messages for one reservation —
|
||||||
|
// the reservation-specific /reserve/pay/<payToken> SMS from issueBookingFromReservation,
|
||||||
|
// AND a second, generic booking.created notification pointing at /booking/detail?ref=,
|
||||||
|
// a page that doesn't work for a traveler with no portal session.
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
await harness.prisma.notificationTemplate.upsert({
|
||||||
|
where: { code: "booking.created" },
|
||||||
|
update: { active: true },
|
||||||
|
create: { code: "booking.created", channel: "SMS,EMAIL", bodyTemplate: "Booking {{bookingRef}} created. Pay: {{payLink}}", active: true },
|
||||||
|
});
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-NOTIFY-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||||
|
"staff-user-5",
|
||||||
|
);
|
||||||
|
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // the reservation flow's own SMS
|
||||||
|
|
||||||
|
// Directly invoke the event handler (the test harness's eventEmitter is a stub, so the
|
||||||
|
// real 'booking.created' emit from issueBookingFromReservation never reaches it) — this
|
||||||
|
// is what NotificationsService would have done had it received that event.
|
||||||
|
await notificationsService.onBookingCreated({ booking: { id: result.booking.id, bookingRef: result.booking.bookingRef } });
|
||||||
|
expect(smsClient.sendSms).toHaveBeenCalledTimes(1); // still 1 — onBookingCreated no-oped
|
||||||
|
expect(emailClient.sendEmail).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Control: a normal (non-reservation) booking must still get the generic notification.
|
||||||
|
const passenger = await harness.prisma.passenger.create({ data: {} });
|
||||||
|
const normalBooking = await harness.prisma.booking.create({
|
||||||
|
data: {
|
||||||
|
bookingRef: `WEB-CTRL-${Date.now()}`,
|
||||||
|
passengerId: passenger.id,
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
status: "PENDING_PAYMENT",
|
||||||
|
totalMinor: 10_000,
|
||||||
|
contactPhone: "+251911234567",
|
||||||
|
source: "WEB",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await notificationsService.onBookingCreated({ booking: { id: normalBooking.id, bookingRef: normalBooking.bookingRef } });
|
||||||
|
expect(smsClient.sendSms).toHaveBeenCalledTimes(2); // suppression didn't leak to non-reservation bookings
|
||||||
|
});
|
||||||
|
|
||||||
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
|
it("PASSENGER path: the seat stays reserved (not publicly available) after the payment link is sent", async () => {
|
||||||
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
|
// Regression for: unblockSeat() released the reservation's SeatBlock and confirmSeats()
|
||||||
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
|
// was a no-op with no SeatHold to extend, so the seat had no SeatBlock, no SeatHold, and
|
||||||
@@ -325,6 +395,87 @@ describe("Reserve seat — issue booking (STAFF / PASSENGER)", () => {
|
|||||||
).rejects.toThrow(/already (held|booked)/i);
|
).rejects.toThrow(/already (held|booked)/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cancelReservationForSeat: cancels the pending booking, frees the seat, and kills the old pay link", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||||
|
const result: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||||
|
"staff-user-7",
|
||||||
|
);
|
||||||
|
const payToken = result.booking.payToken;
|
||||||
|
|
||||||
|
const cancelResult: any = await bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-7");
|
||||||
|
expect(cancelResult.cancelled).toBe(true);
|
||||||
|
expect(cancelResult.bookingRef).toBe(result.booking.bookingRef);
|
||||||
|
|
||||||
|
const cancelledBooking = await harness.prisma.booking.findUnique({ where: { id: result.booking.id } });
|
||||||
|
expect(cancelledBooking?.status).toBe("CANCELLED");
|
||||||
|
|
||||||
|
// The seat is genuinely free — a member of the public can now hold it.
|
||||||
|
await expect(
|
||||||
|
seatsService.holdSeats({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
originStationId: IDS.stationA,
|
||||||
|
destinationStationId: IDS.stationB,
|
||||||
|
passengers: [{ passengerId: "someone-else", seatId: seats[0].id }],
|
||||||
|
} as any),
|
||||||
|
).resolves.toBeTruthy();
|
||||||
|
|
||||||
|
// The old payment link no longer works.
|
||||||
|
await expect(bookingsService.getByPayToken(payToken)).rejects.toThrow(/no longer awaiting payment/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelReservationForSeat 404s when there's no pending reservation for this seat", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-CANCEL-404-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bookingsService.cancelReservationForSeat(seats[0].id, schedule.id, "staff-user-8"),
|
||||||
|
).rejects.toThrow(/no pending reservation/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getSeatMap surfaces the bookingRef (PNR) for a seat with an active reservation — pending payment AND ticketed", async () => {
|
||||||
|
await resetAndSeedCore(harness.prisma);
|
||||||
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
const arr = new Date(dep.getTime() + 100 * 60_000);
|
||||||
|
const { schedule, seats } = await createTestSchedule({ trainNumber: `RES-SEATMAP-${Date.now()}`, departureAt: dep, arrivalAt: arr });
|
||||||
|
|
||||||
|
// Seat 0: PASSENGER reservation — still PENDING_PAYMENT.
|
||||||
|
await seatsService.blockSeat(seats[0].id, "Reserved pending payment", schedule.id);
|
||||||
|
const pending: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[0].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.PASSENGER, phone: "+253771234567" }) as any,
|
||||||
|
"staff-user-6",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Seat 1: STAFF reservation — fee-waived, ticketed, CONFIRMED immediately.
|
||||||
|
await seatsService.blockSeat(seats[1].id, "Reserved for staff issue", schedule.id);
|
||||||
|
const staffResult: any = await guestBookingService.issueBookingFromReservation(
|
||||||
|
seats[1].id,
|
||||||
|
baseDto({ scheduleId: schedule.id, bookingKind: ReservationBookingKind.STAFF }) as any,
|
||||||
|
"staff-user-6",
|
||||||
|
);
|
||||||
|
|
||||||
|
const seatMap: any = await seatsService.getSeatMap(schedule.id);
|
||||||
|
const flatSeats = seatMap.coaches.flatMap((c: any) => c.seats ?? []);
|
||||||
|
const pendingSeat = flatSeats.find((s: any) => s.id === seats[0].id);
|
||||||
|
const ticketedSeat = flatSeats.find((s: any) => s.id === seats[1].id);
|
||||||
|
|
||||||
|
expect(pendingSeat.bookingRef).toBe(pending.booking.bookingRef);
|
||||||
|
expect(pendingSeat.reservationStatus).toBe("PENDING_PAYMENT");
|
||||||
|
expect(pendingSeat.status).toBe("HELD"); // covered by the SeatHold, not a SeatBlock
|
||||||
|
|
||||||
|
expect(ticketedSeat.bookingRef).toBe(staffResult.booking.bookingRef);
|
||||||
|
expect(ticketedSeat.reservationStatus).toBe("CONFIRMED");
|
||||||
|
});
|
||||||
|
|
||||||
it("requires a phone number for a PASSENGER booking", async () => {
|
it("requires a phone number for a PASSENGER booking", async () => {
|
||||||
await resetAndSeedCore(harness.prisma);
|
await resetAndSeedCore(harness.prisma);
|
||||||
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
const dep = new Date(Date.now() + 3 * 60 * 60_000);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react';
|
import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical, Clock } from 'lucide-react';
|
||||||
import DataTable from '@/components/ui/DataTable';
|
import DataTable from '@/components/ui/DataTable';
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
@@ -28,6 +28,7 @@ interface Schedule {
|
|||||||
destinationStation?: { id: string; name: string };
|
destinationStation?: { id: string; name: string };
|
||||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||||
isPackageOnly?: boolean;
|
isPackageOnly?: boolean;
|
||||||
|
liveStatus?: { delayMinutes: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Train {
|
interface Train {
|
||||||
@@ -454,6 +455,16 @@ export default function SchedulesPage() {
|
|||||||
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
|
<span className="font-mono text-sm">{formatDateTime(schedule.arrivalAt)}</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'liveStatus.delayMinutes',
|
||||||
|
label: 'Delay',
|
||||||
|
sortable: true,
|
||||||
|
render: (schedule: Schedule) => {
|
||||||
|
const delay = schedule.liveStatus?.delayMinutes ?? 0;
|
||||||
|
if (delay <= 0) return <span className="text-sm text-muted-foreground">On time</span>;
|
||||||
|
return <span className="edr-badge edr-badge-warning">+{delay} min</span>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'coachAssignments',
|
key: 'coachAssignments',
|
||||||
label: 'Coaches',
|
label: 'Coaches',
|
||||||
@@ -486,6 +497,15 @@ export default function SchedulesPage() {
|
|||||||
|
|
||||||
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||||
|
|
||||||
|
const applyDelayMutation = useMutation({
|
||||||
|
mutationFn: ({ id, minutes }: { id: string; minutes: number }) =>
|
||||||
|
apiClient.post(`/schedules/${id}/delay`, { delayMinutes: minutes }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }),
|
||||||
|
});
|
||||||
|
const [delayPrompt, setDelayPrompt] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null });
|
||||||
|
const [delayMinutesInput, setDelayMinutesInput] = useState('');
|
||||||
|
const [delayError, setDelayError] = useState<string | null>(null);
|
||||||
|
|
||||||
const scheduleActions = [
|
const scheduleActions = [
|
||||||
{
|
{
|
||||||
label: 'Edit',
|
label: 'Edit',
|
||||||
@@ -493,6 +513,17 @@ export default function SchedulesPage() {
|
|||||||
variant: 'secondary' as const,
|
variant: 'secondary' as const,
|
||||||
icon: Edit,
|
icon: Edit,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Report Delay',
|
||||||
|
onClick: (schedule: Schedule) => {
|
||||||
|
setDelayMinutesInput('');
|
||||||
|
setDelayError(null);
|
||||||
|
setDelayPrompt({ isOpen: true, item: schedule });
|
||||||
|
},
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
icon: Clock,
|
||||||
|
hidden: (schedule: Schedule) => schedule.status === 'CANCELLED',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Cancel',
|
label: 'Cancel',
|
||||||
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }),
|
||||||
@@ -653,6 +684,67 @@ export default function SchedulesPage() {
|
|||||||
isLoading={cancelScheduleMutation.isPending}
|
isLoading={cancelScheduleMutation.isPending}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={delayPrompt.isOpen}
|
||||||
|
onClose={() => setDelayPrompt({ isOpen: false, item: null })}
|
||||||
|
title={`Report Delay${delayPrompt.item ? `: ${delayPrompt.item.originStation?.name ?? ''} → ${delayPrompt.item.destinationStation?.name ?? ''}` : ''}`}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{delayPrompt.item && (
|
||||||
|
<form
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const minutes = parseInt(delayMinutesInput, 10);
|
||||||
|
if (Number.isNaN(minutes)) { setDelayError('Enter a whole number of minutes.'); return; }
|
||||||
|
try {
|
||||||
|
await applyDelayMutation.mutateAsync({ id: delayPrompt.item!.id, minutes });
|
||||||
|
setDelayPrompt({ isOpen: false, item: null });
|
||||||
|
} catch (err: any) {
|
||||||
|
setDelayError(err?.response?.data?.message || 'Failed to apply delay.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
{delayError && (
|
||||||
|
<div className="bg-red-50 p-3 rounded-lg text-sm text-red-800">{delayError}</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 rounded-lg border border-border bg-muted/30">
|
||||||
|
<span className="text-sm text-muted-foreground">Current reported delay</span>
|
||||||
|
{(delayPrompt.item.liveStatus?.delayMinutes ?? 0) > 0 ? (
|
||||||
|
<span className="edr-badge edr-badge-warning">+{delayPrompt.item.liveStatus?.delayMinutes} min</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">On time</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Delay (minutes)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={delayMinutesInput}
|
||||||
|
onChange={(e) => setDelayMinutesInput(e.target.value)}
|
||||||
|
placeholder="e.g. 60"
|
||||||
|
className="input"
|
||||||
|
required
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
Adds to the current reported delay above and pushes every downstream station's
|
||||||
|
check-in cutoff back by this many minutes. Use a negative number to correct an
|
||||||
|
over-reported delay.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<ActionButton type="button" variant="secondary" onClick={() => setDelayPrompt({ isOpen: false, item: null })}>
|
||||||
|
Cancel
|
||||||
|
</ActionButton>
|
||||||
|
<ActionButton type="submit" loading={applyDelayMutation.isPending}>
|
||||||
|
Apply Delay
|
||||||
|
</ActionButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.isOpen}
|
isOpen={deleteConfirm.isOpen}
|
||||||
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
onClose={() => setDeleteConfirm({ isOpen: false, item: null })}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default function SeatsPage() {
|
|||||||
phone: '',
|
phone: '',
|
||||||
email: '',
|
email: '',
|
||||||
});
|
});
|
||||||
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string } | null>(null);
|
const [issueBookingResult, setIssueBookingResult] = useState<{ payUrl?: string; bookingRef?: string } | null>(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: schedulesData } = useQuery({
|
const { data: schedulesData } = useQuery({
|
||||||
@@ -126,13 +126,19 @@ export default function SeatsPage() {
|
|||||||
bookingsApi.issueFromReservation(seatId, data),
|
bookingsApi.issueFromReservation(seatId, data),
|
||||||
onSuccess: (result: any) => {
|
onSuccess: (result: any) => {
|
||||||
invalidateSeatData();
|
invalidateSeatData();
|
||||||
setIssueBookingResult({ payUrl: result?.payUrl });
|
// Always show the reference — the PASSENGER path also needs the PNR alongside the
|
||||||
if (!result?.payUrl) {
|
// pay link (staff need to know which booking a seat belongs to, whether it's
|
||||||
// STAFF booking — nothing further to show the admin, close immediately.
|
// awaiting payment or already ticketed), so no longer auto-closing for STAFF.
|
||||||
setShowIssueBookingModal(false);
|
setIssueBookingResult({ payUrl: result?.payUrl, bookingRef: result?.booking?.bookingRef });
|
||||||
setSelectedSeat(null);
|
},
|
||||||
setIssueBookingCoach(null);
|
});
|
||||||
}
|
|
||||||
|
// Cancels a seat's still-unpaid reservation (payment link sent) and releases the seat —
|
||||||
|
// distinct from unblockMutation, which only handles a plain SeatBlock (no booking involved).
|
||||||
|
const cancelReservationMutation = useMutation({
|
||||||
|
mutationFn: (seatId: string) => bookingsApi.cancelReservation(seatId, selectedSchedule),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateSeatData();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,6 +228,16 @@ export default function SeatsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Distinct from handleUnblock — this seat has no SeatBlock (issuing the reservation already
|
||||||
|
// released it), it's HELD by the SeatHold behind an unpaid booking. Cancelling that booking
|
||||||
|
// invalidates its payment link immediately, so warn staff explicitly about that.
|
||||||
|
const handleCancelReservation = async (seat: any) => {
|
||||||
|
if (!selectedSchedule) return;
|
||||||
|
if (confirm(`Cancel the reservation for seat ${seat.seatNumber} (PNR ${seat.bookingRef})? The payment link already sent to the traveler will stop working.`)) {
|
||||||
|
await cancelReservationMutation.mutateAsync(seat.id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleIssueBooking = (seat: any, coach: any) => {
|
const handleIssueBooking = (seat: any, coach: any) => {
|
||||||
if (activeTab !== 'schedule' || !selectedSchedule) {
|
if (activeTab !== 'schedule' || !selectedSchedule) {
|
||||||
alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.');
|
alert('Select a specific schedule (Schedule tab) to issue a booking for a reserved seat.');
|
||||||
@@ -433,6 +449,7 @@ export default function SeatsPage() {
|
|||||||
handleBlock={handleBlock}
|
handleBlock={handleBlock}
|
||||||
handleRemoveSeat={handleRemoveSeat}
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
handleUnblock={handleUnblock}
|
handleUnblock={handleUnblock}
|
||||||
|
handleCancelReservation={handleCancelReservation}
|
||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
handleSetMaintenance={handleSetMaintenance}
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
@@ -529,6 +546,7 @@ export default function SeatsPage() {
|
|||||||
handleBlock={handleBlock}
|
handleBlock={handleBlock}
|
||||||
handleRemoveSeat={handleRemoveSeat}
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
handleUnblock={handleUnblock}
|
handleUnblock={handleUnblock}
|
||||||
|
handleCancelReservation={handleCancelReservation}
|
||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
handleSetMaintenance={handleSetMaintenance}
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
@@ -552,6 +570,7 @@ export default function SeatsPage() {
|
|||||||
handleBlock={handleBlock}
|
handleBlock={handleBlock}
|
||||||
handleRemoveSeat={handleRemoveSeat}
|
handleRemoveSeat={handleRemoveSeat}
|
||||||
handleUnblock={handleUnblock}
|
handleUnblock={handleUnblock}
|
||||||
|
handleCancelReservation={handleCancelReservation}
|
||||||
handleUndoRemove={handleUndoRemove}
|
handleUndoRemove={handleUndoRemove}
|
||||||
handleSetMaintenance={handleSetMaintenance}
|
handleSetMaintenance={handleSetMaintenance}
|
||||||
handleClearMaintenance={handleClearMaintenance}
|
handleClearMaintenance={handleClearMaintenance}
|
||||||
@@ -888,12 +907,25 @@ export default function SeatsPage() {
|
|||||||
title="Issue Booking"
|
title="Issue Booking"
|
||||||
size="md"
|
size="md"
|
||||||
>
|
>
|
||||||
{issueBookingResult?.payUrl ? (
|
{issueBookingResult ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Booking created. A payment link has been sent via SMS to the traveler.
|
{issueBookingResult.payUrl
|
||||||
|
? 'Booking created. A payment link has been sent via SMS to the traveler.'
|
||||||
|
: 'Booking confirmed and ticketed.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
|
{issueBookingResult.bookingRef && (
|
||||||
|
<div>
|
||||||
|
<label className="label">Booking Reference (PNR)</label>
|
||||||
|
<div className="input font-mono font-semibold text-sm">{issueBookingResult.bookingRef}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{issueBookingResult.payUrl && (
|
||||||
|
<div>
|
||||||
|
<label className="label">Payment Link</label>
|
||||||
|
<div className="input break-all text-xs">{issueBookingResult.payUrl}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -1231,6 +1263,7 @@ interface SeatIconProps {
|
|||||||
handleBlock: (seat: any) => void;
|
handleBlock: (seat: any) => void;
|
||||||
handleRemoveSeat: (seat: any) => void;
|
handleRemoveSeat: (seat: any) => void;
|
||||||
handleUnblock: (seat: any) => void;
|
handleUnblock: (seat: any) => void;
|
||||||
|
handleCancelReservation: (seat: any) => void;
|
||||||
handleUndoRemove: (seat: any) => void;
|
handleUndoRemove: (seat: any) => void;
|
||||||
handleSetMaintenance: (seat: any) => void;
|
handleSetMaintenance: (seat: any) => void;
|
||||||
handleClearMaintenance: (seat: any) => void;
|
handleClearMaintenance: (seat: any) => void;
|
||||||
@@ -1248,6 +1281,7 @@ function SeatIcon({
|
|||||||
handleBlock,
|
handleBlock,
|
||||||
handleRemoveSeat,
|
handleRemoveSeat,
|
||||||
handleUnblock,
|
handleUnblock,
|
||||||
|
handleCancelReservation,
|
||||||
handleUndoRemove,
|
handleUndoRemove,
|
||||||
handleSetMaintenance,
|
handleSetMaintenance,
|
||||||
handleClearMaintenance,
|
handleClearMaintenance,
|
||||||
@@ -1285,6 +1319,10 @@ function SeatIcon({
|
|||||||
const color = getSeatColor(status);
|
const color = getSeatColor(status);
|
||||||
const canBlock = status === 'AVAILABLE';
|
const canBlock = status === 'AVAILABLE';
|
||||||
const canUnblock = status === 'BLOCKED';
|
const canUnblock = status === 'BLOCKED';
|
||||||
|
// A HELD seat with a bookingRef + PENDING_PAYMENT is a backoffice reservation awaiting
|
||||||
|
// payment (see resolveActiveReservations) — issuing it already released the SeatBlock, so
|
||||||
|
// it's not reachable via canUnblock anymore; this is the seat's own release path.
|
||||||
|
const canCancelReservation = status === 'HELD' && !!seat.bookingRef && seat.reservationStatus === 'PENDING_PAYMENT';
|
||||||
const canMaintenance = false;
|
const canMaintenance = false;
|
||||||
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
const canClearMaintenance = status === 'UNDER_MAINTENANCE';
|
||||||
|
|
||||||
@@ -1299,7 +1337,7 @@ function SeatIcon({
|
|||||||
{isBedCoach ? (
|
{isBedCoach ? (
|
||||||
<div
|
<div
|
||||||
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
className={`${width} h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||||
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}`}
|
title={`${seat.seatNumber} - ${seat.bedPosition} - ${status}${seat.bookingRef ? ` - PNR ${seat.bookingRef} (${seat.reservationStatus})` : ''}`}
|
||||||
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
|
style={!shouldFlipIcon ? { transform: 'scaleY(-1)' } : undefined}
|
||||||
>
|
>
|
||||||
<Bed className="w-7 h-7 text-white" />
|
<Bed className="w-7 h-7 text-white" />
|
||||||
@@ -1307,14 +1345,23 @@ function SeatIcon({
|
|||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
className={`w-11 h-11 rounded flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity ${color}`}
|
||||||
title={`${seat.seatNumber} - ${status}`}
|
title={`${seat.seatNumber} - ${status}${seat.bookingRef ? ` - PNR ${seat.bookingRef} (${seat.reservationStatus})` : ''}`}
|
||||||
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
style={seat.row % 2 === 0 ? { transform: 'scaleY(-1)' } : undefined}
|
||||||
>
|
>
|
||||||
<Armchair className="w-7 h-7 text-white" />
|
<Armchair className="w-7 h-7 text-white" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(canBlock || canUnblock || canMaintenance || canClearMaintenance) && (
|
{seat.bookingRef && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] leading-3 font-semibold text-foreground/80 mt-0.5 max-w-[3.5rem] truncate"
|
||||||
|
title={`PNR ${seat.bookingRef} — ${seat.reservationStatus}${seat.reservationPassengerName ? ` — ${seat.reservationPassengerName}` : ''}`}
|
||||||
|
>
|
||||||
|
{seat.bookingRef}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(canBlock || canUnblock || canCancelReservation || canMaintenance || canClearMaintenance) && (
|
||||||
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
<div className="absolute top-full mt-1 bg-black/80 rounded shadow-lg flex items-center gap-1 p-1 z-20 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none group-hover:pointer-events-auto">
|
||||||
{canBlock && (
|
{canBlock && (
|
||||||
<>
|
<>
|
||||||
@@ -1334,6 +1381,15 @@ function SeatIcon({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{canCancelReservation && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCancelReservation(seat)}
|
||||||
|
className="p-1 bg-white rounded hover:bg-gray-100 pointer-events-auto"
|
||||||
|
title={`Cancel reservation (PNR ${seat.bookingRef}) — invalidates the payment link`}
|
||||||
|
>
|
||||||
|
<Unlock className="h-3 w-3 text-gray-700" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{canUnblock && (
|
{canUnblock && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export const bookingsApi = {
|
|||||||
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
|
// issued immediately) or PASSENGER (payment link texted to the traveler's phone).
|
||||||
issueFromReservation: (seatId: string, data: any) =>
|
issueFromReservation: (seatId: string, data: any) =>
|
||||||
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
|
apiClient.post<any>(`/bookings/reservations/${seatId}/issue`, data),
|
||||||
|
// Cancels a seat's still-PENDING_PAYMENT reservation (payment link sent, not yet paid) and
|
||||||
|
// releases the seat — the old payment link stops working immediately.
|
||||||
|
cancelReservation: (seatId: string, scheduleId: string) =>
|
||||||
|
apiClient.delete<any>(`/bookings/reservations/${seatId}?scheduleId=${scheduleId}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Passengers API
|
// Passengers API
|
||||||
@@ -141,6 +145,8 @@ export const schedulesApi = {
|
|||||||
update: (id: string, data: any) => apiClient.patch<any>(`/schedules/${id}`, data),
|
update: (id: string, data: any) => apiClient.patch<any>(`/schedules/${id}`, data),
|
||||||
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
|
delete: (id: string) => apiClient.delete(`/schedules/${id}`),
|
||||||
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/schedules/${id}/status`, { status }),
|
updateStatus: (id: string, status: string) => apiClient.patch<any>(`/schedules/${id}/status`, { status }),
|
||||||
|
applyDelay: (id: string, delayMinutes: number, fromSequence?: number) =>
|
||||||
|
apiClient.post<any>(`/schedules/${id}/delay`, { delayMinutes, fromSequence }),
|
||||||
assignCoaches: (scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
|
assignCoaches: (scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) =>
|
||||||
apiClient.post<any>(`/schedules/${scheduleId}/coaches`, { coaches }),
|
apiClient.post<any>(`/schedules/${scheduleId}/coaches`, { coaches }),
|
||||||
getAssignedCoaches: (scheduleId: string) => apiClient.get<any>(`/schedules/${scheduleId}/coaches`),
|
getAssignedCoaches: (scheduleId: string) => apiClient.get<any>(`/schedules/${scheduleId}/coaches`),
|
||||||
|
|||||||
@@ -21,9 +21,19 @@ import {
|
|||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Clock,
|
Clock,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useRef, useState, useCallback } from "react";
|
import { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||||
import ModernDatePicker from "@/components/ModernDatePicker";
|
import ModernDatePicker from "@/components/ModernDatePicker";
|
||||||
|
|
||||||
|
const AVAILABLE_DATES_RANGE_DAYS = 90;
|
||||||
|
|
||||||
|
const toDateStr = (date: Date) =>
|
||||||
|
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||||
|
|
||||||
|
interface AvailableDatesResponse {
|
||||||
|
routeExists: boolean;
|
||||||
|
dates: { date: string; available: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
function useDarkMode() {
|
function useDarkMode() {
|
||||||
const [dark, setDark] = useState(
|
const [dark, setDark] = useState(
|
||||||
() =>
|
() =>
|
||||||
@@ -610,6 +620,7 @@ export default function SearchPage() {
|
|||||||
handleSubmit,
|
handleSubmit,
|
||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
|
setError,
|
||||||
trigger,
|
trigger,
|
||||||
clearErrors,
|
clearErrors,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
@@ -691,6 +702,62 @@ export default function SearchPage() {
|
|||||||
const tripType = watch("tripType");
|
const tripType = watch("tripType");
|
||||||
const totalPassengers = (adultCount || 1) + (childCount || 0);
|
const totalPassengers = (adultCount || 1) + (childCount || 0);
|
||||||
|
|
||||||
|
// Which dates have no bookable schedule for the selected From/To — disables them on the
|
||||||
|
// departure date picker before the user submits a doomed search. Only fetched once both
|
||||||
|
// stations are picked; while loading or unselected, no extra dates are disabled (pickers
|
||||||
|
// keep their existing minDate-only behavior).
|
||||||
|
const { data: availableDates } = useQuery<AvailableDatesResponse>({
|
||||||
|
queryKey: ["available-dates", originId, destId],
|
||||||
|
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: destId,
|
||||||
|
from: toDateStr(from),
|
||||||
|
to: toDateStr(to),
|
||||||
|
},
|
||||||
|
})) as AvailableDatesResponse;
|
||||||
|
},
|
||||||
|
enabled: !!originId && !!destId,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledDates = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
if (!availableDates?.routeExists) return set;
|
||||||
|
for (const d of availableDates.dates) if (!d.available) set.add(d.date);
|
||||||
|
return set;
|
||||||
|
}, [availableDates]);
|
||||||
|
|
||||||
|
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
|
||||||
|
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
|
||||||
|
// window (it was simply never fetched, not confirmed available), so once a route is picked
|
||||||
|
// the picker's own maxDate has to match the same horizon or unchecked future months render
|
||||||
|
// as pickable again.
|
||||||
|
const maxSearchDate = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
const departureMaxDate = originId && destId ? maxSearchDate : undefined;
|
||||||
|
|
||||||
|
// If the currently selected departure date becomes unavailable (From/To changed, or the
|
||||||
|
// availability query just resolved), clear it and surface an inline error rather than
|
||||||
|
// letting the user submit a search that's already known to be empty.
|
||||||
|
useEffect(() => {
|
||||||
|
if (departureDate && disabledDates.has(departureDate)) {
|
||||||
|
setValue("departureDate", "");
|
||||||
|
setError("departureDate", {
|
||||||
|
type: "manual",
|
||||||
|
message:
|
||||||
|
"No trains run this route on the selected date — please pick another date.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [departureDate, disabledDates, setValue, setError]);
|
||||||
|
|
||||||
const saveRecent = useCallback((id: string) => {
|
const saveRecent = useCallback((id: string) => {
|
||||||
setRecentStationIds((prev) => {
|
setRecentStationIds((prev) => {
|
||||||
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
|
const next = [id, ...prev.filter((x) => x !== id)].slice(0, 5);
|
||||||
@@ -1126,6 +1193,8 @@ export default function SearchPage() {
|
|||||||
trigger("departureDate");
|
trigger("departureDate");
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
|
maxDate={departureMaxDate}
|
||||||
|
disabledDates={disabledDates}
|
||||||
placeholder="Departure date"
|
placeholder="Departure date"
|
||||||
error={!!errors.departureDate}
|
error={!!errors.departureDate}
|
||||||
/>
|
/>
|
||||||
@@ -1316,6 +1385,8 @@ export default function SearchPage() {
|
|||||||
trigger("departureDate");
|
trigger("departureDate");
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
|
maxDate={departureMaxDate}
|
||||||
|
disabledDates={disabledDates}
|
||||||
placeholder="Departure"
|
placeholder="Departure"
|
||||||
error={!!errors.departureDate}
|
error={!!errors.departureDate}
|
||||||
/>
|
/>
|
||||||
@@ -1470,6 +1541,8 @@ export default function SearchPage() {
|
|||||||
trigger("returnDate");
|
trigger("returnDate");
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
|
maxDate={departureMaxDate}
|
||||||
|
disabledDates={disabledDates}
|
||||||
placeholder="Departure date"
|
placeholder="Departure date"
|
||||||
/>
|
/>
|
||||||
{errors.departureDate && (
|
{errors.departureDate && (
|
||||||
|
|||||||
@@ -18,15 +18,26 @@ interface ModernDatePickerProps {
|
|||||||
onChange: (date: Date) => void;
|
onChange: (date: Date) => void;
|
||||||
minDate?: Date;
|
minDate?: Date;
|
||||||
maxDate?: Date;
|
maxDate?: Date;
|
||||||
|
// Dates with no bookable schedule for the selected route (YYYY-MM-DD keys) — disabled
|
||||||
|
// alongside the minDate/maxDate range, not just clamping it.
|
||||||
|
disabledDates?: Set<string>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toDateKey = (date: Date) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ModernDatePicker({
|
export default function ModernDatePicker({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
minDate,
|
minDate,
|
||||||
maxDate,
|
maxDate,
|
||||||
|
disabledDates,
|
||||||
placeholder = 'Select date',
|
placeholder = 'Select date',
|
||||||
error = false,
|
error = false,
|
||||||
}: ModernDatePickerProps) {
|
}: ModernDatePickerProps) {
|
||||||
@@ -120,7 +131,7 @@ export default function ModernDatePicker({
|
|||||||
const date = new Date(viewYear, viewMonth, day);
|
const date = new Date(viewYear, viewMonth, day);
|
||||||
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
|
const isSelected = value && date.getDate() === value.getDate() && date.getMonth() === value.getMonth() && date.getFullYear() === value.getFullYear();
|
||||||
const isToday = date.toDateString() === new Date().toDateString();
|
const isToday = date.toDateString() === new Date().toDateString();
|
||||||
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate()));
|
const isDisabled = (minDate && date < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) || (maxDate && date > new Date(maxDate.getFullYear(), maxDate.getMonth(), maxDate.getDate())) || !!disabledDates?.has(toDateKey(date));
|
||||||
return (
|
return (
|
||||||
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
|
<button key={day} type="button" onClick={() => !isDisabled && handleDateSelect(date)} disabled={!!isDisabled}
|
||||||
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||||
@@ -180,6 +191,7 @@ export default function ModernDatePicker({
|
|||||||
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
const [gY, gM, gD] = [gregDate.getFullYear(), gregDate.getMonth(), gregDate.getDate()];
|
||||||
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
|
if (gY > mY || (gY === mY && gM > mM) || (gY === mY && gM === mM && gD > mD)) isDisabled = true;
|
||||||
}
|
}
|
||||||
|
if (!isDisabled && disabledDates?.has(toDateKey(gregDate))) isDisabled = true;
|
||||||
return (
|
return (
|
||||||
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
|
<button key={day} type="button" onClick={() => !isDisabled && handleEthiopianDateSelect(ethDate)} disabled={isDisabled}
|
||||||
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
className={`aspect-square flex items-center justify-center text-sm rounded-lg transition-all
|
||||||
|
|||||||
@@ -9,9 +9,23 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
import { useBookingStore } from '@/lib/booking-store';
|
import { useBookingStore } from '@/lib/booking-store';
|
||||||
import { Station } from '@/types';
|
import { Station } from '@/types';
|
||||||
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
import { MapPin, Users, Search, Plus, Minus, ChevronDown, Globe } from 'lucide-react';
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import ModernDatePicker from '@/components/ModernDatePicker';
|
import ModernDatePicker from '@/components/ModernDatePicker';
|
||||||
|
|
||||||
|
const AVAILABLE_DATES_RANGE_DAYS = 90;
|
||||||
|
|
||||||
|
const toDateStr = (date: Date) => {
|
||||||
|
const y = date.getFullYear();
|
||||||
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AvailableDatesResponse {
|
||||||
|
routeExists: boolean;
|
||||||
|
dates: { date: string; available: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
tripType: z.enum(['ONE_WAY', 'ROUND_TRIP']),
|
||||||
originStationId: z.string().min(1, 'Please select a departure station'),
|
originStationId: z.string().min(1, 'Please select a departure station'),
|
||||||
@@ -129,7 +143,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
queryFn: async () => await apiClient.get('/stations') as Station[],
|
queryFn: async () => await apiClient.get('/stations') as Station[],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { handleSubmit, watch, setValue, clearErrors, formState: { errors } } = useForm<SearchForm>({
|
const { handleSubmit, watch, setValue, clearErrors, setError, formState: { errors } } = useForm<SearchForm>({
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
resolver: zodResolver(searchSchema),
|
resolver: zodResolver(searchSchema),
|
||||||
mode: 'onSubmit',
|
mode: 'onSubmit',
|
||||||
@@ -147,6 +161,7 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
|
|
||||||
const originId = watch('originStationId');
|
const originId = watch('originStationId');
|
||||||
const destinationId = watch('destinationStationId');
|
const destinationId = watch('destinationStationId');
|
||||||
|
const departureDate = watch('departureDate');
|
||||||
const nationality = watch('nationality');
|
const nationality = watch('nationality');
|
||||||
const adultCount = watch('adultCount');
|
const adultCount = watch('adultCount');
|
||||||
const childCount = watch('childCount');
|
const childCount = watch('childCount');
|
||||||
@@ -154,6 +169,62 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: s.name }));
|
const stationOptions = (stations ?? []).map((s) => ({ value: s.id, label: s.name }));
|
||||||
const destinationOptions = stationOptions.map((s) => ({ ...s, disabled: s.value === originId }));
|
const destinationOptions = stationOptions.map((s) => ({ ...s, disabled: s.value === originId }));
|
||||||
|
|
||||||
|
// Which dates have no bookable schedule for the selected route — disables them on the date
|
||||||
|
// picker before the user submits a doomed search. Only fetched once both stations are picked;
|
||||||
|
// while loading or unselected, no extra dates are disabled (picker keeps today's minDate-only
|
||||||
|
// behavior).
|
||||||
|
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,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const disabledDates = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
if (!availableDates) return set;
|
||||||
|
if (!availableDates.routeExists) return set; // no route → don't blanket-disable every date, the submit-time error already covers this
|
||||||
|
for (const d of availableDates.dates) if (!d.available) set.add(d.date);
|
||||||
|
return set;
|
||||||
|
}, [availableDates]);
|
||||||
|
|
||||||
|
// /search/available-dates only ever reports on a bounded window (server-clamped to 90 days —
|
||||||
|
// see AVAILABLE_DATES_RANGE_DAYS). disabledDates alone can't gray out anything past that
|
||||||
|
// window (it was simply never fetched, not confirmed available), so once a route is picked
|
||||||
|
// the picker's own maxDate has to match the same horizon or unchecked future months render
|
||||||
|
// as pickable again.
|
||||||
|
const maxSearchDate = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + AVAILABLE_DATES_RANGE_DAYS);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
const departureMaxDate = originId && destinationId ? maxSearchDate : undefined;
|
||||||
|
|
||||||
|
// If the currently selected date becomes unavailable (route changed, or the availability
|
||||||
|
// query just resolved), clear it and surface an inline error rather than letting the user
|
||||||
|
// submit a search that's already known to be empty.
|
||||||
|
useEffect(() => {
|
||||||
|
if (departureDate && disabledDates.has(departureDate)) {
|
||||||
|
setValue('departureDate', '');
|
||||||
|
setError('departureDate', {
|
||||||
|
type: 'manual',
|
||||||
|
message: 'No trains run this route on the selected date — please pick another date.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [departureDate, disabledDates, setValue, setError]);
|
||||||
|
|
||||||
const onSubmit = (data: SearchForm) => {
|
const onSubmit = (data: SearchForm) => {
|
||||||
setSearchCriteria({ ...data });
|
setSearchCriteria({ ...data });
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
@@ -213,15 +284,14 @@ export function SearchWidget({ fullWidth = false, onSearch }: SearchWidgetProps)
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Date</label>
|
||||||
<ModernDatePicker
|
<ModernDatePicker
|
||||||
value={watch('departureDate') ? new Date(watch('departureDate') + 'T00:00:00') : undefined}
|
value={departureDate ? new Date(departureDate + 'T00:00:00') : undefined}
|
||||||
onChange={(date) => {
|
onChange={(date) => {
|
||||||
const y = date.getFullYear();
|
setValue('departureDate', toDateStr(date));
|
||||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const d = String(date.getDate()).padStart(2, '0');
|
|
||||||
setValue('departureDate', `${y}-${m}-${d}`);
|
|
||||||
clearErrors('departureDate');
|
clearErrors('departureDate');
|
||||||
}}
|
}}
|
||||||
minDate={new Date()}
|
minDate={new Date()}
|
||||||
|
maxDate={departureMaxDate}
|
||||||
|
disabledDates={disabledDates}
|
||||||
placeholder="Select date"
|
placeholder="Select date"
|
||||||
/>
|
/>
|
||||||
{errors.departureDate && (
|
{errors.departureDate && (
|
||||||
|
|||||||
Reference in New Issue
Block a user