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({
)}
-