diff --git a/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql new file mode 100644 index 000000000..f782c3c64 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql @@ -0,0 +1,21 @@ +-- AlterTable: add package_departure_station_id to Booking +ALTER TABLE "passenger"."Booking" + ADD COLUMN "packageDepartureStationId" TEXT; + +-- AlterTable: add package_departure_station_id to PackageBooking +ALTER TABLE "passenger"."PackageBooking" + ADD COLUMN "packageDepartureStationId" TEXT; + +-- AddForeignKey: Booking -> Station +ALTER TABLE "passenger"."Booking" + ADD CONSTRAINT "Booking_packageDepartureStationId_fkey" + FOREIGN KEY ("packageDepartureStationId") + REFERENCES "passenger"."Station"("id") + ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey: PackageBooking -> Station +ALTER TABLE "passenger"."PackageBooking" + ADD CONSTRAINT "PackageBooking_packageDepartureStationId_fkey" + FOREIGN KEY ("packageDepartureStationId") + REFERENCES "passenger"."Station"("id") + ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index ba3571ab1..83c2b884e 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -330,7 +330,9 @@ model Station { originSchedules TrainSchedule[] @relation("OriginTrips") destinationSchedules TrainSchedule[] @relation("DestinationTrips") stopTimes TripStopTime[] - crowdSignals StationCrowdSignal[] + crowdSignals StationCrowdSignal[] + bookingDepartures Booking[] @relation("BookingPackageDepartureStation") + packageBookingDepartures PackageBooking[] @relation("PackageBookingDepartureStation") @@index([city, countryCode]) @@index([sequence]) @@schema("passenger") @@ -541,6 +543,7 @@ model Booking { promoCode String? paidAt DateTime? paymentReminderSentAt DateTime? + packageDepartureStationId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt passenger Passenger @relation(fields: [passengerId], references: [id]) @@ -548,6 +551,7 @@ model Booking { returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id]) package TravelPackage? @relation(fields: [packageId], references: [id]) priceTier PackagePriceTier? @relation(fields: [priceTierId], references: [id]) + departureStation Station? @relation("BookingPackageDepartureStation", fields: [packageDepartureStationId], references: [id]) seats BookingSeat[] paymentIntent PaymentIntent? tickets Ticket[] @@ -1479,10 +1483,12 @@ model PackageBooking { paidAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + packageDepartureStationId String? package TravelPackage @relation(fields: [packageId], references: [id]) priceTier PackagePriceTier @relation(fields: [priceTierId], references: [id]) passenger Passenger? @relation(fields: [passengerId], references: [id]) + departureStation Station? @relation("PackageBookingDepartureStation", fields: [packageDepartureStationId], references: [id]) passengers PackageBookingPassenger[] paymentIntent PackagePaymentIntent? diff --git a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts index f814eb3e7..de61f92ca 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.controller.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.controller.ts @@ -91,6 +91,15 @@ export class PackagesController { return this.service.getBookingByRef(ref); } + @Post('book') + @IsPublic() + @UseGuards(OptionalJwtGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Book a package (public or authenticated)' }) + book(@Body() dto: BookPackageDto, @Request() req: any) { + return this.service.book(dto, req.user?.passengerId); + } + @Get(':id/booking-context') @IsPublic() @ApiOperation({ summary: 'Get booking context for self-service package booking' }) @@ -177,12 +186,4 @@ export class PackagesController { return this.service.deleteTier(tierId); } - @Post('book') - @IsPublic() - @UseGuards(OptionalJwtGuard) - @ApiBearerAuth('JWT-auth') - @ApiOperation({ summary: 'Book a package (public or authenticated)' }) - book(@Body() dto: BookPackageDto, @Request() req: any) { - return this.service.book(dto, req.user?.passengerId); - } } diff --git a/apps/edr-passenger-api/src/modules/seats/seats.service.ts b/apps/edr-passenger-api/src/modules/seats/seats.service.ts index 5c237e6ba..495c394ee 100644 --- a/apps/edr-passenger-api/src/modules/seats/seats.service.ts +++ b/apps/edr-passenger-api/src/modules/seats/seats.service.ts @@ -61,11 +61,12 @@ export class SeatsService { const resolvedBedPosition = isBedCoach ? this.resolveBedPosition(s.col, s.bedPosition) : s.bedPosition; + const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); return { id: s.id, seatNumber: s.seatNumber, label: s.seatNumber, - status: effectiveStatuses.get(s.id) ?? s.status, + status: effectiveStatus, kind: s.kind, row: s.row, col: s.col, @@ -245,11 +246,16 @@ export class SeatsService { holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo); - if (!legsOverlap) continue; - // Check direction conflict const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection); - if (!directionsConflict) continue; + + if (!legsOverlap || !directionsConflict) { + // This hold does not conflict with the requested leg/direction. + // Explicitly mark AVAILABLE so the DB's HELD status (set by the + // opposing-direction hold) does not bleed through via the fallback. + if (!statusMap.has(seatId)) statusMap.set(seatId, 'AVAILABLE'); + continue; + } statusMap.set(seatId, 'HELD'); } @@ -653,7 +659,7 @@ export class SeatsService { const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); const totalSeats = seats.length; const unavailable = seats.filter(s => { - const status = effectiveStatuses.get(s.id) ?? s.status; + const status = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE'); return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED'; }).length; @@ -665,8 +671,8 @@ export class SeatsService { seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], totalSeats, availableSeats: totalSeats - unavailable, - heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').length, - bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'BOOKED').length, + heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'HELD').length, + bookedSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE')) === 'BOOKED').length, }; }); } @@ -861,11 +867,21 @@ export class SeatsService { if (expired.length === 0) return; const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); - // Only reset seats that are still HELD — BOOKED seats have been confirmed and must not be touched. - await this.prisma.seat.updateMany({ - where: { id: { in: expiredSeatIds }, status: 'HELD' }, - data: { status: 'AVAILABLE' }, + + // Only reset seats that have no remaining active holds + const stillHeld = await this.prisma.seatHold.findMany({ + where: { expiresAt: { gte: new Date() }, seatIds: { hasSome: expiredSeatIds } }, + select: { seatIds: true }, }); + const stillHeldIds = new Set(stillHeld.flatMap(h => h.seatIds as string[])); + const toRelease = expiredSeatIds.filter(id => !stillHeldIds.has(id)); + + if (toRelease.length > 0) { + await this.prisma.seat.updateMany({ + where: { id: { in: toRelease }, status: 'HELD' }, + data: { status: 'AVAILABLE' }, + }); + } await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } }); } } diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 61a38ddae..974c25470 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { bookingsApi, apiClient } from '@/lib/api'; -import { formatCurrency, formatDateTime } from '@/lib/utils'; +import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils'; import { BookingFilters } from '@/types'; const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( @@ -150,10 +150,39 @@ function BookingsPageContent() { )} -
{booking.bookingType || 'ONE_WAY'}
+ {booking.isPackageBooking + ?
Boarding at: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}
+ :
{booking.bookingType || 'ONE_WAY'}
+ } ), }, + { + key: 'trip', + label: 'Trip', + render: (booking: any) => { + const isRoundTrip = booking?.bookingType === 'ROUND_TRIP' || booking?.bookingType === 'ROUND_TRIP_TRANSIT'; + const returnDeparture = booking?.returnSchedule?.departureAt; + console.log(JSON.stringify(booking.packageId)); + return ( +
+
+ {booking.schedule?.originStation?.name || 'N/A'} → {booking.schedule?.destinationStation?.name || 'N/A'} +
+
+ {!isRoundTrip ? ( + {booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} + ) : ( + + {booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} · + {returnDeparture ? formatDateTimeShort(returnDeparture) : ''} + + )} +
+
+ ); + }, + }, { key: 'passengerNames', label: 'Names', render: (booking: any) => { @@ -200,14 +229,6 @@ function BookingsPageContent() { ), }, - { - key: 'passengerCount', label: 'Passengers', - render: (booking: any) => { - const adults = booking.adultCount || 0, children = booking.childCount || 0; - if (!adults && !children) return '—'; - return <>
Adult: {adults}
Child: {children}
; - }, - }, { key: 'paymentStatus', label: 'Payment', render: (booking: any) => ( diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 41cf33627..d9ca1da7d 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -71,7 +71,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ title: 'Tourism', items: [ { name: 'Packages', href: '/packages', icon: Package, permission: PERMS.admin }, - // { name: 'Pkg Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin }, + // { name: 'Bookings', href: '/package-bookings', icon: Ticket, permission: PERMS.admin }, { name: 'Inquiries', href: '/package-inquiries', icon: MessageSquare, permission: PERMS.admin }, ] }, diff --git a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx index c56ccd304..6b5bcb715 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/confirmation/page.tsx @@ -153,7 +153,7 @@ export default function ConfirmationPage() { const handleNewBooking = () => { clearBooking(); - router.push('/booking/search'); + window.location.href = '/'; }; useEffect(() => { diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 537e20623..9d289bbe5 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -141,6 +141,9 @@ export default function SeatsPage() { // Maps passenger index -> assigned seat id. A passenger can only get a seat while // they are the "active" passenger, which prevents bulk/batch selection across passengers. const [passengerSeatMap, setPassengerSeatMap] = useState>({}); + // Outbound seat IDs locked in after the outbound hold — used to prevent the + // same physical seat being picked again on the inbound leg. + const [activePassengerIndex, setActivePassengerIndex] = useState(0); const [selectedCoach, setSelectedCoach] = useState(null); const [currentJourneyType, setCurrentJourneyType] = useState< @@ -170,9 +173,12 @@ export default function SeatsPage() { isLoading, error, } = useQuery({ - queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], + queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType, (currentSchedule as any)?.originStationId, (currentSchedule as any)?.destinationStationId], queryFn: async () => { - const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}`; + const scheduleForMap = isRoundTrip && currentJourneyType === "inbound" ? inboundSchedule : (isRoundTrip ? outboundSchedule : selectedSchedule); + const originId = (scheduleForMap as any)?.originStationId || searchCriteria?.originStationId; + const destinationId = (scheduleForMap as any)?.destinationStationId || searchCriteria?.destinationStationId; + const endpoint = `/seats/seatmap/${currentSchedule?.id}?coachTypeId=${coachTypeId}&journeyDirection=${journeyDirection}${originId ? `&originStationId=${originId}` : ''}${destinationId ? `&destinationStationId=${destinationId}` : ''}`; const response = await apiClient.get(endpoint); @@ -333,7 +339,7 @@ export default function SeatsPage() { return seats; }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); - // Seats already claimed by any passenger in this journey leg + // Seats already claimed by any passenger in this journey leg, plus outbound const assignedSeatIds = useMemo( () => new Set(Object.values(passengerSeatMap)), [passengerSeatMap], diff --git a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx index 4b1bd5a98..aee655dd5 100644 --- a/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/packages/[id]/page.tsx @@ -298,16 +298,19 @@ function PassengerCountModal({ loading, error, priceMultiplier, + stations, }: { tier: PriceTier; onClose: () => void; - onConfirm: (adultCount: number, childCount: number) => void; + onConfirm: (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => void; loading: boolean; error: string | null; priceMultiplier: number; + stations: Station[]; }) { const [adultCount, setAdultCount] = useState(1); const [childCount, setChildCount] = useState(0); + const [departureStationId, setDepartureStationId] = useState(''); const remaining = tier.availableSeats - tier.bookedSeats; const childFareMinor = Math.round(tier.priceMinor * PKG_CHILD_FARE_RATIO); const totalMinor = (adultCount * tier.priceMinor + childCount * childFareMinor) * priceMultiplier; @@ -318,7 +321,7 @@ function PassengerCountModal({
-

Number of passengers

+

Number of passengers & boarding station selection

@@ -368,7 +371,28 @@ function PassengerCountModal({
)} -