feat(reservations): desktop bookings and transports in the phone's look

The Bookings and Transports tabs on the desktop take the phone's card
language: a tinted status band, the type chip and framed fields. One bar
holds the search, a filter popover for status, types and travelers, the
view switch and the view options.

Three views over the same filtered entries: cards (four per row at most),
a dense list grouped by day, and a timeline with a trip scale and a day
scale by the hour. A booking opens as a dialog with everything it holds,
the booking code blurred by the existing setting, and "On map".

Work in progress: only en and de are translated, and tests, help guides
and the wiki still follow.
This commit is contained in:
Maurice
2026-09-27 00:34:32 +02:00
parent 237f42a493
commit acd0d6511e
17 changed files with 2709 additions and 955 deletions
File diff suppressed because it is too large Load Diff
@@ -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 (
<article
tabIndex={0}
onClick={p.onSelect}
onKeyDown={e => { 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'}`}
>
<div className="flex items-center gap-2 border-b border-edge-faint px-3 py-2.5" style={{ background: toneTint(tone) }}>
<StatusDot r={r} canToggle={p.canEdit} onToggle={p.onToggleStatus} />
<TypeChip type={r.type} />
<span className="min-w-0 flex-1 truncate font-bold text-content" style={fs(13.5, 'body')}>{displayTitle(r)}</span>
{!!r.needs_review && <ReviewPill />}
<AirTrailPill r={r} />
{p.canEdit && !transit && <RoundAction label={t('common.edit')} onClick={p.onEdit}><Pencil size={12} strokeWidth={2} /></RoundAction>}
{p.canEdit && <RoundAction label={t('common.delete')} onClick={p.onDelete} danger><Trash2 size={12} strokeWidth={2} /></RoundAction>}
</div>
<div className="flex flex-1 flex-col gap-2 px-3 pb-3 pt-2.5">
{(facts.day || facts.time) && (
<div className="flex gap-2">
{facts.day && (
<Field label={t('reservations.date')} className="flex-[1.4]">
{facts.day.label}
{facts.day.date && <span className="ml-1.5 font-medium text-content-faint">{facts.day.date}</span>}
</Field>
)}
{facts.time && <Field label={t('reservations.time')} className="flex-1" tabular>{facts.time}</Field>}
</div>
)}
{transit ? <TransitBody r={r} /> : (
<>
{r.confirmation_number && (
<Field label={t('reservations.confirmationCode')} tabular>
<BlurredCode className="font-geist">{r.confirmation_number}</BlurredCode>
</Field>
)}
{facts.endpoints.length >= 2 && (
<div className={`${BOX} flex flex-wrap items-center justify-center gap-2 px-[10px] py-2 font-semibold text-content`} style={fs(12.5, 'body')}>
{facts.endpoints.map((ep, i) => (
<span key={ep.id ?? i} className="inline-flex min-w-0 items-center gap-2">
{i > 0 && <info.Icon size={13} strokeWidth={2.2} className="flex-none" style={{ color: info.color }} />}
<span className="truncate">{ep.name}</span>
</span>
))}
</div>
)}
{facts.legCodes.length > 0 && (
<div className={`${BOX} flex flex-col gap-1 px-[10px] py-2`}>
{facts.legCodes.map((l, i) => (
<div key={i} className="flex items-center gap-2" style={fs(12, 'body')}>
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary">{l.route || t('reservations.confirmationCode')}</span>
<BlurredCode className="font-geist tabular-nums text-content-muted">{l.code}</BlurredCode>
</div>
))}
</div>
)}
{facts.cells.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{facts.cells.map((c, i) => <Field key={i} label={c.label}>{c.value}</Field>)}
</div>
)}
</>
)}
{(facts.place || facts.accommodation || facts.linked || facts.url) && (
<div className="flex flex-col gap-1">
{facts.place && <IconRow icon={<MapPin size={12} strokeWidth={2} />}>{facts.place}</IconRow>}
{facts.accommodation && <IconRow icon={<Hotel size={12} strokeWidth={2} />}>{facts.accommodation}</IconRow>}
{facts.linked && <IconRow icon={<Link2 size={12} strokeWidth={2} />}>{facts.linked}</IconRow>}
{facts.url && (
<IconRow icon={<ExternalLink size={12} strokeWidth={2} />}>
{facts.url.href
? <a href={facts.url.href} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()} className="text-content hover:underline">{facts.url.href}</a>
: facts.url.text}
</IconRow>
)}
</div>
)}
{r.notes && !transit && (
<div className={`${BOX} collab-note-md line-clamp-4 px-[10px] py-2 text-content-muted`} style={fs(12, 'body')}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{r.notes}</Markdown>
</div>
)}
{(r.travelers || []).length > 0 && (
<div>
<Eyebrow className="mb-[3px]">{t('reservations.travelers.label')}</Eyebrow>
<TravelerChips travelers={r.travelers || []} />
</div>
)}
{p.files.length > 0 && (
<div>
<Eyebrow className="mb-[3px]">{t('files.title')}</Eyebrow>
<FileRows files={p.files} />
</div>
)}
{p.costs.length > 0 && (
<div className="mt-auto flex flex-wrap justify-end gap-1.5 pt-0.5">
{p.costs.map(c => (
<span key={c.currency} className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2.5 py-[3px] font-geist font-semibold tabular-nums text-content-secondary" style={fs(11.5)}>
<Wallet size={11} strokeWidth={2} className="text-content-faint" />
{formatMoney(c.amount, c.currency, locale)}
</span>
))}
</div>
)}
</div>
<PluginCardFooter items={p.contributions} tripId={p.tripId} />
{p.showFrames && p.detailPlugins.length > 0 && (
<div role="presentation" onClick={e => e.stopPropagation()} className="flex flex-col gap-2 px-3 pb-3">
{p.detailPlugins.map(pl => (
<div key={pl.id} className={`${BOX} overflow-hidden`}>
<PluginFrame pluginId={pl.id} tripId={String(p.tripId)} reservationId={String(r.id)} title={pl.name} surface="detail-slot" />
</div>
))}
</div>
)}
</article>
)
}
function IconRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className={`${BOX} flex min-w-0 items-center gap-1.5 px-[10px] py-[7px] font-semibold text-content`} style={fs(12, 'body')}>
<span className="flex-none text-content-muted">{icon}</span>
<span className="min-w-0 truncate">{children}</span>
</div>
)
}
/** 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 && (
<div className={`${BOX} flex flex-col gap-1.5 px-[10px] py-2`}>
<TransitLegChips legs={transit.legs} size="md" t={t} />
{transit.duration ? <span className="font-geist text-content-faint" style={fs(11)}>{fmtTransitDuration(transit.duration, t)}</span> : null}
</div>
)}
{r.notes && (
<div className="truncate px-0.5 font-geist text-content-faint" style={fs(11.5)}>
<Markdown remarkPlugins={[remarkGfm]} allowedElements={['strong', 'em', 'del', 'code', 'a']} unwrapDisallowed>{r.notes.split('\n')[0]}</Markdown>
</div>
)}
</>
)
}
@@ -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<HTMLDivElement>(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 = (
<>
<span className="h-2 w-2 flex-none rounded-full" style={{ background: toneColor(tone) }} />
{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(
<div
role="presentation"
className="trek-modal-backdrop trek-backdrop-enter fixed inset-0 z-[10000] flex items-center justify-center bg-[rgba(15,23,42,0.5)] px-4"
style={{ paddingTop: 40, paddingBottom: 'calc(40px + var(--bottom-nav-h, 0px))' }}
onMouseDown={e => { if (e.target === e.currentTarget && !blocked) onClose() }}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
className="trek-modal-enter flex max-h-full w-full max-w-[600px] flex-col overflow-hidden rounded-[22px] bg-surface-card shadow-2xl outline-none"
>
<header className="flex-none px-6 pb-4 pt-5" style={{ background: toneTint(tone) }}>
<div className="flex items-start gap-3.5">
<TypeTile type={r.type} size={46} raised />
<div className="min-w-0 flex-1 pt-0.5">
{p.canEdit ? (
<button id={titleId} type="button" onClick={() => { setName(r.title); setRenaming(true) }} title={t('reservations.rename')}
className="block w-full truncate text-left font-bold tracking-[-0.01em] text-content hover:underline hover:decoration-edge" style={fs(20, 'subtitle')}>
{displayTitle(r)}
</button>
) : (
<h2 id={titleId} className="m-0 truncate font-bold tracking-[-0.01em] text-content" style={fs(20, 'subtitle')}>{displayTitle(r)}</h2>
)}
{sub && <div className="mt-0.5 truncate font-geist text-content-muted" style={fs(12.5)}>{sub}</div>}
</div>
<button type="button" onClick={onClose} aria-label={t('common.close')}
className="grid h-9 w-9 flex-none place-items-center rounded-full bg-surface-card text-content-muted shadow-sm hover:text-content">
<X size={16} strokeWidth={2.2} />
</button>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2" style={fs(12, 'body')}>
{tone !== 'transit' && (p.canEdit ? (
<button type="button" onClick={p.onToggleStatus} title={t('reservations.status.switchTo', { status: nextStatus })}
aria-label={t('reservations.status.switchTo', { status: nextStatus })} className={`${pillCls} hover:opacity-80`}>
{statusPill}
</button>
) : <span className={pillCls}>{statusPill}</span>)}
<span className={pillCls}><info.Icon size={13} strokeWidth={2.2} style={{ color: info.color }} />{t(info.labelKey)}</span>
{!!r.needs_review && <ReviewPill />}
<AirTrailPill r={r} />
{r.confirmation_number && (
<span className={`${pillCls} ml-auto gap-1 py-0.5 pr-1`}>
<BlurredCode className="font-geist tabular-nums">#{r.confirmation_number}</BlurredCode>
<button type="button" onClick={copyCode} aria-label={t('reservations.copyCode')} title={t('reservations.copyCode')}
className="grid h-6 w-6 place-items-center rounded-full text-content-faint hover:bg-surface-hover hover:text-content">
<Copy size={12} strokeWidth={2} />
</button>
</span>
)}
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-6 pb-6 pt-5">
{stats.length > 0 && (
<div className="flex gap-2">
{stats.map((s, i) => (
<div key={i} className="min-w-0 flex-1 rounded-[12px] bg-surface-tertiary px-3 py-2.5 text-center">
<div className="truncate font-bold tabular-nums tracking-[-0.01em] text-content" style={fs(18, 'subtitle')}>{s.value}</div>
<div className="mt-0.5 truncate font-geist text-content-faint" style={fs(10.5)}>{s.label}</div>
</div>
))}
</div>
)}
{transitLegs.length > 0 && <TransitLegs legs={transitLegs} />}
{facts.endpoints.length > 2 && (
<div className={`${BOX} flex flex-col gap-2 px-3.5 py-3`}>
{facts.endpoints.map((ep, i) => (
<div key={ep.id ?? i} className="flex items-center gap-2.5" style={fs(13, 'body')}>
<span className="w-12 flex-none text-right font-semibold tabular-nums text-content">{ep.local_time ? formatTime(ep.local_time, locale, timeFormat) : ''}</span>
<span className="h-2.5 w-2.5 flex-none rounded-full border-2" style={{ borderColor: 'var(--text-faint)' }} />
<span className="min-w-0 flex-1 truncate font-medium text-content">{ep.code ? <b className="mr-1.5">{ep.code}</b> : null}{ep.name}</span>
</div>
))}
</div>
)}
{facts.legCodes.length > 0 && (
<Section label={t('reservations.confirmationCode')}>
<div className={`${BOX} flex flex-col gap-1.5 px-3.5 py-2.5`}>
{facts.legCodes.map((l, i) => (
<div key={i} className="flex items-center gap-2" style={fs(13, 'body')}>
<span className="min-w-0 flex-1 truncate font-medium text-content-secondary">{l.route || t('reservations.confirmationCode')}</span>
<BlurredCode className="font-geist tabular-nums text-content-muted">#{l.code}</BlurredCode>
</div>
))}
</div>
</Section>
)}
{cells.length > 0 && (
<div className="grid grid-cols-3 gap-2 max-sm:grid-cols-2">
{cells.map((c, i) => <Field key={i} label={c.label}>{c.value}</Field>)}
</div>
)}
{hasDetails && (
<div className={`${BOX} flex flex-col px-3.5`}>
{facts.day && <DetailRow icon={<RouteIcon size={14} />}>{[facts.day.label, facts.day.date].filter(Boolean).join(' ')}</DetailRow>}
{facts.place && <DetailRow icon={<MapPin size={14} />}>{facts.place}</DetailRow>}
{facts.accommodation && <DetailRow icon={<Hotel size={14} />}>{facts.accommodation}</DetailRow>}
{facts.linked && <DetailRow icon={<Link2 size={14} />}>{facts.linked}</DetailRow>}
{facts.url && (
<DetailRow icon={<ExternalLink size={14} />}>
{facts.url.href ? <a href={facts.url.href} target="_blank" rel="noopener noreferrer" className="text-content hover:underline">{facts.url.href}</a> : facts.url.text}
</DetailRow>
)}
</div>
)}
{(r.travelers || []).length > 0 && (
<Section label={t('reservations.travelers.label')}>
<TravelerChips travelers={r.travelers || []} />
</Section>
)}
{r.notes && (
<Section label={t('reservations.notes')}>
<div className={`${BOX} collab-note-md px-3.5 py-3 text-content-secondary`} style={{ ...fs(13, 'body'), wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={markdownLinkComponents}>{r.notes}</Markdown>
</div>
</Section>
)}
{p.linkedCosts.length > 0 && (
<Section label={t('reservations.costsLabel')}>
<div className="flex flex-col gap-1.5">
{p.linkedCosts.map(item => (
<button key={item.id} type="button" disabled={!p.onEditExpense} onClick={() => 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')}>
<Wallet size={14} strokeWidth={2} className="flex-none text-content-faint" />
<span className="min-w-0 flex-1 truncate font-medium text-content">{item.name}</span>
<span className="flex-none font-semibold tabular-nums text-content">{formatMoney(item.total_price, (item.currency || tripCurrency || 'EUR'), locale)}</span>
</button>
))}
</div>
</Section>
)}
{p.files.length > 0 && (
<Section
label={t('files.title')}
action={(
<button type="button" onClick={p.onNavigateToFiles} className="inline-flex items-center gap-1 font-geist font-semibold text-content-muted hover:text-content" style={fs(11)}>
<FolderOpen size={11} strokeWidth={2} />{t('reservations.showInFiles')}
</button>
)}
>
<FileRows files={p.files} />
</Section>
)}
{(p.contributions.length > 0 || p.detailPlugins.length > 0) && (
<div className="flex flex-col gap-2">
<PluginCardFooter items={p.contributions} tripId={p.tripId} />
{p.detailPlugins.map(pl => (
<div key={pl.id} className={`${BOX} overflow-hidden`}>
<PluginFrame pluginId={pl.id} tripId={String(p.tripId)} reservationId={String(r.id)} title={pl.name} surface="detail-slot" />
</div>
))}
</div>
)}
</div>
{(p.onShowOnMap || p.canEdit) && (
<footer className="flex flex-none items-center gap-2 border-t border-edge-faint px-6 py-3.5">
{p.onShowOnMap && (
<button type="button" onClick={p.onShowOnMap} aria-pressed={!!p.onMap}
className={`inline-flex items-center gap-1.5 rounded-[10px] px-3.5 py-2 font-medium ${p.onMap ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content hover:bg-surface-hover'}`} style={fs(13, 'body')}>
<RouteIcon size={14} strokeWidth={2} />{t('mobileTrip.onMap')}
</button>
)}
<span className="flex-1" />
{p.canEdit && (
<button type="button" onClick={p.onDelete} aria-label={t('common.delete')} title={t('common.delete')}
className="grid h-9 w-9 place-items-center rounded-[10px] bg-surface-tertiary text-danger hover:bg-surface-hover">
<Trash2 size={15} strokeWidth={2} />
</button>
)}
{p.canEdit && tone !== 'transit' && (
<button type="button" onClick={p.onEdit} className="inline-flex items-center gap-1.5 rounded-[10px] bg-accent px-4 py-2 font-medium text-accent-text hover:opacity-90" style={fs(13, 'body')}>
<Pencil size={14} strokeWidth={2} />{t('common.edit')}
</button>
)}
</footer>
)}
</div>
<NameDialog
open={renaming}
title={t('reservations.rename')}
placeholder={t('reservations.titlePlaceholder')}
confirmLabel={t('common.save')}
value={name}
onChange={setName}
onConfirm={rename}
onClose={() => setRenaming(false)}
/>
</div>,
document.body,
)
}
function Section({ label, action, children }: { label: string; action?: ReactNode; children: ReactNode }) {
return (
<section>
<div className="mb-2 flex items-center gap-2">
<Eyebrow>{label}</Eyebrow>
{action && <span className="ml-auto">{action}</span>}
</div>
{children}
</section>
)
}
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 (
<div className={`${BOX} flex flex-col gap-2 px-3.5 py-3`}>
{legs.map((leg, i) => {
const walk = leg.mode === 'WALK'
const mins = leg.duration ? Math.round(leg.duration / 60) : null
return (
<div key={i} className="flex items-start gap-2">
{walk ? <Footprints size={13} strokeWidth={2} className="mt-0.5 flex-none text-content-faint" /> : (
<span className="flex-none rounded-[5px] px-1.5 py-px font-bold" style={{
...fs(10.5),
background: leg.line_color || 'var(--bg-tertiary)',
color: leg.line_color ? (leg.line_text_color || '#fff') : 'var(--text-primary)', // theme-lint-disable: a transit line's own colours
}}>{leg.line || leg.mode}</span>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1 font-medium text-content" style={fs(12.5, 'body')}>
{walk ? <span className="text-content-muted">{t('transit.walkTo', { name: leg.to?.name || '' })}</span> : (
<><span className="truncate">{leg.from?.name}</span><ArrowRight size={11} strokeWidth={2} className="flex-none text-content-faint" /><span className="truncate">{leg.to?.name}</span></>
)}
</div>
<div className="font-geist text-content-faint" style={fs(10.5)}>
{[!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(', ')}
</div>
</div>
</div>
)
})}
</div>
)
}
function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-center gap-2.5 border-t border-edge-faint py-2.5 first:border-t-0 text-content-secondary" style={fs(13, 'body')}>
<span className="flex-none text-content-faint">{icon}</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
</div>
)
}
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
}
@@ -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<string>
onToggleType: (type: string) => void
onAllTypes: () => void
members: TripMember[]
showTravelers: boolean
activeTravelers: Set<number>
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<HTMLInputElement>(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 (
<div className="flex flex-wrap items-center gap-2.5 rounded-[18px] bg-surface-tertiary py-3 pl-[22px] pr-3">
<h2 className="m-0 shrink-0 text-subtitle font-semibold tracking-[-0.01em] text-content">{p.title}</h2>
<span className="flex-1" />
{hasEntries && (
<>
<label className="flex h-9 w-[200px] items-center gap-2 rounded-[10px] bg-surface-card px-3 text-content-faint focus-within:ring-2 focus-within:ring-[color:var(--text-primary)]">
<Search size={14} strokeWidth={2} className="flex-none" />
<input
ref={searchRef}
value={p.query}
onChange={e => 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 && <button type="button" onClick={() => p.onQuery('')} aria-label={t('common.clear')} className="flex-none text-content-faint hover:text-content"><X size={13} /></button>}
</label>
{p.filtering && (
<button type="button" onClick={p.onResetFilters} title={t('reservations.resetFilters')}
className="inline-flex h-9 flex-none items-center gap-1 rounded-[10px] bg-surface-card px-2.5 font-geist font-semibold tabular-nums text-content-muted hover:text-content" style={fs(12)}>
{t('reservations.results', { shown: p.shown, total: p.total })}<X size={12} />
</button>
)}
<FilterMenu {...p} />
<div className="inline-flex flex-none rounded-[10px] bg-surface-card p-[3px]" role="group" aria-label={t('reservations.view.label')}>
{views.map(([v, Icon, label]) => (
<Tooltip key={v} label={label}>
<button type="button" onClick={() => 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'}`}>
<Icon size={15} strokeWidth={2} />
</button>
</Tooltip>
))}
</div>
<ViewOptions {...p} />
</>
)}
{p.canEdit && (
<>
{hasEntries && <span className="mx-0.5 h-[22px] w-px flex-none bg-edge-faint" />}
{p.onImport && (
<Tooltip label={t('reservations.import.title')}>
<button type="button" onClick={p.onImport} aria-label={t('reservations.import.title')} className={BAR_BTN}><Download size={15} strokeWidth={2} /></button>
</Tooltip>
)}
{p.onAirTrail && (
<Tooltip label={t('reservations.airtrail.title')}>
<button type="button" onClick={p.onAirTrail} aria-label={t('reservations.airtrail.title')} className={BAR_BTN}><Plane size={15} strokeWidth={2} /></button>
</Tooltip>
)}
<button type="button" onClick={p.onAdd} className="inline-flex h-9 flex-none items-center gap-1.5 rounded-[10px] bg-accent px-3.5 font-medium text-accent-text hover:opacity-90" style={fs(13, 'body')}>
<Plus size={14} strokeWidth={2.5} />{p.addLabel}
</button>
</>
)}
</div>
)
}
/** 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<HTMLDivElement>(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 (
<div role="menu" onKeyDown={e => { if (e.key === 'Escape') onClose() }} className="absolute right-0 top-11 z-30" style={{ ...POPOVER, width }}>
{children}
</div>
)
}
/** 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 (
<div ref={ref} className="relative flex-none">
<Tooltip label={t('reservations.filter')}>
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-label={t('reservations.filter')} className={`${BAR_BTN} ${active ? 'text-content' : ''}`}>
<Filter size={15} strokeWidth={2} />
{active > 0 && <span className="absolute -right-1 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-accent px-1 font-geist font-bold text-accent-text" style={fs(9)}>{active}</span>}
</button>
</Tooltip>
{open && (
<Menu onClose={close} width={280}>
<div style={POPOVER_CAPTION}>{t('reservations.status')}</div>
<div className="mx-1.5 mb-1 mt-0.5 flex rounded-[10px] bg-surface-tertiary p-[3px]">
{statuses.map(([s, label]) => (
<button key={s} type="button" onClick={() => 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}
</button>
))}
</div>
{p.types.length > 1 && (
<>
<div style={POPOVER_DIVIDER} />
<div style={POPOVER_CAPTION}>{t('reservations.group.type')}</div>
{p.types.map(({ type, count }) => {
const info = typeInfo(type)
const on = p.activeTypes.has(type)
return (
<PopoverItem key={type} active={on} onClick={() => p.onToggleType(type)}
icon={<info.Icon size={14} strokeWidth={2} style={{ color: info.color }} />}
label={t(info.labelKey)}
trailing={<span className="flex items-center gap-1.5"><CountPill>{count}</CountPill>{on && <Check size={13} className="text-content-muted" />}</span>} />
)
})}
</>
)}
{p.showTravelers && (
<>
<div style={POPOVER_DIVIDER} />
<div style={POPOVER_CAPTION}>{t('reservations.travelers.label')}</div>
<div className="flex flex-wrap gap-1.5 px-2.5 pb-1.5 pt-0.5">
{p.members.map(m => {
const on = p.activeTravelers.has(m.id)
return (
<button key={m.id} type="button" onClick={() => 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')}>
<span className="grid h-5 w-5 flex-none place-items-center overflow-hidden rounded-full bg-surface-tertiary font-bold" style={fs(9)}>
{m.avatar_url ? <img src={m.avatar_url} alt="" className="h-full w-full object-cover" /> : m.username?.[0]?.toUpperCase()}
</span>
{m.username}
</button>
)
})}
</div>
</>
)}
{active > 0 && (
<>
<div style={POPOVER_DIVIDER} />
<PopoverItem icon={<RotateCcw size={14} />} label={t('reservations.resetFilters')} onClick={() => { p.onResetFilters(); close() }} />
</>
)}
</Menu>
)}
</div>
)
}
/** 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 (
<div ref={ref} className="relative flex-none">
<Tooltip label={t('reservations.viewOptions')}>
<button type="button" onClick={() => setOpen(o => !o)} aria-expanded={open} aria-label={t('reservations.viewOptions')} className={BAR_BTN}>
<SlidersHorizontal size={15} strokeWidth={2} />
{!p.viewIsDefault && <span className="absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full bg-accent" />}
</button>
</Tooltip>
{open && (
<Menu onClose={close} width={250}>
{p.view === 'timeline' ? (
<>
<div style={POPOVER_CAPTION}>{t('reservations.view.timeline')}</div>
<SwitchRow label={t('reservations.timeline.byType')} on={p.byType} onToggle={p.onByType} />
<SwitchRow label={t('reservations.timeline.context')} on={p.showContext} onToggle={p.onShowContext} />
</>
) : (
<>
<div style={POPOVER_CAPTION}>{t('reservations.group.label')}</div>
{groups.map(([g, label]) => (
<PopoverItem key={g} icon={g === 'day' ? <CalendarDays size={14} /> : g === 'type' ? <Tag size={14} /> : <Layers size={14} />} label={label} active={p.group === g} onClick={() => p.onGroup(g)} />
))}
<div style={POPOVER_DIVIDER} />
<div style={POPOVER_CAPTION}>{t('reservations.sort.label')}</div>
{sorts.map(([s, label]) => (
<PopoverItem key={s} icon={<ArrowDownUp size={14} />} label={label} active={p.sort === s} onClick={() => p.onSort(s)} />
))}
<PopoverItem icon={<ArrowDownUp size={14} />} 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} />
</>
)}
<div style={POPOVER_DIVIDER} />
<PopoverItem icon={<RotateCcw size={14} />} label={t('reservations.resetView')} onClick={() => { p.onResetView(); close() }} />
</Menu>
)}
</div>
)
}
function SwitchRow({ label, on, onToggle }: { label: string; on: boolean; onToggle: () => void }) {
return (
<div className="flex items-center gap-3 px-2.5 py-2">
<span className="min-w-0 flex-1 text-content" style={fs(12.5, 'body')}>{label}</span>
<ToggleSwitch on={on} onToggle={onToggle} label={label} />
</div>
)
}
@@ -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<HTMLDivElement>(null)
const flat = p.groups.flatMap(g => (p.collapsed(g.id) ? [] : g.items))
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
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<HTMLElement>(`[data-row="${next.id}"]`)?.focus()
}
return (
<div ref={rootRef} onKeyDown={onKeyDown} className="flex flex-col gap-6">
{p.groups.map(g => {
const open = !p.collapsed(g.id)
return (
<section key={g.id} className="overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
{g.label && (
<button type="button" onClick={() => 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">
<span className={EYEBROW} style={fs(10.5)}>{g.label}</span>
{g.sub && <span className="truncate font-geist font-medium text-content-muted" style={fs(11.5)}>{g.sub}</span>}
<CountPill>{g.items.length}</CountPill>
</button>
)}
{open && g.items.map(r => (
<Row key={r.id} r={r} p={p} facts={p.factsOf(r)} files={p.filesCount(r)} costs={p.costsOf(r)} t={t} locale={locale} />
))}
</section>
)
})}
</div>
)
}
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, string | number>) => 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 (
<div
data-row={r.id}
tabIndex={0}
role="button"
aria-pressed={selected}
aria-label={displayTitle(r)}
onClick={() => 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)]' : ''}`}
>
<StatusDot r={r} canToggle={p.canEdit} onToggle={() => p.onToggleStatus(r)} />
<TypeTile type={r.type} />
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate font-semibold text-content" style={fs(13.5, 'body')}>{displayTitle(r)}</span>
{!!r.needs_review && <ReviewPill />}
<AirTrailPill r={r} />
</div>
{transit ? (
<div className="mt-1"><TransitLegChips legs={Array.isArray(meta.transit?.legs) ? meta.transit.legs : []} size="sm" t={t} /></div>
) : (
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 font-geist text-content-muted" style={fs(11.5)}>
{sub && <span className="truncate">{sub}</span>}
{extras.map(x => <span key={x} className="rounded-full bg-surface-tertiary px-2 py-px">{x}</span>)}
{r.confirmation_number && (
<span className="rounded-full bg-surface-tertiary px-2 py-px tabular-nums">
<BlurredCode interactive={false}>{r.confirmation_number}</BlurredCode>{facts.legCodes.length > 1 ? ` +${facts.legCodes.length - 1}` : ''}
</span>
)}
{costs.map(c => (
<span key={c.currency} className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2 py-px tabular-nums">
<Wallet size={10} strokeWidth={2} />{formatMoney(c.amount, c.currency, locale)}
</span>
))}
{files > 0 && <span className="inline-flex items-center gap-1 rounded-full bg-surface-tertiary px-2 py-px"><Paperclip size={10} strokeWidth={2} />{files}</span>}
</div>
)}
</div>
<div className="text-right tabular-nums">
<div className="font-geist text-content-faint" style={fs(11)}>{facts.day ? [facts.day.label, facts.day.date].filter(Boolean).join(' ') : t('reservations.undated')}</div>
{facts.time && <div className="font-semibold text-content" style={fs(12.5, 'body')}>{facts.time}</div>}
</div>
<div className="flex items-center justify-end gap-1.5">
<TravelerStack travelers={r.travelers || []} />
{p.canEdit && !transit && <RoundAction label={t('common.edit')} onClick={() => p.onEdit(r)}><Pencil size={12} strokeWidth={2} /></RoundAction>}
{p.canEdit && <RoundAction label={t('common.delete')} onClick={() => p.onDelete(r)} danger><Trash2 size={12} strokeWidth={2} /></RoundAction>}
</div>
</div>
)
}
@@ -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<T extends HTMLElement>() {
const ref = useRef<T>(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<HTMLDivElement>()
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<string, Placed[]>()
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 <div key={h} className={`absolute inset-y-0 border-l ${h % 6 === 0 ? 'border-edge' : 'border-edge-faint'} ${h < 6 || h >= 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 <div key={i} className={`absolute inset-y-0 border-l ${wd === 1 ? 'border-edge' : 'border-edge-faint'} ${wd === 0 || wd === 6 ? 'bg-surface-secondary' : ''} ${i === todayIndex ? 'bg-[color:var(--accent-subtle)]' : ''}`} style={{ left: i * 24 * pxPerHour, width: 24 * pxPerHour }} />
})}
</>
)
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 (
<div className="flex flex-col gap-8">
<div className="overflow-hidden rounded-2xl border border-edge-faint bg-surface-card">
<div className="flex flex-wrap items-center gap-2.5 border-b border-edge-faint bg-surface-secondary px-3.5 py-2.5">
{dayMode ? (
<div className="flex items-center gap-1.5">
<button type="button" onClick={() => setDayIndex(Math.max(0, day - 1))} disabled={day === 0} aria-label={t('reservations.timeline.prevDay')} className={navBtn}>
<ChevronLeft size={15} strokeWidth={2.2} />
</button>
<span className="inline-flex items-stretch overflow-hidden rounded-full bg-surface-card font-semibold text-content shadow-sm" style={fs(12, 'body')}>
<span className="px-3 py-1">{dayName(day)}</span>
{axis[day]?.date && (
<>
<span aria-hidden className="w-[3px] bg-surface-secondary" />
<span className="px-3 py-1 text-content-muted">{fmtDay(axis[day].date!, { weekday: 'short', day: 'numeric', month: 'short' })}</span>
</>
)}
</span>
<button type="button" onClick={() => setDayIndex(Math.min(n - 1, day + 1))} disabled={day >= n - 1} aria-label={t('reservations.timeline.nextDay')} className={navBtn}>
<ChevronRight size={15} strokeWidth={2.2} />
</button>
</div>
) : range && (
<span className="inline-flex items-stretch overflow-hidden rounded-full bg-surface-card font-semibold text-content shadow-sm" style={fs(12, 'body')}>
<span className="px-3 py-1">{range}</span>
<span aria-hidden className="w-[3px] bg-surface-secondary" />
<span className="px-3 py-1">{t('reservations.timeline.dayCount', { count: axis.length })}</span>
</span>
)}
<span className="flex-1" />
{showToday && (
<button type="button" onClick={goToday} className="rounded-full border border-edge-faint bg-surface-card px-3 py-1 font-medium text-content hover:bg-surface-hover" style={fs(12, 'body')}>
{t('reservations.timeline.today')}
</button>
)}
<div className="inline-flex rounded-full bg-surface-tertiary p-[3px]" role="group" aria-label={t('reservations.timeline.zoom')}>
<button type="button" onClick={() => p.onZoom('trip')} aria-pressed={!dayMode} className={segBtn(!dayMode)} style={fs(12, 'body')}>{t('reservations.timeline.trip')}</button>
<button type="button" onClick={() => p.onZoom('day')} aria-pressed={dayMode} className={segBtn(dayMode)} style={fs(12, 'body')}>{t('reservations.timeline.day')}</button>
</div>
</div>
<div ref={scrollRef} className="overflow-x-auto" dir="ltr">
<div className="relative" style={{ width: LABEL_W + gridW }}>
<div className="flex border-b border-edge-faint">
<div className="sticky left-0 z-20 flex-none border-r border-edge-faint bg-surface-card" style={{ width: LABEL_W }} />
<div className={`relative flex-none ${win ? 'h-9' : 'h-11'}`} style={{ width: gridW }}>
{win ? (
Array.from({ length: win.to - win.from }, (_, i) => win.from + i).filter(h => (h - win.from) % hourStep === 0).map(h => (
<span key={h} className="absolute top-0 flex h-full items-center px-1.5 font-geist tabular-nums text-content-muted" style={{ left: (h - win.from) * pxPerHour, ...fs(10.5) }}>
{hourLabel(h)}
</span>
))
) : axis.map((a, i) => (
<Tooltip key={i} label={t('reservations.timeline.openDay', { day: dayName(i) })}>
<button type="button" onClick={() => 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 }}>
<span className={`w-full truncate font-geist ${i === todayIndex ? 'font-bold text-content' : 'text-content-muted'}`} style={fs(10.5)}>
{a.date ? fmtDay(a.date, { weekday: 'short', day: 'numeric', ...(i === 0 || a.date.endsWith('-01') ? { month: 'short' } : {}) }) : ''}
</span>
<span className={`w-full truncate font-semibold ${i === todayIndex ? 'text-content' : 'text-content-secondary'}`} style={fs(11.5, 'body')}>
{24 * pxPerHour >= 90 ? t('dayplan.dayN', { n: a.dayNumber ?? i + 1 }) : String(a.dayNumber ?? i + 1)}
</span>
</button>
</Tooltip>
))}
</div>
</div>
{contextBars && contextBars.bars.length > 0 && (
<div className="flex border-b border-edge-faint">
<div className="sticky left-0 z-20 flex flex-none items-center border-r border-edge-faint bg-surface-card px-3" style={{ width: LABEL_W }}>
<span className={EYEBROW} style={fs(9.5)}>{t(p.items.some(r => r.type !== 'hotel') && p.context.some(r => r.type === 'hotel') ? 'reservations.timeline.contextStays' : 'reservations.timeline.contextTransports')}</span>
</div>
<div className="relative flex-none" style={{ width: gridW, height: 8 + contextBars.lanes * 18 }}>
<Cells />
{contextBars.bars.map(b => {
const info = typeInfo(b.r.type)
return (
<div key={b.r.id} {...hoverProps(b.r)} className="absolute truncate rounded-md px-1.5 font-geist text-content-muted opacity-70"
style={{ left: b.x, width: b.w, top: 4 + b.lane * 18, height: 14, lineHeight: '14px', ...fs(9.5), background: `color-mix(in srgb, ${info.color} 14%, var(--bg-card))` }}>
{b.w > 60 ? displayTitle(b.r) : ''}
</div>
)
})}
</div>
</div>
)}
{lanes.map(lane => {
const info = typeInfo(lane.key)
return (
<div key={lane.key} className="flex border-b border-edge-faint last:border-b-0">
<div className="sticky left-0 z-20 flex flex-none items-center gap-2 border-r border-edge-faint bg-surface-card px-3" style={{ width: LABEL_W }}>
{lane.key !== 'all' && <info.Icon size={14} strokeWidth={2} style={{ color: info.color }} className="flex-none" />}
<span className="min-w-0 flex-1 truncate font-semibold text-content" style={fs(12.5, 'body')}>{lane.key === 'all' ? t('common.all') : t(info.labelKey)}</span>
<CountPill>{lane.count}</CountPill>
</div>
<div className="relative flex-none" style={{ width: gridW, height: 12 + lane.lanes * laneH }}>
<Cells />
{lane.bars.map(b => (
<TimelineBar key={b.r.id} bar={b} top={6 + b.lane * laneH} height={barH} detailed={dayMode}
facts={p.factsOf(b.r)} selected={p.selectedId === b.r.id} onSelect={() => p.onSelect(b.r)} hover={hoverProps(b.r)} />
))}
</div>
</div>
)
})}
{lanes.length === 0 && (
<div className="flex">
<div className="sticky left-0 flex-none border-r border-edge-faint bg-surface-card" style={{ width: LABEL_W }} />
<div className="flex-none py-8 text-center font-geist text-content-faint" style={{ width: gridW, ...fs(12) }}>
{t(dayMode ? 'reservations.timeline.nothingThisDay' : 'reservations.timeline.noneDated')}
</div>
</div>
)}
{nowX != null && (
<div className="pointer-events-none absolute bottom-0 top-0 z-10 w-[2px] bg-accent" style={{ left: LABEL_W + nowX }}>
<span className="absolute left-1 top-1 whitespace-nowrap font-geist font-bold text-content" style={fs(9.5)}>{t('reservations.timeline.now')}</span>
</div>
)}
</div>
</div>
</div>
{(['before', 'after', 'undated'] as const).map(kind => {
const list = tray.filter(x => x.pl.kind === kind)
if (list.length === 0) return null
return (
<section key={kind}>
<div className="mb-3 flex items-center gap-2 px-0.5">
<span className={EYEBROW} style={fs(11)}>{t(`reservations.group.${kind}`)}</span>
<CountPill>{list.length}</CountPill>
</div>
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_36px)/4)),1fr))]">
{list.map(({ r }) => <TrayCard key={r.id} r={r} selected={p.selectedId === r.id} onSelect={() => p.onSelect(r)} />)}
</div>
</section>
)
})}
{hover && <HoverCard r={hover.r} rect={hover.rect} facts={p.factsOf(hover.r)} />}
</div>
)
}
interface HoverHandlers {
onMouseEnter: (e: MouseEvent<HTMLElement>) => void
onMouseLeave: () => void
onFocus: (e: FocusEvent<HTMLElement>) => 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 (
<button type="button" onClick={onSelect} aria-label={displayTitle(r)} {...hover}
className={`absolute flex items-center gap-2 overflow-hidden whitespace-nowrap px-2 text-left text-content transition-shadow hover:z-10 hover:shadow-md ${detailed ? 'rounded-[10px]' : 'rounded-lg'} ${pending ? 'border-[1.5px] border-dashed border-warning' : 'border border-edge-faint'} ${selected ? 'ring-2 ring-[color:var(--text-primary)]' : ''}`}
style={{ left: x, width: w, top, height, background: `color-mix(in srgb, ${info.color} 15%, var(--bg-card))`, boxShadow: `inset 3px 0 0 ${info.color}` }}>
{bar.cutStart && <ChevronLeft size={12} strokeWidth={2.4} className="-ml-1 flex-none text-content-faint" />}
<info.Icon size={detailed ? 14 : 12} strokeWidth={2.2} className="flex-none" style={{ color: info.color }} />
{w > (detailed ? 70 : 64) && (
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-semibold" style={fs(detailed ? 12.5 : 11.5, 'body')}>{displayTitle(r)}</span>
{detailed && second && w > 110 && <span className="truncate font-geist tabular-nums text-content-muted" style={fs(10.5)}>{second}</span>}
</span>
)}
{bar.cutEnd && <ChevronRight size={12} strokeWidth={2.4} className="-mr-1 ml-auto flex-none text-content-faint" />}
</button>
)
}
/** 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(
<div role="tooltip" className="trek-popover-enter pointer-events-none fixed z-[100000] rounded-[14px] border border-edge-faint bg-surface-card p-3 shadow-lg"
style={{ width: W, left, top: below ? rect.bottom + 8 : rect.top - 8, transform: below ? undefined : 'translateY(-100%)' }}>
<div className="flex items-center gap-2.5">
<TypeTile type={r.type} size={34} />
<div className="min-w-0 flex-1">
<div className="truncate font-bold text-content" style={fs(13.5, 'body')}>{displayTitle(r)}</div>
<div className="flex items-center gap-1.5 font-geist text-content-muted" style={fs(11)}>
<span className="h-1.5 w-1.5 flex-none rounded-full" style={{ background: toneColor(tone) }} />
<span className="truncate">{[t(info.labelKey), status].filter(Boolean).join(', ')}</span>
</div>
</div>
</div>
{(facts.day || facts.time || route || where) && (
<div className="mt-2.5 flex flex-col gap-1 border-t border-edge-faint pt-2.5">
{facts.day && <HoverLine icon={<CalendarDays size={12} />}>{[facts.day.label, facts.day.date].filter(Boolean).join(' ')}</HoverLine>}
{facts.time && <HoverLine icon={<Clock size={12} />}>{facts.time}</HoverLine>}
{route && <HoverLine icon={<RouteIcon size={12} />}>{route}</HoverLine>}
{where && <HoverLine icon={<MapPin size={12} />}>{where}</HoverLine>}
</div>
)}
</div>,
document.body,
)
}
function HoverLine({ icon, children }: { icon: ReactNode; children: ReactNode }) {
return (
<div className="flex items-center gap-2 text-content-secondary" style={fs(12, 'body')}>
<span className="flex-none text-content-faint">{icon}</span>
<span className="min-w-0 flex-1 truncate tabular-nums">{children}</span>
</div>
)
}
/** 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 (
<button type="button" onClick={onSelect} aria-pressed={selected}
className={`group flex items-center gap-3 rounded-2xl border bg-surface-secondary px-3 py-2.5 text-left transition-shadow hover:shadow-md ${selected ? 'border-[color:var(--text-primary)]' : 'border-edge-faint'}`}>
<TypeTile type={r.type} size={38} raised />
<span className="min-w-0 flex-1">
<span className="block truncate font-bold text-content" style={fs(13.5, 'body')}>{displayTitle(r)}</span>
<span className="mt-0.5 flex items-center gap-1.5 font-geist text-content-muted" style={fs(11.5)}>
<span className="h-1.5 w-1.5 flex-none rounded-full" style={{ background: toneColor(tone) }} />
<span className="truncate">{[t(info.labelKey), status].filter(Boolean).join(', ')}</span>
</span>
</span>
<ChevronRight size={15} strokeWidth={2} className="flex-none text-content-faint transition-transform group-hover:translate-x-0.5" />
</button>
)
}
@@ -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, string | number>) => string
locale: string
timeFormat: string
days: Day[]
assignmentLookup: Record<number, AssignmentLookupEntry>
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,
}
}
@@ -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<StatusTone, string> = { 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 <div className={`${EYEBROW} ${className}`} style={fs(9.5)}>{children}</div>
}
/** 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 (
<div className={`min-w-0 ${className}`}>
<Eyebrow className="mb-[3px]">{label}</Eyebrow>
<div className={`${BOX} truncate px-[10px] py-[7px] text-center font-semibold text-content ${tabular ? 'tabular-nums' : ''}`} style={fs(12.5, 'body')}>
{children}
</div>
</div>
)
}
export function CountPill({ children }: { children: ReactNode }) {
return (
<span className="whitespace-nowrap rounded-full bg-surface-tertiary px-2 py-[2px] font-geist font-bold text-content-muted" style={fs(10)}>
{children}
</span>
)
}
/** 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 (
<button type="button" onClick={onToggle} aria-expanded={open} className="mb-3 flex w-full items-center gap-2 px-0.5 text-left">
<span className={EYEBROW} style={fs(11)}>{label}</span>
{sub && <span className="truncate font-geist font-medium text-content-muted" style={fs(11.5)}>{sub}</span>}
<CountPill>{count}</CountPill>
{open
? <ChevronUp size={14} strokeWidth={2} className="ml-auto flex-none text-content-faint" />
: <ChevronDown size={14} strokeWidth={2} className="ml-auto flex-none text-content-faint" />}
</button>
)
}
/**
* 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 = <span className="block flex-none rounded-full" style={{ width: size, height: size, background: toneColor(tone) }} />
if (!canToggle || tone === 'transit') return <span className="grid h-[22px] w-[22px] flex-none place-items-center">{dot}</span>
const next = tone === 'confirmed' ? t('reservations.pending') : t('reservations.confirmed')
return (
<button
type="button"
onClick={e => { 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}
</button>
)
}
export function TypeChip({ type }: { type: string }) {
const { t } = useTranslation()
const info = typeInfo(type)
return (
<span className="inline-flex flex-none items-center gap-1 rounded-full border border-edge-faint bg-surface-card px-2 py-[2px] font-geist font-bold uppercase tracking-[.06em] text-content-muted" style={fs(9.5)}>
<info.Icon size={11} strokeWidth={2.2} style={{ color: info.color }} />
{t(info.labelKey)}
</span>
)
}
/** 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 (
<span className={`grid flex-none place-items-center ${raised ? 'rounded-[14px] bg-surface-card shadow-sm' : 'rounded-[10px] bg-surface-tertiary'}`} style={{ width: size, height: size }}>
<info.Icon size={Math.round(size * 0.47)} strokeWidth={1.9} style={{ color: info.color }} />
</span>
)
}
export function ReviewPill() {
const { t } = useTranslation()
return (
<span className="inline-flex flex-none items-center gap-1 rounded-full bg-warning-soft px-2 py-[2px] font-geist font-bold uppercase tracking-[.03em] text-warning" style={fs(9)} title={t('reservations.needsReviewHint')}>
<AlertCircle size={10} />
{t('reservations.needsReview')}
</span>
)
}
/**
* 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 (
<span
className={`inline-flex flex-none items-center gap-1 rounded-full px-2 py-[2px] font-geist font-bold uppercase tracking-[.03em] ${live ? 'bg-info-soft text-info' : 'bg-surface-tertiary text-content-faint'}`}
style={fs(9)}
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : multiLeg ? t('reservations.airtrail.layoverHint') : t('reservations.airtrail.notSyncedHint')}
>
<Plane size={10} />
{live ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
</span>
)
}
/** 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 (
<button
type="button"
onClick={e => { 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}
</button>
)
}
/** 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 (
<div className="flex flex-wrap items-center gap-1.5">
{travelers.map(tv => {
const src = tv.avatar_url || avatarSrc(tv.avatar)
return (
<span key={tv.user_id} className={`${BOX} flex items-center gap-1.5 rounded-full py-[3px] pl-[3px] pr-2.5`}>
<span className="grid h-5 w-5 flex-none place-items-center overflow-hidden rounded-full bg-accent font-bold text-accent-text" style={fs(9)}>
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
</span>
<span className="truncate font-semibold text-content" style={fs(12, 'body')}>{tv.username}</span>
{!!tv.is_guest && <GuestBadge size="xs" />}
</span>
)
})}
</div>
)
}
/** 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 (
<span className="flex items-center" title={names} aria-label={names}>
{travelers.slice(0, max).map((tv, i) => {
const src = tv.avatar_url || avatarSrc(tv.avatar)
return (
<span key={tv.user_id} className="grid h-[22px] w-[22px] flex-none place-items-center overflow-hidden rounded-full border-2 border-surface-card bg-accent font-bold text-accent-text" style={{ ...fs(9), marginLeft: i ? -7 : 0 }}>
{src ? <img src={src} alt="" className="h-full w-full object-cover" /> : tv.username?.[0]?.toUpperCase()}
</span>
)
})}
{travelers.length > max && <span className="ml-1 font-geist font-semibold text-content-faint" style={fs(10.5)}>+{travelers.length - max}</span>}
</span>
)
}
/** 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 (
<div className="flex flex-col gap-1">
{files.map(f => (
<button
key={f.id}
type="button"
onClick={e => { e.stopPropagation(); open(f) }}
className={`${BOX} flex items-center gap-1.5 px-[10px] py-[7px] text-left hover:bg-surface-hover`}
>
<FileText size={12} strokeWidth={2} className="flex-none text-content-muted" />
<span className="truncate font-geist font-semibold text-content-muted" style={fs(11.5)}>{f.original_name}</span>
</button>
))}
</div>
)
}
@@ -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<string, { Icon: LucideIcon }> = {
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<number, AssignmentLookupEntry> {
const map: Record<number, AssignmentLookupEntry> = {}
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<string>
status: StatusFilter
travelers: Set<number>
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<string, unknown>) => [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<string, Reservation[]>()
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<string, BookingGroup>()
const push = (id: string, make: () => Omit<BookingGroup, 'items'>, 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<string, number>()
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 || ''
}
@@ -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
}
@@ -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) }
}
@@ -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<T>(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<BookingsView>(() => read(local(), k('view'), 'cards'))
const [groups, setGroups] = useState<Record<'cards' | 'list', GroupBy>>(() => 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<Filters>(() => read(session(), filterKey, { types: [], status: 'all', travelers: [] }))
const [collapsedMap, setCollapsed] = useState<Record<string, boolean>>(() => read(local(), `${k('collapsed')}:${tripId}`, {}))
const [query, setQuery] = useState('')
const [selectedId, setSelectedId] = useState<number | null>(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<typeof timeline>) => { const next = { ...timeline, ...patch }; setTimeline(next); write(local(), k('timeline'), next) }
const setFilters = (patch: Partial<Filters>) => { 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,
}
}
+9
View File
@@ -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)}
/>
</LazyPanel>
</div>
@@ -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)}
/>
</LazyPanel>
</div>
+52
View File
@@ -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;
+1
View File
@@ -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;
+52
View File
@@ -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;
+1
View File
@@ -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;