From 21122069a56b843dd0b95a4086766a5d69ba0436 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Sun, 5 Jul 2026 19:17:55 +0300 Subject: [PATCH] Departure station added to package bookings, and other fixes --- .../migration.sql | 21 ++++++++++ apps/edr-passenger-api/prisma/schema.prisma | 8 +++- .../modules/packages/packages.controller.ts | 17 ++++---- .../backoffice/src/app/bookings/page.tsx | 5 ++- .../src/components/layout/Sidebar.tsx | 2 +- .../portal/src/app/booking/seats/page.tsx | 27 ++++++++---- .../portal/src/app/packages/[id]/page.tsx | 42 +++++++++++++++++-- .../portal/src/lib/booking-store.ts | 10 ++++- 8 files changed, 106 insertions(+), 26 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260706000000_add_departure_station_to_bookings/migration.sql 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-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index 61a38ddae..c89edf5ec 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -150,7 +150,10 @@ function BookingsPageContent() { )} -
{booking.bookingType || 'ONE_WAY'}
+ {booking.isPackageBooking + ?
From: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}
+ :
{booking.bookingType || 'ONE_WAY'}
+ } ), }, 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/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 537e20623..d5cdcf3af 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 [outboundLockedSeatIds, setOutboundLockedSeatIds] = useState>(new Set()); const [activePassengerIndex, setActivePassengerIndex] = useState(0); const [selectedCoach, setSelectedCoach] = useState(null); const [currentJourneyType, setCurrentJourneyType] = useState< @@ -333,10 +336,11 @@ 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 + // locked seats (so inbound cannot reuse the same physical seat IDs). const assignedSeatIds = useMemo( - () => new Set(Object.values(passengerSeatMap)), - [passengerSeatMap], + () => new Set([...Object.values(passengerSeatMap), ...outboundLockedSeatIds]), + [passengerSeatMap, outboundLockedSeatIds], ); // The furthest passenger a user is allowed to jump to — cannot skip ahead of the @@ -355,10 +359,11 @@ export default function SeatsPage() { const isSeatAssignedToOther = useCallback( (seatId: string) => + outboundLockedSeatIds.has(seatId) || Object.entries(passengerSeatMap).some( ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, ), - [passengerSeatMap, activePassengerIndex], + [passengerSeatMap, activePassengerIndex, outboundLockedSeatIds], ); const handleSelectPassenger = useCallback( @@ -371,10 +376,12 @@ export default function SeatsPage() { const handleSeatClick = useCallback( (seatId: string) => { - // Seat already claimed by a different passenger — never allow duplicate assignment - const takenByOther = Object.entries(passengerSeatMap).some( - ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, - ); + // Seat already claimed by a different passenger or locked from outbound — never allow duplicate assignment + const takenByOther = + outboundLockedSeatIds.has(seatId) || + Object.entries(passengerSeatMap).some( + ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, + ); if (takenByOther) return; const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId; @@ -394,7 +401,7 @@ export default function SeatsPage() { if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned); } }, - [passengerSeatMap, activePassengerIndex, passengers], + [passengerSeatMap, activePassengerIndex, passengers, outboundLockedSeatIds], ); const allSeatsAssigned = @@ -429,6 +436,8 @@ export default function SeatsPage() { }); return; } + // Lock outbound seat IDs so they cannot be selected on the inbound leg + setOutboundLockedSeatIds(new Set(seatIds)); setCurrentJourneyType("inbound"); setPassengerSeatMap({}); setActivePassengerIndex(0); 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..5c8d14891 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 @@ -21,6 +21,7 @@ import { Tag, Shield, X, + Navigation, } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -298,16 +299,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; @@ -368,7 +372,29 @@ function PassengerCountModal({ )} -