import { useState, useEffect, useRef } from 'react' import { useParams } from 'react-router-dom' import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, TramFront, 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' import AirportSelect, { type Airport } from './AirportSelect' import LocationSelect, { type LocationPoint } from './LocationSelect' 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 { openFile } from '../../utils/fileDownload' import apiClient from '../../api/client' import type { Day, Place, Accommodation, Reservation, ReservationEndpoint, TripFile, BudgetItem, AssignmentsMap } from '../../types' import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' import { BookingCostsSection } from './BookingCostsSection' import type { BookingExpenseRequest } from './BookingCostsSection.types' import type { BookingReviewDraft } from './parsedItemToDraft' import TransitSearchPanel, { type PickedPlace } from './TransitSearchPanel' import { typeToCostCategory } from '@trek/shared' const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', '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: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''), 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 } function emptyWaypoint(dayId: string | number = ''): WaypointForm { return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' } } // ── 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 } function emptyStationWaypoint(dayId: string | number = ''): StationWaypointForm { return { location: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', train_number: '', platform: '', seat: '' } } 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: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route }, ] 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: '', meta_airline: '', meta_flight_number: '', meta_train_number: '', meta_platform: '', meta_seat: '', } interface TransportModalProps { isOpen: boolean onClose: () => void onSave: (data: Record & { title: string }) => Promise reservation: Reservation | null days: Day[] selectedDayId: number | null files?: TripFile[] onFileUpload?: (fd: FormData) => Promise onFileDelete?: (fileId: number) => Promise onOpenExpense?: (req: BookingExpenseRequest) => void // Pre-fill a brand-new transport booking from a parsed import item (review- // before-save); like `reservation` for the form but stays in create mode. prefill?: BookingReviewDraft | null /** Data for the Automated (public transit) mode's quick picks. */ places?: Place[] /** Day→assignments map, used to scope the quick picks to the chosen day (#1460). */ assignments?: AssignmentsMap accommodations?: Accommodation[] /** Open directly in the Automated public-transit mode (day-header tram button, "change route"). */ initialAutomated?: boolean /** Transit search needs real dates to depart on, so the Automated mode is hidden on a dateless trip. */ tripHasDates?: boolean /** Pre-seed the transit search — used by "change route" on an existing journey. */ transitPrefill?: { from?: PickedPlace | null; to?: PickedPlace | null } | null } export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense, prefill = null, places = [], assignments = {}, accommodations = [], initialAutomated = false, transitPrefill = null, tripHasDates = true }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const budgetItems = useTripStore(s => s.budgetItems) const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem) const loadFiles = useTripStore(s => s.loadFiles) const { id: tripId } = useParams<{ id: string }>() // 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 }) // Manual vs Automated (public transit search) creation mode (#1065). const [automated, setAutomated] = useState(false) 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()]) // Train route as an ordered list of stations (origin .. stops .. destination). const [trainWaypoints, setTrainWaypoints] = useState([emptyStationWaypoint(), emptyStationWaypoint()]) const [uploadingFile, setUploadingFile] = useState(false) const [pendingFiles, setPendingFiles] = useState([]) const [showFilePicker, setShowFilePicker] = useState(false) const [linkedFileIds, setLinkedFileIds] = useState([]) const fileInputRef = useRef(null) useEffect(() => { if (!isOpen) return // 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) // 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') 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 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 || '', meta_airline: meta.airline || '', meta_flight_number: meta.flight_number || '', meta_train_number: meta.train_number || '', meta_platform: meta.platform || '', meta_seat: meta.seat || '', }) 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), arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''), arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), depDayId: legOut?.dep_day_id ?? (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 ?? '') : ''), } }) } else { // Legacy flight with no (or partial) endpoints — seed two waypoints. const dep = emptyWaypoint(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(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 (type === 'train') { // 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), arrDayId: legInto?.arr_day_id ?? (isLast ? (src.end_day_id ?? '') : ''), arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''), depDayId: legOut?.dep_day_id ?? (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 ?? '') : ''), } }) } else { const dep = emptyStationWaypoint(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(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 }) } } 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 ?? '')]) } }, [isOpen, reservation, prefill, selectedDayId, budgetItems]) const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value })) 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 = form.type === 'train' ? trainWaypoints : [] const firstTrainWp = trainWps[0] const lastTrainWp = trainWps[trainWps.length - 1] const trainStations = form.type === 'train' ? 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 } : {}), 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 (form.type === 'train') { // 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 } : {}), 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 (form.type === 'train') { 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)) // Keep the itinerary's transfer stops while the route is unchanged (#1065). const stops = keepTransit ? 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, })) if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', stops.length + 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 : form.type === 'train' ? trainDepDay : (form.start_day_id ? Number(form.start_day_id) : null), end_day_id: form.type === 'flight' ? flightArrDay : form.type === 'train' ? 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 || '') : form.type === 'train' ? 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 || '') : form.type === 'train' // 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, metadata: Object.keys(metadata).length > 0 ? metadata : null, 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 pmeta = prefill.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : {} const price = Number(pmeta.price) if (Number.isFinite(price) && price > 0) { ;(payload as Record).create_budget_entry = { total_price: price, category: typeToCostCategory(form.type) } } } const saved = await onSave(payload) 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) } } // 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. const intent = expenseIntentRef.current expenseIntentRef.current = null if (intent && onOpenExpense && saved?.id) { if (intent.editItem) onOpenExpense({ editItem: intent.editItem }) else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } }) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } finally { setIsSaving(false) } } const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() } const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() } const handleRemoveExpense = async (item: BudgetItem) => { try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) } } // On an import review (not yet saved), preview the parsed price as the cost that will be linked. const prefillMeta = prefill?.metadata && typeof prefill.metadata === 'object' ? (prefill.metadata as Record) : null const prefillPrice = Number(prefillMeta?.price) const pendingExpense = !reservation && Number.isFinite(prefillPrice) && prefillPrice > 0 ? { total_price: prefillPrice, currency: (prefillMeta?.priceCurrency as string | null) ?? null, category: typeToCostCategory(form.type) } : null const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return if (reservation?.id) { setUploadingFile(true) try { const fd = new FormData() fd.append('file', file) fd.append('reservation_id', String(reservation.id)) fd.append('description', reservation.title) await onFileUpload!(fd) toast.success(t('reservations.toast.fileUploaded')) } catch { toast.error(t('reservations.toast.uploadError')) } finally { setUploadingFile(false) e.target.value = '' } } else { setPendingFiles(prev => [...prev, file]) e.target.value = '' } } const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id || linkedFileIds.includes(f.id) || (f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id)) ) : [] const inputClass = 'w-full border border-edge rounded-[10px] px-[12px] py-[8px] text-[13px] font-[inherit] outline-none box-border text-content bg-surface-input' const labelClass = 'block text-[11px] font-semibold text-content-faint mb-[5px] uppercase tracking-[0.03em]' 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, } }), ] return ( {!automated && ( )} } > {/* Manual vs Automated creation switch (#1065) — creating only; editing a journey re-enters via "change route" with the switch hidden. Without trip dates there is nothing to plan a departure against, so Automated is not offered and only the manual form shows. */} {!reservation && tripHasDates && (
{([['manual', t('transport.modeManual')], ['automated', t('transport.modeAutomated')]] as const).map(([m, label]) => { const active = (m === 'automated') === automated return ( ) })}
)} {automated ? ( /* ── Automated: public transit search (#1065) ── */
{/* search header: what this is + the day it plans for */}
{t('transit.title')}
{t('transit.searchHint')}
set('start_day_id', v)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
{(() => { const transitDay = days.find(d => d.id === Number(form.start_day_id)) if (!transitDay) return
{t('transit.pickDay')}
// Quick picks offer the chosen day's itinerary, not the whole trip (#1460). const dayPlaces = (assignments[String(transitDay.id)] || []) .slice().sort((a, b) => a.order_index - b.order_index) .map(a => places.find(p => p.id === a.place_id)) .filter((p): p is Place => p != null) return ( onSave(payload as Record & { title: string })} initialFrom={transitPrefill?.from ?? null} initialTo={transitPrefill?.to ?? null} /> ) })()}
) : (
{/* Type selector */}
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => ( ))}
{/* Title */}
set('title', e.target.value)} required placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
{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} />
updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
)}
{!isLast && ( )}
) })}
) : form.type === 'train' ? ( /* ── Train route: ordered stations (origin · stops · destination) ── */
{trainWaypoints.map((wp, i) => { const isFirst = i === 0 const isLast = i === trainWaypoints.length - 1 const updateWp = (patch: Partial) => setTrainWaypoints(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({ location: l || null })} />
{!isFirst && !isLast && ( )}
{!isFirst && (
updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
updateWp({ arrTime: v })} />
)} {!isLast && ( <>
updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
updateWp({ depTime: v })} />
updateWp({ train_number: e.target.value })} placeholder="ICE 123" className={inputClass} />
updateWp({ platform: e.target.value })} placeholder="12" className={inputClass} />
updateWp({ seat: e.target.value })} placeholder="42A" className={inputClass} />
)}
{!isLast && ( )}
) })}
) : ( <> {/* From / To endpoints (non-flight) */}
setFromPick({ location: l || 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)} />
{/* Arrival row */}
set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
set('arrival_time', v)} />
)} {/* Train-specific fields */} {/* Train number / platform / seat are per-leg now (in the route above). */} {/* Booking Code + Status */}
set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} className={inputClass} />
set('status', value)} options={[ { value: 'pending', label: t('reservations.pending') }, { value: 'confirmed', label: t('reservations.confirmed') }, ]} size="sm" />
{/* Notes */}