mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user