diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index bb5e5f9b..923b45ff 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useMemo, useRef } from 'react' import { useParams } from 'react-router-dom' -import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react' +import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react' import Modal from '../shared/Modal' import CustomSelect from '../shared/CustomSelect' import CustomTimePicker from '../shared/CustomTimePicker' @@ -14,6 +14,7 @@ import { formatDate, splitReservationDateTime } from '../../utils/formatters' import { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' +import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const type TransportType = typeof TRANSPORT_TYPES[number] @@ -23,7 +24,7 @@ interface EndpointPick { location?: LocationPoint } -function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit { +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, @@ -63,6 +64,24 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint 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 +} +function emptyWaypoint(dayId: string | number = ''): WaypointForm { + return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' } +} + const TYPE_OPTIONS = [ { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'train', labelKey: 'reservations.type.train', Icon: Train }, @@ -122,6 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel const [isSaving, setIsSaving] = 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()]) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [showFilePicker, setShowFilePicker] = useState(false) @@ -159,8 +180,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '', }) if (type === 'flight') { - setFromPick({ airport: airportFromEndpoint(from) || undefined }) - setToPick({ airport: airportFromEndpoint(to) || undefined }) + const orderedEps = orderedEndpoints(reservation) + 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), + arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''), + arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), + depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.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 ?? '') : ''), + } + }) + } else { + // Legacy flight with no (or partial) endpoints — seed two waypoints. + const dep = emptyWaypoint(reservation.day_id ?? '') + dep.airport = airportFromEndpoint(from) + dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? '' + dep.airline = meta.airline ?? '' + dep.flight_number = meta.flight_number ?? '' + const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '') + arr.airport = airportFromEndpoint(to) + arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? '' + wps = [dep, arr] + } + setWaypoints(wps) } else { setFromPick({ location: locationFromEndpoint(from) || undefined }) setToPick({ location: locationFromEndpoint(to) || undefined }) @@ -169,6 +220,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) setFromPick({}) setToPick({}) + setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')]) } }, [isOpen, reservation, selectedDayId, budgetItems]) @@ -187,17 +239,45 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel return day?.date ? `${day.date}T${time}` : time } - const metadata: Record = {} + 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] + // 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') { - if (form.meta_airline) metadata.airline = form.meta_airline - if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number - if (fromPick.airport) { - metadata.departure_airport = fromPick.airport.iata - metadata.departure_timezone = fromPick.airport.tz + // 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 (toPick.airport) { - metadata.arrival_airport = toPick.airport.iata - metadata.arrival_timezone = toPick.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 } : {}), + 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 } : {}), + } + }) } } else if (form.type === 'train') { if (form.meta_train_number) metadata.train_number = form.meta_train_number @@ -213,21 +293,35 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel const endDate = (endDay ?? startDay)?.date ?? null const endpoints: ReturnType[] = [] if (form.type === 'flight') { - if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null)) - if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null)) + 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 (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null)) if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null)) } + // Flights 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 payload = { title: form.title, type: form.type, status: form.status, - day_id: form.start_day_id ? Number(form.start_day_id) : null, - end_day_id: form.end_day_id ? Number(form.end_day_id) : null, - reservation_time: buildTime(startDay, form.departure_time), - reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time), + day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null), + end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null), + reservation_time: form.type === 'flight' + ? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '') + : buildTime(startDay, form.departure_time), + reservation_end_time: form.type === 'flight' + ? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '') + : buildTime(endDay ?? startDay, form.arrival_time), location: null, confirmation_number: form.confirmation_number || null, notes: form.notes || null, @@ -348,100 +442,126 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel placeholder={t('reservations.titlePlaceholder')} className={inputClass} /> - {/* From / To endpoints */} -
-
- - {form.type === 'flight' ? ( - setFromPick({ airport: a || undefined })} /> - ) : ( - setFromPick({ location: l || undefined })} /> - )} + {form.type === 'flight' ? ( + /* ── Flight route: ordered airports (origin · stops · destination) ── */ +
+ + {waypoints.map((wp, i) => { + const isFirst = i === 0 + const isLast = i === waypoints.length - 1 + const updateWp = (patch: Partial) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w))) + const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop') + return ( +
+
+
+ {roleLabel} +
+ updateWp({ airport: a || null })} /> +
+ {!isFirst && !isLast && ( + + )} +
+ {!isFirst && ( +
+
+ + updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+
+ + updateWp({ arrTime: v })} /> +
+ {wp.airport && ( +
+ +
{wp.airport.tz}
+
+ )} +
+ )} + {!isLast && ( + <> +
+
+ + updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+
+ + updateWp({ depTime: v })} /> +
+ {wp.airport && ( +
+ +
{wp.airport.tz}
+
+ )} +
+
+
+ + updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} /> +
+
+ + updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} /> +
+
+ + )} +
+ {!isLast && ( + + )} +
+ ) + })}
-
- - {form.type === 'flight' ? ( - setToPick({ airport: a || undefined })} /> - ) : ( - setToPick({ location: l || undefined })} /> - )} -
-
- - {/* Departure row */} -
-
- - set('start_day_id', value)} - placeholder={t('dayplan.dayN', { n: '?' })} - options={dayOptions} - size="sm" - /> -
-
- - set('departure_time', v)} /> -
- {form.type === 'flight' && fromPick.airport && ( -
- -
- {fromPick.airport.tz} + ) : ( + <> + {/* From / To endpoints (non-flight) */} +
+
+ + setFromPick({ location: l || undefined })} /> +
+
+ + setToPick({ location: l || undefined })} />
- )} -
- {/* Arrival row */} -
-
- - set('end_day_id', value)} - placeholder={t('dayplan.dayN', { n: '?' })} - options={dayOptions} - size="sm" - /> -
-
- - set('arrival_time', v)} /> -
- {form.type === 'flight' && toPick.airport && ( -
- -
- {toPick.airport.tz} + {/* Departure row */} +
+
+ + set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+
+ + set('departure_time', v)} />
- )} -
- {/* Flight-specific fields */} - {form.type === 'flight' && ( -
-
- - set('meta_airline', e.target.value)} - placeholder="Lufthansa" className={inputClass} /> + {/* Arrival row */} +
+
+ + set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" /> +
+
+ + set('arrival_time', v)} /> +
-
- - set('meta_flight_number', e.target.value)} - placeholder="LH 123" className={inputClass} /> -
-
+ )} {/* Train-specific fields */} diff --git a/client/src/utils/flightLegs.ts b/client/src/utils/flightLegs.ts new file mode 100644 index 00000000..c9504eb5 --- /dev/null +++ b/client/src/utils/flightLegs.ts @@ -0,0 +1,105 @@ +// Multi-leg (layover) flight support. +// +// A flight booking is ONE reservation whose route is an ordered chain of airports +// (e.g. FRA -> BER -> HND). The geometry + order are the source of truth in +// `reservation.endpoints` (role 'from' for the first airport, 'stop' for each +// intermediate one, 'to' for the last, ordered by `sequence`). The per-leg detail +// — airline, flight number, and each segment's own day/time — lives in +// `metadata.legs`. The top-level metadata (`departure_airport`/`arrival_airport`/ +// `airline`/`flight_number`) and `day_id`/`end_day_id` mirror the FIRST and LAST +// leg so legacy readers keep working. +// +// A legacy single-leg flight (two endpoints, flat metadata, no `metadata.legs`) +// is normalised here into a one-leg chain, so every renderer can use one path. + +import type { Reservation, ReservationEndpoint } from '../types' + +export interface FlightLeg { + from: string | null // IATA code (or null) + to: string | null + airline?: string + flight_number?: string + dep_day_id?: number | null + dep_time?: string | null // 'HH:mm' + arr_day_id?: number | null + arr_time?: string | null +} + +/** reservation.metadata may be a JSON string or an already-parsed object. */ +export function parseReservationMetadata(r: Pick): Record { + const m = r.metadata + if (!m) return {} + if (typeof m === 'string') { + try { + let parsed = JSON.parse(m || '{}') + // Defensive: an earlier bug could double-encode metadata (a JSON string of a + // JSON string) — unwrap it once more so saved flights heal on read. + if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed) } catch { /* keep */ } } + return (parsed && typeof parsed === 'object') ? parsed : {} + } catch { return {} } + } + return m as Record +} + +/** Endpoints ordered by `sequence` (geometry + order source of truth). */ +export function orderedEndpoints(r: Pick): ReservationEndpoint[] { + return (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) +} + +/** + * Ordered legs of a flight. `metadata.legs` is preferred; otherwise a single leg + * is derived from the endpoints (and finally the flat metadata) so that legacy + * single-leg flights — and flights created before this feature — still work. + */ +export function getFlightLegs(r: Reservation): FlightLeg[] { + const meta = parseReservationMetadata(r) + if (Array.isArray(meta.legs) && meta.legs.length > 0) { + return meta.legs.map((l: any): FlightLeg => ({ + from: l.from ?? null, + to: l.to ?? null, + airline: l.airline || undefined, + flight_number: l.flight_number || undefined, + dep_day_id: l.dep_day_id ?? null, + dep_time: l.dep_time ?? null, + arr_day_id: l.arr_day_id ?? null, + arr_time: l.arr_time ?? null, + })) + } + // Legacy fallback: one leg from the endpoints / flat metadata. + const eps = orderedEndpoints(r) + const first = eps[0] + const last = eps[eps.length - 1] + const fromCode = first?.code ?? meta.departure_airport ?? null + const toCode = last?.code ?? meta.arrival_airport ?? null + if (!fromCode && !toCode) return [] + return [{ + from: fromCode, + to: toCode, + airline: meta.airline || undefined, + flight_number: meta.flight_number || undefined, + dep_day_id: r.day_id ?? null, + dep_time: first?.local_time ?? null, + arr_day_id: r.end_day_id ?? r.day_id ?? null, + arr_time: last?.local_time ?? null, + }] +} + +/** Number of flight segments. 1 for a simple from -> to booking. */ +export function legCount(r: Reservation): number { + return getFlightLegs(r).length +} + +export function isMultiLegFlight(r: Reservation): boolean { + return r.type === 'flight' && legCount(r) > 1 +} + +/** + * Ordered route labels (IATA codes, or names when no code) for display, e.g. + * ['FRA','BER','HND']. Uses endpoints; falls back to the flat metadata pair. + */ +export function routeStops(r: Reservation): string[] { + const eps = orderedEndpoints(r) + if (eps.length >= 2) return eps.map(e => e.code || e.name) + const meta = parseReservationMetadata(r) + return [meta.departure_airport, meta.arrival_airport].filter(Boolean) as string[] +} diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index 702ddcbe..87ae0ee7 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -27,6 +27,11 @@ const reservations: TranslationStrings = { 'reservations.meta.flightNumber': 'Flight No.', 'reservations.meta.from': 'From', 'reservations.meta.to': 'To', + 'reservations.layover.route': 'Route', + 'reservations.layover.stop': 'Stop', + 'reservations.layover.addStop': 'Add stop', + 'reservations.layover.connection': 'Connection', + 'reservations.layover.layover': 'Layover', 'reservations.needsReview': 'Review', 'reservations.needsReviewHint': 'Airport could not be matched automatically — please confirm the location.',