UAT issues resolution

This commit is contained in:
Stephanos A
2026-07-05 20:38:49 +03:00
parent 21122069a5
commit cacced8edf
5 changed files with 73 additions and 44 deletions

View File

@@ -61,11 +61,12 @@ export class SeatsService {
const resolvedBedPosition = isBedCoach const resolvedBedPosition = isBedCoach
? this.resolveBedPosition(s.col, s.bedPosition) ? this.resolveBedPosition(s.col, s.bedPosition)
: s.bedPosition; : s.bedPosition;
const effectiveStatus = effectiveStatuses.get(s.id) ?? (s.status === 'BLOCKED' || s.status === 'BOOKED' ? s.status : 'AVAILABLE');
return { return {
id: s.id, id: s.id,
seatNumber: s.seatNumber, seatNumber: s.seatNumber,
label: s.seatNumber, label: s.seatNumber,
status: effectiveStatuses.get(s.id) ?? s.status, status: effectiveStatus,
kind: s.kind, kind: s.kind,
row: s.row, row: s.row,
col: s.col, col: s.col,
@@ -245,11 +246,16 @@ export class SeatsService {
holdFrom === undefined || holdTo === undefined || holdFrom === undefined || holdTo === undefined ||
(holdFrom < reqTo && reqFrom < holdTo); (holdFrom < reqTo && reqFrom < holdTo);
if (!legsOverlap) continue;
// Check direction conflict // Check direction conflict
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection); 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'); statusMap.set(seatId, 'HELD');
} }
@@ -653,7 +659,7 @@ export class SeatsService {
const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-')); const seats = a.coach.seats.filter(s => s.seatNumber && !s.seatNumber.startsWith('-'));
const totalSeats = seats.length; const totalSeats = seats.length;
const unavailable = seats.filter(s => { 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'; return status === 'HELD' || status === 'BOOKED' || status === 'BLOCKED';
}).length; }).length;
@@ -665,8 +671,8 @@ export class SeatsService {
seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [], seatClasses: a.coach.coachType?.seatClasses.map(sc => sc.name) ?? [],
totalSeats, totalSeats,
availableSeats: totalSeats - unavailable, availableSeats: totalSeats - unavailable,
heldSeats: seats.filter(s => (effectiveStatuses.get(s.id) ?? s.status) === 'HELD').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) === 'BOOKED').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; if (expired.length === 0) return;
const expiredSeatIds = expired.flatMap(h => h.seatIds as string[]); 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({ // Only reset seats that have no remaining active holds
where: { id: { in: expiredSeatIds }, status: 'HELD' }, const stillHeld = await this.prisma.seatHold.findMany({
data: { status: 'AVAILABLE' }, 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() } } }); await this.prisma.seatHold.deleteMany({ where: { expiresAt: { lt: new Date() } } });
} }
} }

View File

@@ -13,7 +13,7 @@ import { usePermission } from '@/lib/use-permission';
import { PERMS } from '@/lib/permissions'; import { PERMS } from '@/lib/permissions';
import ConfirmDialog from '@/components/ui/ConfirmDialog'; import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { bookingsApi, apiClient } from '@/lib/api'; import { bookingsApi, apiClient } from '@/lib/api';
import { formatCurrency, formatDateTime } from '@/lib/utils'; import { formatCurrency, formatDateTime, formatDateTimeShort } from '@/lib/utils';
import { BookingFilters } from '@/types'; import { BookingFilters } from '@/types';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => ( const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
@@ -151,12 +151,38 @@ function BookingsPageContent() {
)} )}
</div> </div>
{booking.isPackageBooking {booking.isPackageBooking
? <div className="text-xs text-muted-foreground">From: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}</div> ? <div className="text-xs text-muted-foreground">Boarding at: {booking.departureStationName || booking.schedule?.originStation?.name || '—'}</div>
: <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div> : <div className="text-xs text-muted-foreground">{booking.bookingType || 'ONE_WAY'}</div>
} }
</div> </div>
), ),
}, },
{
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 (
<div>
<div className="font-medium">
{booking.schedule?.originStation?.name || 'N/A'} {booking.schedule?.destinationStation?.name || 'N/A'}
</div>
<div className="text-xs text-muted-foreground">
{!isRoundTrip ? (
<span>{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'}</span>
) : (
<span>
{booking.schedule?.departureAt ? formatDateTimeShort(booking.schedule.departureAt) : 'N/A'} ·
{returnDeparture ? formatDateTimeShort(returnDeparture) : ''}
</span>
)}
</div>
</div>
);
},
},
{ {
key: 'passengerNames', label: 'Names', key: 'passengerNames', label: 'Names',
render: (booking: any) => { render: (booking: any) => {
@@ -203,14 +229,6 @@ function BookingsPageContent() {
</div> </div>
), ),
}, },
{
key: 'passengerCount', label: 'Passengers',
render: (booking: any) => {
const adults = booking.adultCount || 0, children = booking.childCount || 0;
if (!adults && !children) return '—';
return <><div>Adult: {adults}</div><div className="text-sm text-muted-foreground">Child: {children}</div></>;
},
},
{ {
key: 'paymentStatus', label: 'Payment', key: 'paymentStatus', label: 'Payment',
render: (booking: any) => ( render: (booking: any) => (

View File

@@ -153,7 +153,7 @@ export default function ConfirmationPage() {
const handleNewBooking = () => { const handleNewBooking = () => {
clearBooking(); clearBooking();
router.push('/booking/search'); window.location.href = '/';
}; };
useEffect(() => { useEffect(() => {

View File

@@ -143,7 +143,7 @@ export default function SeatsPage() {
const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({}); const [passengerSeatMap, setPassengerSeatMap] = useState<Record<number, string>>({});
// Outbound seat IDs locked in after the outbound hold — used to prevent the // Outbound seat IDs locked in after the outbound hold — used to prevent the
// same physical seat being picked again on the inbound leg. // same physical seat being picked again on the inbound leg.
const [outboundLockedSeatIds, setOutboundLockedSeatIds] = useState<Set<string>>(new Set());
const [activePassengerIndex, setActivePassengerIndex] = useState(0); const [activePassengerIndex, setActivePassengerIndex] = useState(0);
const [selectedCoach, setSelectedCoach] = useState<string | null>(null); const [selectedCoach, setSelectedCoach] = useState<string | null>(null);
const [currentJourneyType, setCurrentJourneyType] = useState< const [currentJourneyType, setCurrentJourneyType] = useState<
@@ -173,9 +173,12 @@ export default function SeatsPage() {
isLoading, isLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType], queryKey: ["seatmap", currentSchedule?.id, coachTypeId, currentJourneyType, (currentSchedule as any)?.originStationId, (currentSchedule as any)?.destinationStationId],
queryFn: async () => { 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); const response = await apiClient.get(endpoint);
@@ -337,10 +340,9 @@ export default function SeatsPage() {
}, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]); }, [allSeats, selectedCoachData, currentSchedule?.selectedSeatClass]);
// Seats already claimed by any passenger in this journey leg, plus outbound // 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( const assignedSeatIds = useMemo(
() => new Set([...Object.values(passengerSeatMap), ...outboundLockedSeatIds]), () => new Set(Object.values(passengerSeatMap)),
[passengerSeatMap, outboundLockedSeatIds], [passengerSeatMap],
); );
// The furthest passenger a user is allowed to jump to — cannot skip ahead of the // 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( const isSeatAssignedToOther = useCallback(
(seatId: string) => (seatId: string) =>
outboundLockedSeatIds.has(seatId) ||
Object.entries(passengerSeatMap).some( Object.entries(passengerSeatMap).some(
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
), ),
[passengerSeatMap, activePassengerIndex, outboundLockedSeatIds], [passengerSeatMap, activePassengerIndex],
); );
const handleSelectPassenger = useCallback( const handleSelectPassenger = useCallback(
@@ -376,12 +377,10 @@ export default function SeatsPage() {
const handleSeatClick = useCallback( const handleSeatClick = useCallback(
(seatId: string) => { (seatId: string) => {
// Seat already claimed by a different passenger or locked from outbound — never allow duplicate assignment // Seat already claimed by a different passenger — never allow duplicate assignment
const takenByOther = const takenByOther = Object.entries(passengerSeatMap).some(
outboundLockedSeatIds.has(seatId) || ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
Object.entries(passengerSeatMap).some( );
([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId,
);
if (takenByOther) return; if (takenByOther) return;
const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId; const isDeselecting = passengerSeatMap[activePassengerIndex] === seatId;
@@ -401,7 +400,7 @@ export default function SeatsPage() {
if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned); if (nextUnassigned !== -1) setActivePassengerIndex(nextUnassigned);
} }
}, },
[passengerSeatMap, activePassengerIndex, passengers, outboundLockedSeatIds], [passengerSeatMap, activePassengerIndex, passengers],
); );
const allSeatsAssigned = const allSeatsAssigned =
@@ -436,8 +435,6 @@ export default function SeatsPage() {
}); });
return; return;
} }
// Lock outbound seat IDs so they cannot be selected on the inbound leg
setOutboundLockedSeatIds(new Set(seatIds));
setCurrentJourneyType("inbound"); setCurrentJourneyType("inbound");
setPassengerSeatMap({}); setPassengerSeatMap({});
setActivePassengerIndex(0); setActivePassengerIndex(0);

View File

@@ -21,7 +21,6 @@ import {
Tag, Tag,
Shield, Shield,
X, X,
Navigation,
} from "lucide-react"; } from "lucide-react";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -322,7 +321,7 @@ function PassengerCountModal({
<div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4"> <div className="fixed inset-0 z-[100] flex items-end sm:items-center justify-center p-0 sm:p-4">
<div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden"> <div className="w-full sm:max-w-sm bg-white dark:bg-gray-900 rounded-t-3xl sm:rounded-2xl shadow-2xl overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800"> <div className="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-gray-800">
<h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers</h2> <h2 className="text-base font-bold text-gray-900 dark:text-white">Number of passengers & boarding station selection</h2>
<button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800"> <button type="button" onClick={onClose} className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 dark:hover:bg-gray-800">
<X className="w-4 h-4 text-gray-500" /> <X className="w-4 h-4 text-gray-500" />
</button> </button>
@@ -374,8 +373,7 @@ function PassengerCountModal({
{/* Departure Station */} {/* Departure Station */}
<div> <div>
<label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5"> <label className="flex items-center gap-1.5 text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1.5 border-t pt-3">
<Navigation className="w-3.5 h-3.5 text-primary" />
Departure Station Departure Station
</label> </label>
<select <select
@@ -431,7 +429,7 @@ export default function PackageDetailPage() {
queryFn: async () => (await apiClient.get(`/stations?pageSize=100`)) as any, queryFn: async () => (await apiClient.get(`/stations?pageSize=100`)) as any,
}); });
const stations: Station[] = stationsData?.items || stationsData?.data?.items || []; const stations: Station[] = Array.isArray(stationsData) ? stationsData : stationsData?.items || stationsData?.data?.items || [];
const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId); const selectedTier = pkg?.priceTiers?.find((t) => t.id === selectedTierId);