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

- {t(titleKey)} -

- - {reservations.length > 0 && ( - <> -
-
- - {TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => { - const active = typeFilters.has(opt.value) - const Icon = opt.Icon - return ( - - ) - })} -
- - )} - - {/* Filter by traveler — members/guests assigned to bookings (#1517/#1557). */} - {tripMembers.length > 1 && reservations.some(r => (r.travelers || []).length > 0) && ( - <> -
- - - )} - - {canEdit && ( -
- {onImport && bookingImportAvailable && ( - - )} - {onAirTrailImport && airTrailAvailable && ( - - )} - -
- )} -
+
+
+ 1 && reservations.some(r => (r.travelers || []).length > 0)} + activeTravelers={v.travelers} + onToggleTraveler={v.toggleTraveler} + onClearTravelers={v.clearTravelers} + group={v.group} + onGroup={v.setGroup} + sort={v.sort.by} + sortDir={v.sort.dir} + onSort={v.setSort} + onSortDir={v.flipSort} + byType={v.timeline.byType} + onByType={v.toggleByType} + showContext={v.timeline.context} + onShowContext={v.toggleContext} + viewIsDefault={v.viewIsDefault} + onResetView={v.resetView} + />
- {/* Content */} -
- {total === 0 && reservations.length === 0 ? ( - - - {onImport && bookingImportAvailable && ( - - )} - {onAirTrailImport && airTrailAvailable && ( - - )} -
- ) : undefined} - /> - ) : total === 0 ? ( -
-

{t('places.noneFound')}

-
- ) : ( - <> - {transitEntries.length > 0 && ( -
- {transitEntries.map(r => )} -
- )} - {allPending.length > 0 && ( -
- {allPending.map(r => )} -
- )} - {allConfirmed.length > 0 && ( -
- {allConfirmed.map(r => )} -
- )} - - )} -
+
{content}
+ + {detail} + + setPendingDelete(null)} + onConfirm={confirmDelete} + title={t('reservations.confirm.deleteTitle')} + message={t('reservations.confirm.deleteBody', { name: pendingDelete?.title ?? '' })} + confirmLabel={t('common.delete')} + cancelLabel={t('common.cancel')} + />
) } diff --git a/client/src/components/Planner/bookings/BookingCard.tsx b/client/src/components/Planner/bookings/BookingCard.tsx new file mode 100644 index 000000000..445975aa3 --- /dev/null +++ b/client/src/components/Planner/bookings/BookingCard.tsx @@ -0,0 +1,209 @@ +import type { ReactNode } from 'react' +import { Hotel, Link2, MapPin, ExternalLink, Pencil, Trash2, Wallet } from 'lucide-react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import type { Reservation, TripFile } from '../../../types' +import type { ViewContribution } from '../../../api/client' +import type { ActivePlugin } from '../../../store/pluginStore' +import { useTranslation } from '../../../i18n' +import { markdownLinkComponents } from '../../shared/markdownLink' +import { BlurredCode } from '../../shared/BookingCode' +import PluginFrame from '../../Plugins/PluginFrame' +import { PluginCardFooter } from '../../Plugins/PluginContributions' +import { TransitLegChips, fmtTransitDuration } from '../transitDisplay' +import { formatMoney } from '../../../utils/formatters' +import { parseMeta, typeInfo, displayTitle, type CostTotal } from './bookingsModel' +import type { BookingFacts } from './bookingFacts' +import { + AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, RoundAction, StatusDot, TravelerChips, TypeChip, + fs, toneOf, toneTint, +} from './bookingParts' + +export interface BookingCardProps { + r: Reservation + facts: BookingFacts + files: TripFile[] + costs: CostTotal[] + tripId: number + canEdit: boolean + selected: boolean + onSelect: () => void + onEdit: () => void + onDelete: () => void + onToggleStatus: () => void + contributions: ViewContribution[] + detailPlugins: ActivePlugin[] + /** False while the detail pane shows this booking, so its plugin frames are not mounted twice. */ + showFrames: boolean +} + +/** + * One booking or transport as the phone draws it: a head band tinted by its + * status with the status switch, the type and the title, then framed fields. + * The desktop keeps every value the old card had, so nothing is behind a tap. + */ +export default function BookingCard(p: BookingCardProps) { + const { r, facts } = p + const { t, locale } = useTranslation() + const info = typeInfo(r.type) + const tone = toneOf(r) + const transit = r.type === 'transit' + + return ( +
{ if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); p.onSelect() } }} + aria-label={displayTitle(r)} + className={`group flex cursor-pointer flex-col overflow-hidden rounded-2xl border bg-surface-secondary transition-shadow hover:shadow-md focus-visible:outline focus-visible:outline-2 focus-visible:outline-[color:var(--text-primary)] ${p.selected ? 'border-[color:var(--text-primary)]' : 'border-edge-faint'}`} + > +
+ + + {displayTitle(r)} + {!!r.needs_review && } + + {p.canEdit && !transit && } + {p.canEdit && } +
+ +
+ {(facts.day || facts.time) && ( +
+ {facts.day && ( + + {facts.day.label} + {facts.day.date && {facts.day.date}} + + )} + {facts.time && {facts.time}} +
+ )} + + {transit ? : ( + <> + {r.confirmation_number && ( + + {r.confirmation_number} + + )} + {facts.endpoints.length >= 2 && ( +
+ {facts.endpoints.map((ep, i) => ( + + {i > 0 && } + {ep.name} + + ))} +
+ )} + {facts.legCodes.length > 0 && ( +
+ {facts.legCodes.map((l, i) => ( +
+ {l.route || t('reservations.confirmationCode')} + {l.code} +
+ ))} +
+ )} + {facts.cells.length > 0 && ( +
+ {facts.cells.map((c, i) => {c.value})} +
+ )} + + )} + + {(facts.place || facts.accommodation || facts.linked || facts.url) && ( +
+ {facts.place && }>{facts.place}} + {facts.accommodation && }>{facts.accommodation}} + {facts.linked && }>{facts.linked}} + {facts.url && ( + }> + {facts.url.href + ? e.stopPropagation()} className="text-content hover:underline">{facts.url.href} + : facts.url.text} + + )} +
+ )} + + {r.notes && !transit && ( +
+ {r.notes} +
+ )} + + {(r.travelers || []).length > 0 && ( +
+ {t('reservations.travelers.label')} + +
+ )} + + {p.files.length > 0 && ( +
+ {t('files.title')} + +
+ )} + + {p.costs.length > 0 && ( +
+ {p.costs.map(c => ( + + + {formatMoney(c.amount, c.currency, locale)} + + ))} +
+ )} +
+ + + {p.showFrames && p.detailPlugins.length > 0 && ( +
e.stopPropagation()} className="flex flex-col gap-2 px-3 pb-3"> + {p.detailPlugins.map(pl => ( +
+ +
+ ))} +
+ )} +
+ ) +} + +function IconRow({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( +
+ {icon} + {children} +
+ ) +} + +/** A transit journey's legs in their line colours, its duration and the first line of its notes. */ +function TransitBody({ r }: { r: Reservation }) { + const { t } = useTranslation() + const meta = parseMeta(r) + const transit = meta.transit && Array.isArray(meta.transit.legs) ? meta.transit : null + return ( + <> + {transit && ( +
+ + {transit.duration ? {fmtTransitDuration(transit.duration, t)} : null} +
+ )} + {r.notes && ( +
+ {r.notes.split('\n')[0]} +
+ )} + + ) +} diff --git a/client/src/components/Planner/bookings/BookingDetailDialog.tsx b/client/src/components/Planner/bookings/BookingDetailDialog.tsx new file mode 100644 index 000000000..d36a341d2 --- /dev/null +++ b/client/src/components/Planner/bookings/BookingDetailDialog.tsx @@ -0,0 +1,416 @@ +import { useEffect, useRef, useState, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { ArrowRight, Copy, ExternalLink, FolderOpen, Footprints, Hotel, Link2, MapPin, Pencil, Route as RouteIcon, Trash2, Wallet, X } from 'lucide-react' +import Markdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import remarkBreaks from 'remark-breaks' +import type { BudgetItem, Reservation, TripFile } from '../../../types' +import type { ViewContribution } from '../../../api/client' +import type { ActivePlugin } from '../../../store/pluginStore' +import { useTranslation } from '../../../i18n' +import { useTripStore } from '../../../store/tripStore' +import { useSettingsStore } from '../../../store/settingsStore' +import { lockBodyScroll } from '../../../utils/bodyScrollLock' +import { useToast } from '../../shared/Toast' +import { BlurredCode } from '../../shared/BookingCode' +import { markdownLinkComponents } from '../../shared/markdownLink' +import NameDialog from '../../shared/NameDialog' +import PluginFrame from '../../Plugins/PluginFrame' +import { PluginCardFooter } from '../../Plugins/PluginContributions' +import { formatMoney, formatTime } from '../../../utils/formatters' +import { parseMeta, displayTitle, typeInfo } from './bookingsModel' +import { formatDay, type BookingFacts } from './bookingFacts' +import { AirTrailPill, BOX, Eyebrow, Field, FileRows, ReviewPill, TravelerChips, TypeTile, fs, toneColor, toneOf, toneTint } from './bookingParts' + +export interface BookingDetailDialogProps { + r: Reservation + facts: BookingFacts + files: TripFile[] + linkedCosts: BudgetItem[] + tripId: number + canEdit: boolean + /** True while a question opened from here (the delete confirmation) sits on top. */ + covered?: boolean + onClose: () => void + onEdit: () => void + onDelete: () => void + onToggleStatus: () => void + onShowOnMap?: () => void + onMap?: boolean + onEditExpense?: (item: BudgetItem) => void + onNavigateToFiles: () => void + contributions: ViewContribution[] + detailPlugins: ActivePlugin[] +} + +/** + * The phone's detail sheet on the desktop, as a dialog over the tab: the head + * band tinted by the status like the card it opens from, everything the booking + * holds below it, and the actions in a bar that stays in reach. Editing opens + * the booking's own dialog. + */ +export default function BookingDetailDialog(p: BookingDetailDialogProps) { + const { r, facts } = p + const { t, locale } = useTranslation() + const toast = useToast() + const updateReservation = useTripStore(s => s.updateReservation) + const tripCurrency = useTripStore(s => s.trip?.currency) + const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' + const [renaming, setRenaming] = useState(false) + const [name, setName] = useState('') + const panelRef = useRef(null) + const meta = parseMeta(r) + const tone = toneOf(r) + const info = typeInfo(r.type) + const transitLegs: TransitLeg[] = Array.isArray(meta.transit?.legs) ? meta.transit.legs : [] + const titleId = `booking-detail-${r.id}` + + // Escape closes, unless the rename field or the delete question is the one being answered. + const { onClose } = p + const blocked = renaming || !!p.covered + useEffect(() => { + if (blocked) return + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [blocked, onClose]) + + // The page stays put behind the dialog, and the card it came from gets the focus back. + useEffect(() => { + const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null + const release = lockBodyScroll() + panelRef.current?.focus() + return () => { release(); opener?.focus() } + }, []) + + const subParts = [meta.airline, meta.flight_number, meta.train_number, facts.endpoints.length >= 2 ? facts.endpoints.map(e => e.code || e.name).join(' → ') : null].filter(Boolean) + const sub = subParts.length ? subParts.join(' ') : facts.day?.label ?? null + + const stats: { value: string; label: string }[] = [] + if (facts.isHotel) { + if (facts.startDate) stats.push({ value: formatDay(facts.startDate, locale, false), label: t('reservations.meta.checkIn') + (meta.check_in_time ? ` ${meta.check_in_time}` : '') }) + if (facts.endDate) stats.push({ value: formatDay(facts.endDate, locale, false), label: t('reservations.meta.checkOut') + (meta.check_out_time ? ` ${meta.check_out_time}` : '') }) + const nights = nightsBetween(facts.startDate, facts.endDate) + if (nights) stats.push({ value: String(nights), label: t('reservations.nights', { count: nights }) }) + } else { + const from = facts.endpoints[0] + const to = facts.endpoints[facts.endpoints.length - 1] + if (facts.startTime) stats.push({ value: facts.time?.split(' → ')[0] || facts.startTime, label: from?.name || t('reservations.start') }) + if (facts.endTime) stats.push({ value: facts.time?.split(' → ')[1] || facts.endTime, label: to?.name || t('reservations.end') }) + if (meta.platform) stats.push({ value: meta.platform, label: t('reservations.meta.platform') }) + if (meta.seat) stats.push({ value: meta.seat, label: t('reservations.meta.seat') }) + } + // Cells already shown as a stat stay out of the field grid below. + const shownAsStat = new Set([t('reservations.meta.platform'), t('reservations.meta.seat'), t('reservations.meta.checkIn'), t('reservations.meta.checkOut')]) + const cells = facts.cells.filter(c => !shownAsStat.has(c.label)) + const hasDetails = !!(facts.place || facts.accommodation || facts.linked || facts.url || facts.day) + + const copyCode = async () => { + try { + await navigator.clipboard.writeText(r.confirmation_number || '') + toast.success(t('common.copied')) + } catch { + toast.error(t('reservations.copyFailed')) + } + } + const rename = async () => { + const title = name.trim() + setRenaming(false) + if (!title || title === r.title) return + try { + await updateReservation(p.tripId, r.id, { title }) + } catch { + toast.error(t('reservations.toast.updateError')) + } + } + + const statusLabel = tone === 'confirmed' ? t('reservations.confirmed') : t('reservations.pending') + const nextStatus = tone === 'confirmed' ? t('reservations.pending') : t('reservations.confirmed') + const statusPill = ( + <> + + {statusLabel} + + ) + const pillCls = 'inline-flex flex-none items-center gap-1.5 rounded-full bg-surface-card px-2.5 py-1 font-semibold text-content shadow-sm' + + return createPortal( +
{ if (e.target === e.currentTarget && !blocked) onClose() }} + > +
+
+
+ +
+ {p.canEdit ? ( + + ) : ( +

{displayTitle(r)}

+ )} + {sub &&
{sub}
} +
+ +
+ +
+ {tone !== 'transit' && (p.canEdit ? ( + + ) : {statusPill})} + {t(info.labelKey)} + {!!r.needs_review && } + + {r.confirmation_number && ( + + #{r.confirmation_number} + + + )} +
+
+ +
+ {stats.length > 0 && ( +
+ {stats.map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ )} + + {transitLegs.length > 0 && } + + {facts.endpoints.length > 2 && ( +
+ {facts.endpoints.map((ep, i) => ( +
+ {ep.local_time ? formatTime(ep.local_time, locale, timeFormat) : ''} + + {ep.code ? {ep.code} : null}{ep.name} +
+ ))} +
+ )} + + {facts.legCodes.length > 0 && ( +
+
+ {facts.legCodes.map((l, i) => ( +
+ {l.route || t('reservations.confirmationCode')} + #{l.code} +
+ ))} +
+
+ )} + + {cells.length > 0 && ( +
+ {cells.map((c, i) => {c.value})} +
+ )} + + {hasDetails && ( +
+ {facts.day && }>{[facts.day.label, facts.day.date].filter(Boolean).join(' ')}} + {facts.place && }>{facts.place}} + {facts.accommodation && }>{facts.accommodation}} + {facts.linked && }>{facts.linked}} + {facts.url && ( + }> + {facts.url.href ? {facts.url.href} : facts.url.text} + + )} +
+ )} + + {(r.travelers || []).length > 0 && ( +
+ +
+ )} + + {r.notes && ( +
+
+ {r.notes} +
+
+ )} + + {p.linkedCosts.length > 0 && ( +
+
+ {p.linkedCosts.map(item => ( + + ))} +
+
+ )} + + {p.files.length > 0 && ( +
+ {t('reservations.showInFiles')} + + )} + > + +
+ )} + + {(p.contributions.length > 0 || p.detailPlugins.length > 0) && ( +
+ + {p.detailPlugins.map(pl => ( +
+ +
+ ))} +
+ )} +
+ + {(p.onShowOnMap || p.canEdit) && ( +
+ {p.onShowOnMap && ( + + )} + + {p.canEdit && ( + + )} + {p.canEdit && tone !== 'transit' && ( + + )} +
+ )} +
+ + setRenaming(false)} + /> +
, + document.body, + ) +} + +function Section({ label, action, children }: { label: string; action?: ReactNode; children: ReactNode }) { + return ( +
+
+ {label} + {action && {action}} +
+ {children} +
+ ) +} + +interface TransitLeg { + mode?: string + line?: string | null + line_color?: string | null + line_text_color?: string | null + duration?: number + stops?: number + from?: { name?: string; time?: string | null } + to?: { name?: string; time?: string | null } +} + +/** A transit journey leg by leg, the way the phone lists it. */ +function TransitLegs({ legs }: { legs: TransitLeg[] }) { + const { t } = useTranslation() + return ( +
+ {legs.map((leg, i) => { + const walk = leg.mode === 'WALK' + const mins = leg.duration ? Math.round(leg.duration / 60) : null + return ( +
+ {walk ? : ( + {leg.line || leg.mode} + )} +
+
+ {walk ? {t('transit.walkTo', { name: leg.to?.name || '' })} : ( + <>{leg.from?.name}{leg.to?.name} + )} +
+
+ {[!walk && leg.from?.time ? `${leg.from.time}${leg.to?.time ? ` → ${leg.to.time}` : ''}` : null, mins ? t('transit.min', { count: mins }) : null, !walk && leg.stops ? t('transit.stops', { count: leg.stops }) : null].filter(Boolean).join(', ')} +
+
+
+ ) + })} +
+ ) +} + +function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) { + return ( +
+ {icon} + {children} +
+ ) +} + +function nightsBetween(a: string | null, b: string | null): number { + if (!a || !b) return 0 + const n = Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / 864e5) + return n > 0 ? n : 0 +} diff --git a/client/src/components/Planner/bookings/BookingsHeader.tsx b/client/src/components/Planner/bookings/BookingsHeader.tsx new file mode 100644 index 000000000..39b56c981 --- /dev/null +++ b/client/src/components/Planner/bookings/BookingsHeader.tsx @@ -0,0 +1,283 @@ +import { useCallback, useRef, useState, type ReactNode } from 'react' +import { + ArrowDownUp, CalendarDays, Check, Download, Filter, GanttChart, Layers, LayoutGrid, Plane, Plus, RotateCcw, Rows3, + Search, SlidersHorizontal, Tag, X, +} from 'lucide-react' +import { useTranslation } from '../../../i18n' +import type { TripMember } from '../../Budget/BudgetPanelMemberChips' +import ToggleSwitch from '../../Settings/ToggleSwitch' +import { POPOVER, POPOVER_CAPTION, POPOVER_DIVIDER, useDismissOnOutside } from '../../Packing/packingPopoverStyles' +import { PopoverItem } from '../../Packing/PackingPopover' +import { Tooltip } from '../../shared/Tooltip' +import { typeInfo, type BookingsView, type GroupBy, type SortBy, type StatusFilter } from './bookingsModel' +import { CountPill, fs } from './bookingParts' + +export interface BookingsHeaderProps { + title: string + total: number + canEdit: boolean + addLabel: string + onAdd: () => void + onImport?: () => void + onAirTrail?: () => void + view: BookingsView + onView: (v: BookingsView) => void + status: StatusFilter + onStatus: (s: StatusFilter) => void + query: string + onQuery: (q: string) => void + shown: number + filtering: boolean + onResetFilters: () => void + types: { type: string; count: number }[] + activeTypes: Set + onToggleType: (type: string) => void + onAllTypes: () => void + members: TripMember[] + showTravelers: boolean + activeTravelers: Set + onToggleTraveler: (id: number) => void + onClearTravelers: () => void + group: GroupBy + onGroup: (g: GroupBy) => void + sort: SortBy + sortDir: 'asc' | 'desc' + onSort: (s: SortBy) => void + onSortDir: () => void + byType: boolean + onByType: () => void + showContext: boolean + onShowContext: () => void + viewIsDefault: boolean + onResetView: () => void +} + +/** A 36 px control on the bar, in the card colour so it reads on the bar's tint. */ +const BAR_BTN = 'relative grid h-9 w-9 flex-none place-items-center rounded-[10px] bg-surface-card text-content-secondary hover:text-content' + +/** + * One bar, as on the other planner tabs: what this is on the left, how to look + * at it and what to add on the right. Filters and view options wait in their + * popovers, so the page below starts with the bookings themselves. + */ +export default function BookingsHeader(p: BookingsHeaderProps) { + const { t } = useTranslation() + const searchRef = useRef(null) + const views: [BookingsView, typeof LayoutGrid, string][] = [ + ['cards', LayoutGrid, t('reservations.view.cards')], + ['list', Rows3, t('reservations.view.list')], + ['timeline', GanttChart, t('reservations.view.timeline')], + ] + const hasEntries = p.total > 0 + + return ( +
+

{p.title}

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