diff --git a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts index ada6e2a78..64dcb2c6c 100644 --- a/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/bookings.service.ts @@ -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({ diff --git a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts index 17e3a585e..af5321e2d 100644 --- a/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts +++ b/apps/edr-passenger-api/src/modules/bookings/guest-booking.service.ts @@ -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; diff --git a/apps/edr-passenger-api/src/modules/packages/packages.service.ts b/apps/edr-passenger-api/src/modules/packages/packages.service.ts index c1a30f243..e16f8f025 100644 --- a/apps/edr-passenger-api/src/modules/packages/packages.service.ts +++ b/apps/edr-passenger-api/src/modules/packages/packages.service.ts @@ -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 { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 6bab9a316..6ecd04834 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -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, })); } diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index 89dfe0ca0..18bd9ce09 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -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 diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 0dcc3c6f5..7f0eede85 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -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('occupancy'); - const [listSearch, setListSearch] = useState(''); - const [filterCoach, setFilterCoach] = useState(''); - const [filterOrigin, setFilterOrigin] = useState(''); + const [scheduleId, setScheduleId] = useState(""); + const [tab, setTab] = useState("occupancy"); + const [listSearch, setListSearch] = useState(""); + const [filterCoach, setFilterCoach] = useState(""); + const [filterOrigin, setFilterOrigin] = useState(""); - const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ - 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({ - 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({ - 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 (
-

Passengers Report

-

Occupancy and passenger breakdown for a schedule

+

+ Passengers Report +

+

+ Occupancy and passenger breakdown for a schedule +

{/* Schedule selector */} @@ -100,19 +193,41 @@ export default function PassengersReportPage() {
- {data && tab === 'occupancy' && ( - Export CSV + {data && tab === "occupancy" && ( + + Export CSV + )} - {(isLoading || listLoading) &&

Loading…

} - {isError &&

Failed to load report.

} + {(isLoading || listLoading) && ( +

Loading…

+ )} + {isError && ( +

Failed to load report.

+ )} {data && ( @@ -125,7 +240,8 @@ export default function PassengersReportPage() {

{data.schedule.trainName}

- {data.schedule.origin} → {data.schedule.destination} · Departure: {formatDateTime(data.schedule.departureAt)} + {data.schedule.origin} → {data.schedule.destination} · + Departure: {formatDateTime(data.schedule.departureAt)}

@@ -133,51 +249,75 @@ export default function PassengersReportPage() { {/* Tabs */}
{/* Occupancy tab */} - {tab === 'occupancy' && ( + {tab === "occupancy" && (
-

Total Seats

-
+

+ Total Seats +

+
+ +
-

{data.summary.totalSeats}

+

+ {data.summary.totalSeats} +

-

Passengers

-
+

+ Passengers +

+
+ +
-

{data.summary.totalPassengers}

+

+ {data.summary.totalPassengers} +

-

Occupancy Rate

-
+

+ Occupancy Rate +

+
+ +
-

{data.summary.occupancyRate}%

+

+ {data.summary.occupancyRate}% +

-
+
-

By Coach

+

+ By Coach +

@@ -190,18 +330,31 @@ export default function PassengersReportPage() { - {data.byCoach.map(c => ( + {data.byCoach.map((c) => ( - - - - + + + + @@ -213,46 +366,77 @@ export default function PassengersReportPage() {
-

By Class

+

+ By Class +

- {data.byClass.map(c => ( + {data.byClass.map((c) => (
{c.className} - {c.booked}/{c.totalSeats} + + {c.booked}/{c.totalSeats} +
-
+
- {c.occupancyRate}% + + {c.occupancyRate}% +
))}
-

By Boarding Station

+

+ By Boarding Station +

- {data.byOrigin.map(o => ( -
- {o.stationName} - {o.passengers} + {data.byOrigin.map((o) => ( +
+ + {o.stationName} + + + {o.passengers} +
))} - {data.byOrigin.length === 0 &&

No data

} + {data.byOrigin.length === 0 && ( +

No data

+ )}
-

By Alighting Station

+

+ By Alighting Station +

- {data.byDestination.map(d => ( -
- {d.stationName} - {d.passengers} + {data.byDestination.map((d) => ( +
+ + {d.stationName} + + + {d.passengers} +
))} - {data.byDestination.length === 0 &&

No data

} + {data.byDestination.length === 0 && ( +

No data

+ )}
@@ -260,7 +444,7 @@ export default function PassengersReportPage() { )} {/* Passenger List tab */} - {tab === 'list' && ( + {tab === "list" && (
setListSearch(e.target.value)} + onChange={(e) => setListSearch(e.target.value)} /> {passengerList.length > 0 && ( - Export CSV + + Export CSV + )}
-
-
-
{c.coachNumber}{c.coachType}{c.totalSeats}{c.booked} + {c.coachNumber} + + {c.coachType} + + {c.totalSeats} + + {c.booked} +
-
+
- {c.occupancyRate}% + + {c.occupancyRate}% +
- - - - - - - - - +
+
#NameCoach · SeatOriginDestinationDateBooking Ref
+ + + + + + + + + + + + {filteredList.map((p, i) => ( + + + + + + + - - - {filteredList.map((p, i) => ( - - - - - - - - - - ))} - {filteredList.length === 0 && ( - - )} - -
NameNationalityCoach · SeatTripAmount PaidBooking Ref
+ {p.passengerName} + + {p.passportNumber ? ( + <> + + {p.passportCountry ?? "Intl"} + + + {p.passportNumber} + + + ) : ( + + {p.idDocumentNumber ?? "—"} + + )} + + {p.coachNumber && p.seatLabel ? ( + <> + {p.coachNumber} · {p.seatLabel} + {p.coachType && ( + + ({p.coachType}) + + )} + + ) : ( + (p.coachNumber ?? p.seatLabel ?? "—") + )} + + {p.origin && p.destination + ? `${p.origin} → ${p.destination}` + : (p.origin ?? p.destination ?? "—")} + +
+ + {(p.amountPaidMinor / 100).toLocaleString( + "en-US", + { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }, + )}{" "} + {p.currency} + + {p.isGroupBooking && ( + + Group + + )} +
+
+ {p.bookingRef} +
{i + 1}{p.passengerName}{p.coachSeat}{p.origin}{p.destination}{p.departureAt ? formatDateTime(p.departureAt) : '—'}{p.bookingRef}
No passengers found
-
+ ))} + {filteredList.length === 0 && ( + + + No passengers found + + + )} + +
)} @@ -313,7 +563,9 @@ export default function PassengersReportPage() { )} {!data && !isLoading && scheduleId && ( -
No data found for this schedule.
+
+ No data found for this schedule. +
)} {!scheduleId && ( diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 2298ebf9f..8eb9e5a99 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -1,16 +1,115 @@ -'use client'; +"use client"; -import { useState, useEffect } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { Plus, Loader2, Zap, Trash2, Edit, Search, X, GripVertical } from 'lucide-react'; -import DataTable from '@/components/ui/DataTable'; -import ActionButton from '@/components/ui/ActionButton'; -import Modal from '@/components/ui/Modal'; -import ConfirmDialog from '@/components/ui/ConfirmDialog'; -import DateTimePicker from '@/components/ui/DateTimePicker'; -import { apiClient } from '@/lib/api-client'; -import { routeCoachTemplatesApi } from '@/lib/api'; -import { formatDateTime } from '@/lib/utils'; +import { useState, useEffect } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Plus, + Loader2, + Zap, + Trash2, + Edit, + Search, + X, + GripVertical, +} from "lucide-react"; + +// ── 12-hour datetime picker ────────────────────────────────────────────────── +interface DTPProps { + label: string; + value: string; + onChange: (v: string) => void; + required?: boolean; +} + +/** value / onChange use "YYYY-MM-DDTHH:mm" (24-hr, local) — same as datetime-local */ +function DateTimePicker({ label, value, onChange, required }: DTPProps) { + const datePart = value.slice(0, 10); + const timePart = value.slice(11, 16); // HH:mm 24-hr + + const hour24 = timePart ? parseInt(timePart.slice(0, 2), 10) : 12; + const minute = timePart ? timePart.slice(3, 5) : "00"; + const period = hour24 >= 12 ? "PM" : "AM"; + const hour12 = hour24 % 12 === 0 ? 12 : hour24 % 12; + + const emit = (d: string, h12: number, m: string, p: string) => { + if (!d) return; + const h24 = + p === "AM" ? (h12 === 12 ? 0 : h12) : h12 === 12 ? 12 : h12 + 12; + onChange(`${d}T${String(h24).padStart(2, "0")}:${m}`); + }; + + return ( +
+ +
+ emit(e.target.value, hour12, minute, period)} + /> + + + +
+
+ ); +} +// ──────────────────────────────────────────────────────────────────────────── +import DataTable from "@/components/ui/DataTable"; +import ActionButton from "@/components/ui/ActionButton"; +import Modal from "@/components/ui/Modal"; +import ConfirmDialog from "@/components/ui/ConfirmDialog"; +import DateTimePicker from "@/components/ui/DateTimePicker"; +import { apiClient } from "@/lib/api-client"; +import { routeCoachTemplatesApi } from "@/lib/api"; +import { formatDateTime } from "@/lib/utils"; // EAT is UTC+3. Convert without depending on the browser's own timezone. const EAT_MS = 3 * 60 * 60 * 1000; @@ -19,7 +118,7 @@ const isoToEAT = (iso: string): string => new Date(new Date(iso).getTime() + EAT_MS).toISOString().slice(0, 16); // EAT "YYYY-MM-DDTHH:mm" → UTC ISO string for API submission const eatToISO = (local: string): string => - new Date(new Date(local + ':00Z').getTime() - EAT_MS).toISOString(); + new Date(new Date(local + ":00Z").getTime() - EAT_MS).toISOString(); // Extract HH:mm in EAT from a UTC ISO datetime (e.g. route stop planned time) const isoToEATTimePart = (iso: string): string | null => { if (!iso) return null; @@ -27,7 +126,7 @@ const isoToEATTimePart = (iso: string): string | null => { const msIntoDay = eatMs % (24 * 60 * 60 * 1000); const h = Math.floor(msIntoDay / 3600000); const m = Math.floor((msIntoDay % 3600000) / 60000); - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; }; interface Schedule { @@ -41,7 +140,11 @@ interface Schedule { train?: { id: string; name: string; number: string }; originStation?: { id: string; name: string }; destinationStation?: { id: string; name: string }; - coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>; + coachAssignments?: Array<{ + coachId: string; + positionNumber: number; + coach?: { id: string; number: string }; + }>; isPackageOnly?: boolean; stopTimes?: Array<{ sequence: number; @@ -78,92 +181,156 @@ export default function SchedulesPage() { const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [editingSchedule, setEditingSchedule] = useState(null); - const [selectedSchedules, setSelectedSchedules] = useState>(new Set()); - const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; isBulk?: boolean; error?: string; cascade?: boolean; cascadeChecked?: boolean }>( - { isOpen: false, item: null } + const [selectedSchedules, setSelectedSchedules] = useState>( + new Set(), ); + const [deleteConfirm, setDeleteConfirm] = useState<{ + isOpen: boolean; + item: any | null; + isBulk?: boolean; + error?: string; + cascade?: boolean; + cascadeChecked?: boolean; + }>({ isOpen: false, item: null }); const [error, setError] = useState(null); const queryClient = useQueryClient(); const [bulkForm, setBulkForm] = useState({ - trainId: '', - routeId: '', - startDateTime: '', - durationHours: '10', - repeatEveryDays: '2', - forNextDays: '15', + trainId: "", + routeId: "", + startDateTime: "", + durationHours: "10", + repeatEveryDays: "2", + forNextDays: "15", }); - const [bulkCoachRows, setBulkCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); + const [bulkCoachRows, setBulkCoachRows] = useState< + { coachId: string; positionNumber: number }[] + >([]); - const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); - const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]); - const [addStopTimes, setAddStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); - const [editStopTimes, setEditStopTimes] = useState<{ sequence: number; stationName: string; plannedArrivalAt: string; plannedDepartureAt: string }[]>([]); - - const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({ - queryKey: ['route-coaches', addForm.routeId], - queryFn: () => routeCoachTemplatesApi.get(addForm.routeId), - enabled: !!addForm.routeId, + const [addForm, setAddForm] = useState({ + trainId: "", + routeId: "", + departureAt: "", + arrivalAt: "", }); + const [addCoachRows, setAddCoachRows] = useState< + { coachId: string; positionNumber: number }[] + >([]); + const [addStopTimes, setAddStopTimes] = useState< + { + sequence: number; + stationName: string; + plannedArrivalAt: string; + plannedDepartureAt: string; + }[] + >([]); + const [editStopTimes, setEditStopTimes] = useState< + { + sequence: number; + stationName: string; + plannedArrivalAt: string; + plannedDepartureAt: string; + }[] + >([]); + + const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = + useQuery({ + queryKey: ["route-coaches", addForm.routeId], + queryFn: () => routeCoachTemplatesApi.get(addForm.routeId), + enabled: !!addForm.routeId, + }); const { data: addRouteDetail } = useQuery({ - queryKey: ['route-detail', addForm.routeId], + queryKey: ["route-detail", addForm.routeId], queryFn: () => apiClient.get(`/routes/${addForm.routeId}`), enabled: !!addForm.routeId, }); const { data: editRouteDetail } = useQuery({ - queryKey: ['route-detail', editingSchedule?.routeId], + queryKey: ["route-detail", editingSchedule?.routeId], queryFn: () => apiClient.get(`/routes/${editingSchedule!.routeId}`), enabled: !!editingSchedule?.routeId, }); useEffect(() => { - if (!addForm.routeId) { setAddCoachRows([]); return; } - const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? []; - setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []); + if (!addForm.routeId) { + setAddCoachRows([]); + return; + } + const rows: any[] = Array.isArray(singleRouteTemplate) + ? singleRouteTemplate + : ((singleRouteTemplate as any)?.coaches ?? []); + setAddCoachRows( + rows.length + ? rows.map((r: any) => ({ + coachId: r.coachId ?? r.coach?.id, + positionNumber: r.positionNumber, + })) + : [], + ); }, [singleRouteTemplate, addForm.routeId]); useEffect(() => { const stops: any[] = (addRouteDetail as any)?.stops ?? []; - if (!stops.length) { setAddStopTimes([]); return; } + if (!stops.length) { + setAddStopTimes([]); + return; + } - const eatDateStr = addForm.departureAt ? addForm.departureAt.slice(0, 10) : null; + const eatDateStr = addForm.departureAt + ? addForm.departureAt.slice(0, 10) + : null; - setAddStopTimes(stops.map((s: any) => { - const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; - const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; - return { - sequence: s.sequence, - stationName: s.station?.name ?? `Stop ${s.sequence}`, - plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', - plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', - }; - })); + setAddStopTimes( + stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime + ? isoToEATTimePart(s.plannedArrivalTime) + : null; + const depTimePart = s.plannedDepartureTime + ? isoToEATTimePart(s.plannedDepartureTime) + : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: + eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : "", + plannedDepartureAt: + eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : "", + }; + }), + ); }, [addRouteDetail, addForm.departureAt]); // Fetch route coach template when route changes const { data: routeTemplate, isLoading: templateLoading } = useQuery({ - queryKey: ['route-coaches', bulkForm.routeId], + queryKey: ["route-coaches", bulkForm.routeId], queryFn: () => routeCoachTemplatesApi.get(bulkForm.routeId), enabled: !!bulkForm.routeId, }); useEffect(() => { - if (!bulkForm.routeId) { setBulkCoachRows([]); return; } - const rows: any[] = Array.isArray(routeTemplate) ? routeTemplate : (routeTemplate as any)?.coaches ?? []; + if (!bulkForm.routeId) { + setBulkCoachRows([]); + return; + } + const rows: any[] = Array.isArray(routeTemplate) + ? routeTemplate + : ((routeTemplate as any)?.coaches ?? []); setBulkCoachRows( rows.length - ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) - : [] + ? rows.map((r: any) => ({ + coachId: r.coachId ?? r.coach?.id, + positionNumber: r.positionNumber, + })) + : [], ); }, [routeTemplate, bulkForm.routeId]); const [editForm, setEditForm] = useState({ - departureAt: '', - arrivalAt: '', - status: 'SCHEDULED', + departureAt: "", + arrivalAt: "", + status: "SCHEDULED", coachIds: [] as string[], isPackageOnly: false, }); @@ -171,91 +338,103 @@ export default function SchedulesPage() { useEffect(() => { const stops: any[] = (editRouteDetail as any)?.stops ?? []; if (!stops.length || !editingSchedule) return; - const hasRouteTimes = stops.some((s: any) => s.plannedArrivalTime || s.plannedDepartureTime); + const hasRouteTimes = stops.some( + (s: any) => s.plannedArrivalTime || s.plannedDepartureTime, + ); if (!hasRouteTimes) return; - const eatDateStr = editForm.departureAt ? editForm.departureAt.slice(0, 10) : null; - setEditStopTimes(stops.map((s: any) => { - const arrTimePart = s.plannedArrivalTime ? isoToEATTimePart(s.plannedArrivalTime) : null; - const depTimePart = s.plannedDepartureTime ? isoToEATTimePart(s.plannedDepartureTime) : null; - return { - sequence: s.sequence, - stationName: s.station?.name ?? `Stop ${s.sequence}`, - plannedArrivalAt: eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : '', - plannedDepartureAt: eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : '', - }; - })); + const eatDateStr = editForm.departureAt + ? editForm.departureAt.slice(0, 10) + : null; + setEditStopTimes( + stops.map((s: any) => { + const arrTimePart = s.plannedArrivalTime + ? isoToEATTimePart(s.plannedArrivalTime) + : null; + const depTimePart = s.plannedDepartureTime + ? isoToEATTimePart(s.plannedDepartureTime) + : null; + return { + sequence: s.sequence, + stationName: s.station?.name ?? `Stop ${s.sequence}`, + plannedArrivalAt: + eatDateStr && arrTimePart ? `${eatDateStr}T${arrTimePart}` : "", + plannedDepartureAt: + eatDateStr && depTimePart ? `${eatDateStr}T${depTimePart}` : "", + }; + }), + ); }, [editRouteDetail, editingSchedule?.id, editForm.departureAt]); // eslint-disable-line react-hooks/exhaustive-deps const [filters, setFilters] = useState({ - search: '', - trainId: '', - routeId: '', - date: '', + search: "", + trainId: "", + routeId: "", + date: "", }); const { data: schedulesData, isLoading: schedulesLoading } = useQuery({ - queryKey: ['schedules', filters], + queryKey: ["schedules", filters], queryFn: () => { const params = new URLSearchParams(); - if (filters.trainId) params.append('trainId', filters.trainId); - if (filters.routeId) params.append('routeId', filters.routeId); - if (filters.date) params.append('date', filters.date); + if (filters.trainId) params.append("trainId", filters.trainId); + if (filters.routeId) params.append("routeId", filters.routeId); + if (filters.date) params.append("date", filters.date); return apiClient.get(`/schedules?${params.toString()}`); }, retry: 1, }); const { data: trainsData } = useQuery({ - queryKey: ['trains'], - queryFn: () => apiClient.get('/fleet/trains'), + queryKey: ["trains"], + queryFn: () => apiClient.get("/fleet/trains"), retry: 1, }); const { data: routesData } = useQuery({ - queryKey: ['routes'], - queryFn: () => apiClient.get('/routes'), + queryKey: ["routes"], + queryFn: () => apiClient.get("/routes"), retry: 1, }); const { data: coachesData } = useQuery({ - queryKey: ['coaches'], - queryFn: () => apiClient.get('/fleet/coaches'), + queryKey: ["coaches"], + queryFn: () => apiClient.get("/fleet/coaches"), retry: 1, }); const bulkGenerateMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/schedules/bulk-generate', data), + mutationFn: (data: any) => apiClient.post("/schedules/bulk-generate", data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); + queryClient.invalidateQueries({ queryKey: ["schedules"] }); setShowModal(false); setBulkForm({ - trainId: '', - routeId: '', - startDateTime: '', - durationHours: '12', - repeatEveryDays: '1', - forNextDays: '30', + trainId: "", + routeId: "", + startDateTime: "", + durationHours: "12", + repeatEveryDays: "1", + forNextDays: "30", }); setBulkCoachRows([]); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to generate schedules'); + setError(err.response?.data?.message || "Failed to generate schedules"); }, }); const createScheduleMutation = useMutation({ - mutationFn: (data: any) => apiClient.post('/schedules', data), + mutationFn: (data: any) => apiClient.post("/schedules", data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); + queryClient.invalidateQueries({ queryKey: ["schedules"] }); setShowAddModal(false); - setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); + setAddForm({ trainId: "", routeId: "", departureAt: "", arrivalAt: "" }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to create schedule'); + setError(err.response?.data?.message || "Failed to create schedule"); }, }); @@ -263,44 +442,63 @@ export default function SchedulesPage() { mutationFn: (data: { id: string; payload: any }) => apiClient.patch(`/schedules/${data.id}`, data.payload), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); + queryClient.invalidateQueries({ queryKey: ["schedules"] }); setShowEditModal(false); setEditingSchedule(null); setEditStopTimes([]); setError(null); }, onError: (err: any) => { - setError(err.response?.data?.message || 'Failed to update schedule'); + setError(err.response?.data?.message || "Failed to update schedule"); }, }); const deleteScheduleMutation = useMutation({ - mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => apiClient.delete(`/schedules/${id}${cascade ? '?cascade=true' : ''}`), + mutationFn: ({ id, cascade }: { id: string; cascade?: boolean }) => + apiClient.delete(`/schedules/${id}${cascade ? "?cascade=true" : ""}`), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); + queryClient.invalidateQueries({ queryKey: ["schedules"] }); }, onError: (err: any) => { - const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedule'; - const isFkError = msg?.includes('Cannot delete') || err?.response?.status === 400; + const msg = + err?.response?.data?.message || + err?.message || + "Failed to delete schedule"; + const isFkError = + msg?.includes("Cannot delete") || err?.response?.status === 400; if (isFkError && !deleteConfirm.cascade) { - setDeleteConfirm(prev => ({ ...prev, cascade: true, cascadeChecked: false, error: Array.isArray(msg) ? msg.join(' ') : msg })); + setDeleteConfirm((prev) => ({ + ...prev, + cascade: true, + cascadeChecked: false, + error: Array.isArray(msg) ? msg.join(" ") : msg, + })); } else { - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + setDeleteConfirm((prev) => ({ + ...prev, + error: Array.isArray(msg) ? msg.join(" ") : msg, + })); } }, }); const bulkDeleteMutation = useMutation({ mutationFn: async (ids: string[]) => { - await Promise.all(ids.map(id => apiClient.delete(`/schedules/${id}`))); + await Promise.all(ids.map((id) => apiClient.delete(`/schedules/${id}`))); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['schedules'] }); + queryClient.invalidateQueries({ queryKey: ["schedules"] }); setSelectedSchedules(new Set()); }, onError: (err: any) => { - const msg = err?.response?.data?.message || err?.message || 'Failed to delete schedules'; - setDeleteConfirm(prev => ({ ...prev, error: Array.isArray(msg) ? msg.join(' ') : msg })); + const msg = + err?.response?.data?.message || + err?.message || + "Failed to delete schedules"; + setDeleteConfirm((prev) => ({ + ...prev, + error: Array.isArray(msg) ? msg.join(" ") : msg, + })); }, }); @@ -309,7 +507,7 @@ export default function SchedulesPage() { setError(null); if (!bulkForm.trainId || !bulkForm.routeId || !bulkForm.startDateTime) { - setError('Train, route, and start date/time are required'); + setError("Train, route, and start date/time are required"); return; } @@ -334,21 +532,32 @@ export default function SchedulesPage() { e.preventDefault(); setError(null); if (!addForm.departureAt || !addForm.arrivalAt) { - setError('Please select departure and arrival date & time'); + setError("Please select departure and arrival date & time"); return; } - if (new Date(addForm.arrivalAt + ':00Z') <= new Date(addForm.departureAt + ':00Z')) { - setError('Arrival must be after departure'); return; + if ( + new Date(addForm.arrivalAt + ":00Z") <= + new Date(addForm.departureAt + ":00Z") + ) { + setError("Arrival must be after departure"); + return; } - const filledStops = addStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); - const plannedTimes = filledStops.length === addStopTimes.length && addStopTimes.length > 0 - ? addStopTimes.map(s => ({ - sequence: s.sequence, - ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), - ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), - })) - : undefined; + const filledStops = addStopTimes.filter( + (s) => s.plannedDepartureAt || s.plannedArrivalAt, + ); + const plannedTimes = + filledStops.length === addStopTimes.length && addStopTimes.length > 0 + ? addStopTimes.map((s) => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt + ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } + : {}), + ...(s.plannedDepartureAt + ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } + : {}), + })) + : undefined; const validCoaches = addCoachRows.filter((r) => r.coachId); await createScheduleMutation.mutateAsync({ @@ -357,7 +566,9 @@ export default function SchedulesPage() { departureAt: eatToISO(addForm.departureAt), arrivalAt: eatToISO(addForm.arrivalAt), ...(plannedTimes ? { plannedTimes } : {}), - ...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }), + ...(validCoaches.length > 0 && { + coachIds: validCoaches.map((r) => r.coachId), + }), }); }; @@ -368,23 +579,34 @@ export default function SchedulesPage() { if (!editingSchedule) return; if (!editForm.departureAt || !editForm.arrivalAt) { - setError('Please select departure and arrival date & time'); + setError("Please select departure and arrival date & time"); return; } - if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) { - setError('Arrival time must be after departure time'); + if ( + new Date(editForm.arrivalAt + ":00Z") <= + new Date(editForm.departureAt + ":00Z") + ) { + setError("Arrival time must be after departure time"); return; } - const filledEditStops = editStopTimes.filter(s => s.plannedDepartureAt || s.plannedArrivalAt); - const editPlannedTimes = filledEditStops.length === editStopTimes.length && editStopTimes.length > 0 - ? editStopTimes.map(s => ({ - sequence: s.sequence, - ...(s.plannedArrivalAt ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } : {}), - ...(s.plannedDepartureAt ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } : {}), - })) - : undefined; + const filledEditStops = editStopTimes.filter( + (s) => s.plannedDepartureAt || s.plannedArrivalAt, + ); + const editPlannedTimes = + filledEditStops.length === editStopTimes.length && + editStopTimes.length > 0 + ? editStopTimes.map((s) => ({ + sequence: s.sequence, + ...(s.plannedArrivalAt + ? { plannedArrivalAt: eatToISO(s.plannedArrivalAt) } + : {}), + ...(s.plannedDepartureAt + ? { plannedDepartureAt: eatToISO(s.plannedDepartureAt) } + : {}), + })) + : undefined; const payload: any = { departureAt: eatToISO(editForm.departureAt), @@ -410,17 +632,24 @@ export default function SchedulesPage() { const handleBulkDelete = () => { if (selectedSchedules.size === 0) return; - setDeleteConfirm({ isOpen: true, item: Array.from(selectedSchedules), isBulk: true }); + setDeleteConfirm({ + isOpen: true, + item: Array.from(selectedSchedules), + isBulk: true, + }); }; const confirmDelete = async () => { - setDeleteConfirm(prev => ({ ...prev, error: undefined })); + setDeleteConfirm((prev) => ({ ...prev, error: undefined })); try { if (deleteConfirm.isBulk) { const ids = deleteConfirm.item as string[]; await bulkDeleteMutation.mutateAsync(ids); } else if (deleteConfirm.item) { - await deleteScheduleMutation.mutateAsync({ id: deleteConfirm.item.id, cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked }); + await deleteScheduleMutation.mutateAsync({ + id: deleteConfirm.item.id, + cascade: deleteConfirm.cascade && deleteConfirm.cascadeChecked, + }); } setDeleteConfirm({ isOpen: false, item: null }); } catch { @@ -440,13 +669,16 @@ export default function SchedulesPage() { }); if (schedule.stopTimes && schedule.stopTimes.length > 0) { - const toDatetimeLocal = (iso: string | null) => iso ? isoToEAT(iso) : ''; - setEditStopTimes(schedule.stopTimes.map(st => ({ - sequence: st.sequence, - stationName: st.station?.name ?? `Stop ${st.sequence}`, - plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt), - plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt), - }))); + const toDatetimeLocal = (iso: string | null) => + iso ? isoToEAT(iso) : ""; + setEditStopTimes( + schedule.stopTimes.map((st) => ({ + sequence: st.sequence, + stationName: st.station?.name ?? `Stop ${st.sequence}`, + plannedArrivalAt: toDatetimeLocal(st.plannedArrivalAt), + plannedDepartureAt: toDatetimeLocal(st.plannedDepartureAt), + })), + ); } else { setEditStopTimes([]); } @@ -455,10 +687,18 @@ export default function SchedulesPage() { setShowEditModal(true); }; - const schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData as any)?.items || []; - const trains = Array.isArray(trainsData) ? trainsData : (trainsData as any)?.items || []; - const routes = Array.isArray(routesData) ? routesData : (routesData as any)?.items || []; - const coaches = Array.isArray(coachesData) ? coachesData : (coachesData as any)?.items || []; + const schedules = Array.isArray(schedulesData) + ? schedulesData + : (schedulesData as any)?.items || []; + const trains = Array.isArray(trainsData) + ? trainsData + : (trainsData as any)?.items || []; + const routes = Array.isArray(routesData) + ? routesData + : (routesData as any)?.items || []; + const coaches = Array.isArray(coachesData) + ? coachesData + : (coachesData as any)?.items || []; const filteredSchedules = schedules.filter((schedule: Schedule) => { if (!filters.search) return true; @@ -473,23 +713,28 @@ export default function SchedulesPage() { }); const statusMap: Record = { - SCHEDULED: 'edr-badge-info', - BOARDING: 'edr-badge-warning', - EN_ROUTE: 'edr-badge-success', - ARRIVED: 'edr-badge-secondary', - CANCELLED: 'edr-badge-danger', + SCHEDULED: "edr-badge-info", + BOARDING: "edr-badge-warning", + EN_ROUTE: "edr-badge-success", + ARRIVED: "edr-badge-secondary", + CANCELLED: "edr-badge-danger", }; const scheduleColumns = [ { - key: 'checkbox', + key: "checkbox", label: ( 0} + checked={ + selectedSchedules.size === filteredSchedules.length && + filteredSchedules.length > 0 + } onChange={(e) => { if (e.target.checked) { - setSelectedSchedules(new Set(filteredSchedules.map((s: Schedule) => s.id))); + setSelectedSchedules( + new Set(filteredSchedules.map((s: Schedule) => s.id)), + ); } else { setSelectedSchedules(new Set()); } @@ -515,50 +760,52 @@ export default function SchedulesPage() { ), }, { - key: 'train.name', - label: 'Train', + key: "train.name", + label: "Train", sortable: true, render: (schedule: Schedule) => ( -
- {schedule.train?.number} -
+
{schedule.train?.number}
), }, { - key: 'route', - label: 'Route', + key: "route", + label: "Route", sortable: true, render: (schedule: Schedule) => (
- {schedule.originStation?.name || 'Unknown'} + {schedule.originStation?.name || "Unknown"} - {schedule.destinationStation?.name || 'Unknown'} + {schedule.destinationStation?.name || "Unknown"}
), }, { - key: 'departureAt', - label: 'Departure', + key: "departureAt", + label: "Departure", sortable: true, render: (schedule: Schedule) => ( - {formatDateTime(schedule.departureAt)} + + {formatDateTime(schedule.departureAt)} + ), }, { - key: 'arrivalAt', - label: 'Arrival', + key: "arrivalAt", + label: "Arrival", sortable: true, render: (schedule: Schedule) => ( - {formatDateTime(schedule.arrivalAt)} + + {formatDateTime(schedule.arrivalAt)} + ), }, { - key: 'coachAssignments', - label: 'Coaches', + key: "coachAssignments", + label: "Coaches", render: (schedule: Schedule) => ( {schedule.coachAssignments?.length || 0} @@ -566,11 +813,13 @@ export default function SchedulesPage() { ), }, { - key: 'status', - label: 'Status', + key: "status", + label: "Status", render: (schedule: Schedule) => (
- + {schedule.status} {schedule.isPackageOnly && ( @@ -582,30 +831,35 @@ export default function SchedulesPage() { ] as any; const cancelScheduleMutation = useMutation({ - mutationFn: (id: string) => apiClient.patch(`/schedules/${id}/status`, { status: 'CANCELLED' }), - onSuccess: () => queryClient.invalidateQueries({ queryKey: ['schedules'] }), + mutationFn: (id: string) => + apiClient.patch(`/schedules/${id}/status`, { status: "CANCELLED" }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ["schedules"] }), }); - const [cancelConfirm, setCancelConfirm] = useState<{ isOpen: boolean; item: Schedule | null }>({ isOpen: false, item: null }); + const [cancelConfirm, setCancelConfirm] = useState<{ + isOpen: boolean; + item: Schedule | null; + }>({ isOpen: false, item: null }); const scheduleActions = [ { - label: 'Edit', + label: "Edit", onClick: handleEditClick, - variant: 'secondary' as const, + variant: "secondary" as const, icon: Edit, }, { - label: 'Cancel', - onClick: (schedule: Schedule) => setCancelConfirm({ isOpen: true, item: schedule }), - variant: 'danger' as const, + label: "Cancel", + onClick: (schedule: Schedule) => + setCancelConfirm({ isOpen: true, item: schedule }), + variant: "danger" as const, icon: X, - hidden: (schedule: Schedule) => schedule.status === 'CANCELLED', + hidden: (schedule: Schedule) => schedule.status === "CANCELLED", }, { - label: 'Delete', + label: "Delete", onClick: handleDelete, - variant: 'danger' as const, + variant: "danger" as const, icon: Trash2, }, ]; @@ -614,8 +868,12 @@ export default function SchedulesPage() {
-

Schedule Management

-

Create and manage train schedules

+

+ Schedule Management +

+

+ Create and manage train schedules +

{selectedSchedules.size > 0 && ( @@ -624,13 +882,17 @@ export default function SchedulesPage() { variant="danger" loading={bulkDeleteMutation.isPending} > - Delete {selectedSchedules.size} Schedule{selectedSchedules.size !== 1 ? 's' : ''} + Delete {selectedSchedules.size} Schedule + {selectedSchedules.size !== 1 ? "s" : ""} )} { setError(null); setShowAddModal(true); }} + onClick={() => { + setError(null); + setShowAddModal(true); + }} > Add Schedule @@ -655,7 +917,9 @@ export default function SchedulesPage() { type="text" placeholder="Search by train name, number, station, or status..." value={filters.search} - onChange={(e) => setFilters({ ...filters, search: e.target.value })} + onChange={(e) => + setFilters({ ...filters, search: e.target.value }) + } className="input pl-10 w-full" />
@@ -665,7 +929,9 @@ export default function SchedulesPage() { setFilters({ ...filters, routeId: e.target.value })} + onChange={(e) => + setFilters({ ...filters, routeId: e.target.value }) + } className="input" > @@ -698,7 +966,9 @@ export default function SchedulesPage() { setFilters({ ...filters, date: e.target.value })} + onChange={(e) => + setFilters({ ...filters, date: e.target.value }) + } className="input" />
@@ -706,7 +976,14 @@ export default function SchedulesPage() {
setFilters({ search: '', trainId: '', routeId: '', date: '' })} + onClick={() => + setFilters({ + search: "", + trainId: "", + routeId: "", + date: "", + }) + } > Clear Filters @@ -722,7 +999,8 @@ export default function SchedulesPage() {
) : filteredSchedules.length === 0 ? (
- No schedules found. {filters.search && 'Try adjusting your search.'} + No schedules found.{" "} + {filters.search && "Try adjusting your search."}
) : ( setDeleteConfirm({ isOpen: false, item: null })} onConfirm={confirmDelete} - title={deleteConfirm.isBulk ? 'Delete Multiple Schedules' : 'Delete Schedule'} + title={ + deleteConfirm.isBulk ? "Delete Multiple Schedules" : "Delete Schedule" + } message={ deleteConfirm.isBulk ? `Are you sure you want to delete ${Array.isArray(deleteConfirm.item) ? deleteConfirm.item.length : 0} schedule(s)? This action cannot be undone.` : `Are you sure you want to delete this schedule departing on ${ - deleteConfirm.item ? formatDateTime(deleteConfirm.item.departureAt) : '' + deleteConfirm.item + ? formatDateTime(deleteConfirm.item.departureAt) + : "" }?` } confirmText="Delete" isDanger={true} - isLoading={deleteScheduleMutation.isPending || bulkDeleteMutation.isPending} + isLoading={ + deleteScheduleMutation.isPending || bulkDeleteMutation.isPending + } error={deleteConfirm.error} - warning={!deleteConfirm.cascade ? "Schedules with existing bookings cannot be deleted." : undefined} - cascadeWarning={deleteConfirm.cascade ? "This schedule has related bookings or tickets that will also be permanently deleted." : undefined} + warning={ + !deleteConfirm.cascade + ? "Schedules with existing bookings cannot be deleted." + : undefined + } + cascadeWarning={ + deleteConfirm.cascade + ? "This schedule has related bookings or tickets that will also be permanently deleted." + : undefined + } cascadeChecked={deleteConfirm.cascadeChecked} - onCascadeChange={(checked) => setDeleteConfirm(prev => ({ ...prev, cascadeChecked: checked }))} + onCascadeChange={(checked) => + setDeleteConfirm((prev) => ({ ...prev, cascadeChecked: checked })) + } /> { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setAddStopTimes([]); setError(null); }} + onClose={() => { + setShowAddModal(false); + setAddForm({ + trainId: "", + routeId: "", + departureAt: "", + arrivalAt: "", + }); + setAddCoachRows([]); + setAddStopTimes([]); + setError(null); + }} title="Add Schedule" size="lg" >
- {error &&
{error}
} + {error && ( +
+ {error} +
+ )}
- + setAddForm({ ...addForm, trainId: e.target.value }) + } + required + > - {trains.map((t: Train) => )} + {trains.map((t: Train) => ( + + ))}
- + setAddForm({ ...addForm, routeId: e.target.value }) + } + required + > - {routes.map((r: Route) => )} + {routes.map((r: Route) => ( + + ))}
@@ -825,30 +1156,44 @@ export default function SchedulesPage() {

- Set planned times for each stop. Leave all blank to auto-generate from distance. + Set planned times for each stop. Leave all blank to + auto-generate from distance.

{addCoachRows.length === 0 ? ( -

No coaches assigned.

+

+ No coaches assigned. +

) : (
- {addCoachRows.length > 1 &&

Drag to reorder

} + {addCoachRows.length > 1 && ( +

+ Drag to reorder +

+ )} {addCoachRows.map((row, i) => { - const selectedIds = new Set(addCoachRows.map((r) => r.coachId).filter(Boolean)); + const selectedIds = new Set( + addCoachRows.map((r) => r.coachId).filter(Boolean), + ); return ( -
e.dataTransfer.setData('add-coach-idx', i.toString())} - onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }} - onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }} +
+ e.dataTransfer.setData("add-coach-idx", i.toString()) + } + onDragOver={(e) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = "0.5"; + }} + onDragLeave={(e) => { + (e.currentTarget as HTMLElement).style.opacity = "1"; + }} onDrop={(e) => { - e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '1'; - const src = parseInt(e.dataTransfer.getData('add-coach-idx')); + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = "1"; + const src = parseInt( + e.dataTransfer.getData("add-coach-idx"), + ); if (src === i) return; const reordered = [...addCoachRows]; const [moved] = reordered.splice(src, 1); reordered.splice(i, 0, moved); - setAddCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); + setAddCoachRows( + reordered.map((r, idx) => ({ + ...r, + positionNumber: idx + 1, + })), + ); }} className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" > - {row.positionNumber} - { + const u = [...addCoachRows]; + u[i] = { ...u[i], coachId: e.target.value }; + setAddCoachRows(u); + }} + > - {coaches.filter((c: Coach) => !selectedIds.has(c.id) || c.id === row.coachId).map((c: Coach) => ( - - ))} + {coaches + .filter( + (c: Coach) => + !selectedIds.has(c.id) || c.id === row.coachId, + ) + .map((c: Coach) => ( + + ))} -
@@ -969,8 +1403,29 @@ export default function SchedulesPage() {
- { setShowAddModal(false); setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' }); setAddCoachRows([]); setError(null); }}>Cancel - Create Schedule + { + setShowAddModal(false); + setAddForm({ + trainId: "", + routeId: "", + departureAt: "", + arrivalAt: "", + }); + setAddCoachRows([]); + setError(null); + }} + > + Cancel + + + Create Schedule +
@@ -981,12 +1436,12 @@ export default function SchedulesPage() { setShowModal(false); setError(null); setBulkForm({ - trainId: '', - routeId: '', - startDateTime: '', - durationHours: '12', - repeatEveryDays: '1', - forNextDays: '30', + trainId: "", + routeId: "", + startDateTime: "", + durationHours: "12", + repeatEveryDays: "1", + forNextDays: "30", }); setBulkCoachRows([]); }} @@ -1005,7 +1460,9 @@ export default function SchedulesPage() { setBulkForm({ ...bulkForm, routeId: e.target.value })} + onChange={(e) => + setBulkForm({ ...bulkForm, routeId: e.target.value }) + } className="input" required > @@ -1036,16 +1495,12 @@ export default function SchedulesPage() {
-
- - setBulkForm({ ...bulkForm, startDateTime: e.target.value })} - className="input" - required - /> -
+ setBulkForm({ ...bulkForm, startDateTime: v })} + required + />
@@ -1054,7 +1509,9 @@ export default function SchedulesPage() { type="number" min="1" placeholder={bulkForm.durationHours} - onChange={(e) => setBulkForm({ ...bulkForm, durationHours: e.target.value })} + onChange={(e) => + setBulkForm({ ...bulkForm, durationHours: e.target.value }) + } className="input" />
@@ -1065,7 +1522,9 @@ export default function SchedulesPage() { type="number" min="1" placeholder={bulkForm.repeatEveryDays} - onChange={(e) => setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value })} + onChange={(e) => + setBulkForm({ ...bulkForm, repeatEveryDays: e.target.value }) + } className="input" />
@@ -1076,7 +1535,9 @@ export default function SchedulesPage() { type="number" min="1" placeholder={bulkForm.forNextDays} - onChange={(e) => setBulkForm({ ...bulkForm, forNextDays: e.target.value })} + onChange={(e) => + setBulkForm({ ...bulkForm, forNextDays: e.target.value }) + } className="input" />
@@ -1087,70 +1548,127 @@ export default function SchedulesPage() {
{templateLoading && bulkForm.routeId && ( - Loading template… + + Loading + template… + )} {!bulkForm.routeId && ( - Select a route to load its coach template + + Select a route to load its coach template + )} - r.coachId).length >= coaches.length} - onClick={() => setBulkCoachRows([...bulkCoachRows, { coachId: '', positionNumber: bulkCoachRows.length + 1 }])}> + r.coachId).length >= + coaches.length + } + onClick={() => + setBulkCoachRows([ + ...bulkCoachRows, + { coachId: "", positionNumber: bulkCoachRows.length + 1 }, + ]) + } + > Add Coach
{bulkCoachRows.length === 0 ? ( -

No coaches assigned — schedules will be created without coach assignments.

+

+ No coaches assigned — schedules will be created without coach + assignments. +

) : (
{bulkCoachRows.length > 1 && ( -

Drag to reorder

+

+ Drag to reorder +

)} {bulkCoachRows.map((row, i) => { - const selectedIds = new Set(bulkCoachRows.map((r) => r.coachId).filter(Boolean)); + const selectedIds = new Set( + bulkCoachRows.map((r) => r.coachId).filter(Boolean), + ); return (
e.dataTransfer.setData('bulk-coach-idx', i.toString())} - onDragOver={(e) => { e.preventDefault(); (e.currentTarget as HTMLElement).style.opacity = '0.5'; }} - onDragLeave={(e) => { (e.currentTarget as HTMLElement).style.opacity = '1'; }} + onDragStart={(e) => + e.dataTransfer.setData("bulk-coach-idx", i.toString()) + } + onDragOver={(e) => { + e.preventDefault(); + (e.currentTarget as HTMLElement).style.opacity = "0.5"; + }} + onDragLeave={(e) => { + (e.currentTarget as HTMLElement).style.opacity = "1"; + }} onDrop={(e) => { e.preventDefault(); - (e.currentTarget as HTMLElement).style.opacity = '1'; - const src = parseInt(e.dataTransfer.getData('bulk-coach-idx')); + (e.currentTarget as HTMLElement).style.opacity = "1"; + const src = parseInt( + e.dataTransfer.getData("bulk-coach-idx"), + ); if (src === i) return; const reordered = [...bulkCoachRows]; const [moved] = reordered.splice(src, 1); reordered.splice(i, 0, moved); - setBulkCoachRows(reordered.map((r, idx) => ({ ...r, positionNumber: idx + 1 }))); + setBulkCoachRows( + reordered.map((r, idx) => ({ + ...r, + positionNumber: idx + 1, + })), + ); }} className="flex gap-2 items-center p-2 bg-muted/50 rounded cursor-move hover:bg-muted transition-colors" > - {row.positionNumber} + + {row.positionNumber} +
diff --git a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx index 95d390182..0ec2599b7 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/review/page.tsx @@ -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({ 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 2c5123def..361090b25 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 @@ -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); }