From 203e425e7e2f71961d4513b90164bc854db2ae11 Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:28:39 +0200 Subject: [PATCH] refactor(planner): share the transport booking form between desktop and phone The transport dialog and the phone transport sheet keep their markup and run on useTransportForm: the route rows, how a saved booking or an import fills them, the day options and the request body come from transportEndpoints. Where the two shells differ today (unknown type fallback, stopover days, which rows are reset, train anchors, empty metadata, the link field) the shared code takes an option. The phone's upload helper moves to bookingFormModel next to the traveller checks both booking forms use. --- .../src/components/Planner/TransportModal.tsx | 579 +------------- .../Planner/bookingFormModel.test.ts | 53 ++ .../components/Planner/bookingFormModel.ts | 47 ++ .../Planner/transportEndpoints.test.ts | 599 ++++++++++++++ .../components/Planner/transportEndpoints.ts | 745 ++++++++++++++++++ .../Planner/useTransportForm.test.ts | 160 ++++ .../components/Planner/useTransportForm.ts | 144 ++++ .../screens/trip/sheets/MReservationSheet.tsx | 6 +- .../trip/sheets/MTransportFormSheet.tsx | 507 +----------- .../screens/trip/sheets/uploadBookingFiles.ts | 24 - 10 files changed, 1829 insertions(+), 1035 deletions(-) create mode 100644 client/src/components/Planner/bookingFormModel.test.ts create mode 100644 client/src/components/Planner/bookingFormModel.ts create mode 100644 client/src/components/Planner/transportEndpoints.test.ts create mode 100644 client/src/components/Planner/transportEndpoints.ts create mode 100644 client/src/components/Planner/useTransportForm.test.ts create mode 100644 client/src/components/Planner/useTransportForm.ts delete mode 100644 client/src/mobile/screens/trip/sheets/uploadBookingFiles.ts 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) - } -}