import { useState, useMemo, useEffect } from 'react' import ReactDOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin, Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users, ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle, } from 'lucide-react' import { openFile } from '../../utils/fileDownload' import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types' 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: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' }, { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' }, { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' }, { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' }, { 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 style ── */ const fieldLabelStyle: React.CSSProperties = { fontSize: 10, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--text-faint)', marginBottom: 5, } const fieldValueStyle: React.CSSProperties = { fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 10, } interface ReservationCardProps { r: Reservation tripId: number onEdit: (reservation: Reservation) => void onDelete: (id: number) => void files?: TripFile[] onNavigateToFiles: () => void assignmentLookup: Record canEdit: boolean days?: Day[] } function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) { const { toggleReservationStatus } = useTripStore() const toast = useToast() const { t, locale } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes) const [codeRevealed, setCodeRevealed] = useState(false) const typeInfo = getType(r.type) const TypeIcon = typeInfo.Icon const confirmed = r.status === 'confirmed' 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 const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const handleToggle = async () => { try { await toggleReservationStatus(tripId, r.id) } catch { toast.error(t('reservations.toast.updateError')) } } 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 fmtDate = (str) => { const dateOnly = str.includes('T') ? str.split('T')[0] : str return new Date(dateOnly + 'T00:00:00Z').toLocaleDateString(locale, { ...(isMobile ? {} : { weekday: 'short' }), day: 'numeric', month: 'short', timeZone: 'UTC' }) } const fmtTime = (str) => { const d = new Date(str) return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' }) } const hasDate = !!r.reservation_time const hasTime = r.reservation_time?.includes('T') const hasCode = !!r.confirmation_number const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const isTransportType = TRANSPORT_TYPES_SET.has(r.type) const isHotel = r.type === 'hotel' const startDay = r.day_id ? days.find(d => d.id === r.day_id) : (isHotel && r.accommodation_start_day_id) ? days.find(d => d.id === r.accommodation_start_day_id) : undefined const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : (isHotel && r.accommodation_end_day_id) ? days.find(d => d.id === r.accommodation_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.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 */} {hasDate && (
{t('reservations.date')}
{fmtDate(r.reservation_time)} {r.reservation_end_time && (r.reservation_end_time.includes('T') ? r.reservation_end_time.split('T')[0] : r.reservation_end_time) !== r.reservation_time.split('T')[0] && ( <> – {fmtDate(r.reservation_end_time)} )}
{hasTime && (
{t('reservations.time')}
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time.includes('T') ? fmtTime(r.reservation_end_time) : fmtTime(r.reservation_time.split('T')[0] + 'T' + r.reservation_end_time)}` : ''}
)}
)} {/* Booking code */} {hasCode && (
{t('reservations.confirmationCode')}
blurCodes && setCodeRevealed(true)} onMouseLeave={() => blurCodes && setCodeRevealed(false)} onClick={() => blurCodes && setCodeRevealed(v => !v)} style={{ ...fieldValueStyle, textAlign: 'center', fontFamily: '"SF Mono", "JetBrains Mono", Menlo, monospace', fontSize: 12.5, filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none', cursor: blurCodes ? 'pointer' : 'default', transition: 'filter 0.2s', }} > {r.confirmation_number}
)} {(() => { const eps = r.endpoints || [] const from = eps.find(e => e.role === 'from') const to = eps.find(e => e.role === 'to') if (!from || !to) return null return (
{from.name} {to.name}
) })()} {/* 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 }) if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: fmtTime('2000-01-01T' + meta.check_in_time) + (meta.check_in_end_time ? ` – ${fmtTime('2000-01-01T' + meta.check_in_end_time)}` : '') }) if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: fmtTime('2000-01-01T' + meta.check_out_time) }) 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 : ''}` : ''}
)} {/* Notes */} {r.notes && (
{t('reservations.notes')}
{r.notes}
)} {/* Files */} {attachedFiles.length > 0 && (
{t('files.title')}
{attachedFiles.map(f => ( { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ display: 'flex', alignItems: 'center', gap: 5, textDecoration: 'none', cursor: 'pointer' }}> {f.original_name} ))}
)}
{/* Delete confirmation */} {showDeleteConfirm && ReactDOM.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}
)}
) } interface ReservationsPanelProps { tripId: number reservations: Reservation[] days: Day[] assignments: AssignmentsMap files?: TripFile[] onAdd: () => void onEdit: (reservation: Reservation) => void onDelete: (id: number) => void onNavigateToFiles: () => void titleKey?: string addManualKey?: string } export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) { const { t, locale } = useTranslation() const can = useCanDo() const trip = useTripStore((s) => s.trip) const canEdit = can('reservation_edit', trip) const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint')) const storageKey = `trek-reservation-filters-${tripId}` 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 }) } const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments]) const filtered = useMemo(() => typeFilters.size === 0 ? reservations : reservations.filter(r => typeFilters.has(r.type)), [reservations, typeFilters]) const allPending = filtered.filter(r => r.status !== 'confirmed') const allConfirmed = filtered.filter(r => r.status === 'confirmed') const total = filtered.length const usedTypes = useMemo(() => new Set(reservations.map(r => r.type)), [reservations]) const typeCounts = useMemo(() => { const counts: Record = {} for (const r of reservations) counts[r.type] = (counts[r.type] || 0) + 1 return counts }, [reservations]) return (
{/* Unified toolbar */}

{t(titleKey)}

{reservations.length > 0 && ( <>
{TYPE_OPTIONS.filter(opt => usedTypes.has(opt.value)).map(opt => { const active = typeFilters.has(opt.value) const Icon = opt.Icon return ( ) })}
)} {canEdit && ( )}
{/* Content */}
{total === 0 && reservations.length === 0 ? (

{t('reservations.empty')}

{t('reservations.emptyHint')}

) : total === 0 ? (

{t('places.noneFound')}

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