import { useState, useEffect, useMemo, useRef } from 'react' import { useParams } from 'react-router-dom' import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } 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' const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const type TransportType = typeof TRANSPORT_TYPES[number] interface EndpointPick { airport?: Airport location?: LocationPoint } function endpointFromAirport(a: Airport, role: 'from' | 'to', 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 } } const TYPE_OPTIONS = [ { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane }, { value: 'train', labelKey: 'reservations.type.train', Icon: Train }, { value: 'car', labelKey: 'reservations.type.car', Icon: Car }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship }, ] 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) => 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({}) 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 || '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') { setFromPick({ airport: airportFromEndpoint(from) || undefined }) setToPick({ airport: airportFromEndpoint(to) || undefined }) } else { setFromPick({ location: locationFromEndpoint(from) || undefined }) setToPick({ location: locationFromEndpoint(to) || undefined }) } } else { setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' }) setFromPick({}) setToPick({}) } }, [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 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 } if (toPick.airport) { metadata.arrival_airport = toPick.airport.iata metadata.arrival_timezone = toPick.airport.tz } } 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') { 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)) } 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)) } 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), 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 inputStyle = { width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)', } const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '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')} style={inputStyle} />
{/* From / To endpoints */}
{form.type === 'flight' ? ( setFromPick({ airport: a || undefined })} /> ) : ( setFromPick({ location: l || undefined })} /> )}
{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}
)}
{/* 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}
)}
{/* Flight-specific fields */} {form.type === 'flight' && (
set('meta_airline', e.target.value)} placeholder="Lufthansa" style={inputStyle} />
set('meta_flight_number', e.target.value)} placeholder="LH 123" style={inputStyle} />
)} {/* Train-specific fields */} {form.type === 'train' && (
set('meta_train_number', e.target.value)} placeholder="ICE 123" style={inputStyle} />
set('meta_platform', e.target.value)} placeholder="12" style={inputStyle} />
set('meta_seat', e.target.value)} placeholder="42A" style={inputStyle} />
)} {/* Booking Code + Status */}
set('confirmation_number', e.target.value)} placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
set('status', value)} options={[ { value: 'pending', label: t('reservations.pending') }, { value: 'confirmed', label: t('reservations.confirmed') }, ]} size="sm" />
{/* Notes */}