refactor(planner): share the transport booking form between desktop and phone

The transport dialog and the phone transport sheet keep their markup and run on useTransportForm: the route rows, how a saved booking or an import fills them, the day options and the request body come from transportEndpoints. Where the two shells differ today (unknown type fallback, stopover days, which rows are reset, train anchors, empty metadata, the link field) the shared code takes an option. The phone's upload helper moves to bookingFormModel next to the traveller checks both booking forms use.
This commit is contained in:
Maurice
2026-10-09 10:28:39 +02:00
parent 131d57b755
commit 203e425e7e
10 changed files with 1829 additions and 1035 deletions
+38 -541
View File
@@ -1,21 +1,19 @@
import { useState, useEffect, useRef, useMemo, type ReactNode } from 'react'
import { useParams } from 'react-router'
import { Plane, Train, Car, Ship, Bus, Sailboat, CableCar, Bike, CarTaxiFront, Route, X, Trash2, ChevronUp, ChevronDown, CalendarDays, type LucideIcon } from 'lucide-react'
import { X, Trash2, ChevronUp, ChevronDown, CalendarDays } from 'lucide-react'
import ConfirmDialog from '../shared/ConfirmDialog'
import CustomSelect from '../shared/CustomSelect'
import { BookingCodeInput } from '../shared/BookingCode'
import CustomTimePicker from '../shared/CustomTimePicker'
import { Tooltip } from '../shared/Tooltip'
import AirportSelect, { type Airport } from './AirportSelect'
import LocationSelect, { type LocationPoint } from './LocationSelect'
import AirportSelect from './AirportSelect'
import LocationSelect from './LocationSelect'
import { toLocationPicks } from './locationPicks'
import { useTranslation } from '../../i18n'
import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime, resolveDayId } from '../../utils/formatters'
import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import { parseReservationMetadata, orderedEndpoints, stripAirportCode, usesStationRoute } from '../../utils/flightLegs'
import type { Day, Place, Accommodation, Reservation, TripFile, BudgetItem, AssignmentsMap } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import { BookingLinkAndFiles } from './BookingLinkAndFiles'
import { importedPriceEntry } from './importedPrice'
@@ -30,132 +28,15 @@ import { INPUT, TEXTAREA, READONLY_BOX, LABEL, GRID_2, GRID_3, PANEL, SEARCH_ON_
import { fs, Eyebrow, type StatusTone } from './bookings/bookingParts'
import { typeInfo } from './bookings/bookingsModel'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
interface EndpointPick {
airport?: Airport
location?: LocationPoint
}
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
code: a.iata,
lat: a.lat, lng: a.lng,
timezone: a.tz,
local_date: date,
local_time: time,
}
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return {
role, sequence,
name: l.name,
code: null,
lat: l.lat, lng: l.lng,
timezone: null,
local_date: date,
local_time: time,
}
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return {
iata: e.code, icao: null,
name: e.name, city: stripAirportCode(e.name),
country: '',
lat: e.lat, lng: e.lng,
tz: e.timezone || '',
}
}
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
// A flight is an ordered list of airports. The origin has only a departure, the
// destination only an arrival, and each intermediate stop has both — plus the
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
// single-leg flight is just two waypoints, so it persists exactly as before.
interface WaypointForm {
airport: Airport | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
airline: string
flight_number: string
seat: string
// Booking reference of the leg leaving this waypoint: airlines often issue one
// per flight rather than one per booking (#1943). Empty means the booking's own.
confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// ── Multi-leg train stations ───────────────────────────────────────────────
// A train mirrors the flight route model, but its waypoints are STATIONS
// (location search, no timezone) and each leg carries a train number + platform
// instead of an airline + flight number. N stations = N-1 legs.
interface StationWaypointForm {
location: LocationPoint | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
train_number: string
platform: string
seat: string
confirmation_number: string
}
// ── Car stops along the drive ──────────────────────────────────────────────
//
// A rental is one booking with one pick-up and one return, so — unlike a flight or a
// train — the stops in between are not legs of their own: they are places the drive
// passes through, each with an optional time the driver plans to be there. They persist
// as the same `role: 'stop'` endpoints every other transport type already uses.
interface CarStopForm {
location: LocationPoint | null
time: string
}
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
const TYPE_OPTIONS: { value: TransportType; labelKey: string; Icon: LucideIcon }[] = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront },
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
{ value: 'cable_car', labelKey: 'reservations.type.cable_car', Icon: CableCar },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
]
import {
EMPTY_TRANSPORT_FIELDS, TRANSPORT_TYPE_OPTIONS as TYPE_OPTIONS, emptyCarStop, emptyStationWaypoint, emptyWaypoint,
transportDayOptions, type StationWaypointForm, type WaypointForm,
} from './transportEndpoints'
import { useTransportForm } from './useTransportForm'
import { travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel'
const defaultForm = {
title: '',
type: 'flight' as TransportType,
status: 'pending' as 'pending' | 'confirmed',
start_day_id: '' as string | number,
end_day_id: '' as string | number,
departure_time: '',
arrival_time: '',
confirmation_number: '',
notes: '',
...EMPTY_TRANSPORT_FIELDS,
url: '',
meta_airline: '',
meta_flight_number: '',
@@ -208,433 +89,66 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
// Set right before submitting when the user clicked "create/edit expense", so
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({ ...defaultForm })
// Trains and cruises share the station form: a row of stops, each with its own
// arrival and departure (#1807).
const stationRoute = usesStationRoute(form.type)
// Manual vs Automated (public transit search) creation mode (#1065).
const [automated, setAutomated] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const {
form, set, stationRoute, automated, setAutomated, fromPick, setFromPick, toPick, setToPick,
waypoints, setWaypoints, trainWaypoints, setTrainWaypoints, carStops, setCarStops, moveCarStop,
pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler, isSaving, setIsSaving,
seed, payload, writesFlightLegs, writesTrainLegs,
} = useTransportForm(defaultForm)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
// Flight route as an ordered list of airports (origin .. stops .. destination).
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
// Train route as an ordered list of stations (origin .. stops .. destination).
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
// A car keeps its pick-up and return as the frame of the rental and gains the stops
// in between (#1797): one booking, one continuous drive, several places along it.
const [carStops, setCarStops] = useState<CarStopForm[]>([])
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
const moveCarStop = (index: number, delta: number): void => {
setCarStops(prev => {
const to = index + delta
if (to < 0 || to >= prev.length) return prev
const next = [...prev]
;[next[index], next[to]] = [next[to], next[index]]
return next
})
}
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
// Travelers assigned to this booking (#1517) — seeded on open, persisted after save.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isOpen) return
setTravelerIds(new Set((reservation?.travelers || []).map(tv => tv.user_id)))
setTravelerIds(travelerIdsOf(reservation))
// Edit uses the saved `reservation`; a review-import populates from `prefill`.
// Either way the init reads the same fields — `reservation` still decides
// edit-vs-create at submit time.
const src = (reservation ?? prefill) as Reservation | null
if (src) setAutomated(initialAutomated)
setAutomated(initialAutomated)
// On a review-import, seed the booking's Files with the parsed source document.
setPendingFiles(!reservation && prefill?._sourceFiles ? prefill._sourceFiles : [])
if (src) {
const meta = typeof src.metadata === 'string'
? JSON.parse(src.metadata || '{}')
: (src.metadata || {})
const eps = src.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
// 'transport_other', not 'flight': an import whose type could not be read has
// to arrive as something the user corrects, and a wrong flight looks right
// enough to be saved unnoticed (#2076).
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type)
? src.type as TransportType
: 'transport_other'
setForm({
title: src.title || '',
type,
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
// from the parsed pick-up/return date so the date isn't lost on save.
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
confirmation_number: src.confirmation_number || '',
notes: src.notes || '',
url: src.url || '',
// An import whose type could not be read opens as 'transport_other', not
// 'flight': it has to arrive as something the user corrects, and a wrong flight
// looks right enough to be saved unnoticed (#2076).
seed(src, days, {
isEdit: !!reservation,
fallbackType: 'transport_other',
dayId: selectedDayId ?? '',
stopDaysFromEndpoints: false,
resetHiddenRoutes: false,
extraFields: (s, meta) => ({
url: s.url || '',
meta_airline: meta.airline || '',
meta_flight_number: meta.flight_number || '',
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
})
// Only an import prefill carries a per-endpoint local_date without a day_id. On an
// edit the saved day wins: local_date is denormalised and can lag behind after a
// day drag, insertDay or a trip-date shift, so resolving from it would silently
// move the booking to another day on a plain re-save.
const endpointDayId = (ep?: { local_date?: string | null } | null) =>
reservation ? '' : resolveDayId(days, ep?.local_date)
if (type === 'flight') {
const orderedEps = orderedEndpoints(src)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: WaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
const legOut = metaLegs[i] // leg departing FROM waypoint i
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
return {
airport: airportFromEndpoint(ep),
// An import prefill gives each endpoint its own local_date but no day_id, so
// resolve the day from that date — otherwise the review's per-leg day
// selectors render empty even though the time, read from the same
// endpoint, is filled.
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// No fallback to src.confirmation_number: that one belongs to the
// whole booking and stays in the form's own field, otherwise a
// plain re-save would copy it onto the first leg.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
// Legacy flight with no (or partial) endpoints — seed two waypoints.
const dep = emptyWaypoint(endpointDayId(from) || (src.day_id ?? ''))
dep.airport = airportFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? ''))
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setWaypoints(wps)
} else if (usesStationRoute(type)) {
// Mirror the flight seeding with stations + per-leg train fields. A
// current single-leg train (2 endpoints, no metadata.legs) round-trips
// through the >=2 branch: the flat train_number/platform/seat land on
// the first station, dep/arr day+time from src.day_id/end_day_id.
const orderedEps = orderedEndpoints(src)
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: StationWaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1]
const legOut = metaLegs[i]
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
return {
location: locationFromEndpoint(ep),
// See the flight branch: resolve each station's day from its endpoint local_date
// so an import prefill doesn't leave the per-leg day selectors empty.
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : '')),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : '')),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// See the flight branch: the booking's own reference stays out of the legs.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
const dep = emptyStationWaypoint(endpointDayId(from) || (src.day_id ?? ''))
dep.location = locationFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.train_number = meta.train_number ?? ''
dep.platform = meta.platform ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyStationWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? ''))
arr.location = locationFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setTrainWaypoints(wps)
setFromPick({})
setToPick({})
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
setCarStops(
src.type === 'car'
? (src.endpoints ?? [])
.filter(e => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
)
}
} else {
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
setAutomated(initialAutomated)
setFromPick({})
setToPick({})
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(selectedDayId ?? ''), emptyStationWaypoint(selectedDayId ?? '')])
setCarStops([])
}
}),
})
}, [isOpen, reservation, prefill, selectedDayId, budgetItems])
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const toggleTraveler = (id: number) => setTravelerIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const startDay = days.find(d => d.id === Number(form.start_day_id))
const endDay = days.find(d => d.id === Number(form.end_day_id))
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null
return day?.date ? `${day.date}T${time}` : time
}
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
// Flight route as an ordered list of airports (origin .. stops .. destination).
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
const firstWp = flightWps[0]
const lastWp = flightWps[flightWps.length - 1]
// Train route: the first/last waypoint drive the span + flat metadata
// (day/time/train number are entered independently of geocoding, exactly
// like the old form fields, so a train with no map-picked station still
// saves its day/time/train number). Only geocoded stations become map
// endpoints + legs, mirroring the old "push only if the location is set".
const trainWps = stationRoute ? trainWaypoints : []
const firstTrainWp = trainWps[0]
const lastTrainWp = trainWps[trainWps.length - 1]
const trainStations = stationRoute ? trainWaypoints.filter(w => w.location) : []
// Per-leg day-plan positions are owned by the day planner, not this form — keep
// them when re-saving so editing a flight doesn't reset where its legs sit.
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
const metadata: Record<string, any> = {}
if (form.type === 'flight') {
// Top-level keys mirror the first/last leg so legacy readers keep working.
if (firstWp?.airline) metadata.airline = firstWp.airline
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
if (firstWp?.airport) {
metadata.departure_airport = firstWp.airport.iata
metadata.departure_timezone = firstWp.airport.tz
}
if (lastWp?.airport) {
metadata.arrival_airport = lastWp.airport.iata
metadata.arrival_timezone = lastWp.airport.tz
}
// Per-leg detail only for true multi-leg flights — a single-leg flight
// keeps the exact same (flat) metadata it had before this feature.
if (flightWps.length > 2) {
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
const next = flightWps[i + 1]
return {
from: w.airport!.iata,
to: next.airport!.iata,
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
if (firstWp?.seat) metadata.seat = firstWp.seat
} else if (stationRoute) {
// Flat keys mirror the first leg so legacy readers keep working; a
// 2-station train emits exactly {train_number?,platform?,seat?} — the
// same shape it saved before this feature.
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
// Per-leg detail only for a true multi-leg train (>2 geocoded stations);
// a simple train keeps the same flat metadata it saved before.
if (trainStations.length > 2) {
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
const next = trainStations[i + 1]
return {
from: w.location!.name,
to: next.location!.name,
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
}
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints,
// neither of which this form shows or edits — so re-saving must not wipe
// them. They're kept only while from/to are unchanged: picking a different
// origin or destination invalidates the stored connection.
const prevMeta = reservation ? parseReservationMetadata(reservation) : {}
const prevEndpointsAll = reservation?.endpoints || []
const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from')
const prevTo = prevEndpointsAll.find(ep => ep.role === 'to')
const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6
const keepTransit = !!(prevMeta.transit && form.type !== 'flight' &&
prevFrom && prevTo && fromPick.location && toPick.location &&
near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) &&
near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng))
if (keepTransit) metadata.transit = prevMeta.transit
// A joined AirTrail import (#1535) records every source flight id in
// metadata.airtrail_ids so the picker doesn't offer those legs again —
// an edit in this form must not drop that linkage.
if (Array.isArray(prevMeta.airtrail_ids)) metadata.airtrail_ids = prevMeta.airtrail_ids
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (form.type === 'flight') {
flightWps.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === flightWps.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
})
} else if (stationRoute) {
trainStations.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === trainStations.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
// The destination date falls back to the departure day (as the old flat
// path did via `endDay ?? startDay`) when the arrival day is left blank.
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints = form.type === 'car'
? carStops
.filter(s => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: []
const stops = keepTransit && form.type !== 'car'
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null,
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
carEndpoints.forEach(e => endpoints.push(e))
const stopCount = stops.length + carEndpoints.length
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
}
// Flights and trains derive their span from the first/last waypoint; other
// transports keep using the single departure/arrival form fields unchanged.
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
const payload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.type === 'flight' ? flightDepDay : stationRoute ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : stationRoute ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
reservation_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
: stationRoute
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
: buildTime(startDay, form.departure_time),
reservation_end_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
: stationRoute
// Fall back to the departure day so a same-day train (arrival day left
// blank) still gets its date, matching the non-flight `endDay ?? startDay`.
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
url: form.url || null,
// An empty object, not null: null clears the column outright, and that
// took the mirrored booking price with it on every edit of a type that
// fills no metadata of its own — restaurant, event, tour, parking, other,
// a hotel without check-in times (#2233). An object still clears what the
// form dropped, and lets the server carry the price across.
metadata,
endpoints,
needs_review: false,
}
// Imported booking → auto-create the linked cost from the parsed price (what the
// old direct import did). Only on create (not edit) and only when there's a price.
if (!reservation && prefill && isBudgetEnabled) {
const entry = importedPriceEntry(prefill.metadata, form.type)
if (entry) (payload as Record<string, unknown>).create_budget_entry = entry
}
const saved = await onSave(payload)
const saved = await onSave(payload(days, {
reservation, prefill, budgetEnabled: isBudgetEnabled, anchorOnStations: false, emptyMetadataAsNull: false, url: form.url,
}))
// Persist the traveler assignment once we have the reservation id (create → save
// result, edit → existing reservation), and only when it actually changed (#1517).
const savedId = saved?.id ?? reservation?.id
if (savedId && tripId) {
const original = (reservation?.travelers || []).map(tv => tv.user_id)
const nextIds = [...travelerIds]
const changed = original.length !== nextIds.length || nextIds.some(id => !original.includes(id))
const { changed, nextIds } = travelersChanged(reservation, travelerIds)
if (changed) {
try { await setReservationTravelers(tripId, savedId, nextIds) } catch { toast.error(t('common.unknownError')) }
}
}
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
await uploadBookingFiles(onFileUpload, saved.id, pendingFiles, form.title)
}
// The user asked to create/edit the linked expense — open the Costs editor
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
@@ -694,24 +208,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
)
: []
const dayOptions = [
{ value: '', label: '—' },
...days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return {
value: d.id,
label: d.title || t('dayplan.dayN', { n: d.day_number }),
badge: dateBadge ?? dayBadge,
}
}),
]
// The per-leg booking code is only offered where handleSubmit actually writes
// metadata.legs: the SAME condition, over the waypoints that become endpoints,
// not over the raw rows. Anywhere else the value would vanish on save (#1943).
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
const dayOptions = transportDayOptions(days, t, locale)
const titleId = 'transport-editor-title'
// The head band takes the colour of the card the booking will sit on.
@@ -0,0 +1,53 @@
// FE-PLANNER-BOOKINGFORM-001 to -004: the travellers and picked files of the
// transport and booking forms, desktop and phone alike.
import { describe, expect, it, vi } from 'vitest';
import { buildReservation } from '../../../tests/helpers/factories';
import type { Reservation } from '../../types';
import { toggledTraveler, travelerIdsOf, travelersChanged, uploadBookingFiles } from './bookingFormModel';
const withTravelers = (...ids: number[]) =>
buildReservation({ travelers: ids.map((user_id) => ({ user_id, username: `u${user_id}` })) } as Partial<Reservation>);
describe('bookingFormModel', () => {
it('FE-PLANNER-BOOKINGFORM-001: a booking opens with its travellers, a new one with nobody', () => {
expect([...travelerIdsOf(withTravelers(3, 1))]).toEqual([3, 1]);
expect(travelerIdsOf(null).size).toBe(0);
expect(travelerIdsOf(buildReservation()).size).toBe(0);
});
it('FE-PLANNER-BOOKINGFORM-002: toggling adds a missing traveller and drops a picked one, on a copy', () => {
const prev = new Set([1]);
const added = toggledTraveler(prev, 2);
expect([...added]).toEqual([1, 2]);
expect([...toggledTraveler(added, 1)]).toEqual([2]);
expect([...prev]).toEqual([1]);
});
it('FE-PLANNER-BOOKINGFORM-003: only a real change counts, whatever the order', () => {
expect(travelersChanged(withTravelers(1, 2), new Set([2, 1]))).toEqual({ changed: false, nextIds: [2, 1] });
expect(travelersChanged(withTravelers(1, 2), new Set([1]))).toEqual({ changed: true, nextIds: [1] });
expect(travelersChanged(withTravelers(1), new Set([2]))).toEqual({ changed: true, nextIds: [2] });
expect(travelersChanged(null, new Set())).toEqual({ changed: false, nextIds: [] });
expect(travelersChanged(undefined, new Set([4]))).toEqual({ changed: true, nextIds: [4] });
});
it('FE-PLANNER-BOOKINGFORM-004: the picked files go up one after the other against the saved booking', async () => {
const order: string[] = [];
const upload = vi.fn(async (fd: FormData) => {
order.push(`start:${(fd.get('file') as File).name}`);
await Promise.resolve();
order.push(`end:${(fd.get('file') as File).name}`);
});
const files = [new File(['a'], 'a.pdf'), new File(['b'], 'b.pdf')];
await uploadBookingFiles(upload, 42, files, 'Hotel stay');
expect(order).toEqual(['start:a.pdf', 'end:a.pdf', 'start:b.pdf', 'end:b.pdf']);
const fd = upload.mock.calls[1][0];
expect(fd.get('reservation_id')).toBe('42');
expect(fd.get('description')).toBe('Hotel stay');
upload.mockClear();
await uploadBookingFiles(upload, 42, [], 'none');
expect(upload).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,47 @@
import type { Reservation } from '../../types';
/**
* What the transport and the booking forms share on both shells: the travellers
* picked for a booking (#1517) and the files picked before it was saved.
*/
/** The travellers a booking opens with. */
export function travelerIdsOf(reservation: Pick<Reservation, 'travelers'> | null | undefined): Set<number> {
return new Set((reservation?.travelers || []).map((tv) => tv.user_id));
}
/** The picked travellers with one more or one less. */
export function toggledTraveler(prev: Set<number>, id: number): Set<number> {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
}
/** Whether the picked travellers differ from the ones the booking had, so the save only writes a real change. */
export function travelersChanged(reservation: Pick<Reservation, 'travelers'> | null | undefined, picked: Set<number>) {
const original = (reservation?.travelers || []).map((tv) => tv.user_id);
const nextIds = [...picked];
return { changed: original.length !== nextIds.length || nextIds.some((id) => !original.includes(id)), nextIds };
}
/**
* Uploads the files picked in a booking form against the saved booking, one after
* the other, each described with the booking's title. Callers run it after both a
* create and an edit: the form only holds files the user just picked, so nothing is
* uploaded twice, and skipping the edit dropped them without a word (#2534).
*/
export async function uploadBookingFiles(
upload: (fd: FormData) => Promise<unknown>,
savedId: number,
files: File[],
description: string
): Promise<void> {
for (const file of files) {
const fd = new FormData();
fd.append('file', file);
fd.append('reservation_id', String(savedId));
fd.append('description', description);
await upload(fd);
}
}
@@ -0,0 +1,599 @@
// FE-PLANNER-TRANSPORTEP-001 to -021: the transport booking form both the desktop
// TransportModal and the phone's transport sheet save through.
import { describe, expect, it } from 'vitest';
import { buildDay, buildReservation } from '../../../tests/helpers/factories';
import type { Reservation, ReservationEndpoint } from '../../types';
import type { Airport } from './AirportSelect';
import type { BookingReviewDraft } from './parsedItemToDraft';
import {
EMPTY_TRANSPORT_FIELDS,
TRANSPORT_TYPE_OPTIONS,
airportFromEndpoint,
blankTransportRoute,
buildTransportPayload,
emptyStationWaypoint,
emptyWaypoint,
endpointFromAirport,
endpointFromLocation,
locationFromEndpoint,
readTransportMeta,
seedTransportRoute,
transportDayOptions,
transportFieldsFrom,
transportTypeOf,
type RouteSeedOptions,
type TransportDraft,
type TransportPayloadOptions,
} from './transportEndpoints';
const d1 = buildDay({ id: 1, date: '2026-05-01', day_number: 1 });
const d2 = buildDay({ id: 2, date: '2026-05-02', day_number: 2 });
const d3 = buildDay({ id: 3, date: '2026-05-03', day_number: 3, title: 'Coast' });
const days = [d1, d2, d3];
function ep(
role: 'from' | 'to' | 'stop',
sequence: number,
extra: Partial<ReservationEndpoint> = {}
): ReservationEndpoint {
return {
role,
sequence,
name: `${role}-${sequence}`,
code: null,
lat: 10 + sequence,
lng: 20 + sequence,
timezone: null,
local_time: null,
local_date: null,
...extra,
};
}
const airport = (iata: string, city = 'City'): Airport => ({
iata,
icao: null,
name: `${iata} Airport`,
city,
country: 'X',
lat: 1,
lng: 2,
tz: 'Europe/Berlin',
});
const DESKTOP: RouteSeedOptions = { isEdit: true, stopDaysFromEndpoints: false, resetHiddenRoutes: false };
const PHONE: RouteSeedOptions = { isEdit: true, stopDaysFromEndpoints: true, resetHiddenRoutes: true };
function draft(overrides: Partial<TransportDraft> = {}): TransportDraft {
return {
form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Trip' },
waypoints: [emptyWaypoint(), emptyWaypoint()],
trainWaypoints: [emptyStationWaypoint(), emptyStationWaypoint()],
fromPick: {},
toPick: {},
carStops: [],
...overrides,
};
}
const DESKTOP_SAVE: TransportPayloadOptions = {
reservation: null,
prefill: null,
budgetEnabled: false,
anchorOnStations: false,
emptyMetadataAsNull: false,
url: '',
};
const PHONE_SAVE: TransportPayloadOptions = {
reservation: null,
prefill: null,
budgetEnabled: false,
anchorOnStations: true,
emptyMetadataAsNull: true,
};
describe('transport types and endpoints', () => {
it('FE-PLANNER-TRANSPORTEP-001: the picker offers every type but transit, and an unknown type falls back', () => {
expect(TRANSPORT_TYPE_OPTIONS.map((o) => o.value)).not.toContain('transit');
expect(TRANSPORT_TYPE_OPTIONS).toHaveLength(10);
expect(transportTypeOf('ferry', 'flight')).toBe('ferry');
expect(transportTypeOf('hotel', 'flight')).toBe('flight');
expect(transportTypeOf('hotel', 'transport_other')).toBe('transport_other');
});
it('FE-PLANNER-TRANSPORTEP-002: an airport and a place become endpoints and back', () => {
const a = endpointFromAirport(airport('MUC', 'Munich'), 'from', 0, '2026-05-01', '09:00');
expect(a).toEqual({
role: 'from',
sequence: 0,
name: 'Munich (MUC)',
code: 'MUC',
lat: 1,
lng: 2,
timezone: 'Europe/Berlin',
local_date: '2026-05-01',
local_time: '09:00',
});
expect(endpointFromAirport(airport('XXX', ''), 'to', 1, null, null).name).toBe('XXX Airport');
expect(endpointFromLocation({ name: 'Station', lat: 3, lng: 4 }, 'stop', 2, null, '10:00')).toEqual({
role: 'stop',
sequence: 2,
name: 'Station',
code: null,
lat: 3,
lng: 4,
timezone: null,
local_date: null,
local_time: '10:00',
});
expect(airportFromEndpoint(ep('from', 0, { code: 'MUC', name: 'Munich (MUC)', timezone: null }))).toEqual({
iata: 'MUC',
icao: null,
name: 'Munich (MUC)',
city: 'Munich',
country: '',
lat: 10,
lng: 20,
tz: '',
});
expect(airportFromEndpoint(ep('from', 0))).toBeNull();
expect(airportFromEndpoint(undefined)).toBeNull();
expect(locationFromEndpoint(ep('to', 1))).toEqual({ name: 'to-1', lat: 11, lng: 21, address: null });
expect(locationFromEndpoint(undefined)).toBeNull();
});
it('FE-PLANNER-TRANSPORTEP-003: metadata is read from a string or an object, and broken JSON throws', () => {
expect(readTransportMeta({ metadata: '{"airline":"LH"}' } as Reservation)).toEqual({ airline: 'LH' });
expect(readTransportMeta({ metadata: '' } as Reservation)).toEqual({});
expect(readTransportMeta({ metadata: null } as Reservation)).toEqual({});
expect(readTransportMeta({ metadata: { seat: '1A' } } as unknown as Reservation)).toEqual({ seat: '1A' });
expect(() => readTransportMeta({ metadata: '{' } as Reservation)).toThrow();
});
it('FE-PLANNER-TRANSPORTEP-004: the fields keep a saved day and resolve an import from its dates', () => {
const saved = buildReservation({
type: 'bus',
status: 'confirmed',
day_id: 2,
end_day_id: 3,
reservation_time: '2026-05-01T08:15',
reservation_end_time: '2026-05-01T10:00',
confirmation_number: 'ABC',
notes: 'n',
});
expect(transportFieldsFrom(saved, days, 'bus')).toEqual({
title: saved.title,
type: 'bus',
status: 'confirmed',
start_day_id: 2,
end_day_id: 3,
departure_time: '08:15',
arrival_time: '10:00',
confirmation_number: 'ABC',
notes: 'n',
});
const imported = buildReservation({
title: '',
status: 'pending',
reservation_time: '2026-05-03T07:00',
reservation_end_time: null,
});
const fields = transportFieldsFrom(imported, days, 'car');
expect(fields).toMatchObject({ title: '', status: 'pending', start_day_id: 3, end_day_id: '', arrival_time: '' });
});
});
describe('seedTransportRoute', () => {
const flight = buildReservation({
type: 'flight',
day_id: 1,
end_day_id: 3,
endpoints: [
ep('from', 0, { code: 'MUC', name: 'Munich (MUC)', local_time: '08:00' }),
ep('stop', 1, { code: 'FRA', name: 'Frankfurt (FRA)', local_time: '12:00', local_date: '2026-05-02' }),
ep('to', 2, { code: 'JFK', name: 'New York (JFK)', local_time: '15:00' }),
],
});
const meta = { airline: 'LH', flight_number: 'LH1', seat: '3C' };
it('FE-PLANNER-TRANSPORTEP-005: a flight seeds its airports; only the phone reads a stopover day from its endpoint', () => {
const desktop = seedTransportRoute(flight, meta, 'flight', days, DESKTOP);
expect(Object.keys(desktop)).toEqual(['waypoints']);
const [first, stop, last] = desktop.waypoints!;
expect(first).toMatchObject({ depDayId: 1, depTime: '08:00', arrTime: '', airline: 'LH', seat: '3C' });
expect(first.airport?.iata).toBe('MUC');
expect(stop).toMatchObject({ arrDayId: '', depDayId: '', arrTime: '12:00', depTime: '12:00', airline: '' });
expect(last).toMatchObject({ arrDayId: 3, arrTime: '15:00', depTime: '' });
const phone = seedTransportRoute(flight, meta, 'flight', days, PHONE);
expect(phone.fromPick).toEqual({});
expect(phone.toPick).toEqual({});
expect(phone.waypoints![1]).toMatchObject({ arrDayId: 2, depDayId: 2 });
});
it('FE-PLANNER-TRANSPORTEP-006: saved legs win over the endpoints, the booking code stays off the legs', () => {
const withLegs = {
...meta,
legs: [
{
dep_day_id: 1,
dep_time: '07:00',
arr_day_id: 1,
arr_time: '09:30',
airline: 'OS',
confirmation_number: 'L1',
},
{ arr_day_id: 2, arr_time: '11:00', dep_day_id: 2, dep_time: '13:00', flight_number: 'UA9' },
],
};
const [first, stop, last] = seedTransportRoute(
{ ...flight, confirmation_number: 'BOOK' },
withLegs,
'flight',
days,
DESKTOP
).waypoints!;
expect(first).toMatchObject({ depTime: '07:00', airline: 'OS', confirmation_number: 'L1', flight_number: 'LH1' });
expect(stop).toMatchObject({ arrDayId: 1, arrTime: '09:30', depDayId: 2, depTime: '13:00', flight_number: 'UA9' });
expect(last).toMatchObject({ arrDayId: 2, arrTime: '11:00', confirmation_number: '' });
});
it('FE-PLANNER-TRANSPORTEP-007: an import resolves each airport day from its own date, an edit does not', () => {
const imported = {
...flight,
day_id: null,
end_day_id: null,
endpoints: [
ep('from', 0, { code: 'MUC', local_date: '2026-05-01' }),
ep('to', 1, { code: 'JFK', local_date: '2026-05-03' }),
],
};
const review = seedTransportRoute(imported, {}, 'flight', days, { ...DESKTOP, isEdit: false }).waypoints!;
expect(review.map((w) => [w.depDayId, w.arrDayId])).toEqual([
[1, 1],
[3, 3],
]);
const edit = seedTransportRoute(imported, {}, 'flight', days, DESKTOP).waypoints!;
expect(edit.map((w) => [w.depDayId, w.arrDayId])).toEqual([
['', ''],
['', ''],
]);
});
it('FE-PLANNER-TRANSPORTEP-008: a legacy flight without both endpoints seeds two rows from the flat fields', () => {
const legacy = buildReservation({
type: 'flight',
day_id: 1,
end_day_id: null,
reservation_time: '2026-05-01T06:30',
reservation_end_time: '2026-05-01T09:45',
endpoints: [ep('from', 0, { code: 'MUC' })],
});
const [dep, arr] = seedTransportRoute(legacy, meta, 'flight', days, DESKTOP).waypoints!;
expect(dep).toMatchObject({ depDayId: 1, depTime: '06:30', airline: 'LH', flight_number: 'LH1', seat: '3C' });
expect(dep.airport?.iata).toBe('MUC');
expect(arr).toMatchObject({ arrDayId: 1, arrTime: '09:45', airport: null });
});
it('FE-PLANNER-TRANSPORTEP-009: a train seeds its stations and clears the plain endpoints on both shells', () => {
const train = buildReservation({
type: 'train',
day_id: 2,
end_day_id: 2,
endpoints: [ep('from', 0, { local_time: '09:00' }), ep('to', 1, { local_time: '11:00' })],
});
const route = seedTransportRoute(
train,
{ train_number: 'ICE 1', platform: '4', seat: '22' },
'train',
days,
DESKTOP
);
expect(route.fromPick).toEqual({});
expect(route.toPick).toEqual({});
expect(route.trainWaypoints![0]).toMatchObject({ depDayId: 2, train_number: 'ICE 1', platform: '4', seat: '22' });
expect(route.trainWaypoints![0].location?.name).toBe('from-0');
expect(route.trainWaypoints![1]).toMatchObject({ arrDayId: 2, arrTime: '11:00' });
const legacy = { ...train, endpoints: [] };
const [dep, arr] = seedTransportRoute(legacy, { train_number: 'RE' }, 'train', days, DESKTOP).trainWaypoints!;
expect(dep).toMatchObject({ location: null, depDayId: 2, train_number: 'RE' });
expect(arr).toMatchObject({ location: null, arrDayId: 2 });
});
it('FE-PLANNER-TRANSPORTEP-010: a car reads its stops in order; another type keeps none; the phone resets the rest', () => {
const car = buildReservation({
type: 'car',
endpoints: [ep('from', 0), ep('stop', 2, { local_time: '14:00' }), ep('stop', 1), ep('to', 3)],
});
const desktop = seedTransportRoute(car, {}, 'car', days, DESKTOP);
expect(desktop.fromPick?.location?.name).toBe('from-0');
expect(desktop.toPick?.location?.name).toBe('to-3');
expect(desktop.carStops).toEqual([
{ location: { name: 'stop-1', lat: 11, lng: 21, address: null }, time: '' },
{ location: { name: 'stop-2', lat: 12, lng: 22, address: null }, time: '14:00' },
]);
expect(desktop.waypoints).toBeUndefined();
const phone = seedTransportRoute(car, {}, 'car', days, PHONE);
expect(phone.waypoints).toEqual([emptyWaypoint(), emptyWaypoint()]);
expect(phone.trainWaypoints).toEqual([emptyStationWaypoint(), emptyStationWaypoint()]);
const bus = seedTransportRoute({ ...car, type: 'bus', endpoints: [] }, {}, 'bus', days, DESKTOP);
expect(bus).toEqual({ fromPick: { location: undefined }, toPick: { location: undefined }, carStops: [] });
});
it('FE-PLANNER-TRANSPORTEP-011: a new booking starts every row on the given day', () => {
expect(blankTransportRoute(2)).toEqual({
fromPick: {},
toPick: {},
waypoints: [emptyWaypoint(2), emptyWaypoint(2)],
trainWaypoints: [emptyStationWaypoint(2), emptyStationWaypoint(2)],
carStops: [],
});
});
it('FE-PLANNER-TRANSPORTEP-012: the day options lead with a dash and badge each day with its date or number', () => {
const t = (key: string, params?: Record<string, string | number>) => `${key}:${params?.n}`;
const options = transportDayOptions(
[d1, d3, buildDay({ id: 9, date: null, title: 'Spare', day_number: 4 })],
t,
'en-US'
);
expect(options[0]).toEqual({ value: '', label: '—' });
expect(options[1]).toMatchObject({ value: 1, label: 'dayplan.dayN:1' });
expect(options[1]).toHaveProperty('badge', expect.stringContaining('May'));
expect(options[2]).toMatchObject({ value: 3, label: 'Coast' });
expect(options[3]).toEqual({ value: 9, label: 'Spare', badge: 'dayplan.dayN:4' });
});
});
describe('buildTransportPayload', () => {
it('FE-PLANNER-TRANSPORTEP-013: a plain booking saves its own days, times and endpoints', () => {
const payload = buildTransportPayload(
draft({
form: {
...EMPTY_TRANSPORT_FIELDS,
title: 'Bus',
type: 'bus',
status: 'confirmed',
start_day_id: 1,
end_day_id: '',
departure_time: '09:00',
arrival_time: '12:00',
confirmation_number: '',
notes: '',
},
fromPick: { location: { name: 'A', lat: 1, lng: 2 } },
toPick: { location: { name: 'B', lat: 3, lng: 4 } },
}),
days,
DESKTOP_SAVE
);
expect(payload).toEqual({
title: 'Bus',
type: 'bus',
status: 'confirmed',
day_id: 1,
end_day_id: null,
reservation_time: '2026-05-01T09:00',
reservation_end_time: '2026-05-01T12:00',
location: null,
confirmation_number: null,
notes: null,
url: null,
metadata: {},
endpoints: [
endpointFromLocation({ name: 'A', lat: 1, lng: 2 }, 'from', 0, '2026-05-01', '09:00'),
endpointFromLocation({ name: 'B', lat: 3, lng: 4 }, 'to', 1, '2026-05-01', '12:00'),
],
needs_review: false,
});
});
it('FE-PLANNER-TRANSPORTEP-014: the phone sends no url and an empty metadata as null; a url is sent when given', () => {
const phone = buildTransportPayload(draft(), days, PHONE_SAVE);
expect(phone).not.toHaveProperty('url');
expect(phone.metadata).toBeNull();
expect(buildTransportPayload(draft(), days, { ...DESKTOP_SAVE, url: 'https://x.test' }).url).toBe('https://x.test');
});
it('FE-PLANNER-TRANSPORTEP-015: a flight with a stopover writes its legs and keeps the planner positions', () => {
const reservation = buildReservation({
type: 'flight',
metadata: JSON.stringify({ legs: [{ day_positions: { 1: 0.5 } }, {}] }),
});
const waypoints = [
{
...emptyWaypoint(1),
airport: airport('MUC'),
depTime: '08:00',
airline: 'LH',
flight_number: 'LH1',
seat: '1A',
},
{ ...emptyWaypoint(), airport: null },
{
...emptyWaypoint(2),
airport: airport('FRA'),
arrTime: '09:00',
depTime: '10:00',
confirmation_number: 'SEG',
},
{ ...emptyWaypoint(3), airport: airport('JFK'), arrTime: '14:00' },
];
const payload = buildTransportPayload(
draft({ form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Fly', type: 'flight' }, waypoints }),
days,
{ ...DESKTOP_SAVE, reservation }
);
expect(payload).toMatchObject({
day_id: 1,
end_day_id: 3,
reservation_time: '2026-05-01T08:00',
reservation_end_time: '2026-05-03T14:00',
});
expect(payload.metadata).toEqual({
airline: 'LH',
flight_number: 'LH1',
departure_airport: 'MUC',
departure_timezone: 'Europe/Berlin',
arrival_airport: 'JFK',
arrival_timezone: 'Europe/Berlin',
legs: [
{
from: 'MUC',
to: 'FRA',
airline: 'LH',
flight_number: 'LH1',
seat: '1A',
dep_day_id: 1,
dep_time: '08:00',
arr_day_id: 2,
arr_time: '09:00',
day_positions: { 1: 0.5 },
},
{
from: 'FRA',
to: 'JFK',
confirmation_number: 'SEG',
dep_day_id: 2,
dep_time: '10:00',
arr_day_id: 3,
arr_time: '14:00',
},
],
seat: '1A',
});
expect(
(payload.endpoints as { role: string; local_date: string | null }[]).map((e) => [e.role, e.local_date])
).toEqual([
['from', '2026-05-01'],
['stop', '2026-05-02'],
['to', '2026-05-03'],
]);
});
it('FE-PLANNER-TRANSPORTEP-016: a two-airport flight keeps the flat metadata with no legs', () => {
const payload = buildTransportPayload(
draft({
form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Fly', type: 'flight' },
waypoints: [
{ ...emptyWaypoint(1), airport: airport('MUC') },
{ ...emptyWaypoint(1), airport: airport('BER') },
],
}),
days,
DESKTOP_SAVE
);
expect(payload.metadata).not.toHaveProperty('legs');
expect(payload.reservation_time).toBeNull();
});
it('FE-PLANNER-TRANSPORTEP-017: the desktop anchors a train on its raw rows, the phone on the picked stations', () => {
const station = (name: string) => ({ name, lat: 1, lng: 1 });
const trainWaypoints = [
{ ...emptyStationWaypoint(1), depTime: '07:00', train_number: 'RAW' },
{ ...emptyStationWaypoint(2), location: station('A'), depTime: '09:00', train_number: 'ICE' },
{ ...emptyStationWaypoint(3), location: station('B'), arrTime: '12:00' },
];
const form = { ...EMPTY_TRANSPORT_FIELDS, title: 'Rail', type: 'train' as const };
const desktop = buildTransportPayload(draft({ form, trainWaypoints }), days, DESKTOP_SAVE);
expect(desktop).toMatchObject({ day_id: 1, end_day_id: 3, reservation_time: '2026-05-01T07:00' });
expect(desktop.metadata).toEqual({ train_number: 'RAW' });
const phone = buildTransportPayload(draft({ form, trainWaypoints }), days, PHONE_SAVE);
expect(phone).toMatchObject({ day_id: 2, end_day_id: 3, reservation_time: '2026-05-02T09:00' });
expect(phone.metadata).toEqual({ train_number: 'ICE' });
// A train without a single picked station still keeps its day and time.
const bare = [{ ...emptyStationWaypoint(1), depTime: '06:00' }, emptyStationWaypoint(1)];
expect(buildTransportPayload(draft({ form, trainWaypoints: bare }), days, PHONE_SAVE)).toMatchObject({
day_id: 1,
reservation_time: '2026-05-01T06:00',
endpoints: [],
});
});
it('FE-PLANNER-TRANSPORTEP-018: a train with three stations writes legs and dates the last stop from the first', () => {
const station = (name: string) => ({ name, lat: 1, lng: 1 });
const trainWaypoints = [
{ ...emptyStationWaypoint(1), location: station('A'), depTime: '07:00', platform: '3' },
{ ...emptyStationWaypoint(''), location: station('B'), depTime: '08:00' },
{ ...emptyStationWaypoint(''), location: station('C'), arrTime: '09:00' },
];
const payload = buildTransportPayload(
draft({ form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Rail', type: 'train' }, trainWaypoints }),
days,
DESKTOP_SAVE
);
expect((payload.metadata as { legs: unknown[] }).legs).toHaveLength(2);
expect((payload.endpoints as { local_date: string | null }[]).map((e) => e.local_date)).toEqual([
'2026-05-01',
null,
'2026-05-01',
]);
expect(payload.reservation_end_time).toBe('2026-05-01T09:00');
});
it('FE-PLANNER-TRANSPORTEP-019: a car writes its picked stops between pick-up and return', () => {
const payload = buildTransportPayload(
draft({
form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Car', type: 'car', start_day_id: 2 },
fromPick: { location: { name: 'Pick', lat: 1, lng: 1 } },
toPick: { location: { name: 'Drop', lat: 2, lng: 2 } },
carStops: [
{ location: { name: 'S1', lat: 5, lng: 5 }, time: '11:00' },
{ location: null, time: '12:00' },
{ location: { name: 'S2', lat: 6, lng: 6 }, time: '' },
],
}),
days,
DESKTOP_SAVE
);
expect((payload.endpoints as ReservationEndpoint[]).map((e) => [e.role, e.sequence, e.name, e.local_time])).toEqual(
[
['from', 0, 'Pick', null],
['stop', 1, 'S1', '11:00'],
['stop', 2, 'S2', null],
['to', 3, 'Drop', null],
]
);
});
it('FE-PLANNER-TRANSPORTEP-020: a transit itinerary and AirTrail ids survive a re-save while from and to stay put', () => {
const reservation = buildReservation({
type: 'bus',
metadata: { transit: { legs: [1] }, airtrail_ids: [7] } as unknown as string,
endpoints: [ep('from', 0), ep('stop', 1, { name: 'Change' }), ep('to', 2)],
});
const same = draft({
form: { ...EMPTY_TRANSPORT_FIELDS, title: 'Bus', type: 'bus' },
fromPick: { location: { name: 'from-0', lat: 10, lng: 20 } },
toPick: { location: { name: 'to-2', lat: 12, lng: 22 } },
});
const kept = buildTransportPayload(same, days, { ...DESKTOP_SAVE, reservation });
expect(kept.metadata).toEqual({ transit: { legs: [1] }, airtrail_ids: [7] });
expect((kept.endpoints as ReservationEndpoint[]).map((e) => e.name)).toEqual(['from-0', 'Change', 'to-2']);
const moved = buildTransportPayload({ ...same, toPick: { location: { name: 'Else', lat: 0, lng: 0 } } }, days, {
...DESKTOP_SAVE,
reservation,
});
expect(moved.metadata).toEqual({ airtrail_ids: [7] });
expect((moved.endpoints as ReservationEndpoint[]).map((e) => e.name)).toEqual(['from-0', 'Else']);
});
it('FE-PLANNER-TRANSPORTEP-021: a reviewed import with a price creates its cost, only on create and with the budget on', () => {
const prefill = { title: 'Fly', metadata: { price: 120, priceCurrency: 'eur' } } as BookingReviewDraft;
const opts = { ...DESKTOP_SAVE, prefill, budgetEnabled: true };
expect(buildTransportPayload(draft(), days, opts).create_budget_entry).toEqual(
expect.objectContaining({ total_price: 120, currency: 'EUR' })
);
expect(buildTransportPayload(draft(), days, { ...opts, budgetEnabled: false })).not.toHaveProperty(
'create_budget_entry'
);
expect(buildTransportPayload(draft(), days, { ...opts, reservation: buildReservation() })).not.toHaveProperty(
'create_budget_entry'
);
});
});
@@ -0,0 +1,745 @@
import {
Bike,
Bus,
CableCar,
Car,
CarTaxiFront,
Plane,
Route,
Sailboat,
Ship,
Train,
type LucideIcon,
} from 'lucide-react';
import type { Day, Reservation, ReservationEndpoint } from '../../types';
import { orderedEndpoints, parseReservationMetadata, stripAirportCode, usesStationRoute } from '../../utils/flightLegs';
import { formatDate, resolveDayId, splitReservationDateTime } from '../../utils/formatters';
import type { Airport } from './AirportSelect';
import { importedPriceEntry } from './importedPrice';
import type { LocationPoint } from './LocationSelect';
import type { BookingReviewDraft } from './parsedItemToDraft';
/**
* The transport booking form both shells edit, the desktop TransportModal and the
* phone's transport sheet: the route rows, how a saved booking fills them and the
* request body they are saved as, so the saved shape is the same on both.
*/
export const TRANSPORT_TYPES = [
'flight',
'train',
'bus',
'car',
'taxi',
'bicycle',
'cruise',
'ferry',
'cable_car',
'transit',
'transport_other',
] as const;
export type TransportType = (typeof TRANSPORT_TYPES)[number];
/** The types the form offers to pick; transit is only ever created by the automated search. */
export const TRANSPORT_TYPE_OPTIONS: { value: TransportType; labelKey: string; Icon: LucideIcon }[] = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront },
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
{ value: 'cable_car', labelKey: 'reservations.type.cable_car', Icon: CableCar },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
];
export interface EndpointPick {
airport?: Airport;
location?: LocationPoint;
}
/**
* A flight is an ordered list of airports. The origin has only a departure, the
* destination only an arrival, and each stop in between has both, plus the airline
* and flight number of the flight LEAVING it. N waypoints = N-1 legs.
*/
export interface WaypointForm {
airport: Airport | null;
arrDayId: string | number;
arrTime: string;
depDayId: string | number;
depTime: string;
airline: string;
flight_number: string;
seat: string;
/** Booking reference of the leg leaving this waypoint (#1943); empty means the booking's own. */
confirmation_number: string;
}
/** A train or cruise mirrors the flight, with stations for airports and a train number and platform per leg. */
export interface StationWaypointForm {
location: LocationPoint | null;
arrDayId: string | number;
arrTime: string;
depDayId: string | number;
depTime: string;
train_number: string;
platform: string;
seat: string;
confirmation_number: string;
}
/** A place a car rental passes between pick-up and return (#1797), with the time the driver plans to be there. */
export interface CarStopForm {
location: LocationPoint | null;
time: string;
}
export function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return {
airport: null,
arrDayId: dayId,
arrTime: '',
depDayId: dayId,
depTime: '',
airline: '',
flight_number: '',
seat: '',
confirmation_number: '',
};
}
export function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return {
location: null,
arrDayId: dayId,
arrTime: '',
depDayId: dayId,
depTime: '',
train_number: '',
platform: '',
seat: '',
confirmation_number: '',
};
}
export const emptyCarStop = (): CarStopForm => ({ location: null, time: '' });
export type NewEndpoint = Omit<ReservationEndpoint, 'id' | 'reservation_id'>;
type EndpointRole = 'from' | 'to' | 'stop';
export function endpointFromAirport(
a: Airport,
role: EndpointRole,
sequence: number,
date: string | null,
time: string | null
): NewEndpoint {
return {
role,
sequence,
name: a.city ? `${a.city} (${a.iata})` : a.name,
code: a.iata,
lat: a.lat,
lng: a.lng,
timezone: a.tz,
local_date: date,
local_time: time,
};
}
export function endpointFromLocation(
l: LocationPoint,
role: EndpointRole,
sequence: number,
date: string | null,
time: string | null
): NewEndpoint {
return {
role,
sequence,
name: l.name,
code: null,
lat: l.lat,
lng: l.lng,
timezone: null,
local_date: date,
local_time: time,
};
}
export function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null;
return {
iata: e.code,
icao: null,
name: e.name,
city: stripAirportCode(e.name),
country: '',
lat: e.lat,
lng: e.lng,
tz: e.timezone || '',
};
}
export function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null;
return { name: e.name, lat: e.lat, lng: e.lng, address: null };
}
/** The fields of the form both shells share; the desktop keeps a few more of its own. */
export interface TransportFields {
title: string;
type: TransportType;
status: 'pending' | 'confirmed';
start_day_id: string | number;
end_day_id: string | number;
departure_time: string;
arrival_time: string;
confirmation_number: string;
notes: string;
}
export const EMPTY_TRANSPORT_FIELDS: TransportFields = {
title: '',
type: 'flight',
status: 'pending',
start_day_id: '',
end_day_id: '',
departure_time: '',
arrival_time: '',
confirmation_number: '',
notes: '',
};
/** One leg of a multi-leg booking as metadata.legs stores it. */
export interface TransportLegMeta {
from?: string;
to?: string;
airline?: string;
flight_number?: string;
train_number?: string;
platform?: string;
seat?: string;
confirmation_number?: string;
dep_day_id?: number | null;
dep_time?: string | null;
arr_day_id?: number | null;
arr_time?: string | null;
day_positions?: Record<string, number>;
}
/** The flat metadata a transport form reads back. */
export interface TransportMetaFields {
airline?: string;
flight_number?: string;
train_number?: string;
platform?: string;
seat?: string;
legs?: unknown;
}
/** The booking's metadata as the form reads it; unlike the other readers, broken JSON is not forgiven here. */
export function readTransportMeta(src: Pick<Reservation, 'metadata'>): TransportMetaFields {
return (
typeof src.metadata === 'string' ? JSON.parse(src.metadata || '{}') : src.metadata || {}
) as TransportMetaFields;
}
const legsOf = (meta: TransportMetaFields): TransportLegMeta[] =>
Array.isArray(meta.legs) ? (meta.legs as TransportLegMeta[]) : [];
/** The type a saved booking opens with; one the form does not know becomes `fallback`. */
export function transportTypeOf(type: string, fallback: TransportType): TransportType {
return (TRANSPORT_TYPES as readonly string[]).includes(type) ? (type as TransportType) : fallback;
}
/** The form fields for a saved booking or an import prefill. */
export function transportFieldsFrom(src: Reservation, days: Day[], type: TransportType): TransportFields {
return {
title: src.title || '',
type,
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
// For an edit, keep the saved day; for an imported prefill (no day_id), resolve it
// from the parsed pick-up/return date so the date isn't lost on save.
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
confirmation_number: src.confirmation_number || '',
notes: src.notes || '',
};
}
/** The route rows of the form. A seed fills only the ones it names; the others keep what they hold. */
export interface TransportRoute {
waypoints?: WaypointForm[];
trainWaypoints?: StationWaypointForm[];
fromPick?: EndpointPick;
toPick?: EndpointPick;
carStops?: CarStopForm[];
}
export interface RouteSeedOptions {
/** Editing a saved booking rather than reviewing an import. */
isEdit: boolean;
/**
* A stopover has no day column of its own, so when metadata.legs is missing
* (imported or MCP-created bookings) the phone seeds it from the endpoint's
* local_date. The desktop leaves it empty.
*/
stopDaysFromEndpoints: boolean;
/** The phone also clears the rows the booking's type does not show. */
resetHiddenRoutes: boolean;
}
/** The empty route of a new booking, every row starting on the given day. */
export function blankTransportRoute(dayId: string | number): Required<TransportRoute> {
return {
fromPick: {},
toPick: {},
waypoints: [emptyWaypoint(dayId), emptyWaypoint(dayId)],
trainWaypoints: [emptyStationWaypoint(dayId), emptyStationWaypoint(dayId)],
carStops: [],
};
}
interface LegDays {
arrDayId: string | number;
arrTime: string;
depDayId: string | number;
depTime: string;
}
/** The waypoint rows of a booking that has endpoints: each row's days and times, then its per-leg fields. */
function waypointRows<W>(
src: Reservation,
meta: TransportMetaFields,
days: Day[],
opts: RouteSeedOptions,
row: (ep: ReservationEndpoint, legOut: TransportLegMeta | undefined, isFirst: boolean, when: LegDays) => W
): W[] {
const eps = orderedEndpoints(src);
const metaLegs = legsOf(meta);
// Only an import prefill carries a per-endpoint local_date without a day_id. On an
// edit the saved day wins: local_date is denormalised and can lag behind after a
// day drag, insertDay or a trip-date shift, so resolving from it would silently
// move the booking to another day on a plain re-save.
const endpointDayId = (ep?: { local_date?: string | null } | null) =>
opts.isEdit ? '' : resolveDayId(days, ep?.local_date);
return eps.map((ep, i) => {
const legInto = metaLegs[i - 1]; // leg arriving INTO waypoint i
const legOut = metaLegs[i]; // leg departing FROM waypoint i
const isFirst = i === 0;
const isLast = i === eps.length - 1;
const stopDay = opts.stopDaysFromEndpoints && !isFirst && !isLast ? resolveDayId(days, ep?.local_date) : '';
return row(ep, legOut, isFirst, {
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : stopDay)),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : stopDay)),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
});
});
}
/** The day a legacy booking without full endpoints seeds its first or last row on. */
function legacyDays(src: Reservation, days: Day[], opts: RouteSeedOptions) {
const eps = src.endpoints || [];
const endpointDayId = (ep?: { local_date?: string | null } | null) =>
opts.isEdit ? '' : resolveDayId(days, ep?.local_date);
return {
from: eps.find((e) => e.role === 'from'),
to: eps.find((e) => e.role === 'to'),
depDay: endpointDayId(eps.find((e) => e.role === 'from')) || (src.day_id ?? ''),
arrDay: endpointDayId(eps.find((e) => e.role === 'to')) || (src.end_day_id ?? src.day_id ?? ''),
};
}
function flightWaypoints(
src: Reservation,
meta: TransportMetaFields,
days: Day[],
opts: RouteSeedOptions
): WaypointForm[] {
if (orderedEndpoints(src).length >= 2) {
return waypointRows(src, meta, days, opts, (ep, legOut, isFirst, when) => ({
airport: airportFromEndpoint(ep),
...when,
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// No fallback to src.confirmation_number: that one belongs to the whole
// booking and stays in the form's own field, otherwise a plain re-save would
// copy it onto the first leg.
confirmation_number: legOut?.confirmation_number ?? '',
}));
}
// Legacy flight with no (or partial) endpoints: seed two waypoints.
const { from, to, depDay, arrDay } = legacyDays(src, days, opts);
const dep = emptyWaypoint(depDay);
dep.airport = airportFromEndpoint(from);
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? '';
dep.airline = meta.airline ?? '';
dep.flight_number = meta.flight_number ?? '';
dep.seat = meta.seat ?? '';
const arr = emptyWaypoint(arrDay);
arr.airport = airportFromEndpoint(to);
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? '';
return [dep, arr];
}
function stationWaypoints(
src: Reservation,
meta: TransportMetaFields,
days: Day[],
opts: RouteSeedOptions
): StationWaypointForm[] {
// A current single-leg train (2 endpoints, no metadata.legs) round-trips through
// the first branch: the flat train_number/platform/seat land on the first station.
if (orderedEndpoints(src).length >= 2) {
return waypointRows(src, meta, days, opts, (ep, legOut, isFirst, when) => ({
location: locationFromEndpoint(ep),
...when,
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// See the flight branch: the booking's own reference stays out of the legs.
confirmation_number: legOut?.confirmation_number ?? '',
}));
}
const { from, to, depDay, arrDay } = legacyDays(src, days, opts);
const dep = emptyStationWaypoint(depDay);
dep.location = locationFromEndpoint(from);
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? '';
dep.train_number = meta.train_number ?? '';
dep.platform = meta.platform ?? '';
dep.seat = meta.seat ?? '';
const arr = emptyStationWaypoint(arrDay);
arr.location = locationFromEndpoint(to);
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? '';
return [dep, arr];
}
/** The route rows a saved booking or an import prefill opens with, for the type it opens as. */
export function seedTransportRoute(
src: Reservation,
meta: TransportMetaFields,
type: TransportType,
days: Day[],
opts: RouteSeedOptions
): TransportRoute {
if (type === 'flight') {
const waypoints = flightWaypoints(src, meta, days, opts);
return opts.resetHiddenRoutes ? { waypoints, fromPick: {}, toPick: {} } : { waypoints };
}
if (usesStationRoute(type)) {
return { trainWaypoints: stationWaypoints(src, meta, days, opts), fromPick: {}, toPick: {} };
}
const eps = src.endpoints || [];
return {
fromPick: { location: locationFromEndpoint(eps.find((e) => e.role === 'from')) || undefined },
toPick: { location: locationFromEndpoint(eps.find((e) => e.role === 'to')) || undefined },
...(opts.resetHiddenRoutes
? {
waypoints: [emptyWaypoint(), emptyWaypoint()],
trainWaypoints: [emptyStationWaypoint(), emptyStationWaypoint()],
}
: {}),
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
carStops:
src.type === 'car'
? eps
.filter((e) => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map((e) => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
};
}
/** The day picker options: a dash for none, then each day by title or number with its date as the badge. */
export function transportDayOptions(
days: Day[],
t: (key: string, params?: Record<string, string | number>) => string,
locale: string
) {
return [
{ value: '', label: '—' },
...days.map((d) => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined;
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined;
return { value: d.id, label: d.title || t('dayplan.dayN', { n: d.day_number }), badge: dateBadge ?? dayBadge };
}),
];
}
export interface TransportDraft {
form: TransportFields;
waypoints: WaypointForm[];
trainWaypoints: StationWaypointForm[];
fromPick: EndpointPick;
toPick: EndpointPick;
carStops: CarStopForm[];
}
export interface TransportPayloadOptions {
/** The booking being edited; null while creating. */
reservation: Reservation | null;
/** The import item under review; its parsed price becomes the linked cost of a new booking. */
prefill: BookingReviewDraft | null;
budgetEnabled: boolean;
/**
* The phone anchors a train's day and time on the stations that become endpoints,
* falling back to the raw rows only when none is picked. The desktop reads the raw
* first and last rows.
*/
anchorOnStations: boolean;
/** The phone sends a booking without metadata as null; the desktop sends an empty object (#2233). */
emptyMetadataAsNull: boolean;
/** The desktop's link field; the phone has none and sends no url. */
url?: string;
}
export type TransportPayload = Record<string, unknown> & { title: string };
const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6;
type LegEnds = Pick<WaypointForm, 'seat' | 'confirmation_number' | 'depDayId' | 'depTime' | 'arrDayId' | 'arrTime'>;
/** The tail every saved flight or train leg shares: seat, booking reference, schedule and the old day positions. */
function legSchedule(w: LegEnds, next: LegEnds, orig: TransportLegMeta | undefined): TransportLegMeta {
return {
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(orig?.day_positions ? { day_positions: orig.day_positions } : {}),
};
}
/** The request body the form saves as. */
export function buildTransportPayload(
draft: TransportDraft,
days: Day[],
opts: TransportPayloadOptions
): TransportPayload {
const { form, waypoints, trainWaypoints, fromPick, toPick, carStops } = draft;
const { reservation } = opts;
const stationRoute = usesStationRoute(form.type);
const startDay = days.find((d) => d.id === Number(form.start_day_id));
const endDay = days.find((d) => d.id === Number(form.end_day_id));
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null;
return day?.date ? `${day.date}T${time}` : time;
};
const dayDate = (id: string | number): string | null => days.find((d) => d.id === Number(id))?.date ?? null;
// Flight route as an ordered list of airports (origin .. stops .. destination).
const flightWps = form.type === 'flight' ? waypoints.filter((w) => w.airport) : [];
const firstWp = flightWps[0];
const lastWp = flightWps[flightWps.length - 1];
// Only geocoded stations become map endpoints and legs.
const trainWps = stationRoute ? trainWaypoints : [];
const trainStations = trainWps.filter((w) => w.location);
const trainAnchors = opts.anchorOnStations && trainStations.length > 0 ? trainStations : trainWps;
const firstTrainWp = trainAnchors[0];
const lastTrainWp = trainAnchors[trainAnchors.length - 1];
// Per-leg day-plan positions are owned by the day planner, not this form: keep
// them when re-saving so editing a flight doesn't reset where its legs sit.
const origLegs: TransportLegMeta[] = reservation ? parseReservationMetadata(reservation).legs || [] : [];
const metadata: Record<string, unknown> = {};
if (form.type === 'flight') {
// Top-level keys mirror the first/last leg so legacy readers keep working.
if (firstWp?.airline) metadata.airline = firstWp.airline;
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number;
if (firstWp?.airport) {
metadata.departure_airport = firstWp.airport.iata;
metadata.departure_timezone = firstWp.airport.tz;
}
if (lastWp?.airport) {
metadata.arrival_airport = lastWp.airport.iata;
metadata.arrival_timezone = lastWp.airport.tz;
}
// Per-leg detail only for true multi-leg flights: a single-leg flight keeps the
// flat metadata it always had.
if (flightWps.length > 2) {
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
const next = flightWps[i + 1];
return {
from: w.airport!.iata,
to: next.airport!.iata,
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...legSchedule(w, next, origLegs[i]),
};
});
}
if (firstWp?.seat) metadata.seat = firstWp.seat;
} else if (stationRoute) {
// Flat keys mirror the first leg so legacy readers keep working.
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number;
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform;
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat;
// Per-leg detail only for a true multi-leg train (more than two geocoded stations).
if (trainStations.length > 2) {
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
const next = trainStations[i + 1];
return {
from: w.location!.name,
to: next.location!.name,
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...legSchedule(w, next, origLegs[i]),
};
});
}
}
// A transit itinerary (#1065) lives in metadata.transit + 'stop' endpoints, neither
// of which this form shows or edits, so re-saving must not wipe them. They're kept
// only while from/to are unchanged: picking a different origin or destination
// invalidates the stored connection.
const prevMeta = reservation ? parseReservationMetadata(reservation) : {};
const prevEndpointsAll = reservation?.endpoints || [];
const prevFrom = prevEndpointsAll.find((ep) => ep.role === 'from');
const prevTo = prevEndpointsAll.find((ep) => ep.role === 'to');
const keepTransit = !!(
prevMeta.transit &&
form.type !== 'flight' &&
prevFrom &&
prevTo &&
fromPick.location &&
toPick.location &&
near(prevFrom.lat, fromPick.location.lat) &&
near(prevFrom.lng, fromPick.location.lng) &&
near(prevTo.lat, toPick.location.lat) &&
near(prevTo.lng, toPick.location.lng)
);
if (keepTransit) metadata.transit = prevMeta.transit;
// A joined AirTrail import (#1535) records every source flight id in
// metadata.airtrail_ids so the picker doesn't offer those legs again.
if (Array.isArray(prevMeta.airtrail_ids)) metadata.airtrail_ids = prevMeta.airtrail_ids;
const startDate = startDay?.date ?? null;
const endDate = (endDay ?? startDay)?.date ?? null;
const endpoints: NewEndpoint[] = [];
if (form.type === 'flight') {
flightWps.forEach((w, i) => {
const isFirst = i === 0;
const isLast = i === flightWps.length - 1;
const role: EndpointRole = isFirst ? 'from' : isLast ? 'to' : 'stop';
const dId = isLast ? w.arrDayId : w.depDayId;
const time = isLast ? w.arrTime : w.depTime;
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null));
});
} else if (stationRoute) {
trainStations.forEach((w, i) => {
const isFirst = i === 0;
const isLast = i === trainStations.length - 1;
const role: EndpointRole = isFirst ? 'from' : isLast ? 'to' : 'stop';
const dId = isLast ? w.arrDayId : w.depDayId;
const time = isLast ? w.arrTime : w.depTime;
// The destination date falls back to the departure day when the arrival day is left blank.
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null);
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null));
});
} else {
if (fromPick.location) {
endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null));
}
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints =
form.type === 'car'
? carStops
.filter((s) => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: [];
const stops =
keepTransit && form.type !== 'car'
? prevEndpointsAll
.filter((ep) => ep.role === 'stop')
.slice()
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: [];
stops.forEach((s, i) =>
endpoints.push({
role: 'stop',
sequence: i + 1,
name: s.name,
code: s.code ?? null,
lat: s.lat,
lng: s.lng,
timezone: s.timezone ?? null,
local_date: s.local_date ?? null,
local_time: s.local_time ?? null,
})
);
carEndpoints.forEach((e) => endpoints.push(e));
const stopCount = stops.length + carEndpoints.length;
if (toPick.location) {
endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null));
}
}
// Flights and trains derive their span from the first/last waypoint; other
// transports use the single departure/arrival fields.
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null;
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null;
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null;
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null;
const ownDay = (id: string | number) => (id ? Number(id) : null);
const payload: TransportPayload = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.type === 'flight' ? flightDepDay : stationRoute ? trainDepDay : ownDay(form.start_day_id),
end_day_id: form.type === 'flight' ? flightArrDay : stationRoute ? trainArrDay : ownDay(form.end_day_id),
reservation_time:
form.type === 'flight'
? buildTime(
days.find((d) => d.id === flightDepDay),
firstWp?.depTime || ''
)
: stationRoute
? buildTime(
days.find((d) => d.id === trainDepDay),
firstTrainWp?.depTime || ''
)
: buildTime(startDay, form.departure_time),
reservation_end_time:
form.type === 'flight'
? buildTime(
days.find((d) => d.id === flightArrDay),
lastWp?.arrTime || ''
)
: stationRoute
? // Fall back to the departure day so a same-day train (arrival day left blank) still gets its date.
buildTime(
days.find((d) => d.id === trainArrDay) ?? days.find((d) => d.id === trainDepDay),
lastTrainWp?.arrTime || ''
)
: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
...(opts.url !== undefined ? { url: opts.url || null } : {}),
metadata: opts.emptyMetadataAsNull && Object.keys(metadata).length === 0 ? null : metadata,
endpoints,
needs_review: false,
};
// Imported booking: auto-create the linked cost from the parsed price. Only on
// create (not edit) and only when there's a price.
if (!reservation && opts.prefill && opts.budgetEnabled) {
const entry = importedPriceEntry(opts.prefill.metadata, form.type);
if (entry) payload.create_budget_entry = entry;
}
return payload;
}
@@ -0,0 +1,160 @@
// FE-PLANNER-TRANSPORTFORM-001 to -007: the transport form state behind the
// desktop TransportModal and the phone's transport sheet.
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { buildDay, buildReservation } from '../../../tests/helpers/factories';
import type { ReservationEndpoint } from '../../types';
import { EMPTY_TRANSPORT_FIELDS, emptyStationWaypoint, emptyWaypoint } from './transportEndpoints';
import { useTransportForm } from './useTransportForm';
const days = [buildDay({ id: 1, date: '2026-05-01' }), buildDay({ id: 2, date: '2026-05-02' })];
const DESKTOP_FORM = { ...EMPTY_TRANSPORT_FIELDS, url: '', meta_seat: '' };
function ep(role: 'from' | 'to' | 'stop', sequence: number, code: string | null = null): ReservationEndpoint {
return {
role,
sequence,
name: `${role}-${sequence}`,
code,
lat: sequence,
lng: sequence,
timezone: null,
local_time: null,
local_date: null,
};
}
const seedOptions = {
isEdit: true,
fallbackType: 'transport_other' as const,
dayId: '' as string | number,
stopDaysFromEndpoints: false,
resetHiddenRoutes: false,
};
describe('useTransportForm', () => {
it('FE-PLANNER-TRANSPORTFORM-001: opens empty, and a field, a traveller and the type flag follow the edits', () => {
const { result } = renderHook(() => useTransportForm(DESKTOP_FORM));
expect(result.current.form).toEqual(DESKTOP_FORM);
expect(result.current.form).not.toBe(DESKTOP_FORM);
expect(result.current.waypoints).toEqual([emptyWaypoint(), emptyWaypoint()]);
expect(result.current.trainWaypoints).toEqual([emptyStationWaypoint(), emptyStationWaypoint()]);
expect(result.current.stationRoute).toBe(false);
act(() => result.current.set('type', 'cruise'));
act(() => result.current.set('title', 'Boat'));
expect(result.current.form).toMatchObject({ type: 'cruise', title: 'Boat' });
expect(result.current.stationRoute).toBe(true);
act(() => result.current.toggleTraveler(4));
act(() => result.current.toggleTraveler(5));
act(() => result.current.toggleTraveler(4));
expect([...result.current.travelerIds]).toEqual([5]);
});
it('FE-PLANNER-TRANSPORTFORM-002: a car stop moves one place at a time and never past either end', () => {
const { result } = renderHook(() => useTransportForm(EMPTY_TRANSPORT_FIELDS));
const stops = ['a', 'b', 'c'].map((name) => ({ location: { name, lat: 0, lng: 0 }, time: '' }));
act(() => result.current.setCarStops(stops));
act(() => result.current.moveCarStop(0, 1));
expect(result.current.carStops.map((s) => s.location?.name)).toEqual(['b', 'a', 'c']);
const before = result.current.carStops;
act(() => result.current.moveCarStop(0, -1));
act(() => result.current.moveCarStop(2, 1));
expect(result.current.carStops).toBe(before);
});
it('FE-PLANNER-TRANSPORTFORM-003: a new booking starts on the given day with an empty route', () => {
const { result } = renderHook(() => useTransportForm(DESKTOP_FORM));
act(() => result.current.setFromPick({ location: { name: 'old', lat: 0, lng: 0 } }));
act(() => result.current.seed(null, days, { ...seedOptions, dayId: 2 }));
expect(result.current.form).toEqual({ ...DESKTOP_FORM, start_day_id: 2, end_day_id: 2 });
expect(result.current.fromPick).toEqual({});
expect(result.current.waypoints).toEqual([emptyWaypoint(2), emptyWaypoint(2)]);
expect(result.current.trainWaypoints).toEqual([emptyStationWaypoint(2), emptyStationWaypoint(2)]);
expect(result.current.carStops).toEqual([]);
});
it('FE-PLANNER-TRANSPORTFORM-004: a saved booking fills the shared fields and the ones the shell adds', () => {
const { result } = renderHook(() => useTransportForm(DESKTOP_FORM));
const saved = buildReservation({
type: 'shuttle',
title: 'Ride',
url: 'https://ride.test',
metadata: JSON.stringify({ seat: '7' }),
endpoints: [ep('from', 0), ep('to', 1)],
});
act(() =>
result.current.seed(saved, days, {
...seedOptions,
extraFields: (src, meta) => ({ url: src.url || '', meta_seat: meta.seat || '' }),
})
);
expect(result.current.form).toMatchObject({
title: 'Ride',
type: 'transport_other',
url: 'https://ride.test',
meta_seat: '7',
});
expect(result.current.fromPick.location?.name).toBe('from-0');
expect(result.current.toPick.location?.name).toBe('to-1');
act(() => result.current.seed(saved, days, { ...seedOptions, fallbackType: 'flight' }));
expect(result.current.form.type).toBe('flight');
expect(result.current.form).not.toHaveProperty('url');
});
it('FE-PLANNER-TRANSPORTFORM-005: the desktop keeps the rows a flight does not show, the phone clears them', () => {
const flight = buildReservation({ type: 'flight', endpoints: [ep('from', 0, 'MUC'), ep('to', 1, 'BER')] });
const picked = { location: { name: 'kept', lat: 0, lng: 0 } };
const desktop = renderHook(() => useTransportForm(EMPTY_TRANSPORT_FIELDS)).result;
act(() => desktop.current.setFromPick(picked));
act(() => desktop.current.seed(flight, days, seedOptions));
expect(desktop.current.fromPick).toBe(picked);
expect(desktop.current.waypoints.map((w) => w.airport?.iata)).toEqual(['MUC', 'BER']);
const phone = renderHook(() => useTransportForm(EMPTY_TRANSPORT_FIELDS)).result;
act(() => phone.current.setFromPick(picked));
act(() => phone.current.seed(flight, days, { ...seedOptions, resetHiddenRoutes: true }));
expect(phone.current.fromPick).toEqual({});
});
it('FE-PLANNER-TRANSPORTFORM-006: a per-leg booking code is offered only with more than two picked stops', () => {
const { result } = renderHook(() => useTransportForm(EMPTY_TRANSPORT_FIELDS));
const airport = { iata: 'X', icao: null, name: 'X', city: '', country: '', lat: 0, lng: 0, tz: '' };
act(() =>
result.current.setWaypoints([{ ...emptyWaypoint(), airport }, emptyWaypoint(), { ...emptyWaypoint(), airport }])
);
expect(result.current.writesFlightLegs).toBe(false);
act(() => result.current.setWaypoints((prev) => prev.map((w) => ({ ...w, airport }))));
expect(result.current.writesFlightLegs).toBe(true);
const station = { name: 'S', lat: 0, lng: 0 };
expect(result.current.writesTrainLegs).toBe(false);
act(() =>
result.current.setTrainWaypoints([1, 2, 3].map(() => ({ ...emptyStationWaypoint(), location: station })))
);
expect(result.current.writesTrainLegs).toBe(true);
});
it('FE-PLANNER-TRANSPORTFORM-007: the payload is built from what the form holds now', () => {
const { result } = renderHook(() => useTransportForm(EMPTY_TRANSPORT_FIELDS));
act(() => result.current.set('type', 'bus'));
act(() => result.current.set('title', 'Coach'));
act(() => result.current.set('start_day_id', 1));
act(() => result.current.setToPick({ location: { name: 'B', lat: 1, lng: 1 } }));
const payload = result.current.payload(days, {
reservation: null,
prefill: null,
budgetEnabled: false,
anchorOnStations: true,
emptyMetadataAsNull: true,
});
expect(payload).toMatchObject({ title: 'Coach', type: 'bus', day_id: 1, metadata: null });
expect(payload.endpoints).toEqual([
expect.objectContaining({ role: 'to', name: 'B', sequence: 1, local_date: '2026-05-01' }),
]);
});
});
@@ -0,0 +1,144 @@
import { useState } from 'react';
import type { Day, Reservation } from '../../types';
import { usesStationRoute } from '../../utils/flightLegs';
import { toggledTraveler } from './bookingFormModel';
import {
blankTransportRoute,
buildTransportPayload,
type CarStopForm,
emptyStationWaypoint,
emptyWaypoint,
type EndpointPick,
readTransportMeta,
type RouteSeedOptions,
seedTransportRoute,
type StationWaypointForm,
type TransportFields,
transportFieldsFrom,
type TransportMetaFields,
type TransportPayloadOptions,
type TransportRoute,
type TransportType,
transportTypeOf,
type WaypointForm,
} from './transportEndpoints';
export interface TransportSeedOptions<F extends TransportFields> extends RouteSeedOptions {
/** The type a saved booking of an unknown type opens as. */
fallbackType: TransportType;
/** The day a new booking starts on. */
dayId: string | number;
/** The fields a shell keeps beyond the shared ones, read from the saved booking. */
extraFields?: (src: Reservation, meta: TransportMetaFields) => Omit<F, keyof TransportFields>;
}
/**
* The transport booking form behind the desktop TransportModal and the phone's
* transport sheet: the fields, the route rows (flight airports, train or cruise
* stations, a car's stops), the travellers and the files picked before saving.
* Each shell decides when to seed it and what to do around a save.
*/
export function useTransportForm<F extends TransportFields>(emptyForm: F) {
const [form, setForm] = useState<F>({ ...emptyForm });
// Trains and cruises share the station form: a row of stops, each with its own
// arrival and departure (#1807).
const stationRoute = usesStationRoute(form.type);
// Manual vs Automated (public transit search) creation mode (#1065).
const [automated, setAutomated] = useState(false);
const [fromPick, setFromPick] = useState<EndpointPick>({});
const [toPick, setToPick] = useState<EndpointPick>({});
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()]);
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([
emptyStationWaypoint(),
emptyStationWaypoint(),
]);
// A car keeps its pick-up and return as the frame of the rental and gains the stops
// in between (#1797): one booking, one continuous drive, several places along it.
const [carStops, setCarStops] = useState<CarStopForm[]>([]);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
// Travelers assigned to this booking (#1517), seeded on open and persisted after the save.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set());
const [isSaving, setIsSaving] = useState(false);
const set = (field: keyof F, value: string | number) => setForm((prev) => ({ ...prev, [field]: value }));
/** Swaps a stop with its neighbour. Purely local: `sequence` is derived on save. */
const moveCarStop = (index: number, delta: number): void => {
setCarStops((prev) => {
const to = index + delta;
if (to < 0 || to >= prev.length) return prev;
const next = [...prev];
[next[index], next[to]] = [next[to], next[index]];
return next;
});
};
const toggleTraveler = (id: number) => setTravelerIds((prev) => toggledTraveler(prev, id));
const applyRoute = (route: TransportRoute) => {
if (route.waypoints) setWaypoints(route.waypoints);
if (route.trainWaypoints) setTrainWaypoints(route.trainWaypoints);
if (route.fromPick) setFromPick(route.fromPick);
if (route.toPick) setToPick(route.toPick);
if (route.carStops) setCarStops(route.carStops);
};
/**
* Fills the form from a saved booking or an import prefill, or empties it for a
* new booking on `opts.dayId`. The fields, the type and the route rows; the shell
* seeds the travellers, the files and the mode itself.
*/
const seed = (src: Reservation | null, days: Day[], opts: TransportSeedOptions<F>) => {
if (src) {
const meta = readTransportMeta(src);
const type = transportTypeOf(src.type, opts.fallbackType);
setForm({ ...transportFieldsFrom(src, days, type), ...opts.extraFields?.(src, meta) } as F);
applyRoute(seedTransportRoute(src, meta, type, days, opts));
} else {
setForm({ ...emptyForm, start_day_id: opts.dayId, end_day_id: opts.dayId });
applyRoute(blankTransportRoute(opts.dayId));
}
};
/** The request body for what the form holds now. */
const payload = (days: Day[], opts: TransportPayloadOptions) =>
buildTransportPayload({ form, waypoints, trainWaypoints, fromPick, toPick, carStops }, days, opts);
// The per-leg booking code is only offered where the save actually writes
// metadata.legs: the same condition, over the waypoints that become endpoints,
// not over the raw rows. Anywhere else the value would vanish on save (#1943).
const writesFlightLegs = waypoints.filter((w) => w.airport).length > 2;
const writesTrainLegs = trainWaypoints.filter((w) => w.location).length > 2;
return {
form,
setForm,
set,
stationRoute,
automated,
setAutomated,
fromPick,
setFromPick,
toPick,
setToPick,
waypoints,
setWaypoints,
trainWaypoints,
setTrainWaypoints,
carStops,
setCarStops,
moveCarStop,
pendingFiles,
setPendingFiles,
travelerIds,
setTravelerIds,
toggleTraveler,
isSaving,
setIsSaving,
seed,
payload,
writesFlightLegs,
writesTrainLegs,
};
}
@@ -12,7 +12,7 @@ import { CustomDatePicker } from '../../../../components/shared/CustomDateTimePi
import { BookingCodeInput } from '../../../../components/shared/BookingCode'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
import { uploadBookingFiles } from './uploadBookingFiles'
import { uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
import { buildAssignmentOptions } from '../../../../components/Planner/assignmentOptions'
import GuestBadge from '../../../../components/shared/GuestBadge'
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
@@ -287,7 +287,9 @@ export default function MReservationSheet({ planner, onOpenExpense }: MReservati
const changed = original.length !== next.length || next.some(id => !original.includes(id))
if (changed) await setReservationTravelers(tripId, savedId, next)
}
await uploadBookingFiles(planner, saved?.id, pendingFiles, form.title)
if (saved?.id && canUploadFiles) {
await uploadBookingFiles(fd => planner.tripActions.addFile(tripId, fd), saved.id, pendingFiles, form.title)
}
if (withExpense && saved?.id) {
onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
@@ -1,26 +1,28 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Bike, Bus, Car, CarTaxiFront, Check, ChevronDown, ChevronUp, Plane, Plus, Route, Sailboat, CableCar, Ship, Train, TrainFront, TramFront, Trash2, X } from 'lucide-react'
import { Check, ChevronDown, ChevronUp, Plus, TrainFront, TramFront, Trash2, X } from 'lucide-react'
import MSheet from '../../../components/MSheet'
import { useAddonStore } from '../../../../store/addonStore'
import { useTranslation } from '../../../../i18n'
import { formatDate, resolveDayId, splitReservationDateTime } from '../../../../utils/formatters'
import { orderedEndpoints, parseReservationMetadata, stripAirportCode, usesStationRoute } from '../../../../utils/flightLegs'
import { typeToCostCategory } from '@trek/shared'
import CustomSelect from '../../../../components/shared/CustomSelect'
import CustomTimePicker from '../../../../components/shared/CustomTimePicker'
import { BookingCodeInput } from '../../../../components/shared/BookingCode'
import AirportSelect, { type Airport } from '../../../../components/Planner/AirportSelect'
import LocationSelect, { type LocationPoint } from '../../../../components/Planner/LocationSelect'
import AirportSelect from '../../../../components/Planner/AirportSelect'
import LocationSelect from '../../../../components/Planner/LocationSelect'
import { toLocationPicks } from '../../../../components/Planner/locationPicks'
import { importedPriceEntry } from '../../../../components/Planner/importedPrice'
import TransitSearchPanel from '../../../../components/Planner/TransitSearchPanel'
import {
EMPTY_TRANSPORT_FIELDS, TRANSPORT_TYPE_OPTIONS as TYPE_OPTIONS, emptyCarStop, emptyStationWaypoint, emptyWaypoint,
transportDayOptions, type StationWaypointForm, type WaypointForm,
} from '../../../../components/Planner/transportEndpoints'
import { useTransportForm } from '../../../../components/Planner/useTransportForm'
import { travelerIdsOf, travelersChanged, uploadBookingFiles } from '../../../../components/Planner/bookingFormModel'
import { Eyebrow, FIELD_AREA_CLS, FIELD_CLS, FormSheetFooter, FormSheetHeader } from './PlSheetChrome'
import MBookingFilesCosts from './MBookingFilesCosts'
import { uploadBookingFiles } from './uploadBookingFiles'
import GuestBadge from '../../../../components/shared/GuestBadge'
import { SPLIT_COLORS } from '../../../../components/Budget/BudgetPanel.constants'
import { useTripStore } from '../../../../store/tripStore'
import type { Day, Place, Reservation, ReservationEndpoint, TripMember } from '../../../../types'
import type { Place, Reservation, TripMember } from '../../../../types'
import type { BookingReviewDraft } from '../../../../components/Planner/parsedItemToDraft'
import type { BookingExpenseRequest } from '../../../../components/Planner/BookingCostsSection.types'
import type { TripPlanner } from '../MTripShell'
@@ -30,113 +32,18 @@ export interface MTransportFormSheetProps {
onOpenExpense: (req: BookingExpenseRequest) => void
}
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'cable_car', 'transit', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
const TYPE_OPTIONS = [
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
{ value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus },
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
{ value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront },
{ value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike },
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
{ value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat },
{ value: 'cable_car', labelKey: 'reservations.type.cable_car', Icon: CableCar },
{ value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route },
]
interface EndpointPick {
airport?: Airport
location?: LocationPoint
}
// ── Endpoint / metadata helpers (ported 1:1 from the desktop TransportModal so
// the saved shape is byte-identical). ──────────────────────────────────────────
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return { role, sequence, name: a.city ? `${a.city} (${a.iata})` : a.name, code: a.iata, lat: a.lat, lng: a.lng, timezone: a.tz, local_date: date, local_time: time }
}
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
return { role, sequence, name: l.name, code: null, lat: l.lat, lng: l.lng, timezone: null, local_date: date, local_time: time }
}
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
if (!e || !e.code) return null
return { iata: e.code, icao: null, name: e.name, city: stripAirportCode(e.name), country: '', lat: e.lat, lng: e.lng, tz: e.timezone || '' }
}
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
if (!e) return null
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
}
// The places a driver planned between pick-up and return (#1797), the same shape the
// desktop TransportModal edits. Without an editor here the save below would rebuild the
// endpoint list from from/to alone and the server, which replaces every endpoint row of a
// booking, would drop the stops a phone never showed.
interface CarStopForm {
location: LocationPoint | null
time: string
}
const emptyCarStop = (): CarStopForm => ({ location: null, time: '' })
// A flight is an ordered list of airports; N waypoints = N-1 legs. The origin
// only departs, the destination only arrives, each stop does both.
interface WaypointForm {
airport: Airport | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
airline: string
flight_number: string
seat: string
// Booking reference of the leg leaving this waypoint (#1943); empty means the
// booking's own reference covers it.
confirmation_number: string
}
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '', confirmation_number: '' }
}
// A train mirrors the flight route model, but its waypoints are STATIONS
// (location search) and each leg carries a train number + platform.
interface StationWaypointForm {
location: LocationPoint | null
arrDayId: string | number
arrTime: string
depDayId: string | number
depTime: string
train_number: string
platform: string
seat: string
confirmation_number: string
}
function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm {
return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '', confirmation_number: '' }
}
// Traveler picker row — same surface as the cost-split rows (bg on --m-ic).
const TRAVELER_ROW_CLS = 'flex w-full items-center gap-[9px] rounded-[12px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] px-3 py-[9px] text-start'
const EMPTY = {
title: '',
type: 'flight' as TransportType,
status: 'pending' as 'pending' | 'confirmed',
start_day_id: '' as string | number,
end_day_id: '' as string | number,
departure_time: '',
arrival_time: '',
confirmation_number: '',
notes: '',
}
/**
* Add/edit transport sheet — the mobile counterpart of the desktop
* TransportModal, driven by the planner's own editor flags (showTransportModal /
* editingTransport / transportPrefill / transportModalAutomated) so every entry
* point (transports tab, day header, timeline, "change route", import review)
* opens it unchanged. The manual tab supports single- and multi-leg flights /
* trains; the automated tab embeds the shared TransitSearchPanel. Saving reuses
* planner.handleSaveTransport, whose payload shape is preserved byte-for-byte.
* trains; the automated tab embeds the shared TransitSearchPanel. The form, its
* route rows and the saved shape come from useTransportForm, as on the desktop;
* saving reuses planner.handleSaveTransport.
*/
export default function MTransportFormSheet({ planner, onOpenExpense }: MTransportFormSheetProps) {
const {
@@ -158,20 +65,12 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
// The trip's places, offered by every location field of the manual tab (#2468).
const locationPicks = useMemo(() => toLocationPicks(places), [places])
const [form, setForm] = useState({ ...EMPTY })
// Trains and cruises share the station list (#1807), as on desktop.
const stationRoute = usesStationRoute(form.type)
const [automated, setAutomated] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
const [trainWaypoints, setTrainWaypoints] = useState<StationWaypointForm[]>([emptyStationWaypoint(), emptyStationWaypoint()])
const [carStops, setCarStops] = useState<CarStopForm[]>([])
const [pendingFiles, setPendingFiles] = useState<File[]>([])
// Travelers assigned to this booking (#1517) — seeded from the editing
// reservation on open, persisted separately after the save resolves.
const [travelerIds, setTravelerIds] = useState<Set<number>>(new Set())
const [isSaving, setIsSaving] = useState(false)
const {
form, set, stationRoute, automated, setAutomated, fromPick, setFromPick, toPick, setToPick,
waypoints, setWaypoints, trainWaypoints, setTrainWaypoints, carStops, setCarStops, moveCarStop,
pendingFiles, setPendingFiles, travelerIds, setTravelerIds, toggleTraveler, isSaving, setIsSaving,
seed, payload: buildPayload, writesFlightLegs, writesTrainLegs,
} = useTransportForm(EMPTY_TRANSPORT_FIELDS)
// Ref (not state) so handleSubmit reads the intent set by the same click — a
// state value would be stale in that render's closure and never open the editor.
const expenseIntentRef = useRef(false)
@@ -187,170 +86,23 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
setDeleteArmed(false)
// On a review-import, seed the booking's Files with the parsed source document.
setPendingFiles(!editingTransport && transportPrefill?._sourceFiles ? transportPrefill._sourceFiles : [])
setTravelerIds(new Set((editingTransport?.travelers || []).map(tv => tv.user_id)))
setTravelerIds(travelerIdsOf(editingTransport))
// Edit uses the saved `editingTransport`; a review-import populates from the
// prefill. Either way the init reads the same fields; the reservation still
// decides edit-vs-create at submit time.
const src = (editingTransport ?? transportPrefill) as Reservation | null
if (src) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const meta = typeof src.metadata === 'string' ? JSON.parse(src.metadata || '{}') : ((src.metadata as any) || {})
const eps = src.endpoints || []
const from = eps.find(e => e.role === 'from')
const to = eps.find(e => e.role === 'to')
const type = (TRANSPORT_TYPES as readonly string[]).includes(src.type) ? (src.type as TransportType) : 'flight'
setForm({
title: src.title || '',
type,
status: src.status === 'confirmed' ? 'confirmed' : 'pending',
// For an edit, keep the saved day; for an imported prefill (no day_id),
// resolve it from the parsed pick-up/return date so it isn't lost.
start_day_id: src.day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_time).date),
end_day_id: src.end_day_id ?? resolveDayId(days, splitReservationDateTime(src.reservation_end_time).date),
departure_time: splitReservationDateTime(src.reservation_time).time ?? '',
arrival_time: splitReservationDateTime(src.reservation_end_time).time ?? '',
confirmation_number: src.confirmation_number || '',
notes: src.notes || '',
})
// Only an import prefill carries a per-endpoint local_date without a day_id. On an
// edit the saved day wins: local_date is denormalised and can lag behind after a
// day drag, insertDay or a trip-date shift (mirrors TransportModal).
const endpointDayId = (ep?: { local_date?: string | null } | null) =>
editingTransport ? '' : resolveDayId(days, ep?.local_date)
// Origin and destination fall back to the reservation's own day columns. An
// intermediate stop has none, so when metadata.legs is missing (imported or
// MCP-created bookings) its local_date is the only day left to seed from.
const stopDayId = (ep?: { local_date?: string | null } | null) => resolveDayId(days, ep?.local_date)
if (type === 'flight') {
const orderedEps = orderedEndpoints(src)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: WaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1]
const legOut = metaLegs[i]
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
const stopDay = !isFirst && !isLast ? stopDayId(ep) : ''
return {
airport: airportFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : stopDay)),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : stopDay)),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// The booking's own reference stays in the form's field, never on a
// leg, so a plain re-save cannot duplicate it onto the first segment.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
const dep = emptyWaypoint(endpointDayId(from) || (src.day_id ?? ''))
dep.airport = airportFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.airline = meta.airline ?? ''
dep.flight_number = meta.flight_number ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? ''))
arr.airport = airportFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setWaypoints(wps)
setFromPick({})
setToPick({})
} else if (usesStationRoute(type)) {
const orderedEps = orderedEndpoints(src)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
let wps: StationWaypointForm[]
if (orderedEps.length >= 2) {
wps = orderedEps.map((ep, i) => {
const legInto = metaLegs[i - 1]
const legOut = metaLegs[i]
const isFirst = i === 0
const isLast = i === orderedEps.length - 1
const stopDay = !isFirst && !isLast ? stopDayId(ep) : ''
return {
location: locationFromEndpoint(ep),
arrDayId: legInto?.arr_day_id ?? (endpointDayId(ep) || (isLast ? (src.end_day_id ?? '') : stopDay)),
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
depDayId: legOut?.dep_day_id ?? (endpointDayId(ep) || (isFirst ? (src.day_id ?? '') : stopDay)),
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
train_number: legOut?.train_number ?? (isFirst ? (meta.train_number ?? '') : ''),
platform: legOut?.platform ?? (isFirst ? (meta.platform ?? '') : ''),
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
// See the flight branch: the booking's own reference stays out of the legs.
confirmation_number: legOut?.confirmation_number ?? '',
}
})
} else {
const dep = emptyStationWaypoint(endpointDayId(from) || (src.day_id ?? ''))
dep.location = locationFromEndpoint(from)
dep.depTime = splitReservationDateTime(src.reservation_time).time ?? ''
dep.train_number = meta.train_number ?? ''
dep.platform = meta.platform ?? ''
dep.seat = meta.seat ?? ''
const arr = emptyStationWaypoint(endpointDayId(to) || (src.end_day_id ?? src.day_id ?? ''))
arr.location = locationFromEndpoint(to)
arr.arrTime = splitReservationDateTime(src.reservation_end_time).time ?? ''
wps = [dep, arr]
}
setTrainWaypoints(wps)
setFromPick({})
setToPick({})
} else {
setFromPick({ location: locationFromEndpoint(from) || undefined })
setToPick({ location: locationFromEndpoint(to) || undefined })
setWaypoints([emptyWaypoint(), emptyWaypoint()])
setTrainWaypoints([emptyStationWaypoint(), emptyStationWaypoint()])
// Stops persist for every type; only a car offers an editor for them, so only a
// car reads them back into one. The others keep passing theirs through untouched.
setCarStops(
src.type === 'car'
? (src.endpoints ?? [])
.filter(e => e.role === 'stop')
.slice()
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
.map(e => ({ location: locationFromEndpoint(e), time: e.local_time ?? '' }))
: [],
)
}
} else {
setForm({ ...EMPTY, start_day_id: transportModalDayId ?? '', end_day_id: transportModalDayId ?? '' })
setFromPick({})
setToPick({})
setWaypoints([emptyWaypoint(transportModalDayId ?? ''), emptyWaypoint(transportModalDayId ?? '')])
setTrainWaypoints([emptyStationWaypoint(transportModalDayId ?? ''), emptyStationWaypoint(transportModalDayId ?? '')])
setCarStops([])
}
seed((editingTransport ?? transportPrefill) as Reservation | null, days, {
isEdit: !!editingTransport,
fallbackType: 'flight',
dayId: transportModalDayId ?? '',
stopDaysFromEndpoints: true,
resetHiddenRoutes: true,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showTransportModal])
const res = snap.res
const prefill = snap.prefill
const set = (field: keyof typeof EMPTY, value: string | number) => setForm(prev => ({ ...prev, [field]: value }))
const moveCarStop = (index: number, delta: number): void => {
setCarStops(prev => {
const to = index + delta
if (to < 0 || to >= prev.length) return prev
const next = [...prev]
;[next[index], next[to]] = [next[to], next[index]]
return next
})
}
const toggleTraveler = (id: number) => setTravelerIds(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id); else next.add(id)
return next
})
const TravelerAvatar = ({ m, idx, dim }: { m: TripMember; idx: number; dim: boolean }) =>
m.avatar_url
@@ -368,20 +120,7 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
const showModeToggle = !res && tripHasDates
const dayOptions = [
{ value: '', label: '—' },
...days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
const dayBadge = d.title ? t('dayplan.dayN', { n: d.day_number }) : undefined
return { value: d.id, label: d.title || t('dayplan.dayN', { n: d.day_number }), badge: dateBadge ?? dayBadge }
}),
]
// Same condition handleSubmit uses to write metadata.legs, over the waypoints
// that actually become endpoints: below it there are no legs to hold a
// per-segment booking code and the value would be dropped on save (#1943).
const writesFlightLegs = waypoints.filter(w => w.airport).length > 2
const writesTrainLegs = trainWaypoints.filter(w => w.location).length > 2
const dayOptions = transportDayOptions(days, t, locale)
const handleClose = () => {
if (importReviewActive) { advanceImportReview(); return }
@@ -407,190 +146,22 @@ export default function MTransportFormSheet({ planner, onOpenExpense }: MTranspo
expenseIntentRef.current = false
setIsSaving(true)
try {
const startDay = days.find(d => d.id === Number(form.start_day_id))
const endDay = days.find(d => d.id === Number(form.end_day_id))
const buildTime = (day: Day | undefined, time: string): string | null => {
if (!time) return null
return day?.date ? `${day.date}T${time}` : time
}
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
const firstWp = flightWps[0]
const lastWp = flightWps[flightWps.length - 1]
const trainWps = stationRoute ? trainWaypoints : []
const trainStations = trainWps.filter(w => w.location)
// The day/time anchors have to be the rows that actually become endpoints
// (same as the flight path); only a train without a single picked station
// falls back to the raw rows so its day and time survive.
const trainAnchors = trainStations.length > 0 ? trainStations : trainWps
const firstTrainWp = trainAnchors[0]
const lastTrainWp = trainAnchors[trainAnchors.length - 1]
// Per-leg day-plan positions are owned by the day planner — keep them on re-save.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const origLegs: any[] = res ? (parseReservationMetadata(res).legs || []) : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const metadata: Record<string, any> = {}
if (form.type === 'flight') {
if (firstWp?.airline) metadata.airline = firstWp.airline
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
if (firstWp?.airport) {
metadata.departure_airport = firstWp.airport.iata
metadata.departure_timezone = firstWp.airport.tz
}
if (lastWp?.airport) {
metadata.arrival_airport = lastWp.airport.iata
metadata.arrival_timezone = lastWp.airport.tz
}
if (flightWps.length > 2) {
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
const next = flightWps[i + 1]
return {
from: w.airport!.iata,
to: next.airport!.iata,
...(w.airline ? { airline: w.airline } : {}),
...(w.flight_number ? { flight_number: w.flight_number } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
if (firstWp?.seat) metadata.seat = firstWp.seat
} else if (stationRoute) {
if (firstTrainWp?.train_number) metadata.train_number = firstTrainWp.train_number
if (firstTrainWp?.platform) metadata.platform = firstTrainWp.platform
if (firstTrainWp?.seat) metadata.seat = firstTrainWp.seat
if (trainStations.length > 2) {
metadata.legs = trainStations.slice(0, -1).map((w, i) => {
const next = trainStations[i + 1]
return {
from: w.location!.name,
to: next.location!.name,
...(w.train_number ? { train_number: w.train_number } : {}),
...(w.platform ? { platform: w.platform } : {}),
...(w.seat ? { seat: w.seat } : {}),
...(w.confirmation_number ? { confirmation_number: w.confirmation_number } : {}),
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
dep_time: w.depTime || null,
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
arr_time: next.arrTime || null,
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
}
})
}
}
// A transit itinerary lives in metadata.transit + 'stop' endpoints, which
// this form neither shows nor edits — keep them while from/to are unchanged.
const prevMeta = res ? parseReservationMetadata(res) : {}
const prevEndpointsAll = res?.endpoints || []
const prevFrom = prevEndpointsAll.find(ep => ep.role === 'from')
const prevTo = prevEndpointsAll.find(ep => ep.role === 'to')
const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6
const keepTransit = !!(prevMeta.transit && form.type !== 'flight' &&
prevFrom && prevTo && fromPick.location && toPick.location &&
near(prevFrom.lat, fromPick.location.lat) && near(prevFrom.lng, fromPick.location.lng) &&
near(prevTo.lat, toPick.location.lat) && near(prevTo.lng, toPick.location.lng))
if (keepTransit) metadata.transit = prevMeta.transit
// A joined AirTrail import records its source flight ids in metadata.airtrail_ids.
if (Array.isArray(prevMeta.airtrail_ids)) metadata.airtrail_ids = prevMeta.airtrail_ids
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
if (form.type === 'flight') {
flightWps.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === flightWps.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
})
} else if (stationRoute) {
trainStations.forEach((w, i) => {
const isFirst = i === 0
const isLast = i === trainStations.length - 1
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
const dId = isLast ? w.arrDayId : w.depDayId
const time = isLast ? w.arrTime : w.depTime
const date = dayDate(dId) ?? (isLast ? dayDate(firstTrainWp?.depDayId ?? '') : null)
endpoints.push(endpointFromLocation(w.location!, role, i, date, time || null))
})
} else {
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
// A car writes the stops the driver planned; every other type keeps passing the
// itinerary's transfer stops through while the route is unchanged (#1065).
const carEndpoints = form.type === 'car'
? carStops
.filter(s => s.location)
.map((s, i) => endpointFromLocation(s.location!, 'stop', i + 1, startDate, s.time || null))
: []
const stops = keepTransit && form.type !== 'car'
? prevEndpointsAll.filter(ep => ep.role === 'stop').slice().sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
: []
stops.forEach((s, i) => endpoints.push({
role: 'stop', sequence: i + 1, name: s.name, code: s.code ?? null,
lat: s.lat, lng: s.lng, timezone: s.timezone ?? null,
local_date: s.local_date ?? null, local_time: s.local_time ?? null,
}))
carEndpoints.forEach(e => endpoints.push(e))
const stopCount = stops.length + carEndpoints.length
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stopCount + 1, endDate, form.arrival_time || null))
}
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
const trainDepDay = firstTrainWp && firstTrainWp.depDayId ? Number(firstTrainWp.depDayId) : null
const trainArrDay = lastTrainWp && lastTrainWp.arrDayId ? Number(lastTrainWp.arrDayId) : null
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const payload: Record<string, any> & { title: string } = {
title: form.title,
type: form.type,
status: form.status,
day_id: form.type === 'flight' ? flightDepDay : stationRoute ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
end_day_id: form.type === 'flight' ? flightArrDay : stationRoute ? trainArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
reservation_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
: stationRoute
? buildTime(days.find(d => d.id === trainDepDay), firstTrainWp?.depTime || '')
: buildTime(startDay, form.departure_time),
reservation_end_time: form.type === 'flight'
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
: stationRoute
? buildTime(days.find(d => d.id === trainArrDay) ?? days.find(d => d.id === trainDepDay), lastTrainWp?.arrTime || '')
: buildTime(endDay ?? startDay, form.arrival_time),
location: null,
confirmation_number: form.confirmation_number || null,
notes: form.notes || null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints,
needs_review: false,
}
// Imported booking → auto-create the linked cost from the parsed price
// (only on create and only when a price is present).
if (!res && prefill && isBudgetEnabled) {
const entry = importedPriceEntry(prefill.metadata, form.type)
if (entry) payload.create_budget_entry = entry
}
const saved = await saveTransport(payload)
const saved = await saveTransport(buildPayload(days, {
reservation: res, prefill, budgetEnabled: isBudgetEnabled, anchorOnStations: true, emptyMetadataAsNull: true,
}))
// Persist the traveler assignment once we have the reservation id (from the
// save result on create, or the edited reservation) — only when it changed.
const savedId = saved?.id ?? res?.id
if (savedId) {
const original = (res?.travelers || []).map(tv => tv.user_id)
const next = [...travelerIds]
const changed = original.length !== next.length || next.some(id => !original.includes(id))
if (changed) await setReservationTravelers(tripId, savedId, next)
const { changed, nextIds } = travelersChanged(res, travelerIds)
if (changed) await setReservationTravelers(tripId, savedId, nextIds)
}
// Runs after both a create and an edit: the sheet only holds files the user just
// picked, so nothing is uploaded twice, and skipping the edit dropped them without
// a word (#2534). A save that did not come back with a record uploads nothing.
if (saved?.id && canUploadFiles) {
await uploadBookingFiles(fd => planner.tripActions.addFile(tripId, fd), saved.id, pendingFiles, form.title)
}
await uploadBookingFiles(planner, saved?.id, pendingFiles, form.title)
if (withExpense && saved?.id) {
onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
@@ -1,24 +0,0 @@
import type { TripPlanner } from '../MTripShell'
/**
* Uploads the files picked in a booking or transport sheet against the saved
* record. Runs after both a create and an edit: the sheet only holds files the
* user just picked, so nothing is uploaded twice, and skipping the edit dropped
* them without a word (#2534). A save that did not come back with a record
* uploads nothing.
*/
export async function uploadBookingFiles(
planner: Pick<TripPlanner, 'tripId' | 'tripActions' | 'canUploadFiles'>,
savedId: number | undefined,
files: File[],
description: string,
): Promise<void> {
if (!savedId || !planner.canUploadFiles) return
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(savedId))
fd.append('description', description)
await planner.tripActions.addFile(planner.tripId, fd)
}
}