import { useState, useMemo, useEffect, type CSSProperties } from 'react' import { createPortal } from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText, MapPin, Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users, ExternalLink, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, Download, TramFront, Footprints, StickyNote, ParkingSquare, } from 'lucide-react' import { openFile } from '../../utils/fileDownload' import { safeExternalHref } from '../../utils/safeUrl' import { TransitTitle, TransitLegChips, TransitMetaBadges, fmtTransitDuration } from './transitDisplay' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' import { markdownLinkComponents } from '../shared/markdownLink' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' import type { ViewContribution } from '../../api/client' import { usePluginViewContributions, PluginCardFooter } from '../Plugins/PluginContributions' import { usePluginStore, type ActivePlugin } from '../../store/pluginStore' import PluginFrame from '../Plugins/PluginFrame' import { splitReservationDateTime, formatTime, cleanAmountText } from '../../utils/formatters' import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs' import EmptyState from '../shared/EmptyState' import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker' import type { TripMember } from '../Budget/BudgetPanelMemberChips' interface AssignmentLookupEntry { dayNumber: number dayTitle: string | null dayDate: string placeName: string startTime: string | null endTime: string | null } const TYPE_OPTIONS = [ { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' }, { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' }, { value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' }, { value: 'bus', labelKey: 'reservations.type.bus', Icon: Bus, color: '#059669' }, { value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' }, { value: 'taxi', labelKey: 'reservations.type.taxi', Icon: CarTaxiFront, color: '#ca8a04' }, { value: 'bicycle', labelKey: 'reservations.type.bicycle', Icon: Bike, color: '#84cc16' }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' }, { value: 'ferry', labelKey: 'reservations.type.ferry', Icon: Sailboat, color: '#0d9488' }, { value: 'transit', labelKey: 'reservations.type.transit', Icon: TramFront, color: '#7c3aed' }, { value: 'transport_other', labelKey: 'reservations.type.transport_other', Icon: Route, color: '#6b7280' }, { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' }, { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' }, { value: 'parking', labelKey: 'reservations.type.parking', Icon: ParkingSquare, color: '#2563eb' }, { value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' }, ] function getType(type) { return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1] } function buildAssignmentLookup(days, assignments) { const map = {} for (const day of (days || [])) { const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index) for (const a of da) { if (!a.place) continue map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time } } } return map } /* ── Shared field label/value styles ── */ const fieldLabelClass = 'text-[10px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[5px]' const fieldValueClass = 'text-[13px] font-medium text-content px-[10px] py-[8px] bg-surface-tertiary rounded-[10px]' // A booking code is only a control while the blur setting is on: then it lifts on // hover and toggles on click or Enter. With blurring off it is plain text, so it // stays a plain element instead of a button that can never do anything. function ConfirmationCode({ code, blurred, revealed, onReveal }: { code: React.ReactNode blurred: boolean revealed: boolean onReveal: (next: boolean | ((v: boolean) => boolean)) => void }) { const codeStyle: CSSProperties = { fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', filter: blurred && !revealed ? 'blur(5px)' : 'none', transition: 'filter 0.2s', } if (!blurred) return
{code}
return ( ) } interface ReservationCardProps { r: Reservation tripId: number onEdit: (reservation: Reservation) => void onDelete: (id: number) => void files?: TripFile[] onNavigateToFiles: () => void assignmentLookup: Record canEdit: boolean days?: Day[] contributions?: ViewContribution[] /** Plugins that declared a reservation-detail slot — mounted at the card's foot, scoped to this reservation. */ detailPlugins?: ActivePlugin[] } function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [], contributions = [], detailPlugins = [] }: ReservationCardProps) { const toast = useToast() const { t, locale } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) const [codeRevealed, setCodeRevealed] = useState(false) const typeInfo = getType(r.type) const TypeIcon = typeInfo.Icon const confirmed = r.status === 'confirmed' // A multi-leg AirTrail import is detached from sync *by design* — AirTrail has // no single flight to round-trip a layover chain to, so it's created with // sync_enabled=0 (#1535). Distinguish it from a flight that was removed // upstream so the badge doesn't falsely claim it "was removed in AirTrail" (#1646). // Mirror the server's hasLocalMultiLegShape (airtrailSync.ts): a metadata legs // array of length > 1, OR — for a flight grown into multiple legs locally, which // carries no legs array — more than two endpoints. Both are detached, not removed. const airtrailMultiLeg = r.external_source === 'airtrail' && !r.sync_enabled && (() => { try { const m = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) if (Array.isArray(m?.legs) && m.legs.length > 1) return true } catch { /* malformed metadata — fall through to the endpoint count */ } return (r.endpoints || []).length > 2 })() const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id)) const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null // A booking link is deliberately free-form - people paste bare hosts and long // provider deep links - so this refuses the schemes that execute in this // origin and leaves everything else linkable. const bookingUrl = safeExternalHref(r.url) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const handleDelete = async () => { setShowDeleteConfirm(false) try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) } } const isMobile = typeof window !== 'undefined' && window.innerWidth < 768 const startDt = splitReservationDateTime(r.reservation_time) const endDt = splitReservationDateTime(r.reservation_end_time) const fmtDate = (date: string) => new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) const hasDate = !!startDt.date const hasTime = !!(startDt.time || endDt.time) const hasCode = !!r.confirmation_number const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other']) const isTransportType = TRANSPORT_TYPES_SET.has(r.type) const isHotel = r.type === 'hotel' // For a hotel linked to an accommodation, the accommodation's own start/end days are // the source of truth for the stay range: a stale day_id left behind by a range edit // would otherwise mislabel the card, so prefer the accommodation ids here (#1383). const startDay = (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id) : r.day_id ? days.find(d => d.id === r.day_id) : undefined const endDay = (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_end_day_id) : r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined const DayLabel = ({ day }: { day: typeof startDay }) => { if (!day) return null const name = day.title || t('dayplan.dayN', { n: day.day_number }) const badge = day.date ? new Date(day.date + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' }) : null return ( {name} {badge && ( {badge} )} ) } return (
e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} > {/* Header — wraps to a second row on narrow screens so the status/category chips never collide with the title. */}
{confirmed ? t('reservations.confirmed') : t('reservations.pending')} {t(typeInfo.labelKey)} {r.needs_review ? ( {t('reservations.needsReview')} ) : null} {r.external_source === 'airtrail' ? ( {r.sync_enabled || airtrailMultiLeg ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')} ) : null}
{r.title} {canEdit && ( )} {canEdit && ( )}
{/* Body */}
{/* Day label for transport/hotel reservations linked to days */} {(isTransportType || isHotel) && startDay && (
{t('reservations.date')}
{endDay && endDay.id !== startDay.id && ( <>– )}
)} {/* Date / Time row — hidden for a hotel linked to an accommodation: its stay already shows as the day-range label above, and a reservation_time stamped on the auto-created reservation would otherwise duplicate it (#1383). */} {(hasDate || hasTime) && !(isHotel && (r.accommodation_start_day_id || r.accommodation_end_day_id)) && (
{hasDate && (
{t('reservations.date')}
{fmtDate(startDt.date!)} {endDt.date && endDt.date !== startDt.date && ( <> – {fmtDate(endDt.date)} )}
)} {hasTime && (
{t('reservations.time')}
{formatTime(startDt.time, locale, timeFormat)} {endDt.time ? ` – ${formatTime(endDt.time, locale, timeFormat)}` : ''}
)}
)} {/* Booking code */} {hasCode && (
{t('reservations.confirmationCode')}
)} {(() => { // Full route over all waypoints (from · stops · to), ordered by sequence. const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)) if (eps.length < 2) return null return (
{eps.map((ep, i) => ( {i > 0 && } {ep.name} ))}
) })()} {/* Per-segment booking codes (#1943). A stopover booking can carry its own reference per leg, and the cell above only ever shows the booking's own. The reveal is shared with that cell on purpose: uncovering the codes is one gesture per card, not one per segment. */} {(() => { if (r.type !== 'flight' && r.type !== 'train') return null const legs = r.type === 'flight' ? getFlightLegs(r) : getTrainLegs(r) if (legs.length < 2) return null const coded = legs.filter(l => l.confirmation_number) if (coded.length === 0) return null return (
1 ? `repeat(${Math.min(coded.length, 3)}, 1fr)` : '1fr' }}> {coded.map((l, i) => (
{[l.from, l.to].filter(Boolean).join(' → ') || t('reservations.confirmationCode')}
))}
) })()} {/* Type-specific metadata */} {(() => { const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) if (!meta || Object.keys(meta).length === 0) return null const hasEndpoints = (r.endpoints || []).some(e => e.role === 'from') && (r.endpoints || []).some(e => e.role === 'to') const cells: { label: string; value: string }[] = [] if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline }) if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number }) if (!hasEndpoints && meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport }) if (!hasEndpoints && meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport }) if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number }) if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform }) if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat + (meta.class ? ` · ${meta.class}` : '') }) if (meta.price != null && meta.price !== '') cells.push({ label: t('reservations.price'), value: `${cleanAmountText(meta.price)}${meta.priceCurrency ? ' ' + meta.priceCurrency : ''}` }) if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: formatTime(meta.check_in_time, locale, timeFormat) + (meta.check_in_end_time ? ` – ${formatTime(meta.check_in_end_time, locale, timeFormat)}` : '') }) if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: formatTime(meta.check_out_time, locale, timeFormat) }) if (cells.length === 0) return null return (
1 ? `repeat(${Math.min(cells.length, 3)}, 1fr)` : '1fr' }}> {cells.map((c, i) => (
{c.label}
{c.value}
))}
) })()} {/* Location / Accommodation / Assignment */} {r.location && (
{t('reservations.locationAddress')}
{r.location}
)} {r.accommodation_name && (
{t('reservations.meta.linkAccommodation')}
{r.accommodation_name}
)} {linked && (
{t('reservations.linkAssignment')}
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName} {linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
)} {/* Link */} {r.url && (
{t('reservations.urlLabel')}
{bookingUrl ? ( {bookingUrl} ) : ( {r.url} )}
)} {/* Notes */} {r.notes && (
{t('reservations.notes')}
{r.notes}
)} {/* Files */} {attachedFiles.length > 0 && (
{t('files.title')}
{attachedFiles.map(f => ( ))}
)} {/* Travelers assigned to this booking — read-only; assignment lives in the edit/create modal (#1517). */} {r.travelers && r.travelers.length > 0 && (
{t('reservations.travelers.label')}
)}
{/* Reservation-detail plugin slots: sandboxed, scoped to this reservation. */} {detailPlugins.length > 0 && (
{detailPlugins.map(p => (
))}
)} {/* Delete confirmation */} {showDeleteConfirm && createPortal(
setShowDeleteConfirm(false)}>
e.stopPropagation()}>
{t('reservations.confirm.deleteTitle')}
{t('reservations.confirm.deleteBody', { name: r.title })}
, document.body )}
) } interface SectionProps { title: string count: number children: React.ReactNode defaultOpen?: boolean accent: 'green' | string storageKey?: string } function Section({ title, count, children, defaultOpen = true, accent, storageKey }: SectionProps) { const [open, setOpen] = useState(() => { if (!storageKey || typeof window === 'undefined') return defaultOpen const stored = window.localStorage.getItem(storageKey) if (stored === null) return defaultOpen return stored === '1' }) useEffect(() => { if (!storageKey || typeof window === 'undefined') return window.localStorage.setItem(storageKey, open ? '1' : '0') }, [open, storageKey]) return (
{open && (
{children}
)}
) } /** * A transit journey's own card (#1065) — leg chips + journey stats instead of * the generic booking layout. Clicking anywhere opens the journey view. */ function TransitJourneyCard({ r, days, onOpen, onDelete, canEdit, tripId, contributions = [], detailPlugins = [] }: { r: Reservation days: Day[] onOpen: (r: Reservation) => void onDelete: (id: number) => void canEdit: boolean tripId: number contributions?: ViewContribution[] detailPlugins?: ActivePlugin[] }) { const { t, locale } = useTranslation() const timeFormat = useSettingsStore(st => st.settings.time_format) || '24h' const [confirmOpen, setConfirmOpen] = useState(false) const meta = typeof r.metadata === 'string' ? (() => { try { return JSON.parse(r.metadata || '{}') } catch { return {} } })() : (r.metadata || {}) const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null const { date, time } = splitReservationDateTime(r.reservation_time) const { time: endTime } = splitReservationDateTime(r.reservation_end_time) const day = r.day_id ? days.find(d => d.id === r.day_id) : undefined const dateStr = date ? new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) : null const mins = transit?.duration ? Math.round(transit.duration / 60) : null return (
onOpen(r)} role="button" // No press-scale on the card — it would shift the nested controls out // from under the pointer mid-click (#2158). data-no-press tabIndex={0} onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); onOpen(r) } }} style={{ borderRadius: 12, border: '1px solid rgba(124,58,237,0.22)', padding: '12px 14px', display: 'flex', flexDirection: 'column', gap: 9, cursor: 'pointer', transition: 'box-shadow 0.15s ease' }} onMouseEnter={e => e.currentTarget.style.boxShadow = '0 2px 12px rgba(0,0,0,0.06)'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'} >
{canEdit && ( )}
{transit && (
)} {r.notes && (
{r.notes.split('\n')[0]}
)} {r.travelers && r.travelers.length > 0 && (
e.stopPropagation()}>
)} {/* Reservation-detail plugin slots: sandboxed, scoped to this journey. The card itself is clickable, so keep frame interactions from opening it. */} {detailPlugins.length > 0 && (
e.stopPropagation()} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}> {detailPlugins.map(p => (
))}
)} {confirmOpen && createPortal(
{ e.stopPropagation(); setConfirmOpen(false) }}>
e.stopPropagation()}>
{t('reservations.confirm.deleteTitle')}
{t('reservations.confirm.deleteBody', { name: r.title })}
, document.body )}
) } interface ReservationsPanelProps { tripId: number reservations: Reservation[] days: Day[] assignments: AssignmentsMap files?: TripFile[] onAdd: () => void onImport?: () => void bookingImportAvailable?: boolean onAirTrailImport?: () => void airTrailAvailable?: boolean onEdit: (reservation: Reservation) => void onDelete: (id: number) => void onNavigateToFiles: () => void titleKey?: string addManualKey?: string /** Which plugin view this panel represents — the transports tab is its own * contribution view, the bookings tab stays 'reservations'. */ contributionView?: 'reservations' | 'transports' /** Trip members + guests, forwarded to each card's traveler picker (#1517). */ tripMembers?: TripMember[] } /** The empty state's call-to-action buttons — same shape as the toolbar's. */ const CTA_STYLE: CSSProperties = { appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6, padding: '9px 14px', borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500, } export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual', contributionView = 'reservations', tripMembers = [] }: ReservationsPanelProps) { const { t, locale } = useTranslation() const can = useCanDo() const trip = useTripStore((s) => s.trip) const canEdit = can('reservation_edit', trip) const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint')) const storageKey = `trek-reservation-filters-${tripId}` // Plugin-contributed columns/actions for this view, keyed by reservation id (#plugins). // The bookings and transports tabs share this panel but are distinct plugin views. const contribFor = usePluginViewContributions(contributionView, tripId) // Plugins that declared a reservation-detail slot mount at the foot of each card, // scoped to that reservation. Filtered inline like the place-/day-detail sites. const reservationDetailPlugins = usePluginStore((s) => s.plugins).filter((p) => p.type === 'widget' && p.slot === 'reservation-detail') const [typeFilters, setTypeFilters] = useState>(() => { try { const saved = sessionStorage.getItem(storageKey) return saved ? new Set(JSON.parse(saved)) : new Set() } catch { return new Set() } }) const toggleTypeFilter = (type: string) => { setTypeFilters(prev => { const next = new Set(prev) if (next.has(type)) next.delete(type); else next.add(type) sessionStorage.setItem(storageKey, JSON.stringify([...next])) return next }) } // Filter by assigned traveler (#1517, #1557), persisted alongside the type filter. const [travelerFilters, setTravelerFilters] = useState>(() => { try { const saved = sessionStorage.getItem(`${storageKey}-travelers`) return saved ? new Set(JSON.parse(saved)) : new Set() } catch { return new Set() } }) const toggleTravelerFilter = (userId: number) => { setTravelerFilters(prev => { const next = new Set(prev) if (next.has(userId)) next.delete(userId); else next.add(userId) sessionStorage.setItem(`${storageKey}-travelers`, JSON.stringify([...next])) return next }) } const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments]) const filtered = useMemo(() => { let list = typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)) if (travelerFilters.size > 0) { list = list.filter(r => (r.travelers || []).some(tv => travelerFilters.has(tv.user_id))) } return list }, [reservations, typeFilters, travelerFilters]) // Chronological order (#1507): day-linked transports often carry no date in // reservation_time, so resolve each entry to an effective departure datetime — // the stamped date when there is one, else the linked day's date (the // accommodation start day for hotels, matching the card label). Entries // without any resolvable date sink to the bottom; creation order breaks ties. const sorted = useMemo(() => { const dayDates = new Map(days.map(d => [d.id, d.date])) const sortKey = (r: Reservation): string | null => { const { date, time } = splitReservationDateTime(r.reservation_time) const dayId = r.type === 'hotel' ? (r.accommodation_start_day_id ?? r.day_id) : r.day_id const effectiveDate = date ?? (dayId != null ? dayDates.get(dayId) : null) if (!effectiveDate) return null return `${effectiveDate}T${time ?? '00:00'}` } return filtered .map(r => ({ r, key: sortKey(r) })) .sort((a, b) => { if (a.key !== b.key) { if (a.key === null) return 1 if (b.key === null) return -1 return a.key < b.key ? -1 : 1 } return (a.r.created_at ?? '').localeCompare(b.r.created_at ?? '') }) .map(({ r }) => r) }, [filtered, days]) // Automated public transit (#1065) gets its own section — journeys planned via // the transit search live alongside manual transports without mixing in. const transitEntries = sorted.filter(r => r.type === 'transit') const nonTransit = sorted.filter(r => r.type !== 'transit') const allPending = nonTransit.filter(r => r.status !== 'confirmed') const allConfirmed = nonTransit.filter(r => r.status === 'confirmed') const total = filtered.length const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations]) const typeCounts = useMemo(() => { const counts: Record = {} for (const r of reservations) counts[r.type] = (counts[r.type] || 0) + 1 return counts }, [reservations]) return (
{/* Unified toolbar */}

{t(titleKey)}

{reservations.length > 0 && ( <>
{TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => { const active = typeFilters.has(opt.value) const Icon = opt.Icon return ( ) })}
)} {/* Filter by traveler — members/guests assigned to bookings (#1517/#1557). */} {tripMembers.length > 1 && reservations.some(r => (r.travelers || []).length > 0) && ( <>
)} {canEdit && (
{onImport && bookingImportAvailable && ( )} {onAirTrailImport && airTrailAvailable && ( )}
)}
{/* Content */}
{total === 0 && reservations.length === 0 ? ( {onImport && bookingImportAvailable && ( )} {onAirTrailImport && airTrailAvailable && ( )}
) : undefined} /> ) : total === 0 ? (

{t('places.noneFound')}

) : ( <> {transitEntries.length > 0 && (
{transitEntries.map(r => )}
)} {allPending.length > 0 && (
{allPending.map(r => )}
)} {allConfirmed.length > 0 && (
{allConfirmed.map(r => )}
)} )}
) }