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, 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 } 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] 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', 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 } 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 }, { 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: '', price: '', budget_category: '', 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 } export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) { const { t, locale } = useTranslation() const toast = useToast() const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const budgetItems = useTripStore(s => s.budgetItems) const loadFiles = useTripStore(s => s.loadFiles) const budgetCategories = useMemo(() => { const cats = new Set() budgetItems.forEach(i => { if (i.category) cats.add(i.category) }) return Array.from(cats).sort() }, [budgetItems]) const { id: tripId } = useParams<{ id: string }>() const [form, setForm] = useState({ ...defaultForm }) 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) const [linkedFileIds, setLinkedFileIds] = useState([]) const fileInputRef = useRef(null) useEffect(() => { if (!isOpen) return if (reservation) { const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {}) const eps = reservation.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(reservation.type) ? reservation.type as TransportType : 'flight' setForm({ title: reservation.title || '', type, status: reservation.status === 'confirmed' ? 'confirmed' : 'pending', start_day_id: reservation.day_id ?? '', end_day_id: reservation.end_day_id ?? '', departure_time: splitReservationDateTime(reservation.reservation_time).time ?? '', arrival_time: splitReservationDateTime(reservation.reservation_end_time).time ?? '', confirmation_number: reservation.confirmation_number || '', notes: reservation.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 || '', price: meta.price || '', budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '', }) if (type === 'flight') { 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 }) } } else { setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) setFromPick({}) setToPick({}) setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')]) } }, [isOpen, reservation, 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] // 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 } : {}), 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 if (form.meta_platform) metadata.platform = form.meta_platform if (form.meta_seat) metadata.seat = form.meta_seat } if (isBudgetEnabled) { if (form.price) metadata.price = form.price if (form.budget_category) metadata.budget_category = form.budget_category } 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 (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.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, metadata: Object.keys(metadata).length > 0 ? metadata : null, endpoints, needs_review: false, } if (isBudgetEnabled) { (payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0 ? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' } : { total_price: 0 } } 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) } } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } finally { setIsSaving(false) } } 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 ( } >
{/* 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} />
)}
{!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 */} {form.type === 'train' && (
set('meta_train_number', e.target.value)} placeholder="ICE 123" className={inputClass} />
set('meta_platform', e.target.value)} placeholder="12" className={inputClass} />
set('meta_seat', e.target.value)} placeholder="42A" className={inputClass} />
)} {/* 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 */}