diff --git a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx index 392791806..350df552a 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/reschedule/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { Suspense, useEffect, useMemo, useState } from "react"; +import { Suspense, useEffect, useMemo, useRef, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { useMutation, useQuery } from "@tanstack/react-query"; import { format } from "date-fns"; @@ -11,6 +11,7 @@ import StationDropdown, { pushRecentStation, readRecentStationIds, } from "@/components/StationDropdown"; +import SeatMap, { buildSeatLabel, getValidSeatsForCoach } from "@/components/SeatMap"; import { formatTime } from "@/utils/format"; import { Station } from "@/types"; @@ -70,6 +71,13 @@ type Quote = { const etb = (minor: number) => `ETB ${(minor / 100).toFixed(2)}`; +/** "Sat 29 Aug, 21:00" — the summary's departure line. Null when the value isn't a real date. */ +const formatDepartureDay = (value: string | Date | undefined | null): string | null => { + if (!value) return null; + const d = value instanceof Date ? value : new Date(value); + return isNaN(d.getTime()) ? null : format(d, "EEE dd MMM, HH:mm"); +}; + function ReschedulePageContent() { const router = useRouter(); const searchParams = useSearchParams(); @@ -81,7 +89,12 @@ function ReschedulePageContent() { const [destinationId, setDestinationId] = useState(""); const [searched, setSearched] = useState<{ originId: string; destinationId: string; date: string } | null>(null); const [schedule, setSchedule] = useState(null); - const [seatIds, setSeatIds] = useState([]); + // Seat per passenger, keyed by index into leg.passengerNames. The API pairs newSeatIds[i] + // with the i-th BookingSeat in that same order, so an explicit map is the only way the + // right passenger keeps the right fare (a free child must not inherit an adult's seat). + const [passengerSeatMap, setPassengerSeatMap] = useState>({}); + const [activePassengerIndex, setActivePassengerIndex] = useState(0); + const [selectedCoach, setSelectedCoach] = useState(null); const [done, setDone] = useState<{ status: string } | null>(null); const [error, setError] = useState(null); const [dateNotice, setDateNotice] = useState(null); @@ -91,6 +104,12 @@ function ReschedulePageContent() { const saveRecent = (id: string) => setRecentStationIds((prev) => pushRecentStation(id, prev)); + const resetSeats = () => { + setPassengerSeatMap({}); + setActivePassengerIndex(0); + setSelectedCoach(null); + }; + const { data: options, isLoading: loadingOptions, error: optionsError } = useQuery({ queryKey: ["reschedule-options", ref], queryFn: () => apiClient.get(`/bookings/${ref}/reschedule`), @@ -110,7 +129,7 @@ function ReschedulePageContent() { setOriginId(leg.originStationId ?? ""); setDestinationId(leg.destinationStationId ?? ""); setSchedule(null); - setSeatIds([]); + resetSeats(); setSearched(null); }, [leg?.leg, leg?.scheduleId]); @@ -163,7 +182,7 @@ function ReschedulePageContent() { setDate(undefined); setSearched(null); setSchedule(null); - setSeatIds([]); + resetSeats(); setDateNotice("No trains run this route on that date — please pick another."); } }, [date, disabledDates]); @@ -175,7 +194,7 @@ function ReschedulePageContent() { setDate(undefined); setSearched(null); setSchedule(null); - setSeatIds([]); + resetSeats(); } }, [noRouteForPair, date]); @@ -208,8 +227,62 @@ function ReschedulePageContent() { enabled: !!scheduleId && !!leg, }); - const quoteBody = leg && scheduleId && seatIds.length === leg.seatCount - ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: seatIds } + const coaches: any[] = useMemo(() => seatMap?.coaches ?? [], [seatMap]); + + // Expand the first coach automatically — with one coach of the booked class on most trains, + // making the user open it before any seat is visible is a click for nothing. Fires once per + // schedule: keying it on `selectedCoach` instead would re-open the coach the moment the user + // collapsed it, since collapsing sets selectedCoach back to null. + const autoExpandedFor = useRef(null); + useEffect(() => { + if (!scheduleId || coaches.length === 0) return; + if (autoExpandedFor.current === scheduleId) return; + autoExpandedFor.current = scheduleId; + setSelectedCoach(coaches[0].id); + }, [scheduleId, coaches]); + + // newSeatIds must line up with the leg's BookingSeats, which the API orders by passenger + // name — the same order `passengerNames` arrives in. Indexing by passenger builds that + // order by construction, so there is nothing for a click sequence to get wrong. + const orderedSeatIds = useMemo( + () => Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => passengerSeatMap[i]).filter(Boolean) as string[], + [passengerSeatMap, leg?.seatCount], + ); + const allSeatsChosen = !!leg && orderedSeatIds.length === leg.seatCount; + + const isSeatSelected = (seatId: string) => passengerSeatMap[activePassengerIndex] === seatId; + const isSeatAssignedToOther = (seatId: string) => + Object.entries(passengerSeatMap).some( + ([idx, sid]) => Number(idx) !== activePassengerIndex && sid === seatId, + ); + + const handleSeatToggle = (seatId: string) => { + if (isSeatAssignedToOther(seatId)) return; + setPassengerSeatMap((prev) => { + const next = { ...prev }; + if (next[activePassengerIndex] === seatId) { + delete next[activePassengerIndex]; + return next; + } + next[activePassengerIndex] = seatId; + // Move to the next passenger still without a seat so a multi-passenger leg can be + // filled by clicking straight down the coach. + const total = leg?.seatCount ?? 1; + const nextUnassigned = Array.from({ length: total }, (_, i) => i).find((i) => !next[i]); + if (nextUnassigned !== undefined) setActivePassengerIndex(nextUnassigned); + return next; + }); + }; + + // No skipping ahead of a passenger who still needs a seat — same rule as /booking/seats. + const firstUnassignedIndex = Array.from({ length: leg?.seatCount ?? 0 }, (_, i) => i).find( + (i) => !passengerSeatMap[i], + ); + const maxSelectableIndex = + firstUnassignedIndex === undefined ? (leg?.seatCount ?? 1) - 1 : firstUnassignedIndex; + + const quoteBody = leg && scheduleId && allSeatsChosen + ? { leg: leg.leg, newScheduleId: scheduleId, newOriginStationId: originId, newDestinationStationId: destinationId, newSeatIds: orderedSeatIds } : null; const { data: quote, isFetching: quoting } = useQuery({ queryKey: ["reschedule-quote", ref, quoteBody], @@ -224,7 +297,7 @@ function ReschedulePageContent() { originStationId: originId, destinationStationId: destinationId, journeyDirection, - passengers: seatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })), + passengers: orderedSeatIds.map((seatId, i) => ({ passengerId: `reschedule-${ref}-${i}`, seatId })), }); return apiClient.post(`/bookings/${ref}/reschedule`, { ...quoteBody, holdId: hold.holdId || hold.id }); }, @@ -235,14 +308,6 @@ function ReschedulePageContent() { onError: (e: any) => setError(e?.response?.data?.message || e?.message || "Could not reschedule"), }); - const toggleSeat = (id: string) => { - setSeatIds((prev) => { - if (prev.includes(id)) return prev.filter((s) => s !== id); - if (prev.length >= (leg?.seatCount ?? 1)) return [...prev.slice(1), id]; - return [...prev, id]; - }); - }; - const stationName = (id: string | null) => stations.find((s) => s.id === id)?.name ?? id ?? "—"; if (!ref) return

Missing booking reference.

; @@ -283,11 +348,133 @@ function ReschedulePageContent() { } const routeLocked = !leg.policy?.routeChangeAllowed; + + // Short label/value pairs rather than prose — the rules are scanned, not read. + const fareRules = leg.policy + ? [ + { + label: "Change fee", + value: + leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 + ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` + : "Free", + }, + { label: "Route change", value: leg.policy.routeChangeAllowed ? "Allowed" : "Not permitted" }, + { + label: "Same-day change", + value: !leg.policy.sameDayAllowed + ? "Not permitted" + : leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 + ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` + : "Free", + }, + { label: "Changes close", value: `${leg.policy.cutoffMinutes} min before departure` }, + { label: "Higher new fare", value: "Payable" }, + { label: "Lower new fare", value: "Not refunded" }, + ] + : []; + const canSearch = !!date && !!originId && !!destinationId && originId !== destinationId && !noRouteForPair; + // Mirrors the booking flow's FareSidebar: a sticky money card that is present from the + // start and fills in as choices are made, rather than a total that appears at the end. + // Rendered twice — inline under the content on mobile, sticky beside it on desktop. + const ChangeSummary = () => ( +
+

+ Change summary +

+ +
+
Currently
+
+ {formatDepartureDay(leg.departureAt)} +
+
+ {stationName(leg.originStationId)} → {stationName(leg.destinationStationId)} +
+
+ + {schedule && ( +
+
Changing to
+ {/* Same date+time line as "Currently" above, so the two are read side by side. */} +
+ {formatDepartureDay(schedule.departureAt) ?? formatTime(schedule.departureAt)} +
+
+ {schedule.trainName || schedule.trainNumber} · arrives {formatTime(schedule.arrivalAt)} +
+
+ {stationName(originId)} → {stationName(destinationId)} +
+
+ )} + +
+ {leg.passengerNames.map((name, i) => { + const seatId = passengerSeatMap[i]; + const seat = seatId + ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === seatId) + : null; + return ( +
+ {name} + + {seat ? `Seat ${buildSeatLabel(seat)}` : "—"} + +
+ ); + })} +
+ + {!quoteBody ? ( +

+ Pick a new train and a seat for {leg.seatCount > 1 ? "every passenger" : "the passenger"} to see what this change costs. +

+ ) : quoting ? ( +
+ +
+ ) : quote ? ( +
+ + + = 0 ? "Fare difference" : "Fare difference (not refunded)"} + value={etb(Math.max(0, quote.fareDifferenceMinor))} + /> + +
+ Total due now + {etb(quote.amountDueMinor)} +
+ {quote.blockers.length > 0 && ( +
+ +
{quote.blockers.map((b) =>
{b}
)}
+
+ )} + {error &&
{error}
} + +
+ ) : null} +
+ ); + return ( - + @@ -306,12 +493,24 @@ function ReschedulePageContent() { )} - {leg.policy && ( -
-
Your fare rules
-
Change fee: {leg.policy.feePercent > 0 || leg.policy.feeMinMinor > 0 ? `${leg.policy.feePercent}% of fare (min ${etb(leg.policy.feeMinMinor)})` : "Free"}. A higher new fare is payable; a lower one is not refunded.
-
Route change: {leg.policy.routeChangeAllowed ? "allowed" : "not permitted"}. Same-day change: {leg.policy.sameDayAllowed ? (leg.policy.sameDayFeePercent > 0 || leg.policy.sameDayFeeMinMinor > 0 ? `${leg.policy.sameDayFeePercent}% (min ${etb(leg.policy.sameDayFeeMinMinor)})` : "free") : "not permitted"}.
-
Changes close {leg.policy.cutoffMinutes} minutes before departure.
+
+ {/* Left column — the choices */} +
+
+ {fareRules.length > 0 && ( +
+
Your fare rules
+
    + {fareRules.map((rule) => ( +
  • + + + {rule.label}:{" "} + {rule.value} + +
  • + ))} +
)} @@ -338,7 +537,7 @@ function ReschedulePageContent() { if (s.id) saveRecent(s.id); setDateNotice(null); setSchedule(null); - setSeatIds([]); + resetSeats(); setSearched(null); }} /> @@ -354,7 +553,7 @@ function ReschedulePageContent() { if (s.id) saveRecent(s.id); setDateNotice(null); setSchedule(null); - setSeatIds([]); + resetSeats(); setSearched(null); }} /> @@ -370,7 +569,7 @@ function ReschedulePageContent() { disabled={noRouteForPair} placeholder="New date" /> -
@@ -396,7 +595,7 @@ function ReschedulePageContent() { const id = s.scheduleId || s.id; const selected = scheduleId === id; return ( - - ); - })} -
-
- ))} - {seatMap && (seatMap.coaches ?? []).length === 0 &&

No coach of your class on this train.

} -
- )} +

+ Pick {leg.seatCount} seat{leg.seatCount > 1 ? "s" : ""} ({orderedSeatIds.length}/{leg.seatCount}) +

+

+ {allSeatsChosen + ? "Every passenger has a seat." + : `Choosing a seat for ${leg.passengerNames[activePassengerIndex] ?? `Passenger ${activePassengerIndex + 1}`}.`} +

- {/* Step 4: quote + confirm */} - {quoteBody && ( -
- {quoting && } - {quote && ( - <> - - - = 0 ? "Fare difference" : "Fare difference (not refunded)"} value={etb(Math.max(0, quote.fareDifferenceMinor))} /> - -
Total due now{etb(quote.amountDueMinor)}
- {quote.blockers.length > 0 && ( -
{quote.blockers.map((b) =>
{b}
)}
- )} - {error &&
{error}
} - - + {/* Who gets which seat. The API pairs seats to passengers by position, so this + mapping is the payload — not a display convenience. */} +
+ {leg.passengerNames.map((name, i) => { + const assignedSeatId = passengerSeatMap[i]; + const assignedSeat = assignedSeatId + ? coaches.flatMap((c: any) => getValidSeatsForCoach(c)).find((s: any) => s.id === assignedSeatId) + : null; + const isActive = i === activePassengerIndex; + const isClickable = i <= maxSelectableIndex; + return ( + + ); + })} +
+ + {loadingSeats ? ( + + ) : ( + )}
)} + )} + {/* end left card */} + + {/* Mobile: the summary sits under the choices instead of beside them */} +
+ +
+ {/* end left column */} + + {/* Right column — sticky change summary (desktop only) */} +
+
+ +
+
+
); } @@ -467,11 +719,19 @@ function Row({ label, value }: { label: string; value: string }) { return
{label}{value}
; } -function Shell({ children }: { children: React.ReactNode }) { +// `wide` switches to the booking flow's two-column width and hands card styling to the +// columns themselves; the narrow single-card form still carries the loading/error states. +function Shell({ children, wide = false }: { children: React.ReactNode; wide?: boolean }) { return (
-
{children}
+ {wide ? ( +
{children}
+ ) : ( +
+ {children} +
+ )}
); diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index fa1a86524..e457c7d62 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -6,157 +6,18 @@ import { useRouter } from "next/navigation"; import { useBookingStore } from "@/lib/booking-store"; import { useQuery, useMutation } from "@tanstack/react-query"; import { apiClient } from "@/lib/api-client"; -import { useState, useEffect, useCallback, useMemo, useRef, memo } from "react"; -import { Armchair, Bed, ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; -import Image from "next/image"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { ChevronLeft, ChevronDown, Train, TrainFront, X } from "lucide-react"; import CustomModal from "@/components/CustomModal"; import { Skeleton } from "@/components/Skeleton"; import { isChild } from "@/utils/fare-utils"; - -const BED_POSITION_SUFFIX: Record = { lower: 'L', middle: 'M', upper: 'U' }; - -const buildSeatLabel = (seat: any): string => { - const base: string = seat.number || seat.label || seat.seatNumber || ''; - if (!base) return ''; - const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? '') : ''; - return suffix ? `${base}${suffix}` : base; -}; - -const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => { - const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; - const bedPosition = bed.bedPosition || ""; - const bedType = - bedPosition === "upper" - ? "Upper" - : bedPosition === "middle" - ? "Middle" - : "Lower"; - const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther; - - return ( - - ); -}); - -BedCard.displayName = "BedCard"; - -// A real berth ladder is a single fixed rail mounted at the end of the bay that a -// passenger climbs to reach every level — not a separate rung floating between each -// pair of beds. So this renders once per bay, right after the last berth card, with -// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. -const LadderConnector = memo(() => ( - -)); - -LadderConnector.displayName = "LadderConnector"; - -const SeatButton = memo( - ({ - seat, - isSelected, - isAssignedToOther, - onToggle, - isBedCoach, - bedLabel, - coachSeatClass, - }: any) => { - const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; - const bedWidth = "w-24"; - const width = isBedCoach ? bedWidth : "w-10"; - const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther; - - return ( -
- -
- ); - }, -); - -SeatButton.displayName = "SeatButton"; +import { + buildSeatLabel, + CoachSeatLayout, + getBedPosition, + getValidSeatsForCoach as getValidSeatsForCoachData, +} from "@/components/SeatMap"; export default function SeatsPage() { const router = useRouter(); @@ -701,72 +562,11 @@ export default function SeatsPage() { [filteredCoaches, selectedCoach], ); - const getBedPosition = (selectedClass: string): string | null => { - const lowerClass = selectedClass.toLowerCase(); - if (lowerClass.includes("upper")) return "upper"; - if (lowerClass.includes("middle")) return "middle"; - if (lowerClass.includes("lower")) return "lower"; - return null; - }; - - // Extracted so it can be applied to ANY coach, not just the one currently expanded — - // Auto Assign needs to look across every coach of this type, not just selectedCoachData. + // Berth-class narrowing lives in the shared SeatMap module so the booking and reschedule + // flows filter beds identically; this wrapper just binds the current leg's fare class. const getValidSeatsForCoach = useCallback( - (coachData: any): any[] => { - if (!coachData) return []; - - // If coach has rooms, extract all beds from rooms - if (coachData.rooms?.length > 0) { - const allBeds: any[] = []; - coachData.rooms.forEach((room: any) => { - if (room.beds) { - allBeds.push(...room.beds); - } - }); - - let beds = allBeds.filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ""; - return seatLabel && !seatLabel.startsWith("-"); - }); - - const isBedCoach = - coachData.seatClass?.toLowerCase().includes("bed") || - coachData.mode?.toLowerCase().includes("bed"); - - if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition( - currentSchedule.selectedSeatClass, - ); - if (selectedBedPosition) { - beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); - } - } - - return beds; - } - - // Fallback to old seat structure - let seats = (coachData.seats || []).filter((s: any) => { - const seatLabel = s.label || s.number || s.seatNumber || ""; - return seatLabel && !seatLabel.startsWith("-"); - }); - const isBedCoach = - coachData.isBedCoach === true || - seats.some((s: any) => s.bedPosition) || - coachData.seatClass?.toLowerCase().includes("bed") || - coachData.mode?.toLowerCase().includes("bed"); - - if (isBedCoach && currentSchedule?.selectedSeatClass) { - const selectedBedPosition = getBedPosition( - currentSchedule.selectedSeatClass, - ); - if (selectedBedPosition) { - seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); - } - } - - return seats; - }, + (coachData: any): any[] => + getValidSeatsForCoachData(coachData, currentSchedule?.selectedSeatClass), [currentSchedule?.selectedSeatClass], ); @@ -1369,355 +1169,6 @@ export default function SeatsPage() { } }, [seatEligibility, seatEligibleIndices, activePassengerIndex]); - const parseSeatArrangement = ( - arrangement: string | null, - seatClasses?: string[], - ): number[] => { - if (!arrangement) return [2, 2]; - - // Check if this is a bed coach based on seat classes - const isBedCoach = seatClasses?.some((sc) => - sc?.toLowerCase().includes("bed"), - ); - - if (isBedCoach) { - // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically - // We want to render them as single column, so return [1] - const parts = arrangement - .split("+") - .map((p) => parseInt(p.trim())) - .filter((n) => !isNaN(n) && n > 0); - return parts.length > 0 ? [Math.max(...parts)] : [3]; - } - - // For regular seats, parse normally (e.g., "3+2" -> [3, 2]) - const parts = arrangement - .split("+") - .map((p) => parseInt(p.trim())) - .filter((n) => !isNaN(n) && n > 0); - return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; - }; - - const renderCoachSeats = (coach: any, isBedCoach: boolean) => { - const arrangement = parseSeatArrangement( - coach.seatArrangement, - coach.seatClasses || [coach.seatClass], - ); - - if (validSeats.length === 0) { - return
No seats
; - } - - const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); - const seatClassStr = - typeof selectedCoachData?.seatClass === "string" - ? selectedCoachData.seatClass - : selectedCoachData?.seatClass?.name || ""; - - // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with - // the single ladder that actually serves the whole bay shown once at the end. - const renderBerthBay = (beds: any[], keyPrefix: string) => ( -
- {beds.map((bed: any) => ( - - ))} - {beds.length > 1 && } -
- ); - - // Two-side compartment: the left bay and right bay each get their own row (berths - // still laid out horizontally within a row), stacked one above the other and split - // by a dashed aisle divider — instead of squeezing both sides into a single row. - const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( -
-
- {leftBay.length > 0 && ( -
{renderBerthBay(leftBay, `${key}-left`)}
- )} - {leftBay.length > 0 && rightBay.length > 0 && ( -
- )} - {rightBay.length > 0 && ( -
{renderBerthBay(rightBay, `${key}-right`)}
- )} -
-
- ); - - // Bay position ordering + left/right side detection shared by both bed layouts below. - const BERTH_ORDER = ["lower", "middle", "upper"]; - const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { - if (bed.position === "LEFT") return true; - if (bed.position === "RIGHT") return false; - const leftCol = leftColByPosition[bed.bedPosition]; - return leftCol ? bed.col === leftCol : true; - }; - - // Bed coach with bed positions (Upper, Middle, Lower) - if (isBedCoach && hasBedPositionData) { - // Check if this is VIP_BED or ECONOMY_BED based on room data - const rooms = (coach as any).rooms || []; - const hasRooms = rooms.length > 0; - - if (hasRooms) { - // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) - return ( -
- {rooms.map((room: any) => { - const isVipBed = - room.category === "VIP_BED" || room.totalBeds === 4; - const isEconomyBed = - room.category === "ECONOMY_BED" || room.totalBeds === 6; - - // Sort beds by position and column - const sortedBeds = [...(room.beds || [])].sort((a, b) => { - const posOrder = { upper: 3, middle: 2, lower: 1 }; - const posA = - posOrder[a.bedPosition as keyof typeof posOrder] || 0; - const posB = - posOrder[b.bedPosition as keyof typeof posOrder] || 0; - if (posA !== posB) return posA - posB; - return (a.col || "").localeCompare(b.col || ""); - }); - - return ( -
- {/* Room Header */} -
-
-

- Room {room.roomNumber} -

-

- {room.category === "VIP_BED" - ? "VIP BED" - : room.category === "ECONOMY_BED" - ? "ECONOMY BED" - : room.category} -

-
-
- {room.totalBeds} beds -
-
- - {/* Legend */} -
-
-
- - Available - -
-
-
- - Booked - -
-
- - {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} - {isVipBed && (() => { - const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); - const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); - const isLeft = (bed: any, idx: number) => - bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; - - const leftBay = [lowerBeds, upperBeds] - .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) - .filter(Boolean); - const rightBay = [lowerBeds, upperBeds] - .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) - .filter(Boolean); - - return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); - })()} - - {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} - {isEconomyBed && (() => { - const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; - const leftBay = BERTH_ORDER - .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) - .filter(Boolean); - const rightBay = BERTH_ORDER - .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) - .filter(Boolean); - - return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); - })()} -
- ); - })} -
- ); - } - - // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), - // then pair adjacent bays into two-side compartments, same as the room-based layouts. - const seatGroups = new Map(); - - for (const seat of validSeats) { - const baseNumber = seat.seatNumber || seat.number || seat.label || ""; - if (!seatGroups.has(baseNumber)) { - seatGroups.set(baseNumber, []); - } - seatGroups.get(baseNumber)!.push(seat); - } - - const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { - const numA = parseInt(a) || 0; - const numB = parseInt(b) || 0; - return numA - numB; - }); - - const bays = sortedGroups - .map(([, beds]) => - BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), - ) - .filter((bay) => bay.length > 0); - - return ( -
- {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { - const leftBay = bays[i * 2] || []; - const rightBay = bays[i * 2 + 1] || []; - return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); - })} -
- ); - } - - // Regular seats with row/column arrangement - const rowMap = new Map(); - for (const seat of validSeats) { - if (!rowMap.has(seat.row)) { - rowMap.set(seat.row, []); - } - rowMap.get(seat.row)!.push(seat); - } - - const rows = Array.from(rowMap.entries()) - .sort(([a], [b]) => a - b) - .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); - - return ( -
- {rows.map((rowSeats: any[], rowIdx: number) => { - const groups: any[][] = []; - - // Split seats into groups based on arrangement - if (arrangement.length === 1) { - // Single group (all seats together) - groups.push(rowSeats); - } else { - // Multiple groups with aisle separation - arrangement.forEach((_groupSize, groupIdx) => { - const startIdx = arrangement - .slice(0, groupIdx) - .reduce((sum, size) => sum + size, 0); - const endIdx = arrangement - .slice(0, groupIdx + 1) - .reduce((sum, size) => sum + size, 0); - const currentGroup = rowSeats.slice(startIdx, endIdx); - if (currentGroup.length > 0) groups.push(currentGroup); - }); - } - - const rowNumber = rowSeats[0]?.row || 1; - const shouldFlipArmchair = rowNumber % 2 === 0; - const showSpacing = rowIdx % 2 === 1; - - return ( -
- {shouldFlipArmchair && ( -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => { - const seatLabel = - seat.label || seat.number || seat.seatNumber || ""; - return ( -
- {seatLabel} -
- ); - })} -
- ))} -
- )} -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => ( - - ))} -
- ))} -
- - {!shouldFlipArmchair && ( -
- {groups.map((group, gIdx) => ( -
- {group.map((seat: any) => { - const seatLabel = - seat.label || seat.number || seat.seatNumber || ""; - return ( -
- {seatLabel} -
- ); - })} -
- ))} -
- )} - - {showSpacing && ( -
- )} -
- ); - })} -
- ); - }; - if ( isRoundTrip ? !outboundSchedule || (!isPackageBooking && !inboundSchedule) || !passengers.length @@ -2414,7 +1865,14 @@ export default function SeatsPage() { No seats in this coach

) : ( - renderCoachSeats(selectedCoachData, isBedCoach) + )}
diff --git a/apps/edr-passenger-web/portal/src/components/SeatMap.tsx b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx new file mode 100644 index 000000000..94fbe7c98 --- /dev/null +++ b/apps/edr-passenger-web/portal/src/components/SeatMap.tsx @@ -0,0 +1,706 @@ +"use client"; + +import { memo } from "react"; +import Image from "next/image"; +import { Armchair, Bed } from "lucide-react"; + +/** + * The seat map shared by the booking flow (`/booking/seats`) and the reschedule flow + * (`/booking/reschedule`). Everything here is presentational: it takes a coach from + * `GET /seats/seatmap/:scheduleId` and three callbacks, and knows nothing about bookings, + * passengers, holds or fares. Both pages must render seats identically, so this is the one + * copy — extend it rather than forking a second layout. + */ + +export const BED_POSITION_SUFFIX: Record = { + lower: "L", + middle: "M", + upper: "U", +}; + +export const buildSeatLabel = (seat: any): string => { + const base: string = seat.number || seat.label || seat.seatNumber || ""; + if (!base) return ""; + const suffix = seat.bedPosition ? (BED_POSITION_SUFFIX[seat.bedPosition] ?? "") : ""; + return suffix ? `${base}${suffix}` : base; +}; + +/** "Economy Bed - Upper" → "upper". Null when the class names no berth level. */ +export const getBedPosition = (selectedClass: string): string | null => { + const lowerClass = selectedClass.toLowerCase(); + if (lowerClass.includes("upper")) return "upper"; + if (lowerClass.includes("middle")) return "middle"; + if (lowerClass.includes("lower")) return "lower"; + return null; +}; + +export const isBedCoachData = (coachData: any): boolean => + coachData?.isBedCoach === true || + coachData?.rooms?.length > 0 || + (coachData?.seats || []).some((s: any) => s.bedPosition) || + coachData?.seatClass?.toLowerCase().includes("bed") || + coachData?.mode?.toLowerCase().includes("bed"); + +/** + * Flattens a coach into the seats that are actually selectable: beds out of `rooms` when the + * coach has them, otherwise `seats`. Placeholder rows (labels starting "-") are dropped, and + * on a bed coach a berth-specific fare class narrows the list to that level. + */ +export const getValidSeatsForCoach = ( + coachData: any, + selectedSeatClass?: string | null, +): any[] => { + if (!coachData) return []; + + if (coachData.rooms?.length > 0) { + const allBeds: any[] = []; + coachData.rooms.forEach((room: any) => { + if (room.beds) allBeds.push(...room.beds); + }); + + let beds = allBeds.filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + if (isBedCoachData(coachData) && selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSeatClass); + if (selectedBedPosition) { + beds = beds.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + return beds; + } + + let seats = (coachData.seats || []).filter((s: any) => { + const seatLabel = s.label || s.number || s.seatNumber || ""; + return seatLabel && !seatLabel.startsWith("-"); + }); + + if (isBedCoachData(coachData) && selectedSeatClass) { + const selectedBedPosition = getBedPosition(selectedSeatClass); + if (selectedBedPosition) { + seats = seats.filter((s: any) => s.bedPosition === selectedBedPosition); + } + } + return seats; +}; + +/** "3+2" → [3, 2] so the aisle gap lands between the groups. Bed coaches collapse to one column. */ +export const parseSeatArrangement = ( + arrangement: string | null, + seatClasses?: (string | undefined)[], +): number[] => { + if (!arrangement) return [2, 2]; + + const isBedCoach = seatClasses?.some((sc) => sc?.toLowerCase().includes("bed")); + + if (isBedCoach) { + // For bed coaches, arrangement like "3+0" means 3 beds stacked vertically — + // render them as a single column. + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); + return parts.length > 0 ? [Math.max(...parts)] : [3]; + } + + const parts = arrangement + .split("+") + .map((p) => parseInt(p.trim())) + .filter((n) => !isNaN(n) && n > 0); + return parts.length >= 2 ? parts : parts.length === 1 ? [parts[0]] : [2, 2]; +}; + +export const BedCard = memo(({ bed, isSelected, isAssignedToOther, onToggle }: any) => { + const seatLabel = bed.label || bed.seatNumber || bed.number || "?"; + const bedPosition = bed.bedPosition || ""; + const bedType = + bedPosition === "upper" ? "Upper" : bedPosition === "middle" ? "Middle" : "Lower"; + const isDisabled = bed.status !== "AVAILABLE" || isAssignedToOther; + + return ( + + ); +}); + +BedCard.displayName = "BedCard"; + +// A real berth ladder is a single fixed rail mounted at the end of the bay that a +// passenger climbs to reach every level — not a separate rung floating between each +// pair of beds. So this renders once per bay, right after the last berth card, with +// solid rounded rails/rungs (like a real metal ladder) rather than thin decorative lines. +export const LadderConnector = memo(() => ( + +)); + +LadderConnector.displayName = "LadderConnector"; + +export const SeatButton = memo( + ({ seat, isSelected, isAssignedToOther, onToggle, isBedCoach, bedLabel, coachSeatClass }: any) => { + const seatLabel = seat.number || seat.label || seat.seatNumber || "?"; + const bedWidth = "w-24"; + const width = isBedCoach ? bedWidth : "w-10"; + const isDisabled = seat.status !== "AVAILABLE" || isAssignedToOther; + + return ( +
+ +
+ ); + }, +); + +SeatButton.displayName = "SeatButton"; + +/** Available / Selected / Booked swatches, shown above every expanded coach. */ +export function SeatLegend() { + return ( +
+ {[ + { color: "bg-green-50 border border-green-300", label: "Available" }, + { color: "bg-blue-50 border-2 border-blue-500", label: "Selected" }, + { color: "bg-red-50 border border-red-300", label: "Booked" }, + ].map(({ color, label }) => ( +
+
+ {label} +
+ ))} +
+ ); +} + +export interface CoachSeatLayoutProps { + coach: any; + isBedCoach: boolean; + /** Already filtered by `getValidSeatsForCoach` — the caller owns berth-class narrowing. */ + seats: any[]; + isSeatSelected: (seatId: string) => boolean; + isSeatAssignedToOther: (seatId: string) => boolean; + onSeatToggle: (seatId: string) => void; +} + +/** + * The seat grid for one coach. Four layouts, picked off the coach's own shape: + * room-based VIP (4 berths), room-based Economy (6 berths), room-less berth bays, and + * regular rows with the aisle gap from `seatArrangement`. + */ +export function CoachSeatLayout({ + coach, + isBedCoach, + seats: validSeats, + isSeatSelected, + isSeatAssignedToOther, + onSeatToggle, +}: CoachSeatLayoutProps) { + const arrangement = parseSeatArrangement( + coach?.seatArrangement, + coach?.seatClasses || [coach?.seatClass], + ); + + if (validSeats.length === 0) { + return
No seats
; + } + + const hasBedPositionData = validSeats.some((s: any) => s.bedPosition); + const seatClassStr = + typeof coach?.seatClass === "string" ? coach.seatClass : coach?.seatClass?.name || ""; + + // Indian-sleeper-style berth bay: Lower / Middle / Upper laid out horizontally, with + // the single ladder that actually serves the whole bay shown once at the end. + const renderBerthBay = (beds: any[], keyPrefix: string) => ( +
+ {beds.map((bed: any) => ( + + ))} + {beds.length > 1 && } +
+ ); + + // Two-side compartment: the left bay and right bay each get their own row (berths + // still laid out horizontally within a row), stacked one above the other and split + // by a dashed aisle divider — instead of squeezing both sides into a single row. + const renderCompartment = (leftBay: any[], rightBay: any[], key: string) => ( +
+
+ {leftBay.length > 0 && ( +
{renderBerthBay(leftBay, `${key}-left`)}
+ )} + {leftBay.length > 0 && rightBay.length > 0 && ( +
+ )} + {rightBay.length > 0 && ( +
{renderBerthBay(rightBay, `${key}-right`)}
+ )} +
+
+ ); + + // Bay position ordering + left/right side detection shared by both bed layouts below. + const BERTH_ORDER = ["lower", "middle", "upper"]; + const bedSideIsLeft = (bed: any, leftColByPosition: Record) => { + if (bed.position === "LEFT") return true; + if (bed.position === "RIGHT") return false; + const leftCol = leftColByPosition[bed.bedPosition]; + return leftCol ? bed.col === leftCol : true; + }; + + if (isBedCoach && hasBedPositionData) { + const rooms = (coach as any)?.rooms || []; + + if (rooms.length > 0) { + // Room-based layout (VIP_BED with 4 beds, ECONOMY_BED with 6 beds) + return ( +
+ {rooms.map((room: any) => { + const isVipBed = room.category === "VIP_BED" || room.totalBeds === 4; + const isEconomyBed = room.category === "ECONOMY_BED" || room.totalBeds === 6; + + const sortedBeds = [...(room.beds || [])].sort((a, b) => { + const posOrder = { upper: 3, middle: 2, lower: 1 }; + const posA = posOrder[a.bedPosition as keyof typeof posOrder] || 0; + const posB = posOrder[b.bedPosition as keyof typeof posOrder] || 0; + if (posA !== posB) return posA - posB; + return (a.col || "").localeCompare(b.col || ""); + }); + + return ( +
+ {/* Room Header */} +
+
+

+ Room {room.roomNumber} +

+

+ {room.category === "VIP_BED" + ? "VIP BED" + : room.category === "ECONOMY_BED" + ? "ECONOMY BED" + : room.category} +

+
+
+ {room.totalBeds} beds +
+
+ + {/* Legend */} +
+
+
+ Available +
+
+
+ Booked +
+
+ + {/* VIP BED Layout — 2-tier compartment (Lower/Upper), left + right of the aisle */} + {isVipBed && (() => { + const lowerBeds = sortedBeds.filter((b: any) => b.bedPosition === "lower"); + const upperBeds = sortedBeds.filter((b: any) => b.bedPosition === "upper"); + const isLeft = (bed: any, idx: number) => + bed.position === "LEFT" ? true : bed.position === "RIGHT" ? false : idx % 2 === 0; + + const leftBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => isLeft(b, i))) + .filter(Boolean); + const rightBay = [lowerBeds, upperBeds] + .map((arr) => arr.find((b: any, i: number) => !isLeft(b, i))) + .filter(Boolean); + + return renderCompartment(leftBay, rightBay, `${room.room_id}-vip`); + })()} + + {/* ECONOMY BED Layout — 3-tier compartment (Lower/Middle/Upper), left + right of the aisle */} + {isEconomyBed && (() => { + const leftColByPosition: Record = { lower: "A", middle: "B", upper: "C" }; + const leftBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + const rightBay = BERTH_ORDER + .map((pos) => sortedBeds.find((b: any) => b.bedPosition === pos && !bedSideIsLeft(b, leftColByPosition))) + .filter(Boolean); + + return renderCompartment(leftBay, rightBay, `${room.room_id}-eco`); + })()} +
+ ); + })} +
+ ); + } + + // Fallback: beds without room data — group into numbered bays (Lower/Middle/Upper), + // then pair adjacent bays into two-side compartments, same as the room-based layouts. + const seatGroups = new Map(); + for (const seat of validSeats) { + const baseNumber = seat.seatNumber || seat.number || seat.label || ""; + if (!seatGroups.has(baseNumber)) seatGroups.set(baseNumber, []); + seatGroups.get(baseNumber)!.push(seat); + } + + const sortedGroups = Array.from(seatGroups.entries()).sort(([a], [b]) => { + const numA = parseInt(a) || 0; + const numB = parseInt(b) || 0; + return numA - numB; + }); + + const bays = sortedGroups + .map(([, beds]) => + BERTH_ORDER.map((pos) => beds.find((seat: any) => seat.bedPosition === pos)).filter(Boolean), + ) + .filter((bay) => bay.length > 0); + + return ( +
+ {Array.from({ length: Math.ceil(bays.length / 2) }, (_, i) => { + const leftBay = bays[i * 2] || []; + const rightBay = bays[i * 2 + 1] || []; + return renderCompartment(leftBay, rightBay, `bay-compartment-${i}`); + })} +
+ ); + } + + // Regular seats with row/column arrangement + const rowMap = new Map(); + for (const seat of validSeats) { + if (!rowMap.has(seat.row)) rowMap.set(seat.row, []); + rowMap.get(seat.row)!.push(seat); + } + + const rows = Array.from(rowMap.entries()) + .sort(([a], [b]) => a - b) + .map(([_, seats]) => seats.sort((a, b) => a.col.localeCompare(b.col))); + + const renderSeatNumberStrip = (groups: any[][], keyPrefix: string) => ( +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( +
+ {seat.label || seat.number || seat.seatNumber || ""} +
+ ))} +
+ ))} +
+ ); + + return ( +
+ {rows.map((rowSeats: any[], rowIdx: number) => { + const groups: any[][] = []; + + if (arrangement.length === 1) { + groups.push(rowSeats); + } else { + arrangement.forEach((_groupSize, groupIdx) => { + const startIdx = arrangement.slice(0, groupIdx).reduce((sum, size) => sum + size, 0); + const endIdx = arrangement.slice(0, groupIdx + 1).reduce((sum, size) => sum + size, 0); + const currentGroup = rowSeats.slice(startIdx, endIdx); + if (currentGroup.length > 0) groups.push(currentGroup); + }); + } + + const rowNumber = rowSeats[0]?.row || 1; + const shouldFlipArmchair = rowNumber % 2 === 0; + const showSpacing = rowIdx % 2 === 1; + + return ( +
+ {shouldFlipArmchair && renderSeatNumberStrip(groups, `before-${rowNumber}`)} + +
+ {groups.map((group, gIdx) => ( +
+ {group.map((seat: any) => ( + + ))} +
+ ))} +
+ + {!shouldFlipArmchair && renderSeatNumberStrip(groups, `after-${rowNumber}`)} + + {showSpacing &&
} +
+ ); + })} +
+ ); +} + +export interface SeatMapProps { + /** Coaches straight off `GET /seats/seatmap/:scheduleId`. */ + coaches: any[]; + selectedCoachId: string | null; + onSelectCoach: (coachId: string | null) => void; + /** Berth-level fare class, e.g. "Economy Bed - Upper". Narrows a bed coach to one level. */ + selectedSeatClass?: string | null; + isSeatSelected: (seatId: string) => boolean; + isSeatAssignedToOther: (seatId: string) => boolean; + onSeatToggle: (seatId: string) => void; + emptyLabel?: string; +} + +/** + * Coach accordion + legend + seat grid, styled as the train itself: coupling joints between + * cars, a brand stripe top and bottom, and per-coach availability bars. + */ +export default function SeatMap({ + coaches, + selectedCoachId, + onSelectCoach, + selectedSeatClass, + isSeatSelected, + isSeatAssignedToOther, + onSeatToggle, + emptyLabel = "No coach of your class on this train.", +}: SeatMapProps) { + if (!coaches || coaches.length === 0) { + return

{emptyLabel}

; + } + + return ( +
+ {coaches.map((coach: any, index: number) => { + const coachSeats = getValidSeatsForCoach(coach, selectedSeatClass); + const available = coachSeats.filter((s: any) => s.status === "AVAILABLE").length; + const total = coachSeats.length; + const isExpanded = selectedCoachId === coach.id; + const isBedCoach = isBedCoachData(coach); + const coachLabel = coach.label || coach.name || coach.coachNumber || `Coach ${index + 1}`; + + return ( +
+ {/* Coupling joint */} +
+
+
+
+
+
+
+ + {/* Coach car */} +
+ {/* Top colour stripe — brand rail */} +
+ + + + {/* Expanded seat map */} + {isExpanded && ( +
+ +
+
+ {coachSeats.length === 0 ? ( +

No seats in this coach

+ ) : ( + + )} +
+
+
+ )} + + {/* Bottom colour stripe */} +
+
+
+ ); + })} +
+ ); +} + +function ChevronDownIcon({ isExpanded }: { isExpanded: boolean }) { + return ( + + + + ); +}