Merge pull request #820 from Tria-plc/alpha

Fix price inconsistency
This commit is contained in:
robiman
2026-07-19 22:50:41 +03:00
committed by GitHub
9 changed files with 1482 additions and 591 deletions

View File

@@ -834,53 +834,63 @@ export class BookingsService {
// Track per-seat fare. Use the client-supplied seatFareMinor when present (berth-specific
// pricing for Upper/Middle/Lower beds). Fall back to the fare engine's baseFareMinor.
let freeChildUsed = false;
let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
} else if (dto.packageId) {
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? fareCalculation.baseFareMinor);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = p.seatFareMinor ?? fareCalculation.baseFareMinor;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? fareCalculation.baseFareMinor) : 0;
}
return { ...p, fareMinor };
});
// Use the sum of per-seat fares as the authoritative total when the client supplied
// seatFareMinor for every seat-holding passenger — this captures berth-specific pricing
// (Upper/Middle/Lower) that the fare engine cannot resolve from seatClassId alone.
// Free children have no seatId and no seatFareMinor — exclude them from the check.
// Determine the authoritative total.
// Priority (one-way, non-package):
// 1. Server-computed sum of per-seat fares when every seated passenger supplied
// seatFareMinor — this captures berth-specific pricing (Upper/Middle/Lower)
// exactly as shown to the user and cannot be corrupted by a frontend race
// condition that sends reviewedTotalMinor before all fares are resolved.
// 2. reviewedTotalMinor from the frontend — fallback when the server doesn't
// have complete per-seat data (e.g. auto-assign with no seat map loaded).
// 3. Fare engine total — last resort when neither is available.
// For package bookings reviewedTotalMinor always wins because the tier price
// may include berth-specific adjustments the server cannot derive alone.
// Free children have no seatId and no seatFareMinor — exclude from the check.
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
// seatFareMinor values from the client are in display-currency minor units (matching
// displayAmountMinor from search results). reviewedTotalMinor is also display-currency minor.
// In both cases: store as displayTotalMinor as-is, back-convert to ETB for totalMinor.
let resolvedTotalMinor: number;
let displayTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
if (allFaresProvided && !dto.packageId) {
// Server has every passenger's berth fare — sum is the authoritative display total.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createOneWayBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
// Package booking: client-supplied tier-adjusted total.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
// For package bookings where per-seat fares weren't supplied, back-derive the
// per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects the
// actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
if (dto.packageId && seatedPassengers.length > 0) {
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (allFaresProvided) {
// seatFareMinor is in display currency — sum is already the display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
// Partial data on server: use client's total as best available.
displayTotalMinor = dto.reviewedTotalMinor;
resolvedTotalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
} else {
resolvedTotalMinor = fareCalculation.totalMinor;
displayTotalMinor = displayCurrency !== Currency.ETB
@@ -1028,8 +1038,6 @@ export class BookingsService {
// Track per-seat fare. Use client-supplied seatFareMinor/returnSeatFareMinor when
// present (berth-specific pricing). Fall back to fare engine values.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
@@ -1037,14 +1045,12 @@ export class BookingsService {
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
} else if (dto.packageId) {
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundFare.baseFareMinor;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = p.returnSeatFareMinor ?? returnFare.baseFareMinor;
// Free children have no outbound seat (frontend excludes them).
// Guard by outboundSeatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.outboundSeatId ? (p.seatFareMinor ?? outboundFare.baseFareMinor) : 0;
returnFareMinor = p.outboundSeatId ? (p.returnSeatFareMinor ?? returnFare.baseFareMinor) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
@@ -1052,33 +1058,39 @@ export class BookingsService {
// Override totalMinor with the sum of actual per-seat fares when all seated passengers
// supplied their fares — free children (no seatId) are excluded from the check.
// Same priority logic as one-way: server-computed sum wins when all per-seat fares
// are present; reviewedTotalMinor is used only as fallback to avoid a frontend
// race condition from under-counting passengers.
const rtSeatedPassengers = passengersData.filter(p => p.outboundSeatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
// For package bookings where per-seat fares weren't supplied, back-derive the
// per-leg per-seat fare from reviewedTotalMinor so BookingSeat.fareMinor reflects
// the actual berth price (Upper/Middle/Lower) rather than the tier's minimum price.
if (dto.packageId) {
const seatedCount = passengersData.filter(p => p.outboundSeatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
}
} else if (allRTFaresProvided && !dto.packageId) {
// seatFareMinor/returnSeatFareMinor are in display currency — sum is already the display total
if (allRTFaresProvided && !dto.packageId) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor !== displayTotalMinor) {
this.logger.warn(`createRoundTripBooking: reviewedTotalMinor=${dto.reviewedTotalMinor} ignored — using server-computed sum=${displayTotalMinor}`);
}
} else if (dto.packageId && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
const seatedCount = rtSeatedPassengers.length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(dto.reviewedTotalMinor, displayCurrency, Currency.ETB)
: dto.reviewedTotalMinor;
}
const booking = await this.prisma.booking.create({

View File

@@ -204,43 +204,41 @@ export class GuestBookingService {
const taxesMinor = 0;
// Per-seat fare: use client-supplied seatFareMinor when present (berth-specific pricing).
// Free children (first child, non-package) get fareMinor=0.
let freeChildUsed = false;
let pkgChildIdx = 0;
const passengersWithFares = passengersData.map(p => {
let fareMinor: number;
if (p.category === PassengerCategory.ADULT) {
fareMinor = p.seatFareMinor ?? baseFareMinor;
} else if (isPackageOneway) {
fareMinor = pkgChildIdx < adultCount ? 0 : (p.seatFareMinor ?? childUnitFare);
pkgChildIdx++;
} else {
if (!freeChildUsed) { fareMinor = 0; freeChildUsed = true; }
else fareMinor = p.seatFareMinor ?? childUnitFare;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
fareMinor = p.seatId ? (p.seatFareMinor ?? childUnitFare) : 0;
}
return { ...p, fareMinor };
});
// reviewedTotalMinor and seatFareMinor are both in display-currency minor units.
// Store as displayTotalMinor as-is; back-convert to ETB for totalMinor.
// Server-computed sum from per-seat fares is the authoritative total when all
// seated passengers supplied seatFareMinor. This prevents a frontend race
// condition (fareBreakdown not yet loaded → only partial fares summed →
// reviewedTotalMinor reflects one passenger's fare instead of all).
const displayCurrency = dto.displayCurrency || Currency.ETB;
const seatedPassengers = passengersData.filter(p => p.seatId);
const allFaresProvided = seatedPassengers.length > 0 && seatedPassengers.every(p => p.seatFareMinor != null);
let displayTotalMinor: number;
let resolvedTotalMinor: number;
if (dto.reviewedTotalMinor != null) {
if (allFaresProvided && !isPackageOneway) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (isPackageOneway && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
// For package bookings, back-derive per-seat fareMinor from reviewedTotalMinor
// so BookingSeat records store the actual berth price, not the tier minimum.
if (isPackageOneway && seatedPassengers.length > 0) {
if (seatedPassengers.length > 0) {
const perSeatFare = Math.round(dto.reviewedTotalMinor / seatedPassengers.length);
passengersWithFares.forEach(p => {
if (p.fareMinor > 0) p.fareMinor = p.seatFareMinor ?? perSeatFare;
});
}
} else if (allFaresProvided) {
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.fareMinor, 0);
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
} else {
// fare engine returns ETB — convert forward to display currency
const etbTotal = Math.max(0, totalBaseFareMinor - discountMinor);
@@ -494,22 +492,18 @@ export class GuestBookingService {
: totalMinor;
// Per-seat fares: use client-supplied seatFareMinor/returnSeatFareMinor when present.
let outboundFreeChildUsed = false;
let returnFreeChildUsed = false;
const passengersWithFares = passengersData.map(p => {
let outboundFareMinor: number;
let returnFareMinor: number;
if (p.category === PassengerCategory.ADULT) {
outboundFareMinor = p.seatFareMinor ?? outboundBaseFare;
returnFareMinor = p.returnSeatFareMinor ?? returnBaseFare;
} else if (isPackageRoundTrip) {
outboundFareMinor = 0;
returnFareMinor = 0;
} else {
if (!outboundFreeChildUsed) { outboundFareMinor = 0; outboundFreeChildUsed = true; }
else outboundFareMinor = p.seatFareMinor ?? outboundChildUnitFare;
if (!returnFreeChildUsed) { returnFareMinor = 0; returnFreeChildUsed = true; }
else returnFareMinor = p.returnSeatFareMinor ?? returnChildUnitFare;
// Free children have no seat (frontend excludes them from the DTO).
// Guard by seatId: unseated = free (0), seated = paid child.
// Applies to both package and regular bookings.
outboundFareMinor = p.seatId ? (p.seatFareMinor ?? outboundChildUnitFare) : 0;
returnFareMinor = p.seatId ? (p.returnSeatFareMinor ?? returnChildUnitFare) : 0;
}
return { ...p, outboundFareMinor, returnFareMinor };
});
@@ -519,25 +513,28 @@ export class GuestBookingService {
const rtSeatedPassengers = passengersData.filter(p => p.seatId);
const allRTFaresProvided = rtSeatedPassengers.length > 0 &&
rtSeatedPassengers.every(p => p.seatFareMinor != null && p.returnSeatFareMinor != null);
if (dto.reviewedTotalMinor != null) {
if (allRTFaresProvided && !isPackageRoundTrip) {
// Server-computed sum is authoritative — prevents race-condition under-count.
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
} else if (isPackageRoundTrip && dto.reviewedTotalMinor != null) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;
// For package bookings, back-derive per-leg per-seat fareMinor from reviewedTotalMinor.
if (isPackageRoundTrip) {
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
const seatedCount = passengersData.filter(p => p.seatId).length;
if (seatedCount > 0) {
const perSeatPerLeg = Math.round(dto.reviewedTotalMinor / (seatedCount * 2));
passengersWithFares.forEach(p => {
if (p.outboundFareMinor > 0) p.outboundFareMinor = p.seatFareMinor ?? perSeatPerLeg;
if (p.returnFareMinor > 0) p.returnFareMinor = p.returnSeatFareMinor ?? perSeatPerLeg;
});
}
} else if (allRTFaresProvided && !isPackageRoundTrip) {
// seatFareMinor/returnSeatFareMinor are display-currency — sum is already display total
displayTotalMinor = passengersWithFares.reduce((sum, p) => sum + p.outboundFareMinor + p.returnFareMinor, 0);
} else if (dto.reviewedTotalMinor != null && dto.reviewedTotalMinor > 0) {
displayTotalMinor = dto.reviewedTotalMinor;
totalMinor = displayCurrency !== Currency.ETB
? await this.currencyService.convertAmount(displayTotalMinor, displayCurrency, Currency.ETB)
: displayTotalMinor;

View File

@@ -236,18 +236,27 @@ export class PackagesService {
});
if (!pkg) throw new NotFoundException('Package not found');
// Fetch live route stops so the departure station dropdown always reflects
// the current route definition, not stale TripStopTime snapshots.
// Build departure station list from live route stops when a routeId exists,
// falling back to the schedule's own stopTimes (already included in the query).
let routeStops: { sequence: number; station: any }[] = [];
if (pkg.outboundSchedule.routeId) {
const stops = await this.prisma.routeStop.findMany({
where: { routeId: pkg.outboundSchedule.routeId },
orderBy: { sequence: 'asc' },
});
const stationIds = stops.map((s) => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
if (stops.length > 0) {
const stationIds = stops.map((s) => s.stationId);
const stations = await this.prisma.station.findMany({ where: { id: { in: stationIds } } });
const stationMap = Object.fromEntries(stations.map((s) => [s.id, s]));
routeStops = stops.map((s) => ({ sequence: s.sequence, station: stationMap[s.stationId] }));
}
}
// Fall back to the schedule's own TripStopTimes when RouteStop table has no rows
// for this route (e.g. route exists but stops were never seeded).
if (routeStops.length === 0) {
routeStops = (pkg.outboundSchedule.stopTimes ?? [])
.filter((st: any) => st.station)
.map((st: any) => ({ sequence: st.sequence, station: st.station }));
}
return {

View File

@@ -442,9 +442,12 @@ export class ReportsService {
status: true,
originStationId: true,
destinationStationId: true,
totalMinor: true,
currency: true,
_count: { select: { seats: true } },
},
},
seat: { include: { coach: { select: { number: true } } } },
seat: { include: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
},
orderBy: [{ seat: { coach: { number: "asc" } } }, { seat: { seatNumber: "asc" } }],
});
@@ -465,12 +468,19 @@ export class ReportsService {
return seats.map((bs) => ({
bookingRef: bs.booking.bookingRef,
passengerName: bs.passengerName,
coachSeat: bs.seat?.coach?.number && bs.seatLabelSnapshot
? `${bs.seat.coach.number}·${bs.seatLabelSnapshot}`
: (bs.seatLabelSnapshot ?? '—'),
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? '—') : '—',
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? '—') : '—',
departureAt: schedule?.departureAt ?? null,
passengerCategory: bs.passengerCategory,
idDocumentType: bs.idDocumentType,
idDocumentNumber: bs.idDocumentNumber,
passportNumber: bs.passportNumber,
passportCountry: bs.passportCountry,
seatLabel: bs.seatLabelSnapshot,
coachNumber: bs.seat?.coach?.number ?? null,
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
origin: bs.booking.originStationId ? (stationName.get(bs.booking.originStationId) ?? null) : null,
destination: bs.booking.destinationStationId ? (stationName.get(bs.booking.destinationStationId) ?? null) : null,
amountPaidMinor: bs.booking.totalMinor,
currency: bs.booking.currency ?? 'ETB',
isGroupBooking: (bs.booking._count?.seats ?? 0) > 1,
}));
}

View File

@@ -595,10 +595,20 @@ export class SearchService {
});
const freeChildrenAllowed = groupFare.freeChildrenCount;
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
// Pre-compute isFree per passenger index synchronously so the race-free
// counter assignment isn't corrupted by concurrent Promise.all resolution.
let freeChildrenUsed = 0;
const isFreeByIndex = categorised.map(p => {
if (p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed) {
freeChildrenUsed++;
return true;
}
return false;
});
// Calculate per-passenger fare rate (engine called with 1 adult, 0 children — pure rate lookup)
const passengerLines = await Promise.all(
categorised.map(async (p) => {
categorised.map(async (p, idx) => {
const fare = await this.fareEngine.calculate({
routeId: schedule.routeId!,
originStationId: dto.originStationId,
@@ -610,8 +620,7 @@ export class SearchService {
childCount: 0,
});
const isFree = p.category === 'CHILD' && freeChildrenUsed < freeChildrenAllowed;
if (isFree) freeChildrenUsed++;
const isFree = isFreeByIndex[idx];
const fareMinor = isFree
? fare.premiumPerPassenger + fare.insurancePerPassenger

View File

@@ -1,19 +1,44 @@
'use client';
"use client";
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Users, Armchair, BarChart3, Train, Download } from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { formatDateTime } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton';
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Users, Armchair, BarChart3, Train, Download } from "lucide-react";
import { apiClient } from "@/lib/api-client";
import { formatDateTime } from "@/lib/utils";
import ActionButton from "@/components/ui/ActionButton";
interface ScheduleOption { id: string; label: string; }
interface ScheduleOption {
id: string;
label: string;
}
interface PassengersReport {
schedule: { id: string; trainName: string; origin: string; destination: string; departureAt: string; arrivalAt: string; };
summary: { totalSeats: number; totalPassengers: number; occupancyRate: number };
byCoach: { coachNumber: string; coachType: string; totalSeats: number; booked: number; occupancyRate: number }[];
byClass: { className: string; totalSeats: number; booked: number; occupancyRate: number }[];
schedule: {
id: string;
trainName: string;
origin: string;
destination: string;
departureAt: string;
arrivalAt: string;
};
summary: {
totalSeats: number;
totalPassengers: number;
occupancyRate: number;
};
byCoach: {
coachNumber: string;
coachType: string;
totalSeats: number;
booked: number;
occupancyRate: number;
}[];
byClass: {
className: string;
totalSeats: number;
booked: number;
occupancyRate: number;
}[];
byOrigin: { stationName: string; passengers: number }[];
byDestination: { stationName: string; passengers: number }[];
}
@@ -21,75 +46,143 @@ interface PassengersReport {
interface PassengerRow {
bookingRef: string;
passengerName: string;
coachSeat: string;
origin: string;
destination: string;
departureAt: string | null;
passengerCategory: string;
idDocumentType: string | null;
idDocumentNumber: string | null;
passportNumber: string | null;
passportCountry: string | null;
seatLabel: string | null;
coachNumber: string | null;
coachType: string | null;
origin: string | null;
destination: string | null;
amountPaidMinor: number;
currency: string;
isGroupBooking: boolean;
}
type Tab = 'occupancy' | 'list';
type Tab = "occupancy" | "list";
export default function PassengersReportPage() {
const [scheduleId, setScheduleId] = useState('');
const [tab, setTab] = useState<Tab>('occupancy');
const [listSearch, setListSearch] = useState('');
const [filterCoach, setFilterCoach] = useState('');
const [filterOrigin, setFilterOrigin] = useState('');
const [scheduleId, setScheduleId] = useState("");
const [tab, setTab] = useState<Tab>("occupancy");
const [listSearch, setListSearch] = useState("");
const [filterCoach, setFilterCoach] = useState("");
const [filterOrigin, setFilterOrigin] = useState("");
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ['report-schedules'],
queryFn: () => apiClient.get('/reports/schedules'),
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
ScheduleOption[]
>({
queryKey: ["report-schedules"],
queryFn: () => apiClient.get("/reports/schedules"),
});
const schedules = schedulesRaw ?? [];
const { data, isLoading, isError } = useQuery<PassengersReport>({
queryKey: ['passengers-report', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
queryKey: ["passengers-report", scheduleId],
queryFn: () =>
apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const { data: passengerList = [], isLoading: listLoading } = useQuery<PassengerRow[]>({
queryKey: ['passengers-list', scheduleId],
queryFn: () => apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
const { data: passengerList = [], isLoading: listLoading } = useQuery<
PassengerRow[]
>({
queryKey: ["passengers-list", scheduleId],
queryFn: () =>
apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const filteredList = listSearch.trim()
? passengerList.filter(p =>
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()),
)
: passengerList;
const coachOptions = [
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
].sort() as string[];
const originOptions = [
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
].sort() as string[];
const filteredList = passengerList
.filter((p) => {
if (filterCoach && p.coachNumber !== filterCoach) return false;
if (filterOrigin && p.origin !== filterOrigin) return false;
if (listSearch.trim()) {
const q = listSearch.toLowerCase();
return (
p.passengerName.toLowerCase().includes(q) ||
p.bookingRef.toLowerCase().includes(q) ||
(p.idDocumentNumber ?? "").toLowerCase().includes(q) ||
(p.passportNumber ?? "").toLowerCase().includes(q)
);
}
return true;
})
.sort((a, b) => a.bookingRef.localeCompare(b.bookingRef));
const downloadCsv = (csv: string, filename: string) => {
const blob = new Blob([csv], { type: 'text/csv' });
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const doExportOccupancy = () => {
if (!data) return;
const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]);
downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`);
const rows = data.byCoach.map((c) => [
c.coachNumber,
c.coachType,
String(c.totalSeats),
String(c.booked),
`${c.occupancyRate}%`,
]);
downloadCsv(
[
["Coach", "Type", "Total Seats", "Booked", "Occupancy"].join(","),
...rows.map((r) => r.join(",")),
].join("\n"),
`occupancy-${scheduleId}.csv`,
);
};
const doExportList = () => {
if (!passengerList.length) return;
const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref'];
const headers = [
"#",
"Name",
"Coach·Seat",
"Origin",
"Destination",
"Date",
"Booking Ref",
];
const rows = passengerList.map((p, i) =>
[String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination, p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef]
.map(v => `"${String(v).replace(/"/g, '""')}"`)
[
String(i + 1),
p.passengerName,
p.coachSeat,
p.origin,
p.destination,
p.departureAt ? formatDateTime(p.departureAt) : "—",
p.bookingRef,
].map((v) => `"${String(v).replace(/"/g, '""')}"`),
);
downloadCsv(
[headers.join(","), ...rows.map((r) => r.join(","))].join("\n"),
`passengers-${scheduleId}.csv`,
);
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
<p className="text-muted-foreground mt-1">Occupancy and passenger breakdown for a schedule</p>
<h1 className="text-3xl font-bold text-foreground">
Passengers Report
</h1>
<p className="text-muted-foreground mt-1">
Occupancy and passenger breakdown for a schedule
</p>
</div>
{/* Schedule selector */}
@@ -100,19 +193,41 @@ export default function PassengersReportPage() {
<select
className="input"
value={scheduleId}
onChange={e => { setScheduleId(e.target.value); setTab('occupancy'); setListSearch(''); setFilterCoach(''); setFilterOrigin(''); }}
onChange={(e) => {
setScheduleId(e.target.value);
setTab("occupancy");
setListSearch("");
setFilterCoach("");
setFilterOrigin("");
}}
disabled={loadingSchedules}
>
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
{schedules.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
<option value="">
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
</option>
{schedules.map((s) => (
<option key={s.id} value={s.id}>
{s.label}
</option>
))}
</select>
</div>
{data && tab === 'occupancy' && (
<ActionButton icon={Download} variant="secondary" onClick={doExportOccupancy}>Export CSV</ActionButton>
{data && tab === "occupancy" && (
<ActionButton
icon={Download}
variant="secondary"
onClick={doExportOccupancy}
>
Export CSV
</ActionButton>
)}
</div>
{(isLoading || listLoading) && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
{isError && <p className="text-xs text-red-500 mt-2">Failed to load report.</p>}
{(isLoading || listLoading) && (
<p className="text-xs text-muted-foreground mt-2">Loading</p>
)}
{isError && (
<p className="text-xs text-red-500 mt-2">Failed to load report.</p>
)}
</div>
{data && (
@@ -125,7 +240,8 @@ export default function PassengersReportPage() {
<div>
<p className="font-semibold">{data.schedule.trainName}</p>
<p className="text-sm text-muted-foreground">
{data.schedule.origin} {data.schedule.destination} · Departure: {formatDateTime(data.schedule.departureAt)}
{data.schedule.origin} {data.schedule.destination} ·
Departure: {formatDateTime(data.schedule.departureAt)}
</p>
</div>
</div>
@@ -133,51 +249,75 @@ export default function PassengersReportPage() {
{/* Tabs */}
<div className="border-b border-border flex">
<button
onClick={() => setTab('occupancy')}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'occupancy' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
onClick={() => setTab("occupancy")}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "occupancy" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
Occupancy
</button>
<button
onClick={() => setTab('list')}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === 'list' ? 'border-emerald-500 text-emerald-600 dark:text-emerald-400' : 'border-transparent text-muted-foreground hover:text-foreground'}`}
onClick={() => setTab("list")}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "list" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
>
Passenger List{passengerList.length > 0 ? ` (${passengerList.length})` : ''}
Passenger List
{passengerList.length > 0 ? ` (${passengerList.length})` : ""}
</button>
</div>
{/* Occupancy tab */}
{tab === 'occupancy' && (
{tab === "occupancy" && (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Total Seats</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5"><Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Total Seats
</p>
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
<Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalSeats}</p>
<p className="text-2xl font-bold tabular-nums mt-1">
{data.summary.totalSeats}
</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Passengers</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5"><Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /></div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Passengers
</p>
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.totalPassengers}</p>
<p className="text-2xl font-bold tabular-nums mt-1">
{data.summary.totalPassengers}
</p>
</div>
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Occupancy Rate</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5"><BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Occupancy Rate
</p>
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{data.summary.occupancyRate}%</p>
<p className="text-2xl font-bold tabular-nums mt-1">
{data.summary.occupancyRate}%
</p>
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
<div className="bg-purple-500 h-1.5 rounded-full" style={{ width: `${data.summary.occupancyRate}%` }} />
<div
className="bg-purple-500 h-1.5 rounded-full"
style={{ width: `${data.summary.occupancyRate}%` }}
/>
</div>
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Coach</h3>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Coach
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
@@ -190,18 +330,31 @@ export default function PassengersReportPage() {
</tr>
</thead>
<tbody className="divide-y divide-border">
{data.byCoach.map(c => (
{data.byCoach.map((c) => (
<tr key={c.coachNumber} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-semibold">{c.coachNumber}</td>
<td className="py-2 pr-4 text-muted-foreground">{c.coachType}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.totalSeats}</td>
<td className="py-2 pr-4 text-right tabular-nums">{c.booked}</td>
<td className="py-2 pr-4 font-semibold">
{c.coachNumber}
</td>
<td className="py-2 pr-4 text-muted-foreground">
{c.coachType}
</td>
<td className="py-2 pr-4 text-right tabular-nums">
{c.totalSeats}
</td>
<td className="py-2 pr-4 text-right tabular-nums">
{c.booked}
</td>
<td className="py-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
<div
className="bg-emerald-500 h-1.5 rounded-full"
style={{ width: `${c.occupancyRate}%` }}
/>
</div>
<span className="tabular-nums text-xs w-10 text-right">{c.occupancyRate}%</span>
<span className="tabular-nums text-xs w-10 text-right">
{c.occupancyRate}%
</span>
</div>
</td>
</tr>
@@ -213,46 +366,77 @@ export default function PassengersReportPage() {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Class</h3>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Class
</h3>
<div className="space-y-3">
{data.byClass.map(c => (
{data.byClass.map((c) => (
<div key={c.className}>
<div className="flex justify-between text-sm mb-1">
<span className="font-medium">{c.className}</span>
<span className="tabular-nums text-muted-foreground">{c.booked}/{c.totalSeats}</span>
<span className="tabular-nums text-muted-foreground">
{c.booked}/{c.totalSeats}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${c.occupancyRate}%` }} />
<div
className="bg-blue-500 h-1.5 rounded-full"
style={{ width: `${c.occupancyRate}%` }}
/>
</div>
<span className="text-xs tabular-nums w-10 text-right">{c.occupancyRate}%</span>
<span className="text-xs tabular-nums w-10 text-right">
{c.occupancyRate}%
</span>
</div>
</div>
))}
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Boarding Station</h3>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Boarding Station
</h3>
<div className="space-y-2">
{data.byOrigin.map(o => (
<div key={o.stationName} className="flex justify-between text-sm">
<span className="text-muted-foreground truncate">{o.stationName}</span>
<span className="font-semibold tabular-nums ml-2">{o.passengers}</span>
{data.byOrigin.map((o) => (
<div
key={o.stationName}
className="flex justify-between text-sm"
>
<span className="text-muted-foreground truncate">
{o.stationName}
</span>
<span className="font-semibold tabular-nums ml-2">
{o.passengers}
</span>
</div>
))}
{data.byOrigin.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
{data.byOrigin.length === 0 && (
<p className="text-xs text-muted-foreground">No data</p>
)}
</div>
</div>
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Alighting Station</h3>
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
By Alighting Station
</h3>
<div className="space-y-2">
{data.byDestination.map(d => (
<div key={d.stationName} className="flex justify-between text-sm">
<span className="text-muted-foreground truncate">{d.stationName}</span>
<span className="font-semibold tabular-nums ml-2">{d.passengers}</span>
{data.byDestination.map((d) => (
<div
key={d.stationName}
className="flex justify-between text-sm"
>
<span className="text-muted-foreground truncate">
{d.stationName}
</span>
<span className="font-semibold tabular-nums ml-2">
{d.passengers}
</span>
</div>
))}
{data.byDestination.length === 0 && <p className="text-xs text-muted-foreground">No data</p>}
{data.byDestination.length === 0 && (
<p className="text-xs text-muted-foreground">No data</p>
)}
</div>
</div>
</div>
@@ -260,7 +444,7 @@ export default function PassengersReportPage() {
)}
{/* Passenger List tab */}
{tab === 'list' && (
{tab === "list" && (
<div className="space-y-4">
<div className="flex items-center gap-3 flex-wrap">
<input
@@ -268,44 +452,110 @@ export default function PassengersReportPage() {
className="input max-w-sm flex-1"
placeholder="Search by name or booking ref…"
value={listSearch}
onChange={e => setListSearch(e.target.value)}
onChange={(e) => setListSearch(e.target.value)}
/>
{passengerList.length > 0 && (
<ActionButton icon={Download} variant="secondary" onClick={doExportList}>Export CSV</ActionButton>
<ActionButton
icon={Download}
variant="secondary"
onClick={doExportList}
>
Export CSV
</ActionButton>
)}
</div>
<div className="card p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="px-4 py-3">#</th>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Coach · Seat</th>
<th className="px-4 py-3">Origin</th>
<th className="px-4 py-3">Destination</th>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Booking Ref</th>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Nationality</th>
<th className="pb-2 pr-4">Coach · Seat</th>
<th className="pb-2 pr-4">Trip</th>
<th className="pb-2 pr-4">Amount Paid</th>
<th className="pb-2">Booking Ref</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr
key={`${p.bookingRef}-${i}`}
className="hover:bg-muted/30"
>
<td className="py-2 pr-4 font-medium">
{p.passengerName}
</td>
<td className="py-2 pr-4 text-xs">
{p.passportNumber ? (
<>
<span className="text-muted-foreground">
{p.passportCountry ?? "Intl"}
</span>
<span className="ml-1 font-mono">
{p.passportNumber}
</span>
</>
) : (
<span className="text-muted-foreground">
{p.idDocumentNumber ?? "—"}
</span>
)}
</td>
<td className="py-2 pr-4 font-mono text-xs">
{p.coachNumber && p.seatLabel ? (
<>
{p.coachNumber} · {p.seatLabel}
{p.coachType && (
<span className="font-sans text-muted-foreground ml-1">
({p.coachType})
</span>
)}
</>
) : (
(p.coachNumber ?? p.seatLabel ?? "—")
)}
</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">
{p.origin && p.destination
? `${p.origin}${p.destination}`
: (p.origin ?? p.destination ?? "—")}
</td>
<td className="py-2 pr-4 text-xs">
<div className="flex items-center gap-1.5">
<span className="tabular-nums font-medium">
{(p.amountPaidMinor / 100).toLocaleString(
"en-US",
{
minimumFractionDigits: 2,
maximumFractionDigits: 2,
},
)}{" "}
{p.currency}
</span>
{p.isGroupBooking && (
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Group
</span>
)}
</div>
</td>
<td className="py-2 font-mono text-xs">
{p.bookingRef}
</td>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredList.map((p, i) => (
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
<td className="px-4 py-3 text-muted-foreground tabular-nums">{i + 1}</td>
<td className="px-4 py-3 font-medium">{p.passengerName}</td>
<td className="px-4 py-3 font-mono text-xs">{p.coachSeat}</td>
<td className="px-4 py-3 text-muted-foreground">{p.origin}</td>
<td className="px-4 py-3 text-muted-foreground">{p.destination}</td>
<td className="px-4 py-3 text-xs text-muted-foreground whitespace-nowrap">{p.departureAt ? formatDateTime(p.departureAt) : '—'}</td>
<td className="px-4 py-3 font-mono text-xs">{p.bookingRef}</td>
</tr>
))}
{filteredList.length === 0 && (
<tr><td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
)}
</tbody>
</table>
</div>
))}
{filteredList.length === 0 && (
<tr>
<td
colSpan={6}
className="py-8 text-center text-sm text-muted-foreground"
>
No passengers found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
)}
@@ -313,7 +563,9 @@ export default function PassengersReportPage() {
)}
{!data && !isLoading && scheduleId && (
<div className="card py-12 text-center text-muted-foreground">No data found for this schedule.</div>
<div className="card py-12 text-center text-muted-foreground">
No data found for this schedule.
</div>
)}
{!scheduleId && (

File diff suppressed because it is too large Load Diff

View File

@@ -190,13 +190,18 @@ const adultPassengerCount = searchCriteria?.adultCount ?? passengers.filter(p =>
}
return null;
}
// One-way: seat-specific fare (set at seat selection) is the authoritative price —
// it is exactly what was shown to the user. Use the fare-breakdown API only as fallback
// for cases where seatFareMinor was not captured (e.g. auto-assign without seat map).
if (p.seatFareMinor != null) {
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
}
if (!isPackageBooking && fareBreakdown?.passengers && index != null) {
const line = fareBreakdown.passengers[index];
const displayFare = line?.displayFareMinor ?? line?.fareMinor;
if (displayFare != null) return displayFare;
}
if (p.seatFareMinor == null) return null;
return isPackageBooking ? p.seatFareMinor * 2 : p.seatFareMinor;
return null;
};
const createBookingMutation = useMutation({

View File

@@ -603,11 +603,20 @@ export default function PackageDetailPage() {
})),
});
const outboundSched = toSchedule(ctx.outboundSchedule);
// booking-context returns flat originStationId/destinationStationId, not nested objects.
// Use the user's selected departure station as the true origin for both the
// search criteria and the schedule objects so hold/booking APIs get the right segment.
const outboundDestId = ctx.outboundSchedule.destinationStationId ?? ctx.outboundSchedule.destinationStation?.id ?? "";
const outboundSched = {
...toSchedule(ctx.outboundSchedule),
originStationId: departureStationId,
origin: departureStationName,
};
setSearchCriteria({
originStationId: ctx.outboundSchedule.originStation?.id ?? "",
destinationStationId: ctx.outboundSchedule.destinationStation?.id ?? "",
originStationId: departureStationId,
destinationStationId: outboundDestId,
departureDate: ctx.outboundSchedule.departureAt?.slice(0, 10) ?? "",
returnDate: ctx.returnSchedule?.departureAt?.slice(0, 10),
tripType: isRoundTrip ? "ROUND_TRIP" : "ONE_WAY",
@@ -618,7 +627,11 @@ export default function PackageDetailPage() {
if (isRoundTrip) {
setOutboundSchedule(outboundSched);
setInboundSchedule(toSchedule(ctx.returnSchedule));
setInboundSchedule({
...toSchedule(ctx.returnSchedule),
destinationStationId: departureStationId,
destination: departureStationName,
});
} else {
setSelectedSchedule(outboundSched);
}