diff --git a/client/src/components/Planner/ReservationsPanel.tsx b/client/src/components/Planner/ReservationsPanel.tsx
index 7d9ddde3e..72c30af61 100644
--- a/client/src/components/Planner/ReservationsPanel.tsx
+++ b/client/src/components/Planner/ReservationsPanel.tsx
@@ -1,705 +1,29 @@
-import { useState, useMemo, useEffect, type CSSProperties } from 'react'
-import { createPortal } from 'react-dom'
+import { useCallback, useMemo, useState, type CSSProperties } from 'react'
+import { Download, Plane, Plus, SearchX } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
+import { usePluginStore } from '../../store/pluginStore'
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, formatPriceText } from '../../utils/formatters'
-import { getFlightLegs, getTrainLegs } from '../../utils/flightLegs'
+import type { Reservation, Day, TripFile, AssignmentsMap, BudgetItem } from '../../types'
+import { usePluginViewContributions } from '../Plugins/PluginContributions'
import EmptyState from '../shared/EmptyState'
-import { TravelerAvatarRow, TravelerFilterAvatars } from './TravelerPicker'
+import ConfirmDialog from '../shared/ConfirmDialog'
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 (
- onReveal(true)}
- onMouseLeave={() => onReveal(false)}
- onClick={() => onReveal(v => !v)}
- >
- {code}
-
- )
-}
-
-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 tripCurrency = useTripStore(s => s.trip?.currency)
- 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 && (
-
onEdit(r)} title={t('common.edit')} className="bg-transparent text-content-faint" style={{
- appearance: 'none', border: 'none',
- width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
- cursor: 'pointer', flexShrink: 0,
- }}
- onMouseEnter={e => { e.currentTarget.style.background = 'rgba(0,0,0,0.05)'; e.currentTarget.style.color = 'var(--text-primary)' }}
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
-
-
- )}
- {canEdit && (
-
setShowDeleteConfirm(true)} title={t('common.delete')} className="bg-transparent text-content-faint" style={{
- appearance: 'none', border: 'none',
- width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center',
- cursor: 'pointer', flexShrink: 0,
- }}
- onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}>
-
-
- )}
-
-
-
- {/* 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: formatPriceText(meta.price, meta.priceCurrency, tripCurrency, locale) })
- 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')}
-
-
- )}
-
- {/* Notes */}
- {r.notes && (
-
-
{t('reservations.notes')}
-
- {r.notes}
-
-
- )}
-
- {/* Files */}
- {attachedFiles.length > 0 && (
-
-
{t('files.title')}
-
- {attachedFiles.map(f => (
- { openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, cursor: 'pointer', background: 'none', border: 'none', padding: 0, textAlign: 'left', fontFamily: 'inherit' }}>
-
- {f.original_name}
-
- ))}
-
-
- )}
- {/* 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 })}
-
-
- setShowDeleteConfirm(false)} className="text-content-muted" style={{
- fontSize: 'calc(12px * var(--fs-scale-body, 1))', background: 'none', border: '1px solid var(--border-primary)',
- borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit',
- }}>{t('common.cancel')}
- {t('common.confirm')}
-
-
-
,
- 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 (
-
-
setOpen(o => !o)} style={{
- display: 'flex', alignItems: 'center', gap: 8, width: '100%',
- background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 12, fontFamily: 'inherit',
- userSelect: 'none',
- }}>
- {open ? : }
- {title}
- {count}
-
- {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 && (
-
{ e.stopPropagation(); setConfirmOpen(true) }}
- title={t('common.delete')}
- className="bg-transparent text-content-faint"
- style={{ appearance: 'none', border: 'none', width: 26, height: 26, borderRadius: 6, display: 'grid', placeItems: 'center', cursor: 'pointer', flexShrink: 0 }}
- onMouseEnter={e => { e.currentTarget.style.background = 'rgba(239,68,68,0.08)'; e.currentTarget.style.color = '#ef4444' }}
- onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = 'var(--text-faint)' }}
- >
-
-
- )}
-
- {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 })}
-
- { e.stopPropagation(); setConfirmOpen(false) }} className="text-content-muted" style={{ padding: '7px 14px', borderRadius: 9, border: '1px solid var(--border-primary)', background: 'none', fontSize: 'calc(12px * var(--fs-scale-body, 1))', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}
- { e.stopPropagation(); setConfirmOpen(false); onDelete(r.id) }} style={{ padding: '7px 14px', borderRadius: 9, border: 'none', background: '#ef4444', color: '#fff', fontSize: 'calc(12px * var(--fs-scale-body, 1))', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.delete')}
-
-
-
,
- document.body
- )}
-
- )
-}
+import {
+ applyFilters, buildAssignmentLookup, costsFor, groupReservations, sortReservations, TYPE_ORDER, typeInfo,
+ type BookingsKind,
+} from './bookings/bookingsModel'
+import { bookingFacts, formatDay, type BookingFacts } from './bookings/bookingFacts'
+import { filesFor, SectionHead, fs } from './bookings/bookingParts'
+import { useBookingsView } from './bookings/useBookingsView'
+import BookingsHeader from './bookings/BookingsHeader'
+import BookingCard from './bookings/BookingCard'
+import BookingsList from './bookings/BookingsList'
+import BookingsTimeline from './bookings/BookingsTimeline'
+import BookingDetailDialog from './bookings/BookingDetailDialog'
+import { canShowOnMap } from './bookings/showOnMap'
interface ReservationsPanelProps {
tripId: number
@@ -720,10 +44,20 @@ interface ReservationsPanelProps {
/** 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). */
+ /** Trip members + guests, for the traveller filter (#1517). */
tripMembers?: TripMember[]
+ /** The other tab's bookings, shown dimmed on the timeline for orientation. */
+ contextReservations?: Reservation[]
+ /** Shows a booking on the plan map: its route for a transport, its place otherwise. */
+ onShowOnMap?: (reservation: Reservation) => void
+ isOnMap?: (reservation: Reservation) => boolean
+ /** Opens the expense editor for an expense linked to a booking. */
+ onEditExpense?: (item: BudgetItem) => void
}
+// Cards take a quarter of the row at most, so a wide screen shows four and a narrow one fewer.
+const CARD_GRID = 'grid gap-3.5 [grid-template-columns:repeat(auto-fill,minmax(max(300px,calc((100%_-_42px)/4)),1fr))]'
+
/** 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',
@@ -732,279 +66,267 @@ const CTA_STYLE: CSSProperties = {
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) {
+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 = [], contextReservations = [], onShowOnMap, isOnMap, onEditExpense,
+}: ReservationsPanelProps) {
const { t, locale } = useTranslation()
+ const toast = useToast()
const can = useCanDo()
- const trip = useTripStore((s) => s.trip)
+ const trip = useTripStore(s => s.trip)
+ const budgetItems = useTripStore(s => s.budgetItems)
+ const toggleReservationStatus = useTripStore(s => s.toggleReservationStatus)
+ const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
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 kind: BookingsKind = contributionView === 'transports' ? 'transports' : 'bookings'
+ const v = useBookingsView(kind, tripId)
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 detailPlugins = usePluginStore(s => s.plugins).filter(p => p.type === 'widget' && p.slot === 'reservation-detail')
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const labelOf = useCallback((type: string) => t(typeInfo(type).labelKey), [t])
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
+ const tripCurrency = trip?.currency || 'EUR'
- 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)))
+ const filtered = useMemo(
+ () => applyFilters(reservations, { types: v.types, status: v.status, travelers: v.travelers, query: v.query }, labelOf),
+ [reservations, v.types, v.status, v.travelers, v.query, labelOf],
+ )
+ const sorted = useMemo(() => sortReservations(filtered, days, v.sort.by, v.sort.dir, labelOf), [filtered, days, v.sort.by, v.sort.dir, labelOf])
+ const groups = useMemo(() => groupReservations(sorted, v.group, days, trip?.start_date, trip?.end_date, {
+ confirmed: t('reservations.confirmed'), pending: t('reservations.pending'), transit: t('transit.sectionTitle'),
+ before: t('reservations.group.before'), after: t('reservations.group.after'), undated: t('reservations.group.undated'),
+ dayN: n => t('dayplan.dayN', { n }), typeLabel: labelOf, dayDate: d => formatDay(d, locale),
+ }), [sorted, v.group, days, trip?.start_date, trip?.end_date, locale, t, labelOf])
+
+ const costsOf = useCallback((r: Reservation) => costsFor(r.id, budgetItems, tripCurrency), [budgetItems, tripCurrency])
+ // Formatted once per booking object and setting; a changed booking is a new object and formats again.
+ const factsOf = useMemo(() => {
+ const cache = new WeakMap()
+ return (r: Reservation) => {
+ let f = cache.get(r)
+ if (!f) {
+ f = bookingFacts(r, { t, locale, timeFormat, days, assignmentLookup, tripCurrency, hasLinkedCost: costsOf(r).length > 0 })
+ cache.set(r, f)
+ }
+ return f
}
- return list
- }, [reservations, typeFilters, travelerFilters])
+ }, [t, locale, timeFormat, days, assignmentLookup, tripCurrency, costsOf])
- // 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])
+ const counts = new Map()
+ for (const r of reservations) counts.set(r.type, (counts.get(r.type) ?? 0) + 1)
+ for (const type of v.types) if (!counts.has(type)) counts.set(type, 0)
+ return [...counts.entries()].sort((a, b) => TYPE_ORDER.indexOf(a[0]) - TYPE_ORDER.indexOf(b[0])).map(([type, count]) => ({ type, count }))
+ }, [reservations, v.types])
+ const selected = v.selectedId != null ? reservations.find(r => r.id === v.selectedId) ?? null : null
+
+ const select = (r: Reservation) => {
+ // A transit journey has its own detail view, shared with the phone.
+ if (r.type === 'transit') { onEdit(r); return }
+ v.setSelectedId(v.selectedId === r.id ? null : r.id)
+ }
+ // Whatever leaves the tab's own view closes the detail first: an editor, the map, an expense.
+ const closeDetail = () => v.setSelectedId(null)
+ const edit = (r: Reservation) => {
+ closeDetail()
+ onEdit(r)
+ }
+ const toggleStatus = (r: Reservation) => {
+ toggleReservationStatus(tripId, r.id).catch(() => toast.error(t('reservations.toast.updateError')))
+ }
+ const confirmDelete = async () => {
+ const r = pendingDelete
+ setPendingDelete(null)
+ if (!r) return
+ if (v.selectedId === r.id) v.setSelectedId(null)
+ try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
+ }
+
+ const importAction = onImport && bookingImportAvailable ? onImport : undefined
+ const airTrailAction = onAirTrailImport && airTrailAvailable ? onAirTrailImport : undefined
+ const linkedCosts = (r: Reservation) => budgetItems.filter(b => b.reservation_id === r.id)
+
+ const detail = selected && (
+ edit(selected)}
+ onDelete={() => setPendingDelete(selected)}
+ onToggleStatus={() => toggleStatus(selected)}
+ onShowOnMap={onShowOnMap && canShowOnMap(selected) ? () => { closeDetail(); onShowOnMap(selected) } : undefined}
+ onMap={isOnMap?.(selected)}
+ onEditExpense={onEditExpense ? item => { closeDetail(); onEditExpense(item) } : undefined}
+ onNavigateToFiles={onNavigateToFiles}
+ contributions={contribFor(selected.id)}
+ detailPlugins={detailPlugins}
+ />
+ )
+
+ const content = (() => {
+ if (reservations.length === 0) {
+ return (
+
+ {t(addManualKey)}
+ {importAction && {t('reservations.import.cta')} }
+ {airTrailAction && {t('reservations.airtrail.cta')} }
+
+ ) : undefined}
+ />
+ )
+ }
+ if (sorted.length === 0) {
+ return (
+
+
+
{t('reservations.noMatches')}
+
+ {t('reservations.resetFilters')}
+
+
+ )
+ }
+ if (v.view === 'timeline') {
+ return (
+
+ )
+ }
+ if (v.view === 'list') {
+ return (
+ filesFor(r, files).length}
+ costsOf={costsOf}
+ selectedId={v.selectedId}
+ canEdit={canEdit}
+ collapsed={v.collapsed}
+ onToggleGroup={v.toggleGroup}
+ onSelect={select}
+ onEdit={edit}
+ onDelete={setPendingDelete}
+ onToggleStatus={toggleStatus}
+ />
+ )
+ }
+ return (
+
+ {groups.map(g => {
+ const open = !v.collapsed(g.id)
+ return (
+
+ {g.label &&
v.toggleGroup(g.id)} />}
+ {open && (
+
+ {g.items.map(r => (
+ select(r)}
+ onEdit={() => edit(r)}
+ onDelete={() => setPendingDelete(r)}
+ onToggleStatus={() => toggleStatus(r)}
+ contributions={contribFor(r.id)}
+ detailPlugins={detailPlugins}
+ showFrames={v.selectedId !== r.id}
+ />
+ ))}
+
+ )}
+
+ )
+ })}
+
+ )
+ })()
return (
-
- {/* Unified toolbar */}
-
-
-
- {t(titleKey)}
-
-
- {reservations.length > 0 && (
- <>
-
-
- { setTypeFilters(new Set()); sessionStorage.removeItem(storageKey) }}
- className={typeFilters.size === 0 ? 'bg-surface-card text-content' : 'bg-transparent text-content-muted'}
- style={{
- appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
- display: 'inline-flex', alignItems: 'center', gap: 6,
- padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap',
- fontWeight: typeFilters.size === 0 ? 500 : 400,
- boxShadow: typeFilters.size === 0 ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
- transition: 'all 0.15s ease',
- }}
- >
- {t('common.all')}
- {reservations.length}
-
- {TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => {
- const active = typeFilters.has(opt.value)
- const Icon = opt.Icon
- return (
- toggleTypeFilter(opt.value)}
- className={active ? 'bg-surface-card text-content' : 'bg-transparent text-content-muted'}
- style={{
- appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
- display: 'inline-flex', alignItems: 'center', gap: 6,
- padding: '6px 12px', borderRadius: 99, fontSize: 'calc(13px * var(--fs-scale-body, 1))', whiteSpace: 'nowrap',
- fontWeight: active ? 500 : 400,
- boxShadow: active ? '0 1px 2px rgba(0,0,0,0.06)' : 'none',
- transition: 'all 0.15s ease',
- }}
- >
-
- {t(opt.labelKey)}
- {typeCounts[opt.value] || 0}
-
- )
- })}
-
- >
- )}
-
- {/* Filter by traveler — members/guests assigned to bookings (#1517/#1557). */}
- {tripMembers.length > 1 && reservations.some(r => (r.travelers || []).length > 0) && (
- <>
-
-
- >
- )}
-
- {canEdit && (
-
- {onImport && bookingImportAvailable && (
-
e.currentTarget.style.opacity = '0.75'}
- onMouseLeave={e => e.currentTarget.style.opacity = '1'}
- title={t('reservations.import.title')}
- >
-
- {t('reservations.import.cta')}
-
- )}
- {onAirTrailImport && airTrailAvailable && (
-
e.currentTarget.style.opacity = '0.75'}
- onMouseLeave={e => e.currentTarget.style.opacity = '1'}
- title={t('reservations.airtrail.title')}
- >
-
- {t('reservations.airtrail.cta')}
-
- )}
-
e.currentTarget.style.opacity = '0.88'}
- onMouseLeave={e => e.currentTarget.style.opacity = '1'}
- >
-
- {t(addManualKey)}
-
-
- )}
-
+
+
+ 1 && reservations.some(r => (r.travelers || []).length > 0)}
+ activeTravelers={v.travelers}
+ onToggleTraveler={v.toggleTraveler}
+ onClearTravelers={v.clearTravelers}
+ group={v.group}
+ onGroup={v.setGroup}
+ sort={v.sort.by}
+ sortDir={v.sort.dir}
+ onSort={v.setSort}
+ onSortDir={v.flipSort}
+ byType={v.timeline.byType}
+ onByType={v.toggleByType}
+ showContext={v.timeline.context}
+ onShowContext={v.toggleContext}
+ viewIsDefault={v.viewIsDefault}
+ onResetView={v.resetView}
+ />
- {/* Content */}
-
- {total === 0 && reservations.length === 0 ? (
-
-
-
- {t(addManualKey)}
-
- {onImport && bookingImportAvailable && (
-
-
- {t('reservations.import.cta')}
-
- )}
- {onAirTrailImport && airTrailAvailable && (
-
-
- {t('reservations.airtrail.cta')}
-
- )}
-
- ) : undefined}
- />
- ) : total === 0 ? (
-
-
{t('places.noneFound')}
-
- ) : (
- <>
- {transitEntries.length > 0 && (
-
- {transitEntries.map(r => )}
-
- )}
- {allPending.length > 0 && (
-
- {allPending.map(r => )}
-
- )}
- {allConfirmed.length > 0 && (
-
- {allConfirmed.map(r => )}
-
- )}
- >
- )}
-
+
{content}
+
+ {detail}
+
+
setPendingDelete(null)}
+ onConfirm={confirmDelete}
+ title={t('reservations.confirm.deleteTitle')}
+ message={t('reservations.confirm.deleteBody', { name: pendingDelete?.title ?? '' })}
+ confirmLabel={t('common.delete')}
+ cancelLabel={t('common.cancel')}
+ />
)
}
diff --git a/client/src/components/Planner/bookings/BookingCard.tsx b/client/src/components/Planner/bookings/BookingCard.tsx
new file mode 100644
index 000000000..445975aa3
--- /dev/null
+++ b/client/src/components/Planner/bookings/BookingCard.tsx
@@ -0,0 +1,209 @@
+import type { ReactNode } from 'react'
+import { Hotel, Link2, MapPin, ExternalLink, Pencil, Trash2, Wallet } from 'lucide-react'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
+import type { Reservation, TripFile } from '../../../types'
+import type { ViewContribution } from '../../../api/client'
+import type { ActivePlugin } from '../../../store/pluginStore'
+import { useTranslation } from '../../../i18n'
+import { markdownLinkComponents } from '../../shared/markdownLink'
+import { BlurredCode } from '../../shared/BookingCode'
+import PluginFrame from '../../Plugins/PluginFrame'
+import { PluginCardFooter } from '../../Plugins/PluginContributions'
+import { TransitLegChips, fmtTransitDuration } from '../transitDisplay'
+import { formatMoney } from '../../../utils/formatters'
+import { parseMeta, typeInfo, displayTitle, type CostTotal } from './bookingsModel'
+import type { BookingFacts } from './bookingFacts'
+import {
+ AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, RoundAction, StatusDot, TravelerChips, TypeChip,
+ fs, toneOf, toneTint,
+} from './bookingParts'
+
+export interface BookingCardProps {
+ r: Reservation
+ facts: BookingFacts
+ files: TripFile[]
+ costs: CostTotal[]
+ tripId: number
+ canEdit: boolean
+ selected: boolean
+ onSelect: () => void
+ onEdit: () => void
+ onDelete: () => void
+ onToggleStatus: () => void
+ contributions: ViewContribution[]
+ detailPlugins: ActivePlugin[]
+ /** False while the detail pane shows this booking, so its plugin frames are not mounted twice. */
+ showFrames: boolean
+}
+
+/**
+ * One booking or transport as the phone draws it: a head band tinted by its
+ * status with the status switch, the type and the title, then framed fields.
+ * The desktop keeps every value the old card had, so nothing is behind a tap.
+ */
+export default function BookingCard(p: BookingCardProps) {
+ const { r, facts } = p
+ const { t, locale } = useTranslation()
+ const info = typeInfo(r.type)
+ const tone = toneOf(r)
+ const transit = r.type === 'transit'
+
+ return (
+
{ if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); p.onSelect() } }}
+ aria-label={displayTitle(r)}
+ className={`group flex cursor-pointer flex-col overflow-hidden rounded-2xl border bg-surface-secondary transition-shadow hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--text-primary)] ${p.selected ? 'border-[color:var(--text-primary)]' : 'border-edge-faint'}`}
+ >
+
+
+
+
{displayTitle(r)}
+ {!!r.needs_review &&
}
+
+ {p.canEdit && !transit &&
}
+ {p.canEdit &&
}
+
+
+
+ {(facts.day || facts.time) && (
+
+ {facts.day && (
+
+ {facts.day.label}
+ {facts.day.date && {facts.day.date} }
+
+ )}
+ {facts.time && {facts.time} }
+
+ )}
+
+ {transit ?
: (
+ <>
+ {r.confirmation_number && (
+
+ {r.confirmation_number}
+
+ )}
+ {facts.endpoints.length >= 2 && (
+
+ {facts.endpoints.map((ep, i) => (
+
+ {i > 0 && }
+ {ep.name}
+
+ ))}
+
+ )}
+ {facts.legCodes.length > 0 && (
+
+ {facts.legCodes.map((l, i) => (
+
+ {l.route || t('reservations.confirmationCode')}
+ {l.code}
+
+ ))}
+
+ )}
+ {facts.cells.length > 0 && (
+
+ {facts.cells.map((c, i) => {c.value} )}
+
+ )}
+ >
+ )}
+
+ {(facts.place || facts.accommodation || facts.linked || facts.url) && (
+
+ )}
+
+ {r.notes && !transit && (
+
+ {r.notes}
+
+ )}
+
+ {(r.travelers || []).length > 0 && (
+
+ {t('reservations.travelers.label')}
+
+
+ )}
+
+ {p.files.length > 0 && (
+
+ {t('files.title')}
+
+
+ )}
+
+ {p.costs.length > 0 && (
+
+ {p.costs.map(c => (
+
+
+ {formatMoney(c.amount, c.currency, locale)}
+
+ ))}
+
+ )}
+
+
+
+ {p.showFrames && p.detailPlugins.length > 0 && (
+ e.stopPropagation()} className="flex flex-col gap-2 px-3 pb-3">
+ {p.detailPlugins.map(pl => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+function IconRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
+ return (
+
+ {icon}
+ {children}
+
+ )
+}
+
+/** A transit journey's legs in their line colours, its duration and the first line of its notes. */
+function TransitBody({ r }: { r: Reservation }) {
+ const { t } = useTranslation()
+ const meta = parseMeta(r)
+ const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null
+ return (
+ <>
+ {transit && (
+
+
+ {transit.duration ? {fmtTransitDuration(transit.duration, t)} : null}
+
+ )}
+ {r.notes && (
+
+ {r.notes.split('\n')[0]}
+
+ )}
+ >
+ )
+}
diff --git a/client/src/components/Planner/bookings/BookingDetailDialog.tsx b/client/src/components/Planner/bookings/BookingDetailDialog.tsx
new file mode 100644
index 000000000..d36a341d2
--- /dev/null
+++ b/client/src/components/Planner/bookings/BookingDetailDialog.tsx
@@ -0,0 +1,416 @@
+import { useEffect, useRef, useState, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { ArrowRight, Copy, ExternalLink, FolderOpen, Footprints, Hotel, Link2, MapPin, Pencil, Route as RouteIcon, Trash2, Wallet, X } from 'lucide-react'
+import Markdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import remarkBreaks from 'remark-breaks'
+import type { BudgetItem, Reservation, TripFile } from '../../../types'
+import type { ViewContribution } from '../../../api/client'
+import type { ActivePlugin } from '../../../store/pluginStore'
+import { useTranslation } from '../../../i18n'
+import { useTripStore } from '../../../store/tripStore'
+import { useSettingsStore } from '../../../store/settingsStore'
+import { lockBodyScroll } from '../../../utils/bodyScrollLock'
+import { useToast } from '../../shared/Toast'
+import { BlurredCode } from '../../shared/BookingCode'
+import { markdownLinkComponents } from '../../shared/markdownLink'
+import NameDialog from '../../shared/NameDialog'
+import PluginFrame from '../../Plugins/PluginFrame'
+import { PluginCardFooter } from '../../Plugins/PluginContributions'
+import { formatMoney, formatTime } from '../../../utils/formatters'
+import { parseMeta, displayTitle, typeInfo } from './bookingsModel'
+import { formatDay, type BookingFacts } from './bookingFacts'
+import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, TypeTile, fs, toneColor, toneOf, toneTint } from './bookingParts'
+
+export interface BookingDetailDialogProps {
+ r: Reservation
+ facts: BookingFacts
+ files: TripFile[]
+ linkedCosts: BudgetItem[]
+ tripId: number
+ canEdit: boolean
+ /** True while a question opened from here (the delete confirmation) sits on top. */
+ covered?: boolean
+ onClose: () => void
+ onEdit: () => void
+ onDelete: () => void
+ onToggleStatus: () => void
+ onShowOnMap?: () => void
+ onMap?: boolean
+ onEditExpense?: (item: BudgetItem) => void
+ onNavigateToFiles: () => void
+ contributions: ViewContribution[]
+ detailPlugins: ActivePlugin[]
+}
+
+/**
+ * The phone's detail sheet on the desktop, as a dialog over the tab: the head
+ * band tinted by the status like the card it opens from, everything the booking
+ * holds below it, and the actions in a bar that stays in reach. Editing opens
+ * the booking's own dialog.
+ */
+export default function BookingDetailDialog(p: BookingDetailDialogProps) {
+ const { r, facts } = p
+ const { t, locale } = useTranslation()
+ const toast = useToast()
+ const updateReservation = useTripStore(s => s.updateReservation)
+ const tripCurrency = useTripStore(s => s.trip?.currency)
+ const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
+ const [renaming, setRenaming] = useState(false)
+ const [name, setName] = useState('')
+ const panelRef = useRef
(null)
+ const meta = parseMeta(r)
+ const tone = toneOf(r)
+ const info = typeInfo(r.type)
+ const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : []
+ const titleId = `booking-detail-${r.id}`
+
+ // Escape closes, unless the rename field or the delete question is the one being answered.
+ const { onClose } = p
+ const blocked = renaming || !!p.covered
+ useEffect(() => {
+ if (blocked) return
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [blocked, onClose])
+
+ // The page stays put behind the dialog, and the card it came from gets the focus back.
+ useEffect(() => {
+ const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null
+ const release = lockBodyScroll()
+ panelRef.current?.focus()
+ return () => { release(); opener?.focus() }
+ }, [])
+
+ const subParts = [meta.airline, meta.flight_number, meta.train_number, facts.endpoints.length >= 2 ? facts.endpoints.map(e => e.code || e.name).join(' → ') : null].filter(Boolean)
+ const sub = subParts.length ? subParts.join(' ') : facts.day?.label ?? null
+
+ const stats: { value: string; label: string }[] = []
+ if (facts.isHotel) {
+ if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') })
+ if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') })
+ const nights = nightsBetween(facts.startDate, facts.endDate)
+ if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) })
+ } else {
+ const from = facts.endpoints[0]
+ const to = facts.endpoints[facts.endpoints.length - 1]
+ if (facts.startTime) stats.push({ value: facts.time?.split(' → ')[0] || facts.startTime, label: from?.name || t('reservations.start') })
+ if (facts.endTime) stats.push({ value: facts.time?.split(' → ')[1] || facts.endTime, label: to?.name || t('reservations.end') })
+ if (meta.platform) stats.push({ value: meta.platform, label: t('reservations.meta.platform') })
+ if (meta.seat) stats.push({ value: meta.seat, label: t('reservations.meta.seat') })
+ }
+ // Cells already shown as a stat stay out of the field grid below.
+ const shownAsStat = new Set([t('reservations.meta.platform'), t('reservations.meta.seat'), t('reservations.meta.checkIn'), t('reservations.meta.checkOut')])
+ const cells = facts.cells.filter(c => !shownAsStat.has(c.label))
+ const hasDetails = !!(facts.place || facts.accommodation || facts.linked || facts.url || facts.day)
+
+ const copyCode = async () => {
+ try {
+ await navigator.clipboard.writeText(r.confirmation_number || '')
+ toast.success(t('common.copied'))
+ } catch {
+ toast.error(t('reservations.copyFailed'))
+ }
+ }
+ const rename = async () => {
+ const title = name.trim()
+ setRenaming(false)
+ if (!title || title === r.title) return
+ try {
+ await updateReservation(p.tripId, r.id, { title })
+ } catch {
+ toast.error(t('reservations.toast.updateError'))
+ }
+ }
+
+ const statusLabel = tone === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')
+ const nextStatus = tone === 'confirmed' ? t('reservations.pending') : t('reservations.confirmed')
+ const statusPill = (
+ <>
+
+ {statusLabel}
+ >
+ )
+ const pillCls = 'inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content shadow-sm'
+
+ return createPortal(
+ { if (e.target === e.currentTarget && !blocked) onClose() }}
+ >
+
+
+
+
+ {stats.length > 0 && (
+
+ {stats.map((s, i) => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+ )}
+
+ {transitLegs.length > 0 &&
}
+
+ {facts.endpoints.length > 2 && (
+
+ {facts.endpoints.map((ep, i) => (
+
+ {ep.local_time ? formatTime(ep.local_time, locale, timeFormat) : ''}
+
+ {ep.code ? {ep.code} : null}{ep.name}
+
+ ))}
+
+ )}
+
+ {facts.legCodes.length > 0 && (
+
+
+ {facts.legCodes.map((l, i) => (
+
+ {l.route || t('reservations.confirmationCode')}
+ #{l.code}
+
+ ))}
+
+
+ )}
+
+ {cells.length > 0 && (
+
+ {cells.map((c, i) => {c.value} )}
+
+ )}
+
+ {hasDetails && (
+
+ {facts.day &&
}>{[facts.day.label, facts.day.date].filter(Boolean).join(' ')}}
+ {facts.place &&
}>{facts.place}}
+ {facts.accommodation &&
}>{facts.accommodation}}
+ {facts.linked &&
}>{facts.linked}}
+ {facts.url && (
+
}>
+ {facts.url.href ?
{facts.url.href} : facts.url.text}
+
+ )}
+
+ )}
+
+ {(r.travelers || []).length > 0 && (
+
+ )}
+
+ {r.notes && (
+
+ )}
+
+ {p.linkedCosts.length > 0 && (
+
+
+ {p.linkedCosts.map(item => (
+ p.onEditExpense?.(item)}
+ className={`${BOX} flex items-center gap-2.5 px-3.5 py-2.5 text-left enabled:hover:bg-surface-hover`} style={fs(13, 'body')}>
+
+ {item.name}
+ {formatMoney(item.total_price, (item.currency || tripCurrency || 'EUR'), locale)}
+
+ ))}
+
+
+ )}
+
+ {p.files.length > 0 && (
+
+ {t('reservations.showInFiles')}
+
+ )}
+ >
+
+
+ )}
+
+ {(p.contributions.length > 0 || p.detailPlugins.length > 0) && (
+
+
+ {p.detailPlugins.map(pl => (
+
+ ))}
+
+ )}
+
+
+ {(p.onShowOnMap || p.canEdit) && (
+
+ {p.onShowOnMap && (
+
+ {t('mobileTrip.onMap')}
+
+ )}
+
+ {p.canEdit && (
+
+
+
+ )}
+ {p.canEdit && tone !== 'transit' && (
+
+ {t('common.edit')}
+
+ )}
+
+ )}
+
+
+
setRenaming(false)}
+ />
+ ,
+ document.body,
+ )
+}
+
+function Section({ label, action, children }: { label: string; action?: ReactNode; children: ReactNode }) {
+ return (
+
+
+ {label}
+ {action && {action} }
+
+ {children}
+
+ )
+}
+
+interface TransitLeg {
+ mode?: string
+ line?: string | null
+ line_color?: string | null
+ line_text_color?: string | null
+ duration?: number
+ stops?: number
+ from?: { name?: string; time?: string | null }
+ to?: { name?: string; time?: string | null }
+}
+
+/** A transit journey leg by leg, the way the phone lists it. */
+function TransitLegs({ legs }: { legs: TransitLeg[] }) {
+ const { t } = useTranslation()
+ return (
+
+ {legs.map((leg, i) => {
+ const walk = leg.mode === 'WALK'
+ const mins = leg.duration ? Math.round(leg.duration / 60) : null
+ return (
+
+ {walk ?
: (
+
{leg.line || leg.mode}
+ )}
+
+
+ {walk ?
{t('transit.walkTo', { name: leg.to?.name || '' })} : (
+ <>
{leg.from?.name} {leg.to?.name} >
+ )}
+
+
+ {[!walk && leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` → ${leg.to.time}` : ''}` : null, mins ? t('transit.min', { count: mins }) : null, !walk && leg.stops ? t('transit.stops', { count: leg.stops }) : null].filter(Boolean).join(', ')}
+
+
+
+ )
+ })}
+
+ )
+}
+
+function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
+ return (
+
+ {icon}
+ {children}
+
+ )
+}
+
+function nightsBetween(a: string | null, b: string | null): number {
+ if (!a || !b) return 0
+ const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5)
+ return n > 0 ? n : 0
+}
diff --git a/client/src/components/Planner/bookings/BookingsHeader.tsx b/client/src/components/Planner/bookings/BookingsHeader.tsx
new file mode 100644
index 000000000..39b56c981
--- /dev/null
+++ b/client/src/components/Planner/bookings/BookingsHeader.tsx
@@ -0,0 +1,283 @@
+import { useCallback, useRef, useState, type ReactNode } from 'react'
+import {
+ ArrowDownUp, CalendarDays, Check, Download, Filter, GanttChart, Layers, LayoutGrid, Plane, Plus, RotateCcw, Rows3,
+ Search, SlidersHorizontal, Tag, X,
+} from 'lucide-react'
+import { useTranslation } from '../../../i18n'
+import type { TripMember } from '../../Budget/BudgetPanelMemberChips'
+import ToggleSwitch from '../../Settings/ToggleSwitch'
+import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER, useDismissOnOutside } from '../../Packing/packingPopoverStyles'
+import { PopoverItem } from '../../Packing/PackingPopover'
+import { Tooltip } from '../../shared/Tooltip'
+import { typeInfo, type BookingsView, type GroupBy, type SortBy, type StatusFilter } from './bookingsModel'
+import { CountPill, fs } from './bookingParts'
+
+export interface BookingsHeaderProps {
+ title: string
+ total: number
+ canEdit: boolean
+ addLabel: string
+ onAdd: () => void
+ onImport?: () => void
+ onAirTrail?: () => void
+ view: BookingsView
+ onView: (v: BookingsView) => void
+ status: StatusFilter
+ onStatus: (s: StatusFilter) => void
+ query: string
+ onQuery: (q: string) => void
+ shown: number
+ filtering: boolean
+ onResetFilters: () => void
+ types: { type: string; count: number }[]
+ activeTypes: Set
+ onToggleType: (type: string) => void
+ onAllTypes: () => void
+ members: TripMember[]
+ showTravelers: boolean
+ activeTravelers: Set
+ onToggleTraveler: (id: number) => void
+ onClearTravelers: () => void
+ group: GroupBy
+ onGroup: (g: GroupBy) => void
+ sort: SortBy
+ sortDir: 'asc' | 'desc'
+ onSort: (s: SortBy) => void
+ onSortDir: () => void
+ byType: boolean
+ onByType: () => void
+ showContext: boolean
+ onShowContext: () => void
+ viewIsDefault: boolean
+ onResetView: () => void
+}
+
+/** A 36 px control on the bar, in the card colour so it reads on the bar's tint. */
+const BAR_BTN = 'relative grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-card text-content-secondary hover:text-content'
+
+/**
+ * One bar, as on the other planner tabs: what this is on the left, how to look
+ * at it and what to add on the right. Filters and view options wait in their
+ * popovers, so the page below starts with the bookings themselves.
+ */
+export default function BookingsHeader(p: BookingsHeaderProps) {
+ const { t } = useTranslation()
+ const searchRef = useRef(null)
+ const views: [BookingsView, typeof LayoutGrid, string][] = [
+ ['cards', LayoutGrid, t('reservations.view.cards')],
+ ['list', Rows3, t('reservations.view.list')],
+ ['timeline', GanttChart, t('reservations.view.timeline')],
+ ]
+ const hasEntries = p.total > 0
+
+ return (
+
+
{p.title}
+
+
+ {hasEntries && (
+ <>
+
+
+ p.onQuery(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Escape') { p.onQuery(''); searchRef.current?.blur() } }}
+ placeholder={t('reservations.searchPlaceholder')}
+ aria-label={t('common.search')}
+ className="min-w-0 flex-1 border-0 bg-transparent text-content outline-none placeholder:text-content-faint"
+ style={fs(13, 'body')}
+ />
+ {p.query && p.onQuery('')} aria-label={t('common.clear')} className="flex-none text-content-faint hover:text-content"> }
+
+ {p.filtering && (
+
+ {t('reservations.results', { shown: p.shown, total: p.total })}
+
+ )}
+
+
+ {views.map(([v, Icon, label]) => (
+
+ p.onView(v)} aria-pressed={p.view === v} aria-label={label}
+ className={`grid h-[30px] w-[34px] place-items-center rounded-[8px] ${p.view === v ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`}>
+
+
+
+ ))}
+
+
+ >
+ )}
+
+ {p.canEdit && (
+ <>
+ {hasEntries &&
}
+ {p.onImport && (
+
+
+
+ )}
+ {p.onAirTrail && (
+
+
+
+ )}
+
+ {p.addLabel}
+
+ >
+ )}
+
+ )
+}
+
+/** Opens a popover under a bar button and closes it on a press outside or on Escape. */
+function usePopover() {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+ const close = useCallback(() => setOpen(false), [])
+ useDismissOnOutside(ref, open, close)
+ return { open, setOpen, ref, close }
+}
+
+function Menu({ children, onClose, width = 260 }: { children: ReactNode; onClose: () => void; width?: number }) {
+ return (
+ { if (e.key === 'Escape') onClose() }} className="absolute right-0 top-11 z-30" style={{ ...POPOVER, width }}>
+ {children}
+
+ )
+}
+
+/** Status, types and travellers in one place; the button counts what is switched on. */
+function FilterMenu(p: BookingsHeaderProps) {
+ const { t } = useTranslation()
+ const { open, setOpen, ref, close } = usePopover()
+ const active = (p.status !== 'all' ? 1 : 0) + p.activeTypes.size + p.activeTravelers.size
+ const statuses: [StatusFilter, string][] = [['all', t('common.all')], ['confirmed', t('reservations.confirmed')], ['pending', t('reservations.pending')]]
+
+ return (
+
+
+ setOpen(o => !o)} aria-expanded={open} aria-label={t('reservations.filter')} className={`${BAR_BTN} ${active ? 'text-content' : ''}`}>
+
+ {active > 0 && {active} }
+
+
+ {open && (
+
+ {t('reservations.status')}
+
+ {statuses.map(([s, label]) => (
+ p.onStatus(s)} aria-pressed={p.status === s}
+ className={`flex-1 rounded-[8px] py-1 font-medium ${p.status === s ? 'bg-surface-card text-content shadow-sm' : 'text-content-muted hover:text-content'}`} style={fs(12, 'body')}>
+ {label}
+
+ ))}
+
+
+ {p.types.length > 1 && (
+ <>
+
+ {t('reservations.group.type')}
+ {p.types.map(({ type, count }) => {
+ const info = typeInfo(type)
+ const on = p.activeTypes.has(type)
+ return (
+ p.onToggleType(type)}
+ icon={ }
+ label={t(info.labelKey)}
+ trailing={{count} {on && } } />
+ )
+ })}
+ >
+ )}
+
+ {p.showTravelers && (
+ <>
+
+ {t('reservations.travelers.label')}
+
+ {p.members.map(m => {
+ const on = p.activeTravelers.has(m.id)
+ return (
+
p.onToggleTraveler(m.id)} aria-pressed={on} title={m.username}
+ className={`flex items-center gap-1.5 rounded-full border py-[3px] pl-[3px] pr-2.5 ${on ? 'border-[color:var(--text-primary)] text-content' : 'border-edge-faint text-content-muted hover:text-content'}`} style={fs(12, 'body')}>
+
+ {m.avatar_url ? : m.username?.[0]?.toUpperCase()}
+
+ {m.username}
+
+ )
+ })}
+
+ >
+ )}
+
+ {active > 0 && (
+ <>
+
+ } label={t('reservations.resetFilters')} onClick={() => { p.onResetFilters(); close() }} />
+ >
+ )}
+
+ )}
+
+ )
+}
+
+/** How the entries are laid out: group and sort for cards and list, lanes for the timeline. */
+function ViewOptions(p: BookingsHeaderProps) {
+ const { t } = useTranslation()
+ const { open, setOpen, ref, close } = usePopover()
+ const groups: [GroupBy, string][] = [['status', t('reservations.group.status')], ['day', t('reservations.group.day')], ['type', t('reservations.group.type')], ['none', t('reservations.group.none')]]
+ const sorts: [SortBy, string][] = [['date', t('reservations.sort.date')], ['title', t('reservations.sort.title')], ['type', t('reservations.sort.type')], ['status', t('reservations.sort.status')]]
+
+ return (
+
+
+ setOpen(o => !o)} aria-expanded={open} aria-label={t('reservations.viewOptions')} className={BAR_BTN}>
+
+ {!p.viewIsDefault && }
+
+
+ {open && (
+
+ {p.view === 'timeline' ? (
+ <>
+ {t('reservations.view.timeline')}
+
+
+ >
+ ) : (
+ <>
+ {t('reservations.group.label')}
+ {groups.map(([g, label]) => (
+ : g === 'type' ? : } label={label} active={p.group === g} onClick={() => p.onGroup(g)} />
+ ))}
+
+ {t('reservations.sort.label')}
+ {sorts.map(([s, label]) => (
+ } label={label} active={p.sort === s} onClick={() => p.onSort(s)} />
+ ))}
+ } muted label={p.sortDir === 'asc' ? t(p.sort === 'date' ? 'reservations.sort.earlyFirst' : 'reservations.sort.aToZ') : t(p.sort === 'date' ? 'reservations.sort.lateFirst' : 'reservations.sort.zToA')} onClick={p.onSortDir} />
+ >
+ )}
+
+ } label={t('reservations.resetView')} onClick={() => { p.onResetView(); close() }} />
+
+ )}
+
+ )
+}
+
+function SwitchRow({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
+ return (
+
+ {label}
+
+
+ )
+}
diff --git a/client/src/components/Planner/bookings/BookingsList.tsx b/client/src/components/Planner/bookings/BookingsList.tsx
new file mode 100644
index 000000000..8f960f1d5
--- /dev/null
+++ b/client/src/components/Planner/bookings/BookingsList.tsx
@@ -0,0 +1,137 @@
+import { useRef, type KeyboardEvent } from 'react'
+import { Paperclip, Pencil, Trash2, Wallet } from 'lucide-react'
+import type { Reservation } from '../../../types'
+import { useTranslation } from '../../../i18n'
+import { BlurredCode } from '../../shared/BookingCode'
+import { formatMoney } from '../../../utils/formatters'
+import { TransitLegChips } from '../transitDisplay'
+import { displayTitle, parseMeta, type BookingGroup, type CostTotal } from './bookingsModel'
+import type { BookingFacts } from './bookingFacts'
+import { AirTrailPill, CountPill, EYEBROW, ReviewPill, RoundAction, StatusDot, TravelerStack, TypeTile, fs } from './bookingParts'
+
+export interface BookingsListProps {
+ groups: BookingGroup[]
+ factsOf: (r: Reservation) => BookingFacts
+ filesCount: (r: Reservation) => number
+ costsOf: (r: Reservation) => CostTotal[]
+ selectedId: number | null
+ canEdit: boolean
+ collapsed: (groupId: string) => boolean
+ onToggleGroup: (groupId: string) => void
+ onSelect: (r: Reservation) => void
+ onEdit: (r: Reservation) => void
+ onDelete: (r: Reservation) => void
+ onToggleStatus: (r: Reservation) => void
+}
+
+/**
+ * The dense view: one card per group, one row per booking, like the phone's
+ * lists. A row opens the detail dialog; the arrow keys move through the rows
+ * and Enter or Space opens the one in focus.
+ */
+export default function BookingsList(p: BookingsListProps) {
+ const { t, locale } = useTranslation()
+ const rootRef = useRef(null)
+ const flat = p.groups.flatMap(g => (p.collapsed(g.id) ? [] : g.items))
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return
+ e.preventDefault()
+ const focused = document.activeElement instanceof HTMLElement ? Number(document.activeElement.dataset.row) : NaN
+ const i = flat.findIndex(r => r.id === focused)
+ const next = flat[e.key === 'ArrowDown' ? Math.min(flat.length - 1, i + 1) : Math.max(0, i - 1)]
+ if (next) rootRef.current?.querySelector(`[data-row="${next.id}"]`)?.focus()
+ }
+
+ return (
+
+ {p.groups.map(g => {
+ const open = !p.collapsed(g.id)
+ return (
+
+ {g.label && (
+ p.onToggleGroup(g.id)} aria-expanded={open}
+ className="flex w-full items-center gap-2 bg-surface-secondary px-3.5 py-2.5 text-left">
+ {g.label}
+ {g.sub && {g.sub} }
+ {g.items.length}
+
+ )}
+ {open && g.items.map(r => (
+
+ ))}
+
+ )
+ })}
+
+ )
+}
+
+function Row({ r, p, facts, files, costs, t, locale }: {
+ r: Reservation
+ p: BookingsListProps
+ facts: BookingFacts
+ files: number
+ costs: CostTotal[]
+ t: (k: string, v?: Record) => string
+ locale: string
+}) {
+ const selected = p.selectedId === r.id
+ const transit = r.type === 'transit'
+ const meta = parseMeta(r)
+ const sub = facts.endpoints.length >= 2
+ ? facts.endpoints.map(e => e.code || e.name).join(' → ')
+ : facts.accommodation || facts.place || ''
+ const extras: string[] = [meta.airline && meta.flight_number ? `${meta.airline} ${meta.flight_number}` : meta.flight_number || meta.airline, meta.train_number, meta.seat, meta.platform].filter(Boolean)
+
+ return (
+ p.onSelect(r)}
+ onKeyDown={e => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); p.onSelect(r) } }}
+ className={`group relative grid cursor-pointer grid-cols-[22px_32px_minmax(0,1fr)_minmax(118px,auto)_auto] items-center gap-3 border-t border-edge-faint px-3.5 py-2.5 first:border-t-0 hover:bg-surface-hover focus-visible:outline-none focus-visible:bg-surface-hover ${selected ? 'bg-surface-hover shadow-[inset_3px_0_0_0_var(--text-primary)]' : ''}`}
+ >
+
p.onToggleStatus(r)} />
+
+
+
+
{displayTitle(r)}
+ {!!r.needs_review &&
}
+
+
+ {transit ? (
+
+ ) : (
+
+ {sub &&
{sub} }
+ {extras.map(x =>
{x} )}
+ {r.confirmation_number && (
+
+ {r.confirmation_number} {facts.legCodes.length > 1 ? ` +${facts.legCodes.length - 1}` : ''}
+
+ )}
+ {costs.map(c => (
+
+ {formatMoney(c.amount, c.currency, locale)}
+
+ ))}
+ {files > 0 &&
{files}}
+
+ )}
+
+
+
{facts.day ? [facts.day.label, facts.day.date].filter(Boolean).join(' ') : t('reservations.undated')}
+ {facts.time &&
{facts.time}
}
+
+
+
+ {p.canEdit && !transit &&
p.onEdit(r)}> }
+ {p.canEdit &&
p.onDelete(r)} danger> }
+
+
+ )
+}
diff --git a/client/src/components/Planner/bookings/BookingsTimeline.tsx b/client/src/components/Planner/bookings/BookingsTimeline.tsx
new file mode 100644
index 000000000..fff2be0da
--- /dev/null
+++ b/client/src/components/Planner/bookings/BookingsTimeline.tsx
@@ -0,0 +1,411 @@
+import { useEffect, useMemo, useRef, useState, type FocusEvent, type MouseEvent, type ReactNode } from 'react'
+import { createPortal } from 'react-dom'
+import { CalendarDays, ChevronLeft, ChevronRight, Clock, MapPin, Route as RouteIcon } from 'lucide-react'
+import type { Day, Reservation } from '../../../types'
+import { useTranslation } from '../../../i18n'
+import { useSettingsStore } from '../../../store/settingsStore'
+import { formatTime } from '../../../utils/formatters'
+import { Tooltip } from '../../shared/Tooltip'
+import { typeInfo, displayTitle, TYPE_ORDER } from './bookingsModel'
+import type { BookingFacts } from './bookingFacts'
+import { absHour, buildAxis, dayWindow, packBars, placeReservation, type Bar, type Moment } from './timelineModel'
+import { CountPill, EYEBROW, TypeTile, fs, toneColor, toneOf } from './bookingParts'
+
+/** The whole trip in the width, or one day on an hour scale. */
+export type TimelineZoom = 'trip' | 'day'
+
+export interface BookingsTimelineProps {
+ items: Reservation[]
+ /** The other tab's bookings, drawn dimmed in a thin lane on top for orientation. */
+ context: Reservation[]
+ days: Day[]
+ zoom: TimelineZoom
+ onZoom: (z: TimelineZoom) => void
+ byType: boolean
+ showContext: boolean
+ selectedId: number | null
+ onSelect: (r: Reservation) => void
+ factsOf: (r: Reservation) => BookingFacts
+}
+
+const LABEL_W = 156
+// Below these a label stops being readable, so a long trip or a long day scrolls instead.
+const MIN_DAY_W = 56
+const MIN_HOUR_W = 52
+
+type Placed = { r: Reservation; start: Moment; end: Moment }
+
+function useWidth() {
+ const ref = useRef(null)
+ const [width, setWidth] = useState(0)
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width))
+ ro.observe(el)
+ return () => ro.disconnect()
+ }, [])
+ return [ref, width] as const
+}
+
+const localToday = () => {
+ const d = new Date()
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+}
+
+/**
+ * The bookings of a tab on the trip's days: one lane per type, bars from start
+ * to end in local time. The trip view fits every day into the width, the day
+ * view spreads one day over an hour scale with larger bars that carry their
+ * times and route. Nothing is hidden: what has no date or lies outside the trip
+ * waits in the cards below.
+ */
+export default function BookingsTimeline(p: BookingsTimelineProps) {
+ const { t, locale } = useTranslation()
+ const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
+ const [scrollRef, width] = useWidth()
+ const axis = useMemo(() => buildAxis(p.days), [p.days])
+ const n = Math.max(axis.length, 1)
+ const todayIndex = axis.findIndex(a => a.date === localToday())
+ const dayMode = p.zoom === 'day'
+
+ const placed = useMemo(() => p.items.map(r => ({ r, pl: placeReservation(r, axis, p.days) })), [p.items, axis, p.days])
+ const onAxis = useMemo(() => placed.flatMap(x => (x.pl.kind === 'on' ? [{ r: x.r, start: x.pl.start, end: x.pl.end }] : [])), [placed])
+ const tray = placed.filter(x => x.pl.kind !== 'on')
+ const contextOnAxis = useMemo(() => (p.showContext
+ ? p.context.flatMap(r => { const pl = placeReservation(r, axis, p.days); return pl.kind === 'on' ? [{ r, start: pl.start, end: pl.end }] : [] })
+ : []), [p.context, p.showContext, axis, p.days])
+
+ // The day view opens on today during the trip, otherwise on the first day with something on it.
+ const [dayIndex, setDayIndex] = useState(() => {
+ if (todayIndex >= 0) return todayIndex
+ const first = onAxis.reduce((min, it) => Math.min(min, it.start.day), Infinity)
+ return Number.isFinite(first) ? first : 0
+ })
+ const day = Math.min(dayIndex, n - 1)
+ const openDay = (i: number) => { setDayIndex(i); p.onZoom('day') }
+
+ // One scale for both views: a moment in pixels, the drawn width, and what is in view.
+ const avail = Math.max(0, width - LABEL_W - 2)
+ const win = dayMode ? dayWindow(onAxis, day) : null
+ const winFrom = win ? day * 24 + win.from : 0
+ const winTo = win ? day * 24 + win.to : n * 24
+ const pxPerHour = win
+ ? Math.max(MIN_HOUR_W, avail / (win.to - win.from))
+ : Math.max(MIN_DAY_W, Math.floor(avail / n)) / 24
+ const gridW = Math.round((winTo - winFrom) * pxPerHour)
+ const toX = (m: Moment) => (absHour(m) - winFrom) * pxPerHour
+ const inView = (it: Placed) => absHour(it.start) < winTo && absHour(it.end) > winFrom
+ const scrolls = width > 0 && LABEL_W + gridW > width + 1
+ const barH = dayMode ? 50 : 30
+ const laneH = barH + 6
+
+ // A handful of bookings per trip: packing them on every render costs nothing worth memoising.
+ const groups = new Map()
+ for (const it of onAxis) {
+ if (!inView(it)) continue
+ const key = p.byType ? it.r.type : 'all'
+ if (!groups.has(key)) groups.set(key, [])
+ groups.get(key)!.push(it)
+ }
+ const lanes = [...groups.keys()]
+ .sort((a, b) => TYPE_ORDER.indexOf(a) - TYPE_ORDER.indexOf(b))
+ .map(key => ({ key, count: groups.get(key)!.length, ...packBars(groups.get(key)!, toX, gridW, dayMode ? 34 : 26) }))
+ const contextBars = contextOnAxis.length ? packBars(contextOnAxis.filter(inView), toX, gridW, 12, 2) : null
+
+ const now = new Date()
+ const nowAbs = todayIndex >= 0 ? todayIndex * 24 + now.getHours() + now.getMinutes() / 60 : null
+ const nowX = nowAbs != null && nowAbs >= winFrom && nowAbs <= winTo ? (nowAbs - winFrom) * pxPerHour : null
+
+ const fmtDay = (date: string, opts: Intl.DateTimeFormatOptions) => new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...opts, timeZone: 'UTC' })
+ const dayName = (i: number) => axis[i]?.title || t('dayplan.dayN', { n: axis[i]?.dayNumber ?? i + 1 })
+ const range = axis[0]?.date && axis[axis.length - 1]?.date
+ ? `${fmtDay(axis[0].date!, { day: 'numeric', month: 'short' })} → ${fmtDay(axis[axis.length - 1].date!, { day: 'numeric', month: 'short' })}`
+ : null
+ const hourLabel = (h: number) => formatTime(`${String(h % 24).padStart(2, '0')}:00`, locale, timeFormat)
+ const hourStep = pxPerHour >= (timeFormat === '12h' ? 72 : 56) ? 1 : 2
+
+ const [hover, setHover] = useState<{ r: Reservation; rect: DOMRect } | null>(null)
+ const hoverProps = (r: Reservation): HoverHandlers => ({
+ onMouseEnter: e => setHover({ r, rect: e.currentTarget.getBoundingClientRect() }),
+ onMouseLeave: () => setHover(null),
+ onFocus: e => setHover({ r, rect: e.currentTarget.getBoundingClientRect() }),
+ onBlur: () => setHover(null),
+ })
+
+ // Weekend days in the trip view, the night hours in the day view.
+ const Cells = () => win ? (
+ <>
+ {Array.from({ length: win.to - win.from }, (_, i) => {
+ const h = win.from + i
+ return = 22 ? 'bg-surface-secondary' : ''}`} style={{ left: i * pxPerHour, width: pxPerHour }} />
+ })}
+ >
+ ) : (
+ <>
+ {axis.map((a, i) => {
+ const wd = a.date ? new Date(`${a.date}T00:00:00Z`).getUTCDay() : -1
+ return
+ })}
+ >
+ )
+
+ const segBtn = (on: boolean) => `rounded-full px-3 py-1 font-medium ${on ? 'bg-accent text-accent-text' : 'text-content-muted hover:text-content'}`
+ const navBtn = 'grid h-7 w-7 place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content disabled:opacity-40 disabled:hover:text-content-muted'
+ const showToday = todayIndex >= 0 && (dayMode ? todayIndex !== day : scrolls)
+ const goToday = () => {
+ if (dayMode) { setDayIndex(todayIndex); return }
+ if (nowX != null) scrollRef.current?.scrollTo({ left: Math.max(0, nowX - 200), behavior: 'smooth' })
+ }
+
+ return (
+
+
+
+ {dayMode ? (
+
+ setDayIndex(Math.max(0, day - 1))} disabled={day === 0} aria-label={t('reservations.timeline.prevDay')} className={navBtn}>
+
+
+
+ {dayName(day)}
+ {axis[day]?.date && (
+ <>
+
+ {fmtDay(axis[day].date!, { weekday: 'short', day: 'numeric', month: 'short' })}
+ >
+ )}
+
+ setDayIndex(Math.min(n - 1, day + 1))} disabled={day >= n - 1} aria-label={t('reservations.timeline.nextDay')} className={navBtn}>
+
+
+
+ ) : range && (
+
+ {range}
+
+ {t('reservations.timeline.dayCount', { count: axis.length })}
+
+ )}
+
+ {showToday && (
+
+ {t('reservations.timeline.today')}
+
+ )}
+
+ p.onZoom('trip')} aria-pressed={!dayMode} className={segBtn(!dayMode)} style={fs(12, 'body')}>{t('reservations.timeline.trip')}
+ p.onZoom('day')} aria-pressed={dayMode} className={segBtn(dayMode)} style={fs(12, 'body')}>{t('reservations.timeline.day')}
+
+
+
+
+
+
+
+
+ {win ? (
+ Array.from({ length: win.to - win.from }, (_, i) => win.from + i).filter(h => (h - win.from) % hourStep === 0).map(h => (
+
+ {hourLabel(h)}
+
+ ))
+ ) : axis.map((a, i) => (
+
+ openDay(i)}
+ className="absolute top-0 flex h-full flex-col justify-center px-2 text-left hover:bg-surface-hover" style={{ left: i * 24 * pxPerHour, width: 24 * pxPerHour }}>
+
+ {a.date ? fmtDay(a.date, { weekday: 'short', day: 'numeric', ...(i === 0 || a.date.endsWith('-01') ? { month: 'short' } : {}) }) : ''}
+
+
+ {24 * pxPerHour >= 90 ? t('dayplan.dayN', { n: a.dayNumber ?? i + 1 }) : String(a.dayNumber ?? i + 1)}
+
+
+
+ ))}
+
+
+
+ {contextBars && contextBars.bars.length > 0 && (
+
+
+ {t(p.items.some(r => r.type !== 'hotel') && p.context.some(r => r.type === 'hotel') ? 'reservations.timeline.contextStays' : 'reservations.timeline.contextTransports')}
+
+
+
+ {contextBars.bars.map(b => {
+ const info = typeInfo(b.r.type)
+ return (
+
+ {b.w > 60 ? displayTitle(b.r) : ''}
+
+ )
+ })}
+
+
+ )}
+
+ {lanes.map(lane => {
+ const info = typeInfo(lane.key)
+ return (
+
+
+ {lane.key !== 'all' && }
+ {lane.key === 'all' ? t('common.all') : t(info.labelKey)}
+ {lane.count}
+
+
+
+ {lane.bars.map(b => (
+ p.onSelect(b.r)} hover={hoverProps(b.r)} />
+ ))}
+
+
+ )
+ })}
+
+ {lanes.length === 0 && (
+
+
+
+ {t(dayMode ? 'reservations.timeline.nothingThisDay' : 'reservations.timeline.noneDated')}
+
+
+ )}
+
+ {nowX != null && (
+
+ {t('reservations.timeline.now')}
+
+ )}
+
+
+
+
+ {(['before', 'after', 'undated'] as const).map(kind => {
+ const list = tray.filter(x => x.pl.kind === kind)
+ if (list.length === 0) return null
+ return (
+
+
+ {t(`reservations.group.${kind}`)}
+ {list.length}
+
+
+ {list.map(({ r }) => p.onSelect(r)} />)}
+
+
+ )
+ })}
+
+ {hover &&
}
+
+ )
+}
+
+interface HoverHandlers {
+ onMouseEnter: (e: MouseEvent
) => void
+ onMouseLeave: () => void
+ onFocus: (e: FocusEvent) => void
+ onBlur: () => void
+}
+
+/**
+ * One bar. On the trip view it carries the icon and the title; on the day view
+ * it is tall enough for the times and the route below the title, and an arrow
+ * marks the side where it runs on past the hours in view.
+ */
+function TimelineBar({ bar, top, height, detailed, facts, selected, onSelect, hover }: {
+ bar: Bar; top: number; height: number; detailed: boolean; facts: BookingFacts; selected: boolean; onSelect: () => void; hover: HoverHandlers
+}) {
+ const { r, x, w } = bar
+ const info = typeInfo(r.type)
+ const pending = r.type !== 'transit' && r.status !== 'confirmed'
+ const route = facts.endpoints.length >= 2 ? facts.endpoints.map(e => e.code || e.name).join(' → ') : null
+ const second = [facts.time, route].filter(Boolean).join(' ')
+ return (
+
+ {bar.cutStart && }
+
+ {w > (detailed ? 70 : 64) && (
+
+ {displayTitle(r)}
+ {detailed && second && w > 110 && {second} }
+
+ )}
+ {bar.cutEnd && }
+
+ )
+}
+
+/** What a bar holds, beside it while the pointer rests on it or it has the focus. */
+function HoverCard({ r, rect, facts }: { r: Reservation; rect: DOMRect; facts: BookingFacts }) {
+ const { t } = useTranslation()
+ const info = typeInfo(r.type)
+ const tone = toneOf(r)
+ const status = tone === 'transit' ? null : tone === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')
+ const route = facts.endpoints.length >= 2 ? facts.endpoints.map(e => e.code || e.name).join(' → ') : null
+ const where = facts.place || facts.accommodation
+ const W = 290
+ const below = rect.bottom + 200 < window.innerHeight
+ const left = Math.max(8, Math.min(rect.left, window.innerWidth - W - 8))
+ return createPortal(
+
+
+
+
+
{displayTitle(r)}
+
+
+ {[t(info.labelKey), status].filter(Boolean).join(', ')}
+
+
+
+ {(facts.day || facts.time || route || where) && (
+
+ {facts.day && }>{[facts.day.label, facts.day.date].filter(Boolean).join(' ')}}
+ {facts.time && }>{facts.time}}
+ {route && }>{route}}
+ {where && }>{where}}
+
+ )}
+
,
+ document.body,
+ )
+}
+
+function HoverLine({ icon, children }: { icon: ReactNode; children: ReactNode }) {
+ return (
+
+ {icon}
+ {children}
+
+ )
+}
+
+/** A booking the axis cannot place, as a small card of its own below the timeline. */
+function TrayCard({ r, selected, onSelect }: { r: Reservation; selected: boolean; onSelect: () => void }) {
+ const { t } = useTranslation()
+ const info = typeInfo(r.type)
+ const tone = toneOf(r)
+ const status = tone === 'transit' ? null : tone === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending')
+ return (
+
+
+
+ {displayTitle(r)}
+
+
+ {[t(info.labelKey), status].filter(Boolean).join(', ')}
+
+
+
+
+ )
+}
diff --git a/client/src/components/Planner/bookings/bookingFacts.ts b/client/src/components/Planner/bookings/bookingFacts.ts
new file mode 100644
index 000000000..daa66b7e3
--- /dev/null
+++ b/client/src/components/Planner/bookings/bookingFacts.ts
@@ -0,0 +1,112 @@
+import type { Day, Reservation, ReservationEndpoint } from '../../../types'
+import { formatPriceText, formatTime, splitReservationDateTime } from '../../../utils/formatters'
+import { getFlightLegs, getTrainLegs } from '../../../utils/flightLegs'
+import { safeExternalHref } from '../../../utils/safeUrl'
+import { daySpan, parseMeta, type AssignmentLookupEntry } from './bookingsModel'
+
+export interface FactsContext {
+ t: (key: string, params?: Record) => string
+ locale: string
+ timeFormat: string
+ days: Day[]
+ assignmentLookup: Record
+ tripCurrency?: string | null
+ /** Whether an expense is linked; then the mirrored meta price is not shown a second time. */
+ hasLinkedCost: boolean
+}
+
+export interface BookingFacts {
+ /** The day or day range, e.g. "Day 2 → Day 7", with the calendar date beside it. */
+ day: { label: string; date: string | null } | null
+ /** Start and end time, or null without either. */
+ time: string | null
+ startTime: string | null
+ endTime: string | null
+ startDate: string | null
+ endDate: string | null
+ cells: { label: string; value: string }[]
+ endpoints: ReservationEndpoint[]
+ legCodes: { route: string; code: string }[]
+ place: string | null
+ accommodation: string | null
+ linked: string | null
+ url: { href: string | null; text: string } | null
+ isHotel: boolean
+}
+
+const fmtDate = (date: string, locale: string, weekday = true) =>
+ new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, { ...(weekday ? { weekday: 'short' } : {}), day: 'numeric', month: 'short', timeZone: 'UTC' })
+
+export function formatDay(date: string, locale: string, weekday = true): string {
+ return fmtDate(date, locale, weekday)
+}
+
+/** Everything a card, a row and the detail pane print about one booking, formatted once. */
+export function bookingFacts(r: Reservation, c: FactsContext): BookingFacts {
+ const { t, locale, timeFormat } = c
+ const meta = parseMeta(r)
+ const isHotel = r.type === 'hotel'
+ const span = daySpan(r, c.days)
+ const startDt = splitReservationDateTime(r.reservation_time)
+ const endDt = splitReservationDateTime(r.reservation_end_time)
+ const dayName = (d: Day) => d.title || t('dayplan.dayN', { n: d.day_number })
+
+ let day: BookingFacts['day'] = null
+ if (span.start) {
+ const label = span.end && span.end.id !== span.start.id ? `${dayName(span.start)} → ${dayName(span.end)}` : dayName(span.start)
+ const dates = [span.start.date, span.end && span.end.id !== span.start.id ? span.end.date : null].filter(Boolean) as string[]
+ day = { label, date: dates.length ? dates.map(d => fmtDate(d, locale, dates.length === 1)).join(' → ') : null }
+ } else if (startDt.date) {
+ const label = endDt.date && endDt.date !== startDt.date ? `${fmtDate(startDt.date, locale)} → ${fmtDate(endDt.date, locale)}` : fmtDate(startDt.date, locale)
+ day = { label, date: null }
+ }
+
+ const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
+ const from = eps.find(e => e.role === 'from')
+ const to = eps.find(e => e.role === 'to')
+ const startTime = startDt.time || from?.local_time || null
+ const endTime = endDt.time || to?.local_time || null
+ const time = startTime || endTime
+ ? `${startTime ? formatTime(startTime, locale, timeFormat) : ''}${endTime ? ` → ${formatTime(endTime, locale, timeFormat)}` : ''}`.trim()
+ : null
+
+ const hasEndpoints = !!from && !!to
+ const cells: BookingFacts['cells'] = []
+ 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.class ? `${meta.seat}, ${meta.class}` : meta.seat })
+ if (!c.hasLinkedCost && meta.price != null && meta.price !== '') {
+ cells.push({ label: t('reservations.price'), value: formatPriceText(meta.price, meta.priceCurrency, c.tripCurrency, locale) })
+ }
+ 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) })
+
+ // Per-segment codes (#1943), only on a real stopover booking.
+ const legs = r.type === 'flight' ? getFlightLegs(r) : r.type === 'train' ? getTrainLegs(r) : []
+ const legCodes = legs.length > 1
+ ? legs.filter(l => l.confirmation_number).map(l => ({ route: [l.from, l.to].filter(Boolean).join(' → '), code: l.confirmation_number as string }))
+ : []
+
+ const linkedEntry = r.assignment_id ? c.assignmentLookup[r.assignment_id] : null
+ const linked = linkedEntry
+ ? [linkedEntry.dayTitle || t('dayplan.dayN', { n: linkedEntry.dayNumber }), linkedEntry.placeName, linkedEntry.startTime ? `${linkedEntry.startTime}${linkedEntry.endTime ? ` → ${linkedEntry.endTime}` : ''}` : null].filter(Boolean).join(', ')
+ : null
+
+ return {
+ day, time, startTime, endTime,
+ startDate: startDt.date || from?.local_date || span.start?.date || null,
+ endDate: endDt.date || to?.local_date || span.end?.date || null,
+ cells, endpoints: eps.length >= 2 ? eps : [], legCodes,
+ place: r.location || r.place_name || null,
+ accommodation: r.accommodation_name || null,
+ linked,
+ url: r.url ? { href: safeExternalHref(r.url), text: r.url } : null,
+ isHotel,
+ }
+}
diff --git a/client/src/components/Planner/bookings/bookingParts.tsx b/client/src/components/Planner/bookings/bookingParts.tsx
new file mode 100644
index 000000000..4f9de9c27
--- /dev/null
+++ b/client/src/components/Planner/bookings/bookingParts.tsx
@@ -0,0 +1,231 @@
+import type { CSSProperties, ReactNode } from 'react'
+import { AlertCircle, ChevronDown, ChevronUp, FileText, Plane } from 'lucide-react'
+import type { Reservation, TripFile } from '../../../types'
+import type { ReservationTraveler } from '@trek/shared'
+import { useTranslation } from '../../../i18n'
+import { avatarSrc } from '../../../utils/avatarSrc'
+import { openFile } from '../../../utils/fileDownload'
+import GuestBadge from '../../shared/GuestBadge'
+import { useToast } from '../../shared/Toast'
+import { parseMeta, typeInfo } from './bookingsModel'
+
+// The phone's card language on the desktop: small Geist eyebrows, framed value
+// boxes, count pills. Sizes scale with the user's text size setting.
+export const fs = (px: number, tier: 'caption' | 'body' | 'subtitle' = 'caption'): CSSProperties => ({ fontSize: `calc(${px}px * var(--fs-scale-${tier}, 1))` })
+export const EYEBROW = 'font-geist font-bold uppercase tracking-[.08em] text-content-faint'
+export const BOX = 'rounded-[10px] border border-edge-faint bg-surface-card'
+
+export type StatusTone = 'confirmed' | 'pending' | 'transit'
+
+export function toneOf(r: Reservation): StatusTone {
+ if (r.type === 'transit') return 'transit'
+ return r.status === 'confirmed' ? 'confirmed' : 'pending'
+}
+
+const TONE_VAR: Record = { confirmed: 'var(--success)', pending: 'var(--warning)', transit: 'var(--info)' }
+
+/** The status colour, and the head band's tint derived from it. */
+export const toneColor = (tone: StatusTone) => TONE_VAR[tone]
+export const toneTint = (tone: StatusTone) => `color-mix(in srgb, ${TONE_VAR[tone]} 11%, transparent)`
+
+export function Eyebrow({ children, className = '' }: { children: ReactNode; className?: string }) {
+ return {children}
+}
+
+/** An uppercase label over a framed value, the field every phone card is made of. */
+export function Field({ label, children, className = '', tabular = false }: { label: string; children: ReactNode; className?: string; tabular?: boolean }) {
+ return (
+
+
{label}
+
+ {children}
+
+
+ )
+}
+
+export function CountPill({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
+
+/** A collapsible section head: the label, what the day is, the count, and the chevron on the right. */
+export function SectionHead({ label, sub, count, open, onToggle }: { label: string; sub?: string; count: number; open: boolean; onToggle: () => void }) {
+ return (
+
+ {label}
+ {sub && {sub} }
+ {count}
+ {open
+ ?
+ : }
+
+ )
+}
+
+/**
+ * The status dot. With the right to edit bookings it is a switch between
+ * confirmed and pending, as on the phone; a transit journey has no status and
+ * shows its dot in the info colour.
+ */
+export function StatusDot({ r, canToggle, onToggle, size = 8 }: { r: Reservation; canToggle: boolean; onToggle: () => void; size?: number }) {
+ const { t } = useTranslation()
+ const tone = toneOf(r)
+ const dot =
+ if (!canToggle || tone === 'transit') return {dot}
+ const next = tone === 'confirmed' ? t('reservations.pending') : t('reservations.confirmed')
+ return (
+ { e.stopPropagation(); onToggle() }}
+ title={t('reservations.status.switchTo', { status: next })}
+ aria-label={t('reservations.status.switchTo', { status: next })}
+ className="grid h-[22px] w-[22px] flex-none place-items-center rounded-full hover:bg-surface-hover"
+ >
+ {dot}
+
+ )
+}
+
+export function TypeChip({ type }: { type: string }) {
+ const { t } = useTranslation()
+ const info = typeInfo(type)
+ return (
+
+
+ {t(info.labelKey)}
+
+ )
+}
+
+/** The square tile with the type's icon, used by rows and the detail pane. */
+export function TypeTile({ type, size = 32, raised = false }: { type: string; size?: number; raised?: boolean }) {
+ const info = typeInfo(type)
+ return (
+
+
+
+ )
+}
+
+export function ReviewPill() {
+ const { t } = useTranslation()
+ return (
+
+
+ {t('reservations.needsReview')}
+
+ )
+}
+
+/**
+ * The AirTrail link of a booking. A multi-leg import is detached from sync by
+ * design (#1535), which is not the same as a flight removed upstream (#1646).
+ */
+export function AirTrailPill({ r }: { r: Reservation }) {
+ const { t } = useTranslation()
+ if (r.external_source !== 'airtrail') return null
+ const meta = parseMeta(r)
+ const multiLeg = !r.sync_enabled && ((Array.isArray(meta.legs) && meta.legs.length > 1) || (r.endpoints || []).length > 2)
+ const live = !!r.sync_enabled || multiLeg
+ return (
+
+
+ {live ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
+
+ )
+}
+
+/** A round 26 px action on a card head, quiet until the card is hovered or focused. */
+export function RoundAction({ label, onClick, children, danger = false }: { label: string; onClick: () => void; children: ReactNode; danger?: boolean }) {
+ return (
+ { e.stopPropagation(); onClick() }}
+ title={label}
+ aria-label={label}
+ className={`grid h-[26px] w-[26px] flex-none place-items-center rounded-full bg-surface-card text-content-muted opacity-60 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 ${danger ? 'hover:text-danger' : 'hover:text-content'}`}
+ >
+ {children}
+
+ )
+}
+
+/** Who a booking is for, as the phone shows them: avatar and name in a soft chip. */
+export function TravelerChips({ travelers }: { travelers: ReservationTraveler[] }) {
+ if (travelers.length === 0) return null
+ return (
+
+ {travelers.map(tv => {
+ const src = tv.avatar_url || avatarSrc(tv.avatar)
+ return (
+
+
+ {src ? : tv.username?.[0]?.toUpperCase()}
+
+ {tv.username}
+ {!!tv.is_guest && }
+
+ )
+ })}
+
+ )
+}
+
+/** Stacked avatars for a dense row, named in the tooltip and for screen readers. */
+export function TravelerStack({ travelers, max = 3 }: { travelers: ReservationTraveler[]; max?: number }) {
+ if (travelers.length === 0) return null
+ const names = travelers.map(tv => tv.username).join(', ')
+ return (
+
+ {travelers.slice(0, max).map((tv, i) => {
+ const src = tv.avatar_url || avatarSrc(tv.avatar)
+ return (
+
+ {src ? : tv.username?.[0]?.toUpperCase()}
+
+ )
+ })}
+ {travelers.length > max && +{travelers.length - max} }
+
+ )
+}
+
+/** Opens an attached file, and says so when it cannot. */
+export function useOpenFile() {
+ const { t } = useTranslation()
+ const toast = useToast()
+ return (f: TripFile) => { openFile(f.url, f.original_name).catch(() => toast.error(t('files.openError'))) }
+}
+
+export function filesFor(r: Reservation, files: TripFile[]): TripFile[] {
+ return files.filter(f => !f.deleted_at && (f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id)))
+}
+
+/** The attached files, one framed row each, opening the file. */
+export function FileRows({ files }: { files: TripFile[] }) {
+ const open = useOpenFile()
+ if (files.length === 0) return null
+ return (
+
+ {files.map(f => (
+ { e.stopPropagation(); open(f) }}
+ className={`${BOX} flex items-center gap-1.5 px-[10px] py-[7px] text-left hover:bg-surface-hover`}
+ >
+
+ {f.original_name}
+
+ ))}
+
+ )
+}
diff --git a/client/src/components/Planner/bookings/bookingsModel.ts b/client/src/components/Planner/bookings/bookingsModel.ts
new file mode 100644
index 000000000..30e27a055
--- /dev/null
+++ b/client/src/components/Planner/bookings/bookingsModel.ts
@@ -0,0 +1,265 @@
+import {
+ Plane, Hotel, Utensils, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Ticket, FileText,
+ TramFront, Users, ParkingSquare, type LucideIcon,
+} from 'lucide-react'
+import type { AssignmentsMap, Day, Reservation } from '../../../types'
+import { TRANSPORT_TYPE_COLOR } from '../../../mobile/screens/trip/tabs/transportsModel'
+import { BOOKING_TYPE_COLOR } from '../../../mobile/screens/trip/tabs/bookingsModel'
+import { splitReservationDateTime } from '../../../utils/formatters'
+import { parseReservationMetadata } from '../../../utils/flightLegs'
+
+/** Which of the two planner tabs a panel shows. */
+export type BookingsKind = 'transports' | 'bookings'
+export type BookingsView = 'cards' | 'list' | 'timeline'
+export type StatusFilter = 'all' | 'confirmed' | 'pending'
+export type GroupBy = 'status' | 'day' | 'type' | 'none'
+export type SortBy = 'date' | 'title' | 'type' | 'status'
+
+export interface ReservationTypeInfo {
+ Icon: LucideIcon
+ labelKey: string
+ color: string
+}
+
+// Icons and label keys for every reservation type. The colours come from the phone's
+// models so the two shells can never drift apart on what a flight looks like.
+const TYPES: Record = {
+ flight: { Icon: Plane }, train: { Icon: Train }, bus: { Icon: Bus }, car: { Icon: Car },
+ taxi: { Icon: CarTaxiFront }, bicycle: { Icon: Bike }, cruise: { Icon: Ship }, ferry: { Icon: Sailboat },
+ transit: { Icon: TramFront }, transport_other: { Icon: Route },
+ hotel: { Icon: Hotel }, restaurant: { Icon: Utensils }, event: { Icon: Ticket }, tour: { Icon: Users },
+ parking: { Icon: ParkingSquare }, other: { Icon: FileText },
+}
+
+export function typeInfo(type: string): ReservationTypeInfo {
+ const known = TYPES[type] ? type : 'other'
+ return {
+ Icon: TYPES[known].Icon,
+ labelKey: `reservations.type.${known}`,
+ color: TRANSPORT_TYPE_COLOR[known] || BOOKING_TYPE_COLOR[known] || '#6b7280', // theme-lint-disable: fallback type colour, as on the phone
+ }
+}
+
+/** The order the type pills appear in, whichever types a trip uses. */
+export const TYPE_ORDER = [
+ 'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other',
+ 'hotel', 'restaurant', 'event', 'tour', 'parking', 'other',
+]
+
+/** A booking's metadata, read the way the rest of the planner reads it (double-encoded JSON included). */
+export const parseMeta = parseReservationMetadata
+
+export interface AssignmentLookupEntry {
+ dayNumber: number
+ dayTitle: string | null
+ dayDate: string | null
+ placeName: string
+ startTime: string | null
+ endTime: string | null
+}
+
+/** Each day-plan assignment by id, so a booking linked to one can name its day, place and time. */
+export function buildAssignmentLookup(days: Day[], assignments: AssignmentsMap | undefined): Record {
+ const map: Record = {}
+ for (const day of days || []) {
+ const list = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
+ for (const a of list) {
+ if (!a.place) continue
+ map[a.id] = { dayNumber: day.day_number, dayTitle: day.title ?? null, dayDate: day.date ?? null, placeName: a.place.name, startTime: a.place.place_time ?? null, endTime: a.place.end_time ?? null }
+ }
+ }
+ return map
+}
+
+export interface DaySpan {
+ start?: Day
+ end?: Day
+}
+
+/** The days a booking sits on. A hotel reads its stay from the accommodation, which is the source of truth for the range (#1383). */
+export function daySpan(r: Reservation, days: Day[]): DaySpan {
+ const byId = (id?: number | null) => (id != null ? days.find(d => d.id === id) : undefined)
+ if (r.type === 'hotel' && (r.accommodation_start_day_id || r.accommodation_end_day_id)) {
+ return { start: byId(r.accommodation_start_day_id), end: byId(r.accommodation_end_day_id) }
+ }
+ return { start: byId(r.day_id), end: byId(r.end_day_id) }
+}
+
+/**
+ * The moment a booking starts, as "YYYY-MM-DDTHH:MM" in its own local time, or null
+ * without any date. A day-linked transport often carries no date in reservation_time,
+ * so the linked day stands in (the stay's first day for a hotel).
+ */
+export function startKey(r: Reservation, days: Day[]): 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 dayDate = dayId != null ? days.find(d => d.id === dayId)?.date : null
+ const effective = date ?? dayDate ?? null
+ if (!effective) return null
+ return `${effective}T${time ?? '00:00'}`
+}
+
+export type Phase = 'before' | 'during' | 'after' | 'undated'
+
+export function phaseOf(key: string | null, tripStart?: string | null, tripEnd?: string | null): Phase {
+ if (!key) return 'undated'
+ const date = key.slice(0, 10)
+ if (tripStart && date < tripStart) return 'before'
+ if (tripEnd && date > tripEnd) return 'after'
+ return 'during'
+}
+
+export interface BookingFilters {
+ types: Set
+ status: StatusFilter
+ travelers: Set
+ query: string
+}
+
+/** Everything a person could look a booking up by: its title, type, route, codes, carrier, place and people. */
+export function searchText(r: Reservation, typeLabel: string): string {
+ const meta = parseMeta(r)
+ const legs = Array.isArray(meta.legs) ? meta.legs : []
+ return [
+ r.title, typeLabel, r.location, r.place_name, r.accommodation_name, r.notes, r.confirmation_number,
+ meta.airline, meta.flight_number, meta.train_number, meta.departure_airport, meta.arrival_airport,
+ ...(r.endpoints || []).flatMap(e => [e.name, e.code]),
+ ...legs.flatMap((l: Record) => [l.confirmation_number, l.flight_number, l.airline, l.train_number]),
+ ...(r.travelers || []).map(tv => tv.username),
+ ].filter(v => typeof v === 'string' && v).join(' ').toLowerCase()
+}
+
+/** Transit journeys carry no status of their own, so a status filter leaves them out. */
+function matchesStatus(r: Reservation, status: StatusFilter): boolean {
+ if (status === 'all') return true
+ if (r.type === 'transit') return false
+ return status === 'confirmed' ? r.status === 'confirmed' : r.status !== 'confirmed'
+}
+
+export function applyFilters(list: Reservation[], f: BookingFilters, labelOf: (type: string) => string): Reservation[] {
+ const q = f.query.trim().toLowerCase()
+ return list.filter(r =>
+ (f.types.size === 0 || f.types.has(r.type))
+ && matchesStatus(r, f.status)
+ && (f.travelers.size === 0 || (r.travelers || []).some(tv => f.travelers.has(tv.user_id)))
+ && (!q || searchText(r, labelOf(r.type)).includes(q)),
+ )
+}
+
+/** Chronological, undated last, creation order on a tie (#1507); other keys fall back to that order. */
+export function sortReservations(list: Reservation[], days: Day[], by: SortBy, dir: 'asc' | 'desc', labelOf: (type: string) => string): Reservation[] {
+ const keyed = list.map(r => ({ r, key: startKey(r, days) }))
+ const byDate = (a: typeof keyed[number], b: typeof keyed[number]) => {
+ 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 ?? '')
+ }
+ const primary = (a: typeof keyed[number], b: typeof keyed[number]): number => {
+ if (by === 'title') return a.r.title.localeCompare(b.r.title)
+ if (by === 'type') return labelOf(a.r.type).localeCompare(labelOf(b.r.type))
+ if (by === 'status') return statusRank(a.r) - statusRank(b.r)
+ return 0
+ }
+ const sign = dir === 'desc' ? -1 : 1
+ return keyed
+ .sort((a, b) => {
+ if (by === 'date') {
+ // Undated entries stay at the bottom in both directions.
+ if (a.key === null || b.key === null) return byDate(a, b)
+ return sign * byDate(a, b)
+ }
+ return sign * primary(a, b) || byDate(a, b)
+ })
+ .map(x => x.r)
+}
+
+function statusRank(r: Reservation): number {
+ if (r.type === 'transit') return 2
+ return r.status === 'confirmed' ? 0 : 1
+}
+
+export interface BookingGroup {
+ id: string
+ label: string
+ /** A second part of the head, after a straight cut (a day's date and title). */
+ sub?: string
+ items: Reservation[]
+}
+
+export interface GroupLabels {
+ confirmed: string
+ pending: string
+ transit: string
+ before: string
+ after: string
+ undated: string
+ dayN: (n: number) => string
+ typeLabel: (type: string) => string
+ dayDate: (date: string) => string
+}
+
+/** Sections of a sorted list. Status follows the phone's order: confirmed, pending, transit. */
+export function groupReservations(sorted: Reservation[], by: GroupBy, days: Day[], tripStart: string | null | undefined, tripEnd: string | null | undefined, L: GroupLabels): BookingGroup[] {
+ if (by === 'none') return [{ id: 'all', label: '', items: sorted }]
+ if (by === 'status') {
+ const groups: BookingGroup[] = [
+ { id: 'confirmed', label: L.confirmed, items: sorted.filter(r => r.type !== 'transit' && r.status === 'confirmed') },
+ { id: 'pending', label: L.pending, items: sorted.filter(r => r.type !== 'transit' && r.status !== 'confirmed') },
+ { id: 'transit', label: L.transit, items: sorted.filter(r => r.type === 'transit') },
+ ]
+ return groups.filter(g => g.items.length > 0)
+ }
+ if (by === 'type') {
+ const order: string[] = []
+ const map = new Map()
+ for (const r of sorted) {
+ if (!map.has(r.type)) { map.set(r.type, []); order.push(r.type) }
+ map.get(r.type)!.push(r)
+ }
+ return order.map(type => ({ id: `type-${type}`, label: L.typeLabel(type), items: map.get(type)! }))
+ }
+ // By day: the day an entry starts on, with separate groups before and after the trip and without a date.
+ const out = new Map()
+ const push = (id: string, make: () => Omit, r: Reservation) => {
+ if (!out.has(id)) out.set(id, { ...make(), items: [] })
+ out.get(id)!.items.push(r)
+ }
+ for (const r of sorted) {
+ const key = startKey(r, days)
+ const phase = phaseOf(key, tripStart, tripEnd)
+ const { start } = daySpan(r, days)
+ const day = start ?? (key ? days.find(d => d.date === key.slice(0, 10)) : undefined)
+ if (phase === 'before') push('before', () => ({ id: 'before', label: L.before }), r)
+ else if (phase === 'after') push('after', () => ({ id: 'after', label: L.after }), r)
+ else if (day) push(`day-${day.id}`, () => ({ id: `day-${day.id}`, label: L.dayN(day.day_number), sub: [day.date ? L.dayDate(day.date) : null, day.title].filter(Boolean).join(' ') || undefined }), r)
+ else if (key) push(`date-${key.slice(0, 10)}`, () => ({ id: `date-${key.slice(0, 10)}`, label: L.dayDate(key.slice(0, 10)) }), r)
+ else push('undated', () => ({ id: 'undated', label: L.undated }), r)
+ }
+ const groups = [...out.values()]
+ const rank = (g: BookingGroup) => (g.id === 'before' ? 0 : g.id === 'after' ? 2 : g.id === 'undated' ? 3 : 1)
+ return groups.sort((a, b) => rank(a) - rank(b))
+}
+
+export interface CostTotal { currency: string; amount: number }
+
+/** Linked expenses summed per currency. Money in two currencies is never added up. */
+export function costsFor(reservationId: number, items: { reservation_id?: number | null; total_price: number; currency?: string | null }[], tripCurrency: string): CostTotal[] {
+ const sums = new Map()
+ for (const it of items) {
+ if (it.reservation_id !== reservationId) continue
+ const cur = (it.currency || tripCurrency || 'EUR').toUpperCase()
+ sums.set(cur, Math.round(((sums.get(cur) ?? 0) + it.total_price) * 100) / 100)
+ }
+ return [...sums.entries()].map(([currency, amount]) => ({ currency, amount }))
+}
+
+/** A title for an entry saved without one: its route, else its place, else nothing. */
+export function displayTitle(r: Reservation): string {
+ if (r.title?.trim()) return r.title
+ const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
+ if (eps.length >= 2) return eps.map(e => e.code || e.name).join(' → ')
+ return r.place_name || r.location || ''
+}
diff --git a/client/src/components/Planner/bookings/showOnMap.ts b/client/src/components/Planner/bookings/showOnMap.ts
new file mode 100644
index 000000000..db4304d50
--- /dev/null
+++ b/client/src/components/Planner/bookings/showOnMap.ts
@@ -0,0 +1,39 @@
+import type { Reservation } from '../../../types'
+
+export interface ShowOnMapDeps {
+ visibleConnections: number[]
+ toggleConnection: (id: number) => void
+ selectDay: (dayId: number | null) => void
+ selectPlace: (placeId: number | null) => void
+ openPlan: () => void
+}
+
+/** Whether a booking has a route the plan map can draw: two endpoints with coordinates. */
+export function hasRoute(r: Reservation): boolean {
+ return (r.endpoints || []).filter(e => Number.isFinite(e.lat) && Number.isFinite(e.lng)).length >= 2
+}
+
+/**
+ * The desktop half of the phone's "On map": a transport switches its route on and
+ * the plan opens on its day; a booking with a place opens the plan on that place.
+ * Pressed again while the route is drawn, it switches the route off, as on the phone.
+ */
+export function showReservationOnMap(r: Reservation, d: ShowOnMapDeps): void {
+ if (hasRoute(r)) {
+ if (d.visibleConnections.includes(r.id)) { d.toggleConnection(r.id); return }
+ d.toggleConnection(r.id)
+ d.selectDay(r.day_id ?? r.end_day_id ?? null)
+ d.openPlan()
+ return
+ }
+ const placeId = r.type === 'hotel' ? (r.accommodation_place_id ?? r.place_id) : r.place_id
+ if (placeId == null) return
+ d.selectDay(r.type === 'hotel' ? (r.accommodation_start_day_id ?? r.day_id ?? null) : (r.day_id ?? null))
+ d.selectPlace(placeId)
+ d.openPlan()
+}
+
+/** Whether "On map" can do anything for this booking. */
+export function canShowOnMap(r: Reservation): boolean {
+ return hasRoute(r) || (r.type === 'hotel' ? (r.accommodation_place_id ?? r.place_id) : r.place_id) != null
+}
diff --git a/client/src/components/Planner/bookings/timelineModel.ts b/client/src/components/Planner/bookings/timelineModel.ts
new file mode 100644
index 000000000..13cf50fbf
--- /dev/null
+++ b/client/src/components/Planner/bookings/timelineModel.ts
@@ -0,0 +1,118 @@
+import type { Day, Reservation } from '../../../types'
+import { daySpan, parseMeta } from './bookingsModel'
+import { splitReservationDateTime } from '../../../utils/formatters'
+
+export interface AxisDay {
+ /** The calendar date, or null on a trip whose days carry no dates. */
+ date: string | null
+ dayNumber: number | null
+ dayId: number | null
+ title: string | null
+}
+
+/** The trip's days in order: its dated days, or day 1..N when the days have no dates. */
+export function buildAxis(days: Day[]): AxisDay[] {
+ const dated = days.filter(d => d.date).sort((a, b) => (a.date! < b.date! ? -1 : a.date! > b.date! ? 1 : 0))
+ if (dated.length > 0) return dated.map(d => ({ date: d.date!, dayNumber: d.day_number, dayId: d.id, title: d.title ?? null }))
+ return days.slice().sort((a, b) => a.day_number - b.day_number).map(d => ({ date: null, dayNumber: d.day_number, dayId: d.id, title: d.title ?? null }))
+}
+
+export interface Moment { day: number; hour: number }
+export type Placement = { kind: 'on'; start: Moment; end: Moment } | { kind: 'before' | 'after' | 'undated' }
+
+const toHour = (time: string | null | undefined, fallback: number) => {
+ if (!time) return fallback
+ const [h, m] = time.split(':').map(Number)
+ return Number.isFinite(h) ? h + (Number.isFinite(m) ? m / 60 : 0) : fallback
+}
+
+/**
+ * Where a booking sits on the axis, both ends in their own local time as the day
+ * plan draws them. A hotel runs from check-in to check-out (15:00 and 11:00 when
+ * the booking does not say), anything without an end gets one hour.
+ */
+export function placeReservation(r: Reservation, axis: AxisDay[], days: Day[]): Placement {
+ const meta = parseMeta(r)
+ const span = daySpan(r, days)
+ const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
+ const from = eps.find(e => e.role === 'from')
+ const to = eps.find(e => e.role === 'to')
+ const s = splitReservationDateTime(r.reservation_time)
+ const e = splitReservationDateTime(r.reservation_end_time)
+ const hotel = r.type === 'hotel'
+
+ const index = (date: string | null | undefined, dayId: number | null | undefined): number | 'before' | 'after' | null => {
+ if (axis.length === 0) return null
+ if (axis[0].date) {
+ if (!date) return null
+ if (date < axis[0].date) return 'before'
+ if (date > axis[axis.length - 1].date!) return 'after'
+ const i = axis.findIndex(a => a.date === date)
+ return i >= 0 ? i : null
+ }
+ if (dayId == null) return null
+ const i = axis.findIndex(a => a.dayId === dayId)
+ return i >= 0 ? i : null
+ }
+
+ const startDate = hotel ? span.start?.date ?? s.date : s.date ?? from?.local_date ?? span.start?.date
+ const startDayId = hotel ? span.start?.id ?? r.day_id : r.day_id ?? span.start?.id
+ const si = index(startDate, startDayId)
+ if (si === null) return { kind: 'undated' }
+ if (si === 'before' || si === 'after') return { kind: si }
+
+ const startHour = hotel ? toHour(meta.check_in_time, 15) : toHour(s.time || from?.local_time, 9)
+ const endDate = hotel ? span.end?.date ?? e.date : e.date ?? to?.local_date ?? span.end?.date ?? startDate
+ const endDayId = hotel ? span.end?.id ?? r.end_day_id : r.end_day_id ?? span.end?.id ?? startDayId
+ let ei = index(endDate, endDayId)
+ if (ei === null) ei = hotel ? Math.min(si + 1, axis.length - 1) : si
+ const endHourRaw = hotel ? toHour(meta.check_out_time, 11) : toHour(e.time || to?.local_time, startHour + 1)
+ const end: Moment = ei === 'after' ? { day: axis.length - 1, hour: 24 } : ei === 'before' ? { day: si, hour: startHour + 1 } : { day: ei, hour: endHourRaw }
+ // An arrival that reads earlier than the departure in local time (a date line, a typo) keeps a small bar.
+ if (end.day < si || (end.day === si && end.hour <= startHour)) return { kind: 'on', start: { day: si, hour: startHour }, end: { day: si, hour: startHour + 1 } }
+ return { kind: 'on', start: { day: si, hour: startHour }, end }
+}
+
+/** A moment as hours since the start of the axis' first day. */
+export const absHour = (m: Moment) => m.day * 24 + m.hour
+
+/**
+ * The hours a day view shows: 06:00 to 22:00, widened to whatever starts or
+ * ends on that day earlier or later, so nothing on the day is cut off.
+ */
+export function dayWindow(items: { start: Moment; end: Moment }[], day: number): { from: number; to: number } {
+ let from = 6
+ let to = 22
+ for (const it of items) {
+ if (it.start.day === day) { from = Math.min(from, Math.floor(it.start.hour)); to = Math.max(to, Math.ceil(it.start.hour + 1)) }
+ if (it.end.day === day) { to = Math.max(to, Math.ceil(it.end.hour)); from = Math.min(from, Math.floor(it.end.hour - 1)) }
+ }
+ return { from: Math.max(0, from), to: Math.min(24, to) }
+}
+
+export interface Bar { r: Reservation; lane: number; x: number; w: number; cutStart: boolean; cutEnd: boolean }
+
+/**
+ * Packs bars into as few lanes as possible, using the width a bar is DRAWN with
+ * (never narrower than `minWidth`), so two clickable bars never overlap. `toX`
+ * places a moment in pixels; a bar reaching past 0 or `max` is cut there and
+ * says so, for the day view's arrows.
+ */
+export function packBars(items: { r: Reservation; start: Moment; end: Moment }[], toX: (m: Moment) => number, max: number, minWidth = 26, gap = 4): { bars: Bar[]; lanes: number } {
+ const sorted = items.map(i => {
+ const a = toX(i.start)
+ const b = toX(i.end)
+ const w = Math.max(minWidth, Math.min(max, b) - Math.max(0, a))
+ const x = Math.min(Math.max(0, a), Math.max(0, max - w))
+ return { r: i.r, x, w, cutStart: a < 0, cutEnd: b > max }
+ }).sort((p, q) => p.x - q.x)
+ const laneEnds: number[] = []
+ const bars: Bar[] = []
+ for (const it of sorted) {
+ let lane = laneEnds.findIndex(end => end + gap <= it.x)
+ if (lane === -1) { lane = laneEnds.length; laneEnds.push(0) }
+ laneEnds[lane] = it.x + it.w
+ bars.push({ ...it, lane })
+ }
+ return { bars, lanes: Math.max(1, laneEnds.length) }
+}
diff --git a/client/src/components/Planner/bookings/useBookingsView.ts b/client/src/components/Planner/bookings/useBookingsView.ts
new file mode 100644
index 000000000..9f9e1b3b1
--- /dev/null
+++ b/client/src/components/Planner/bookings/useBookingsView.ts
@@ -0,0 +1,96 @@
+import { useCallback, useMemo, useState } from 'react'
+import type { BookingsKind, BookingsView, GroupBy, SortBy, StatusFilter } from './bookingsModel'
+import type { TimelineZoom } from './BookingsTimeline'
+
+// Storage can be blocked (private windows, strict settings); the view then just starts fresh.
+function read(store: Storage | undefined, key: string, fallback: T): T {
+ try {
+ const raw = store?.getItem(key)
+ return raw == null ? fallback : (JSON.parse(raw) as T)
+ } catch {
+ return fallback
+ }
+}
+function write(store: Storage | undefined, key: string, value: unknown) {
+ try { store?.setItem(key, JSON.stringify(value)) } catch { /* storage unavailable: the choice lasts until reload */ }
+}
+const local = () => (typeof window === 'undefined' ? undefined : window.localStorage)
+const session = () => (typeof window === 'undefined' ? undefined : window.sessionStorage)
+
+const DEFAULT_GROUP: Record<'cards' | 'list', GroupBy> = { cards: 'status', list: 'day' }
+
+interface Filters { types: string[]; status: StatusFilter; travelers: number[] }
+
+/**
+ * How one of the two tabs is looked at, remembered per tab: the view, grouping
+ * and sort in localStorage, the filters per trip in sessionStorage. The keys
+ * carry the tab, so filtering Transports to flights no longer empties Bookings.
+ */
+export function useBookingsView(kind: BookingsKind, tripId: number) {
+ const k = (name: string) => `trek:bookings-${kind}-${name}`
+ const filterKey = `trek-bookings-filters-${kind}-${tripId}`
+
+ const [view, setViewState] = useState(() => read(local(), k('view'), 'cards'))
+ const [groups, setGroups] = useState>(() => read(local(), k('group'), DEFAULT_GROUP))
+ const [sort, setSortState] = useState<{ by: SortBy; dir: 'asc' | 'desc' }>(() => read(local(), k('sort'), { by: 'date', dir: 'asc' }))
+ const [timeline, setTimeline] = useState<{ zoom: TimelineZoom; byType: boolean; context: boolean }>(() => {
+ const stored = read(local(), k('timeline'), { zoom: 'trip' as TimelineZoom, byType: true, context: true })
+ // Anything but 'day' (an older build stored three steps) opens on the whole trip.
+ return { ...stored, zoom: stored.zoom === 'day' ? 'day' : 'trip' }
+ })
+ const [filters, setFiltersState] = useState(() => read(session(), filterKey, { types: [], status: 'all', travelers: [] }))
+ const [collapsedMap, setCollapsed] = useState>(() => read(local(), `${k('collapsed')}:${tripId}`, {}))
+ const [query, setQuery] = useState('')
+ const [selectedId, setSelectedId] = useState(null)
+
+ const setView = (v: BookingsView) => { setViewState(v); write(local(), k('view'), v) }
+ const group: GroupBy = view === 'timeline' ? 'none' : groups[view]
+ const setGroup = (g: GroupBy) => {
+ if (view === 'timeline') return
+ const next = { ...groups, [view]: g }
+ setGroups(next); write(local(), k('group'), next)
+ }
+ const setSort = (by: SortBy) => { const next = { by, dir: sort.by === by ? sort.dir : 'asc' as const }; setSortState(next); write(local(), k('sort'), next) }
+ const flipSort = () => { const next = { ...sort, dir: sort.dir === 'asc' ? 'desc' as const : 'asc' as const }; setSortState(next); write(local(), k('sort'), next) }
+ const patchTimeline = (patch: Partial) => { const next = { ...timeline, ...patch }; setTimeline(next); write(local(), k('timeline'), next) }
+ const setFilters = (patch: Partial) => { const next = { ...filters, ...patch }; setFiltersState(next); write(session(), filterKey, next) }
+
+ const toggleType = (type: string) => setFilters({ types: filters.types.includes(type) ? filters.types.filter(x => x !== type) : [...filters.types, type] })
+ const toggleTraveler = (id: number) => setFilters({ travelers: filters.travelers.includes(id) ? filters.travelers.filter(x => x !== id) : [...filters.travelers, id] })
+ const resetFilters = () => { setFilters({ types: [], status: 'all', travelers: [] }); setQuery('') }
+
+ const viewIsDefault = view === 'timeline'
+ ? timeline.byType && timeline.context
+ : groups[view] === DEFAULT_GROUP[view] && sort.by === 'date' && sort.dir === 'asc'
+ const resetView = () => {
+ if (view === 'timeline') { patchTimeline({ byType: true, context: true }); return }
+ setGroup(DEFAULT_GROUP[view])
+ const next = { by: 'date' as const, dir: 'asc' as const }
+ setSortState(next); write(local(), k('sort'), next)
+ }
+
+ const types = useMemo(() => new Set(filters.types), [filters.types])
+ const travelers = useMemo(() => new Set(filters.travelers), [filters.travelers])
+
+ const collapsed = useCallback((groupId: string) => !!collapsedMap[`${group}:${groupId}`], [collapsedMap, group])
+ const toggleGroup = (groupId: string) => {
+ const next = { ...collapsedMap, [`${group}:${groupId}`]: !collapsedMap[`${group}:${groupId}`] }
+ setCollapsed(next); write(local(), `${k('collapsed')}:${tripId}`, next)
+ }
+
+ return {
+ view, setView, group, setGroup, sort, setSort, flipSort,
+ timeline, setZoom: (zoom: TimelineZoom) => patchTimeline({ zoom }),
+ toggleByType: () => patchTimeline({ byType: !timeline.byType }),
+ toggleContext: () => patchTimeline({ context: !timeline.context }),
+ types, status: filters.status, travelers,
+ setStatus: (status: StatusFilter) => setFilters({ status }),
+ toggleType, clearTypes: () => setFilters({ types: [] }),
+ toggleTraveler, clearTravelers: () => setFilters({ travelers: [] }),
+ query, setQuery, resetFilters,
+ filtering: filters.types.length > 0 || filters.status !== 'all' || filters.travelers.length > 0 || query.trim() !== '',
+ viewIsDefault, resetView,
+ collapsed, toggleGroup,
+ selectedId, setSelectedId,
+ }
+}
diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index f982b7bea..07ccbf9a2 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -57,6 +57,7 @@ import { usePoiExplore } from '../components/Map/usePoiExplore'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
+import { showReservationOnMap } from '../components/Planner/bookings/showOnMap'
// The tab panels are the planner's dead weight: each one mounts only while its
// own tab is active, so the page chunk carried code most sessions never run. They
@@ -1017,6 +1018,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
addManualKey="transport.addManual"
contributionView="transports"
tripMembers={tripMembers}
+ contextReservations={reservations.filter(r => r.type === 'hotel')}
+ onEditExpense={(item) => openBookingExpense({ editItem: item })}
+ onShowOnMap={(r) => showReservationOnMap(r, { visibleConnections, toggleConnection, selectDay: (id) => handleSelectDay(id), selectPlace: setSelectedPlaceId, openPlan: () => handleTabChange('plan') })}
+ isOnMap={(r) => visibleConnections.includes(r.id)}
/>
@@ -1038,6 +1043,10 @@ function TripPlannerPageDesktop(): React.ReactElement | null {
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
tripMembers={tripMembers}
+ contextReservations={reservations.filter(r => TRANSPORT_TYPES.has(r.type))}
+ onEditExpense={(item) => openBookingExpense({ editItem: item })}
+ onShowOnMap={(r) => showReservationOnMap(r, { visibleConnections, toggleConnection, selectDay: (id) => handleSelectDay(id), selectPlace: setSelectedPlaceId, openPlan: () => handleTabChange('plan') })}
+ isOnMap={(r) => visibleConnections.includes(r.id)}
/>
diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts
index 052255254..d2cfd5cdf 100644
--- a/shared/src/i18n/de/reservations.ts
+++ b/shared/src/i18n/de/reservations.ts
@@ -182,5 +182,57 @@ const reservations: TranslationStrings = {
'reservations.travelers.label': 'Reisende',
'reservations.travelers.assign': 'Reisende zuweisen',
'reservations.travelers.none': 'Noch keine Reisenden im Trip.',
+ 'reservations.status.switchTo': 'Auf {status} setzen',
+ 'reservations.view.label': 'Ansicht',
+ 'reservations.view.cards': 'Karten',
+ 'reservations.view.list': 'Liste',
+ 'reservations.view.timeline': 'Zeitleiste',
+ 'reservations.filter': 'Filtern',
+ 'reservations.searchPlaceholder': 'Suchen',
+ 'reservations.results': '{shown} von {total}',
+ 'reservations.resetFilters': 'Filter zurücksetzen',
+ 'reservations.noMatches': 'Nichts passt zu diesen Filtern',
+ 'reservations.viewOptions': 'Ansicht anpassen',
+ 'reservations.resetView': 'Ansicht zurücksetzen',
+ 'reservations.group.label': 'Gruppieren nach',
+ 'reservations.group.status': 'Status',
+ 'reservations.group.day': 'Tag',
+ 'reservations.group.type': 'Typ',
+ 'reservations.group.none': 'Nicht gruppieren',
+ 'reservations.group.before': 'Vor der Reise',
+ 'reservations.group.after': 'Nach der Reise',
+ 'reservations.group.undated': 'Ohne Datum',
+ 'reservations.sort.label': 'Sortieren nach',
+ 'reservations.sort.date': 'Datum',
+ 'reservations.sort.title': 'Titel',
+ 'reservations.sort.type': 'Typ',
+ 'reservations.sort.status': 'Status',
+ 'reservations.sort.earlyFirst': 'Früheste zuerst',
+ 'reservations.sort.lateFirst': 'Späteste zuerst',
+ 'reservations.sort.aToZ': 'A bis Z',
+ 'reservations.sort.zToA': 'Z bis A',
+ 'reservations.timeline.dayCount': '{count} Tage',
+ 'reservations.timeline.zoom': 'Zoom',
+ 'reservations.timeline.trip': 'Reise',
+ 'reservations.timeline.day': 'Tag',
+ 'reservations.timeline.prevDay': 'Vorheriger Tag',
+ 'reservations.timeline.nextDay': 'Nächster Tag',
+ 'reservations.timeline.openDay': '{day} nach Stunden öffnen',
+ 'reservations.timeline.nothingThisDay': 'An diesem Tag steht nichts an.',
+ 'reservations.timeline.today': 'Heute',
+ 'reservations.timeline.now': 'jetzt',
+ 'reservations.timeline.byType': 'Eine Spur pro Typ',
+ 'reservations.timeline.context': 'Anderen Tab einblenden',
+ 'reservations.timeline.contextStays': 'Unterkünfte',
+ 'reservations.timeline.contextTransports': 'Transporte',
+ 'reservations.timeline.noneDated': 'Hier hat noch nichts ein Datum.',
+ 'reservations.undated': 'Ohne Datum',
+ 'reservations.nights': 'Nächte',
+ 'reservations.start': 'Beginn',
+ 'reservations.end': 'Ende',
+ 'reservations.rename': 'Umbenennen',
+ 'reservations.copyCode': 'Buchungscode kopieren',
+ 'reservations.copyFailed': 'Kopieren hat nicht geklappt',
+ 'reservations.showInFiles': 'In Dateien zeigen',
};
export default reservations;
diff --git a/shared/src/i18n/de/transport.ts b/shared/src/i18n/de/transport.ts
index ad383802a..fc30e11d0 100644
--- a/shared/src/i18n/de/transport.ts
+++ b/shared/src/i18n/de/transport.ts
@@ -6,5 +6,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.edit': 'Transport bearbeiten',
'transport.title': 'Transporte',
'transport.addManual': 'Transport',
+ 'transport.empty': 'Noch keine Transporte',
};
export default transport;
diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts
index a93b28fbd..75116a6ff 100644
--- a/shared/src/i18n/en/reservations.ts
+++ b/shared/src/i18n/en/reservations.ts
@@ -181,5 +181,57 @@ const reservations: TranslationStrings = {
'reservations.travelers.label': 'Travelers',
'reservations.travelers.assign': 'Assign travelers',
'reservations.travelers.none': 'No trip members yet.',
+ 'reservations.status.switchTo': 'Set to {status}',
+ 'reservations.view.label': 'View',
+ 'reservations.view.cards': 'Cards',
+ 'reservations.view.list': 'List',
+ 'reservations.view.timeline': 'Timeline',
+ 'reservations.filter': 'Filter',
+ 'reservations.searchPlaceholder': 'Search',
+ 'reservations.results': '{shown} of {total}',
+ 'reservations.resetFilters': 'Reset filters',
+ 'reservations.noMatches': 'Nothing matches these filters',
+ 'reservations.viewOptions': 'View options',
+ 'reservations.resetView': 'Reset view',
+ 'reservations.group.label': 'Group by',
+ 'reservations.group.status': 'Status',
+ 'reservations.group.day': 'Day',
+ 'reservations.group.type': 'Type',
+ 'reservations.group.none': 'No grouping',
+ 'reservations.group.before': 'Before the trip',
+ 'reservations.group.after': 'After the trip',
+ 'reservations.group.undated': 'No date',
+ 'reservations.sort.label': 'Sort by',
+ 'reservations.sort.date': 'Date',
+ 'reservations.sort.title': 'Title',
+ 'reservations.sort.type': 'Type',
+ 'reservations.sort.status': 'Status',
+ 'reservations.sort.earlyFirst': 'Earliest first',
+ 'reservations.sort.lateFirst': 'Latest first',
+ 'reservations.sort.aToZ': 'A to Z',
+ 'reservations.sort.zToA': 'Z to A',
+ 'reservations.timeline.dayCount': '{count} days',
+ 'reservations.timeline.zoom': 'Zoom',
+ 'reservations.timeline.trip': 'Trip',
+ 'reservations.timeline.day': 'Day',
+ 'reservations.timeline.prevDay': 'Previous day',
+ 'reservations.timeline.nextDay': 'Next day',
+ 'reservations.timeline.openDay': 'Open {day} by the hour',
+ 'reservations.timeline.nothingThisDay': 'Nothing on this day.',
+ 'reservations.timeline.today': 'Today',
+ 'reservations.timeline.now': 'now',
+ 'reservations.timeline.byType': 'One lane per type',
+ 'reservations.timeline.context': 'Show the other tab',
+ 'reservations.timeline.contextStays': 'Stays',
+ 'reservations.timeline.contextTransports': 'Transports',
+ 'reservations.timeline.noneDated': 'None of these has a date yet.',
+ 'reservations.undated': 'No date',
+ 'reservations.nights': 'Nights',
+ 'reservations.start': 'Start',
+ 'reservations.end': 'End',
+ 'reservations.rename': 'Rename',
+ 'reservations.copyCode': 'Copy booking code',
+ 'reservations.copyFailed': 'Could not copy',
+ 'reservations.showInFiles': 'Show in files',
};
export default reservations;
diff --git a/shared/src/i18n/en/transport.ts b/shared/src/i18n/en/transport.ts
index 55271584d..ee3535447 100644
--- a/shared/src/i18n/en/transport.ts
+++ b/shared/src/i18n/en/transport.ts
@@ -6,5 +6,6 @@ const transport: TranslationStrings = {
'transport.modalTitle.edit': 'Edit transport',
'transport.title': 'Transports',
'transport.addManual': 'Transport',
+ 'transport.empty': 'No transports yet',
};
export default transport;