mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Departure station added to package bookings, and other fixes
This commit is contained in:
@@ -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;
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,10 @@ function BookingsPageContent() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||
{booking.isPackageBooking
|
||||
? <div className="text-xs text-muted-foreground">From: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}</div>
|
||||
: <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
|
||||
}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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 },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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<Record<number, string>>({});
|
||||
// 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<Set<string>>(new Set());
|
||||
const [activePassengerIndex, setActivePassengerIndex] = useState(0);
|
||||
const [selectedCoach, setSelectedCoach] = useState<string | null>(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);
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button type="button" onClick={() => onConfirm(adultCount, childCount)}
|
||||
{/* Departure Station */}
|
||||
<div>
|
||||
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5">
|
||||
<Navigation className="w-3.5 h-3.5 text-primary" />
|
||||
Departure Station
|
||||
</label>
|
||||
<select
|
||||
value={departureStationId}
|
||||
onChange={(e) => setDepartureStationId(e.target.value)}
|
||||
className="w-full rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-white px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
>
|
||||
<option value="">Select your boarding station</option>
|
||||
{stations.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.name.trim()} ({s.code})</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[10px] text-gray-400 mt-1">For informational purposes — pricing remains fixed regardless of boarding point.</p>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => {
|
||||
const station = stations.find(s => s.id === departureStationId);
|
||||
onConfirm(adultCount, childCount, departureStationId, station?.name ?? '');
|
||||
}}
|
||||
disabled={loading || adultCount + childCount < 1}
|
||||
className="w-full py-3.5 bg-[rgb(20,113,76)] hover:bg-[rgb(16,89,60)] disabled:opacity-60 text-white font-bold text-sm rounded-xl transition-all shadow-lg flex items-center justify-center gap-2">
|
||||
{loading ? <><Loader2 className="w-4 h-4 animate-spin" /> Loading...</> : <>Continue <ArrowRight className="w-4 h-4" /></>}
|
||||
@@ -400,11 +426,18 @@ export default function PackageDetailPage() {
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const { data: stationsData } = useQuery({
|
||||
queryKey: ["stations-simple"],
|
||||
queryFn: async () => (await apiClient.get(`/stations?pageSize=100`)) as any,
|
||||
});
|
||||
|
||||
const stations: Station[] = stationsData?.items || stationsData?.data?.items || [];
|
||||
|
||||
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);
|
||||
|
||||
const isRoundTripPkg = pkg?.journeyType === 'ROUND_TRIP';
|
||||
|
||||
const handleBookNow = async (adultCount: number, childCount: number) => {
|
||||
const handleBookNow = async (adultCount: number, childCount: number, departureStationId: string, departureStationName: string) => {
|
||||
if (!selectedTier || !pkg) return;
|
||||
setBookingContextLoading(true);
|
||||
setBookingContextError(null);
|
||||
@@ -467,7 +500,7 @@ export default function PackageDetailPage() {
|
||||
);
|
||||
|
||||
// Store per-adult tier price (×1 leg); review page applies round-trip multiplier and child pricing
|
||||
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name);
|
||||
setPackageContext(id, selectedTier.id, selectedTier.priceMinor, pkg.name, departureStationId, departureStationName);
|
||||
|
||||
router.push("/booking/passengers");
|
||||
} catch (err: any) {
|
||||
@@ -522,6 +555,7 @@ export default function PackageDetailPage() {
|
||||
loading={bookingContextLoading}
|
||||
error={bookingContextError}
|
||||
priceMultiplier={isRoundTripPkg ? 2 : 1}
|
||||
stations={stations}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -88,6 +88,8 @@ interface BookingState {
|
||||
packageName: string | null;
|
||||
priceTierId: string | null;
|
||||
packageTierPriceMinor: number | null;
|
||||
packageDepartureStationId: string | null;
|
||||
packageDepartureStationName: string | null;
|
||||
|
||||
setSearchCriteria: (criteria: SearchCriteria) => void;
|
||||
setSelectedSchedule: (schedule: SelectedSchedule) => void;
|
||||
@@ -100,7 +102,7 @@ interface BookingState {
|
||||
setPaymentMethod: (method: string) => void;
|
||||
setCreateAccount: (create: boolean) => void;
|
||||
setPassengerId: (id: string | null) => void;
|
||||
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string) => void;
|
||||
setPackageContext: (packageId: string, priceTierId: string, priceMinor: number, packageName?: string, departureStationId?: string, departureStationName?: string) => void;
|
||||
clearBooking: () => void;
|
||||
}
|
||||
|
||||
@@ -121,9 +123,11 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
packageName: null,
|
||||
priceTierId: null,
|
||||
packageTierPriceMinor: null,
|
||||
packageDepartureStationId: null,
|
||||
packageDepartureStationName: null,
|
||||
|
||||
setSearchCriteria: (criteria) => set({ searchCriteria: criteria }),
|
||||
setPackageContext: (packageId, priceTierId, priceMinor, packageName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor }),
|
||||
setPackageContext: (packageId, priceTierId, priceMinor, packageName, departureStationId, departureStationName) => set({ packageId, packageName: packageName ?? null, priceTierId, packageTierPriceMinor: priceMinor, packageDepartureStationId: departureStationId ?? null, packageDepartureStationName: departureStationName ?? null }),
|
||||
setSelectedSchedule: (schedule) => set({ selectedSchedule: schedule }),
|
||||
setOutboundSchedule: (schedule) => set({ outboundSchedule: schedule }),
|
||||
setInboundSchedule: (schedule) => set({ inboundSchedule: schedule }),
|
||||
@@ -150,6 +154,8 @@ export const useBookingStore = create<BookingState>()(persist(
|
||||
packageName: null,
|
||||
priceTierId: null,
|
||||
packageTierPriceMinor: null,
|
||||
packageDepartureStationId: null,
|
||||
packageDepartureStationName: null,
|
||||
}),
|
||||
} as BookingState)),
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user