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 c89edf5ec..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 }) => ( @@ -151,12 +151,38 @@ function BookingsPageContent() { )} {booking.isPackageBooking - ?
From: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}
+ ?
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) => { @@ -203,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/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 d5cdcf3af..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 @@ -143,7 +143,7 @@ export default function SeatsPage() { 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< @@ -173,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); @@ -337,10 +340,9 @@ export default function SeatsPage() { }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); // 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), ...outboundLockedSeatIds]), - [passengerSeatMap, outboundLockedSeatIds], + () => new Set(Object.values(passengerSeatMap)), + [passengerSeatMap], ); // The furthest passenger a user is allowed to jump to — cannot skip ahead of the @@ -359,11 +361,10 @@ 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, outboundLockedSeatIds], + [passengerSeatMap, activePassengerIndex], ); const handleSelectPassenger = useCallback( @@ -376,12 +377,10 @@ export default function SeatsPage() { const handleSeatClick = useCallback( (seatId: string) => { - // 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, - ); + // Seat already claimed by a different passenger — never allow duplicate assignment + const takenByOther = Object.entries(passengerSeatMap).some( + ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, + ); if (takenByOther) return; const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId; @@ -401,7 +400,7 @@ export default function SeatsPage() { if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned); } }, - [passengerSeatMap, activePassengerIndex, passengers, outboundLockedSeatIds], + [passengerSeatMap, activePassengerIndex, passengers], ); const allSeatsAssigned = @@ -436,8 +435,6 @@ 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 5c8d14891..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 @@ -21,7 +21,6 @@ import { Tag, Shield, X, - Navigation, } from "lucide-react"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -322,7 +321,7 @@ function PassengerCountModal({
-

Number of passengers

+

Number of passengers & boarding station selection

@@ -374,8 +373,7 @@ function PassengerCountModal({ {/* Departure Station */}
-