diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index 3c26795f3..413ccfc4c 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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 { - 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 { - 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({}) - const [toPick, setToPick] = useState({}) - // Flight route as an ordered list of airports (origin .. stops .. destination). - const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]) - // Train route as an ordered list of stations (origin .. stops .. destination). - const [trainWaypoints, setTrainWaypoints] = useState([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([]) - - /** 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([]) const [linkedFileIds, setLinkedFileIds] = useState([]) - // Travelers assigned to this booking (#1517) — seeded on open, persisted after save. - const [travelerIds, setTravelerIds] = useState>(new Set()) const fileInputRef = useRef(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 = {} - 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[] = [] - 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).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. diff --git a/client/src/components/Planner/bookingFormModel.test.ts b/client/src/components/Planner/bookingFormModel.test.ts new file mode 100644 index 000000000..553b0d343 --- /dev/null +++ b/client/src/components/Planner/bookingFormModel.test.ts @@ -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); + +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(); + }); +}); diff --git a/client/src/components/Planner/bookingFormModel.ts b/client/src/components/Planner/bookingFormModel.ts new file mode 100644 index 000000000..5723ed84d --- /dev/null +++ b/client/src/components/Planner/bookingFormModel.ts @@ -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 | null | undefined): Set { + return new Set((reservation?.travelers || []).map((tv) => tv.user_id)); +} + +/** The picked travellers with one more or one less. */ +export function toggledTraveler(prev: Set, id: number): Set { + 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 | null | undefined, picked: Set) { + 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, + savedId: number, + files: File[], + description: string +): Promise { + 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); + } +} diff --git a/client/src/components/Planner/transportEndpoints.test.ts b/client/src/components/Planner/transportEndpoints.test.ts new file mode 100644 index 000000000..7a95828a7 --- /dev/null +++ b/client/src/components/Planner/transportEndpoints.test.ts @@ -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 { + 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 { + 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) => `${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' + ); + }); +}); diff --git a/client/src/components/Planner/transportEndpoints.ts b/client/src/components/Planner/transportEndpoints.ts new file mode 100644 index 000000000..ddb829ccf --- /dev/null +++ b/client/src/components/Planner/transportEndpoints.ts @@ -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; +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; +} + +/** 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): 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 { + 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( + 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, + 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 & { title: string }; + +const near = (a?: number | null, b?: number | null) => a != null && b != null && Math.abs(a - b) < 1e-6; + +type LegEnds = Pick; + +/** 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 = {}; + 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; +} diff --git a/client/src/components/Planner/useTransportForm.test.ts b/client/src/components/Planner/useTransportForm.test.ts new file mode 100644 index 000000000..8e808c9ff --- /dev/null +++ b/client/src/components/Planner/useTransportForm.test.ts @@ -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' }), + ]); + }); +}); diff --git a/client/src/components/Planner/useTransportForm.ts b/client/src/components/Planner/useTransportForm.ts new file mode 100644 index 000000000..ec1357373 --- /dev/null +++ b/client/src/components/Planner/useTransportForm.ts @@ -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 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; +} + +/** + * 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(emptyForm: F) { + const [form, setForm] = useState({ ...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({}); + const [toPick, setToPick] = useState({}); + const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]); + const [trainWaypoints, setTrainWaypoints] = useState([ + 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([]); + const [pendingFiles, setPendingFiles] = useState([]); + // Travelers assigned to this booking (#1517), seeded on open and persisted after the save. + const [travelerIds, setTravelerIds] = useState>(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) => { + 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, + }; +} diff --git a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx index 5cbcc101c..e035fc913 100644 --- a/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MReservationSheet.tsx @@ -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) } }) } diff --git a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx index a09c63e29..8df8d56f7 100644 --- a/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx +++ b/client/src/mobile/screens/trip/sheets/MTransportFormSheet.tsx @@ -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 { - 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 { - 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({}) - const [toPick, setToPick] = useState({}) - const [waypoints, setWaypoints] = useState([emptyWaypoint(), emptyWaypoint()]) - const [trainWaypoints, setTrainWaypoints] = useState([emptyStationWaypoint(), emptyStationWaypoint()]) - const [carStops, setCarStops] = useState([]) - const [pendingFiles, setPendingFiles] = useState([]) - // Travelers assigned to this booking (#1517) — seeded from the editing - // reservation on open, persisted separately after the save resolves. - const [travelerIds, setTravelerIds] = useState>(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 = {} - 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[] = [] - 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 & { 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) } }) } diff --git a/client/src/mobile/screens/trip/sheets/uploadBookingFiles.ts b/client/src/mobile/screens/trip/sheets/uploadBookingFiles.ts deleted file mode 100644 index a952d0e25..000000000 --- a/client/src/mobile/screens/trip/sheets/uploadBookingFiles.ts +++ /dev/null @@ -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, - savedId: number | undefined, - files: File[], - description: string, -): Promise { - 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) - } -}