/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' } declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Trash2, Car, Lock, Hotel, Footprints, Route as RouteIcon } from 'lucide-react' import { assignmentsApi, reservationsApi } from '../../api/client' import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator' import PlaceAvatar from '../shared/PlaceAvatar' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' import WeatherWidget from '../Weather/WeatherWidget' import { useToast } from '../shared/Toast' import { getCategoryIcon } from '../shared/categoryIcons' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' import { isDayInAccommodationRange } from '../../utils/dayOrder' import { TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems, type MergedItem, } from '../../utils/dayMerge' import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants' import { RouteConnector } from './DayPlanSidebarRouteConnector' import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton' import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar' import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal' import { DayPlanSidebarTimeConfirmModal } from './DayPlanSidebarTimeConfirmModal' import { DayPlanSidebarTransportDetailModal } from './DayPlanSidebarTransportDetailModal' import { DayPlanSidebarFooter } from './DayPlanSidebarFooter' import type { Trip, Day, Place, Category, Assignment, Accommodation, Reservation, AssignmentsMap, RouteResult, RouteSegment, DayNote } from '../../types' interface DayPlanSidebarProps { tripId: number trip: Trip days: Day[] places: Place[] categories: Category[] assignments: AssignmentsMap selectedDayId: number | null selectedPlaceId: number | null selectedAssignmentId: number | null onSelectDay: (dayId: number | null, skipFit?: boolean) => void onPlaceClick: (placeId: number | null, assignmentId?: number | null) => void onDayDetail: (day: Day) => void accommodations?: Accommodation[] onReorder: (dayId: number, orderedIds: number[]) => void onUpdateDayTitle: (dayId: number, title: string) => void onRouteCalculated: (route: RouteResult | null) => void onAssignToDay: (placeId: number, dayId: number, position?: number) => void onRemoveAssignment: (dayId: number, assignmentId: number) => void onEditPlace: (place: Place, assignmentId?: number) => void onDeletePlace: (placeId: number) => void reservations?: Reservation[] visibleConnectionIds?: number[] onToggleConnection?: (reservationId: number) => void externalTransportDetail?: Reservation | null onExternalTransportDetailHandled?: () => void onAddReservation: (dayId: number) => void onNavigateToFiles?: () => void routeShown?: boolean routeProfile?: 'driving' | 'walking' onToggleRoute?: () => void onSetRouteProfile?: (profile: 'driving' | 'walking') => void onAddPlace?: () => void onAddPlaceToDay?: (placeId: number, dayId: number) => void onExpandedDaysChange?: (expandedDayIds: Set) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void canUndo?: boolean lastActionLabel?: string | null onUndo?: () => void onRouteRefresh?: () => void onAddTransport?: (dayId: number) => void onEditTransport?: (reservation: Reservation) => void onEditReservation?: (reservation: Reservation) => void onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void initialScrollTop?: number onScrollTopChange?: (top: number) => void } /** * Day-plan state + behaviour: expand/collapse, inline title edit, route legs + * optimisation, day notes, and the drag-and-drop reorder/move machinery across * days (places, transports, notes). Returns everything the timeline view renders * from, keeping DayPlanSidebar a thin shell over one large day list. */ function useDayPlanSidebar(props: DayPlanSidebarProps) { const { tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onReorder, onUpdateDayTitle, onRouteCalculated, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], visibleConnectionIds = [], onToggleConnection, externalTransportDetail, onExternalTransportDetailHandled, onAddReservation, onAddPlace, onAddPlaceToDay, onNavigateToFiles, routeShown = false, routeProfile = 'driving', onToggleRoute, onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo = false, lastActionLabel = null, onUndo, onRouteRefresh, onAddTransport, onEditTransport, onEditReservation, onAddBookingToAssignment, initialScrollTop, onScrollTopChange, } = props const toast = useToast() const { t, language, locale } = useTranslation() const ctxMenu = useContextMenu() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const tripActions = useRef(useTripStore.getState()).current const can = useCanDo() const canEditDays = can('day_edit', trip) const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId) const [expandedDays, setExpandedDays] = useState(() => { try { const saved = sessionStorage.getItem(`day-expanded-${tripId}`) if (saved) return new Set(JSON.parse(saved) as number[]) } catch {} return new Set(days.map(d => d.id)) }) useEffect(() => { onExpandedDaysChange?.(expandedDays) }, [expandedDays]) const [editingDayId, setEditingDayId] = useState(null) const [editTitle, setEditTitle] = useState('') const [isCalculating, setIsCalculating] = useState(false) const [routeInfo, setRouteInfo] = useState(null) const [routeLegs, setRouteLegs] = useState>({}) const legsAbortRef = useRef(null) const [draggingId, setDraggingId] = useState(null) const [lockedIds, setLockedIds] = useState(new Set()) const [lockHoverId, setLockHoverId] = useState(null) const [undoHover, setUndoHover] = useState(false) const [pdfHover, setPdfHover] = useState(false) const [icsHover, setIcsHover] = useState(false) const [hoveredAssignmentId, setHoveredAssignmentId] = useState(null) const [dropTargetKey, _setDropTargetKey] = useState(null) const dropTargetRef = useRef(null) const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const [dragOverDayId, setDragOverDayId] = useState(null) const [transportDetail, setTransportDetail] = useState(null) const [transportPosVersion, setTransportPosVersion] = useState(0) useEffect(() => { if (externalTransportDetail) { setTransportDetail(externalTransportDetail) onExternalTransportDetailHandled?.() } }, [externalTransportDetail, onExternalTransportDetailHandled]) const [timeConfirm, setTimeConfirm] = useState<{ dayId: number; fromId: number; time: string; // For drag & drop reorder fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; // For arrow reorder reorderIds?: number[]; } | null>(null) const inputRef = useRef(null) const dragDataRef = useRef(null) const scrollContainerRef = useRef(null) useLayoutEffect(() => { if (scrollContainerRef.current && initialScrollTop) { scrollContainerRef.current.scrollTop = initialScrollTop } }, []) const initedTransportIds = useRef(new Set()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) // Remember which assignment we last auto-scrolled into view so we don't // keep yanking the user back whenever they scroll away while the same // place stays selected. const lastAutoScrolledIdRef = useRef(null) useEffect(() => { // Reset the scroll-lock whenever selection moves, so the next selected // row triggers a fresh scroll-into-view on its ref. if (!selectedAssignmentId && !selectedPlaceId) { lastAutoScrolledIdRef.current = null } }, [selectedAssignmentId, selectedPlaceId]) const currency = trip?.currency || 'EUR' // Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren) const getDragData = (e) => { const dt = e?.dataTransfer // Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt) if (dragDataRef.current) { return { placeId: '', assignmentId: dragDataRef.current.assignmentId || '', noteId: dragDataRef.current.noteId || '', reservationId: dragDataRef.current.reservationId || '', fromDayId: parseInt(dragDataRef.current.fromDayId) || 0, phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end', } } // Externer Drag (aus PlacesSidebar) const ext = window.__dragData || {} const placeId = dt?.getData('placeId') || ext.placeId || '' return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const } } // Only auto-expand genuinely new days (not on initial load from storage) const prevDayCount = React.useRef(days.length) useEffect(() => { if (days.length > prevDayCount.current) { // New days added — expand only those setExpandedDays(prev => { const n = new Set(prev) days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) }) try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } prevDayCount.current = days.length }, [days.length, tripId]) useEffect(() => { if (editingDayId && inputRef.current) inputRef.current.focus() }, [editingDayId]) // Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen useEffect(() => { const cleanup = () => { setDraggingId(null) setDropTargetKey(null) setDragOverDayId(null) dragDataRef.current = null window.__dragData = null } document.addEventListener('dragend', cleanup) return () => document.removeEventListener('dragend', cleanup) }, []) // Initialize missing transport positions outside of render to avoid setState-during-render // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { days.forEach(day => initTransportPositions(day.id)) }, [days, reservations]) const toggleDay = (dayId, e) => { e.stopPropagation() setExpandedDays(prev => { const n = new Set(prev) n.has(dayId) ? n.delete(dayId) : n.add(dayId) try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {} return n }) } // Get phase label for multi-day badge const getSpanLabel = (r: Reservation, phase: string): string | null => { if (phase === 'single') return null if (r.type === 'flight') return t(`reservations.span.${phase === 'start' ? 'departure' : phase === 'end' ? 'arrival' : 'inTransit'}`) if (r.type === 'car') return t(`reservations.span.${phase === 'start' ? 'pickup' : phase === 'end' ? 'return' : 'active'}`) return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`) } const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day) const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => { const startId = r.day_id ?? targetDayId const endId = r.end_day_id ?? startId const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 } if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId } if (phase === 'start') { if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId } return { day_id: targetDayId, end_day_id: endId } } // phase === 'end' if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId } return { day_id: startId, end_day_id: targetDayId } } const getTransportForDay = (dayId: number) => _getTransportForDay({ reservations, dayId, dayAssignmentIds: (assignments[String(dayId)] || []).map(a => a.id), days }) // Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline const getActiveRentalsForDay = (dayId: number) => { return reservations.filter(r => { if (r.type !== 'car') return false const startDayId = r.day_id const endDayId = r.end_day_id if (!startDayId || !endDayId || endDayId === startDayId) return false const startDay = days.find(d => d.id === startDayId) const endDay = days.find(d => d.id === endDayId) const thisDay = days.find(d => d.id === dayId) if (!startDay || !endDay || !thisDay) return false return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay) }) } const getDayAssignments = (dayId) => (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) // Compute initial day_plan_position for a transport based on time const computeTransportPosition = (r, da) => { const minutes = parseTimeToMinutes(r.reservation_time) ?? 0 // Find the last place with time <= transport time let afterIdx = -1 for (const a of da) { const pm = parseTimeToMinutes(a.place?.place_time) if (pm !== null && pm <= minutes) afterIdx = a.order_index } // Position: midpoint between afterIdx and afterIdx+1 (leaves room for other items) return afterIdx >= 0 ? afterIdx + 0.5 : da.length + 0.5 } // Auto-initialize transport positions on first render if not set const initTransportPositions = (dayId) => { const da = getDayAssignments(dayId) const transport = getTransportForDay(dayId) const needsInit = transport.filter(r => r.day_plan_position == null && !initedTransportIds.current.has(r.id)) if (needsInit.length === 0) return const sorted = [...needsInit].sort((a, b) => (parseTimeToMinutes(a.reservation_time) ?? 0) - (parseTimeToMinutes(b.reservation_time) ?? 0) ) const positions = sorted.map((r, idx) => ({ id: r.id, day_plan_position: computeTransportPosition(r, da) + idx * 0.01, })) // Mark as initialized immediately to prevent re-entry for (const p of positions) initedTransportIds.current.add(p.id) // Update store so subscribers see the new positions useTripStore.setState(state => ({ reservations: state.reservations.map(r => { const p = positions.find(x => x.id === r.id) if (!p) return r return { ...r, day_plan_position: p.day_plan_position } }) })) // Persist to server (fire and forget) reservationsApi.updatePositions(tripId, positions).catch(() => {}) } const getMergedItems = (dayId: number): MergedItem[] => _getMergedItems({ dayAssignments: getDayAssignments(dayId), dayNotes: (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order), dayTransports: getTransportForDay(dayId), dayId, getDisplayTime: getDisplayTimeForDay, }) // Pre-compute merged items for all days so the render loop doesn't recompute on unrelated state changes (e.g. hover) // eslint-disable-next-line react-hooks/exhaustive-deps const mergedItemsMap = useMemo(() => { const map: Record> = {} days.forEach(day => { map[day.id] = getMergedItems(day.id) }) return map // getMergedItems is redefined each render but captures assignments/dayNotes/reservations/days via closure // eslint-disable-next-line react-hooks/exhaustive-deps }, [days, assignments, dayNotes, reservations, transportPosVersion]) // Per-segment driving times for the selected day's connectors. Groups located // places into runs (split at transports), one cached OSRM call per run, keyed by // the start place's assignment id. Shares RouteCalculator's cache with the map. useEffect(() => { if (legsAbortRef.current) legsAbortRef.current.abort() if (!selectedDayId || !routeShown) { setRouteLegs({}); return } const merged = mergedItemsMap[selectedDayId] || [] const runs: { id: number; lat: number; lng: number }[][] = [] let cur: { id: number; lat: number; lng: number }[] = [] for (const it of merged) { if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng }) } else if (it.type === 'transport') { if (cur.length >= 2) runs.push(cur) cur = [] } } if (cur.length >= 2) runs.push(cur) if (runs.length === 0) { setRouteLegs({}); return } const controller = new AbortController() legsAbortRef.current = controller ;(async () => { const map: Record = {} for (const run of runs) { try { const r = await calculateRouteWithLegs(run.map(p => ({ lat: p.lat, lng: p.lng })), { signal: controller.signal, profile: routeProfile }) r.legs.forEach((leg, i) => { map[run[i].id] = leg }) } catch (err) { if (err instanceof Error && err.name === 'AbortError') return } } if (!controller.signal.aborted) setRouteLegs(map) })() }, [selectedDayId, routeShown, routeProfile, mergedItemsMap]) const openAddNote = (dayId, e) => { e?.stopPropagation() _openAddNote(dayId, getMergedItems, (id) => { if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id])) }) } // Check if a proposed reorder of place IDs would break chronological order // of ALL timed items (places with time + transport bookings) const wouldBreakChronology = (dayId: number, newPlaceIds: number[]) => { const da = getDayAssignments(dayId) const transport = getTransportForDay(dayId) // Simulate the merged list with places in new order + transports at their positions // Places get sequential integer positions const simItems: { pos: number; minutes: number }[] = [] newPlaceIds.forEach((id, idx) => { const a = da.find(x => x.id === id) const m = parseTimeToMinutes(a?.place?.place_time) if (m !== null) simItems.push({ pos: idx, minutes: m }) }) // Transports: compute where they'd go with the new place order for (const r of transport) { const rMin = parseTimeToMinutes(r.reservation_time) if (rMin === null) continue // Find the last place (in new order) with time <= transport time let afterIdx = -1 newPlaceIds.forEach((id, idx) => { const a = da.find(x => x.id === id) const pm = parseTimeToMinutes(a?.place?.place_time) if (pm !== null && pm <= rMin) afterIdx = idx }) const pos = afterIdx >= 0 ? afterIdx + 0.5 : newPlaceIds.length + 0.5 simItems.push({ pos, minutes: rMin }) } // Sort by position and check chronological order simItems.sort((a, b) => a.pos - b.pos) return !simItems.every((item, i) => i === 0 || item.minutes >= simItems[i - 1].minutes) } const openEditNote = (dayId: number, note: DayNote, e?: React.MouseEvent) => { e?.stopPropagation() _openEditNote(dayId, note) } const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => { e?.stopPropagation() await _deleteNote(dayId, noteId) } // Unified reorder: assigns positions to ALL item types based on new visual order const applyMergedOrder = async (dayId: number, newOrder: { type: string; data: any }[]) => { // Capture previous place order for undo const prevAssignmentIds = getDayAssignments(dayId).map(a => a.id) // Places get sequential integer positions (0, 1, 2, ...) // Non-place items between place N-1 and place N get fractional positions const assignmentIds: number[] = [] const noteUpdates: { id: number; sort_order: number }[] = [] const transportUpdates: { id: number; day_plan_position: number }[] = [] let placeCount = 0 let i = 0 while (i < newOrder.length) { if (newOrder[i].type === 'place') { assignmentIds.push(newOrder[i].data.id) placeCount++ i++ } else { // Collect consecutive non-place items const group: { type: string; data: any }[] = [] while (i < newOrder.length && newOrder[i].type !== 'place') { group.push(newOrder[i]) i++ } // Fractional positions between (placeCount-1) and placeCount const base = placeCount > 0 ? placeCount - 1 : -1 group.forEach((g, idx) => { const pos = base + (idx + 1) / (group.length + 1) if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos }) else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos }) }) } } try { // Update transport positions in store FIRST so the useEffect triggered by // onReorder's optimistic assignment update reads the correct positions. if (transportUpdates.length) { useTripStore.setState(state => ({ reservations: state.reservations.map(r => { const tu = transportUpdates.find(u => u.id === r.id) if (!tu) return r const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position } return { ...r, day_plan_position: tu.day_plan_position, day_positions } }) })) setTransportPosVersion(v => v + 1) } if (assignmentIds.length) await onReorder(dayId, assignmentIds) if (transportUpdates.length) { onRouteRefresh?.() await reservationsApi.updatePositions(tripId, transportUpdates, dayId) } for (const n of noteUpdates) { await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } if (prevAssignmentIds.length) { const capturedDayId = dayId const capturedPrevIds = prevAssignmentIds pushUndo?.(t('undo.reorder'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds) }) } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } } const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { const m = getMergedItems(dayId) // Check if a timed place is being moved → would it break chronological order? if (fromType === 'place') { const fromItem = m.find(i => i.type === 'place' && i.data.id === fromId) const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time) if (fromItem && fromMinutes !== null) { const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) if (fromIdx !== -1 && toIdx !== -1) { const simulated = [...m] const [moved] = simulated.splice(fromIdx, 1) let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId) if (insertIdx === -1) insertIdx = simulated.length if (insertAfter) insertIdx += 1 simulated.splice(insertIdx, 0, moved) const timedInOrder = simulated .map(i => { if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time) if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time) return null }) .filter(t => t !== null) const isChronological = timedInOrder.every((t, i) => i === 0 || t >= timedInOrder[i - 1]) if (!isChronological) { const placeTime = fromItem.data.place.place_time const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr }) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return } } } } // Build new order: remove the dragged item, insert at target position const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return } const newOrder = [...m] const [moved] = newOrder.splice(fromIdx, 1) let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId) if (adjustedTo === -1) adjustedTo = newOrder.length if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) await applyMergedOrder(dayId, newOrder) setDraggingId(null) setDropTargetKey(null) dragDataRef.current = null } const confirmTimeRemoval = async () => { if (!timeConfirm) return const saved = { ...timeConfirm } const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved setTimeConfirm(null) // Remove time from assignment try { await assignmentsApi.updateTime(tripId, fromId, { place_time: null, end_time: null }) const key = String(dayId) const currentAssignments = { ...assignments } if (currentAssignments[key]) { currentAssignments[key] = currentAssignments[key].map(a => a.id === fromId ? { ...a, place: { ...a.place, place_time: null, end_time: null } } : a ) tripActions.setAssignments(currentAssignments) } } catch (err) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) return } // Build new merged order from either arrow reorderIds or drag & drop params const m = getMergedItems(dayId) if (reorderIds) { // Arrow reorder: rebuild merged list with places in the new order, // keeping transports and notes at their relative positions const newMerged: typeof m = [] let rIdx = 0 for (const item of m) { if (item.type === 'place') { // Replace with the place from reorderIds at this position const nextId = reorderIds[rIdx++] const replacement = m.find(i => i.type === 'place' && i.data.id === nextId) if (replacement) newMerged.push(replacement) } else { newMerged.push(item) } } await applyMergedOrder(dayId, newMerged) return } // Drag & drop reorder if (fromType && toType) { const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId) const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId) if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return const newOrder = [...m] const [moved] = newOrder.splice(fromIdx, 1) let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId) if (adjustedTo === -1) adjustedTo = newOrder.length if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) await applyMergedOrder(dayId, newOrder) } } const moveNote = async (dayId, noteId, direction) => { await _moveNote(dayId, noteId, direction, getMergedItems) } const startEditTitle = (day, e) => { e.stopPropagation() setEditTitle(day.title || '') setEditingDayId(day.id) } const saveTitle = async (dayId) => { setEditingDayId(null) await onUpdateDayTitle?.(dayId, editTitle.trim()) } const handleCalculateRoute = async () => { if (!selectedDayId) return const da = getDayAssignments(selectedDayId) const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng).map(p => ({ lat: p.lat, lng: p.lng })) if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return } setIsCalculating(true) try { const result = await calculateRoute(waypoints, 'walking') // Luftlinien zwischen Wegpunkten anzeigen const lineCoords = waypoints.map(p => [p.lat, p.lng] as [number, number]) setRouteInfo({ distance: result.distanceText, duration: result.durationText }) onRouteCalculated?.({ ...result, coordinates: lineCoords }) } catch { toast.error(t('dayplan.toast.routeError')) } finally { setIsCalculating(false) } } const toggleLock = (assignmentId) => { const prevLocked = new Set(lockedIds) setLockedIds(prev => { const next = new Set(prev) if (next.has(assignmentId)) next.delete(assignmentId) else next.add(assignmentId) return next }) pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) }) } const handleOptimize = async () => { if (!selectedDayId) return const da = getDayAssignments(selectedDayId) if (da.length < 3) return const prevIds = da.map(a => a.id) // Separate locked (stay at their index) and unlocked assignments const locked = new Map() // index -> assignment const unlocked = [] da.forEach((a, i) => { if (lockedIds.has(a.id)) locked.set(i, a) else unlocked.push(a) }) // Optimize only unlocked assignments (work on assignments, not places) const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng) const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng) const optimizedAssignments = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean) : unlockedWithCoords const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords] // Merge: locked stay at their index, fill gaps with optimized const result = new Array(da.length) locked.forEach((a, i) => { result[i] = a }) let qi = 0 for (let i = 0; i < result.length; i++) { if (!result[i]) result[i] = optimizedQueue[qi++] } await onReorder(selectedDayId, result.map(a => a.id)) toast.success(t('dayplan.toast.routeOptimized')) const capturedDayId = selectedDayId pushUndo?.(t('undo.optimize'), async () => { await tripActions.reorderAssignments(tripId, capturedDayId, prevIds) }) } const handleDropOnDay = (e, dayId) => { e.preventDefault() e.stopPropagation() setDragOverDayId(null) const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (fromReservationId && fromDayId !== dayId) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return } if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { const srcAssignment = (useTripStore.getState().assignments[String(fromDayId)] || []).find(a => a.id === Number(assignmentId)) const capturedFromDayId = fromDayId const capturedOrderIndex = srcAssignment?.order_index ?? 0 tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId) .then(() => { pushUndo?.(t('undo.moveDay'), async () => { await tripActions.moveAssignment(tripId, Number(assignmentId), dayId, capturedFromDayId, capturedOrderIndex) }) }) .catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId && fromDayId !== dayId) { tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null) setDropTargetKey(null) dragDataRef.current = null window.__dragData = null } const handleDropOnRow = (e, dayId, toIdx) => { e.preventDefault() e.stopPropagation() setDragOverDayId(null) const placeId = e.dataTransfer.getData('placeId') const fromAssignmentId = e.dataTransfer.getData('assignmentId') if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (fromAssignmentId) { const da = getDayAssignments(dayId) const fromIdx = da.findIndex(a => String(a.id) === fromAssignmentId) if (fromIdx === -1 || fromIdx === toIdx) { setDraggingId(null); dragDataRef.current = null; return } const ids = da.map(a => a.id) const [removed] = ids.splice(fromIdx, 1) ids.splice(toIdx, 0, removed) onReorder(dayId, ids) } setDraggingId(null) } const totalCost = useMemo(() => days.reduce((s, d) => { const da = assignments[String(d.id)] || [] return s + da.reduce((s2, a) => s2 + (Number(a.place?.price) || 0), 0) }, 0), [days, assignments]) // Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng) const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng) return { tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations, onReorder, onUpdateDayTitle, onRouteCalculated, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations, visibleConnectionIds, onToggleConnection, externalTransportDetail, onExternalTransportDetailHandled, onAddReservation, onAddPlace, onAddPlaceToDay, onNavigateToFiles, routeShown, routeProfile, onToggleRoute, onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo, lastActionLabel, onUndo, onRouteRefresh, onAddTransport, onEditTransport, onEditReservation, onAddBookingToAssignment, initialScrollTop, onScrollTopChange, toast, t, language, locale, ctxMenu, timeFormat, tripActions, can, canEditDays, noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote, expandedDays, setExpandedDays, editingDayId, setEditingDayId, editTitle, setEditTitle, isCalculating, setIsCalculating, routeInfo, setRouteInfo, routeLegs, setRouteLegs, legsAbortRef, draggingId, setDraggingId, lockedIds, setLockedIds, lockHoverId, setLockHoverId, undoHover, setUndoHover, pdfHover, setPdfHover, icsHover, setIcsHover, hoveredAssignmentId, setHoveredAssignmentId, dropTargetKey, _setDropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId, transportDetail, setTransportDetail, transportPosVersion, setTransportPosVersion, timeConfirm, setTimeConfirm, inputRef, dragDataRef, scrollContainerRef, initedTransportIds, lastAutoScrolledIdRef, currency, getDragData, prevDayCount, toggleDay, getSpanLabel, getDayOrder, computeMultiDayMove, getTransportForDay, getActiveRentalsForDay, getDayAssignments, computeTransportPosition, initTransportPositions, getMergedItems, mergedItemsMap, wouldBreakChronology, applyMergedOrder, handleMergedDrop, confirmTimeRemoval, startEditTitle, saveTitle, handleCalculateRoute, toggleLock, handleOptimize, handleDropOnDay, handleDropOnRow, totalCost, anyGeoAssignment, anyGeoPlace, } } const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarProps) { const S = useDayPlanSidebar(props) const { tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations, onReorder, onUpdateDayTitle, onRouteCalculated, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations, visibleConnectionIds, onToggleConnection, externalTransportDetail, onExternalTransportDetailHandled, onAddReservation, onAddPlace, onAddPlaceToDay, onNavigateToFiles, routeShown, routeProfile, onToggleRoute, onSetRouteProfile, onExpandedDaysChange, pushUndo, canUndo, lastActionLabel, onUndo, onRouteRefresh, onAddTransport, onEditTransport, onEditReservation, onAddBookingToAssignment, initialScrollTop, onScrollTopChange, toast, t, language, locale, ctxMenu, timeFormat, tripActions, can, canEditDays, noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote, expandedDays, setExpandedDays, editingDayId, setEditingDayId, editTitle, setEditTitle, isCalculating, setIsCalculating, routeInfo, setRouteInfo, routeLegs, setRouteLegs, legsAbortRef, draggingId, setDraggingId, lockedIds, setLockedIds, lockHoverId, setLockHoverId, undoHover, setUndoHover, pdfHover, setPdfHover, icsHover, setIcsHover, hoveredAssignmentId, setHoveredAssignmentId, dropTargetKey, _setDropTargetKey, dropTargetRef, setDropTargetKey, dragOverDayId, setDragOverDayId, transportDetail, setTransportDetail, transportPosVersion, setTransportPosVersion, timeConfirm, setTimeConfirm, inputRef, dragDataRef, scrollContainerRef, initedTransportIds, lastAutoScrolledIdRef, currency, getDragData, prevDayCount, toggleDay, getSpanLabel, getDayOrder, computeMultiDayMove, getTransportForDay, getActiveRentalsForDay, getDayAssignments, computeTransportPosition, initTransportPositions, getMergedItems, mergedItemsMap, wouldBreakChronology, applyMergedOrder, handleMergedDrop, confirmTimeRemoval, startEditTitle, saveTitle, handleCalculateRoute, toggleLock, handleOptimize, handleDropOnDay, handleDropOnRow, totalCost, anyGeoAssignment, anyGeoPlace, } = S return (
{/* Toolbar */} {/* Tagesliste */}
onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}> {days.map((day, index) => { const isSelected = selectedDayId === day.id const isExpanded = expandedDays.has(day.id) const da = getDayAssignments(day.id) const cost = dayTotalCost(day.id, assignments, currency) const formattedDate = formatDate(day.date, locale) const loc = da.find(a => a.place?.lat && a.place?.lng) const isDragTarget = dragOverDayId === day.id const merged = mergedItemsMap[day.id] || [] const dayNoteUi = noteUi[day.id] const placeItems = merged.filter(i => i.type === 'place') return (
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
{ onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }} onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragOverDayId(null) }} onDrop={e => handleDropOnDay(e, day.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px 11px 16px', cursor: 'pointer', background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-selected)' : 'transparent'), transition: 'background 0.12s', userSelect: 'none', outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', outlineOffset: -2, borderRadius: isDragTarget ? 8 : 0, touchAction: 'manipulation', }} onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-tertiary)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }} > {/* Tages-Badge: Nummer oben, darunter (falls vorhanden) das Wetter des Tages */} {(() => { // anyGeoPlace is an assignment (has .place) or a bare place — read coords from either. const geoLat = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lat : anyGeoPlace.lat) : undefined const geoLng = anyGeoPlace ? ('place' in anyGeoPlace ? anyGeoPlace.place?.lng : anyGeoPlace.lng) : undefined const wLat = loc?.place?.lat ?? geoLat const wLng = loc?.place?.lng ?? geoLng const hasWeather = !!(day.date && anyGeoPlace && wLat != null && wLng != null) return (
{index + 1}
{hasWeather && ( <>
)}
) })()}
{editingDayId === day.id ? ( setEditTitle(e.target.value)} onBlur={() => saveTitle(day.id)} onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }} onClick={e => e.stopPropagation()} style={{ width: '100%', border: 'none', outline: 'none', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', background: 'transparent', padding: 0, fontFamily: 'inherit', borderBottom: '1.5px solid var(--text-primary)', }} /> ) : (<>
{day.title || t('dayplan.dayN', { n: index + 1 })} {formattedDate && ( <> {formattedDate} )}
{(() => { const hasAccs = accommodations.some(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) const hasRentals = getActiveRentalsForDay(day.id).length > 0 if (!hasAccs && !hasRentals) return null return
})()}
{(() => { const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days)) // Sort: check-out first, then ongoing stays, then check-in last .sort((a, b) => { const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id const bIsOut = b.end_day_id === day.id && b.start_day_id !== day.id const aIsIn = a.start_day_id === day.id const bIsIn = b.start_day_id === day.id if (aIsOut && !bIsOut) return -1 if (!aIsOut && bIsOut) return 1 if (aIsIn && !bIsIn) return 1 if (!aIsIn && bIsIn) return -1 return 0 }) if (dayAccs.length === 0) return null return dayAccs.map(acc => { const isCheckIn = acc.start_day_id === day.id const isCheckOut = acc.end_day_id === day.id const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-faint)' return ( { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: (acc as any).place_id ? 'pointer' : 'default', borderRadius: 7, padding: '2px 7px 2px 6px' }}> {(acc as any).place_name || (acc as any).reservation_title} ) }) })()} {/* Active rental car badges */} {(() => { const activeRentals = getActiveRentalsForDay(day.id) if (activeRentals.length === 0) return null return activeRentals.map(r => ( { e.stopPropagation(); setTransportDetail(r) }} className="bg-surface-hover" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 1, minWidth: 0, cursor: 'pointer', borderRadius: 7, padding: '2px 7px 2px 6px' }}> {r.title} )) })()}
)} {cost && (
{cost}
)}
{canEditDays ? ( (() => { const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const const div = '1px solid var(--border-faint)' return (
{onAddTransport ? ( ) :
}
) })() ) : ( )}
{/* Aufgeklappte Orte + Notizen */} {isExpanded && (
{ e.preventDefault(); const cur = dropTargetRef.current; if (draggingId && (!cur || cur.startsWith('end-'))) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault() e.stopPropagation() const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Drop on transport card (detected via dropTargetRef for sync accuracy) if (dropTargetRef.current?.startsWith('transport-')) { const isAfter = dropTargetRef.current.startsWith('transport-after-') const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-') const transportId = Number(parts[0]) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter) } else if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (assignmentId) { handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null return } if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return } if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) if (m.length === 0) return const lastItem = m[m.length - 1] if (assignmentId && String(lastItem?.data?.id) !== assignmentId) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true) else if (noteId && String(lastItem?.data?.id) !== noteId) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) }} > {merged.length === 0 && !dayNoteUi ? (
{ e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }} onDrop={e => handleDropOnDay(e, day.id)} className={dragOverDayId === day.id ? 'bg-[rgba(17,24,39,0.05)]' : 'bg-transparent'} style={{ padding: '16px', textAlign: 'center', borderRadius: 8, border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent', }} > {t('dayplan.emptyDay')}
) : ( merged.map((item, idx) => { const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}` if (item.type === 'place') { const assignment = item.data const place = assignment.place if (!place) return null const cat = categories.find(c => c.id === place.category_id) const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId const isDraggingThis = draggingId === assignment.id const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id) const arrowMove = (direction: 'up' | 'down') => { const m = getMergedItems(day.id) const myIdx = m.findIndex(i => i.type === 'place' && i.data.id === assignment.id) if (myIdx === -1) return const targetIdx = direction === 'up' ? myIdx - 1 : myIdx + 1 if (targetIdx < 0 || targetIdx >= m.length) return // Build new order: swap this item with its neighbor in the merged list const newOrder = [...m] ;[newOrder[myIdx], newOrder[targetIdx]] = [newOrder[targetIdx], newOrder[myIdx]] // Check chronological order of all timed items in the new order const placeTime = place.place_time if (parseTimeToMinutes(placeTime) !== null) { const timedInNewOrder = newOrder .map(i => { if (i.type === 'transport') return parseTimeToMinutes(i.data?.reservation_time) if (i.type === 'place') return parseTimeToMinutes(i.data?.place?.place_time) return null }) .filter(t => t !== null) const isChronological = timedInNewOrder.every((t, i) => i === 0 || t >= timedInNewOrder[i - 1]) if (!isChronological) { const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime // Store the new merged order for confirm action setTimeConfirm({ dayId: day.id, fromId: assignment.id, time: timeStr, reorderIds: newOrder.filter(i => i.type === 'place').map(i => i.data.id) }) return } } applyMergedOrder(day.id, newOrder) } const moveUp = (e) => { e.stopPropagation(); arrowMove('up') } const moveDown = (e) => { e.stopPropagation(); arrowMove('down') } return (
{ if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('assignmentId', String(assignment.id)) e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) } setDraggingId(assignment.id) }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { const pos = placeItems.findIndex(i => i.data.id === assignment.id) onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined) setDropTargetKey(null); window.__dragData = null } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id) } else if (fromAssignmentId && fromDayId !== day.id) { const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id) tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id) } else if (noteId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id) } }} ref={el => { // Auto-scroll the selected row into view — but only on // the transition "just became selected". Once we've // scrolled for this assignment id, we won't scroll // again until selection actually moves somewhere else. if (el && isPlaceSelected && lastAutoScrolledIdRef.current !== assignment.id) { const rect = el.getBoundingClientRect() const nearTop = rect.top < 80 const nearBottom = rect.bottom > window.innerHeight - 80 if (nearTop || nearBottom) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }) } lastAutoScrolledIdRef.current = assignment.id } }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }} onContextMenu={e => ctxMenu.open(e, [ canEditDays && onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) }, canEditDays && onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) }, place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') }, (place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') }, { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} onMouseEnter={e => { if (!isPlaceSelected && !lockedIds.has(assignment.id)) e.currentTarget.style.background = 'var(--bg-hover)' const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '1' setHoveredAssignmentId(assignment.id) }} onMouseLeave={e => { if (!isPlaceSelected && !lockedIds.has(assignment.id)) e.currentTarget.style.background = 'transparent' const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '0.3' setHoveredAssignmentId(null) }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 10px', cursor: 'pointer', background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.08)' : isPlaceSelected ? 'var(--bg-selected)' : 'transparent', borderLeft: lockedIds.has(assignment.id) ? '3px solid #dc2626' : '3px solid transparent', borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, transition: 'background 0.15s, border-color 0.15s', opacity: isDraggingThis ? 0.4 : 1, }} > {canEditDays &&
}
{ e.stopPropagation(); toggleLock(assignment.id) }} onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }} onMouseLeave={() => setLockHoverId(null)} style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }} > {/* Hover/locked overlay */} {(lockHoverId === assignment.id || lockedIds.has(assignment.id)) && (
)} {/* Custom tooltip */} {lockHoverId === assignment.id && (
{lockedIds.has(assignment.id) ? t('planner.clickToUnlock') : t('planner.keepPosition')}
)}
{cat && (() => { const CatIcon = getCategoryIcon(cat.icon) return })()} {place.name} {place.place_time && ( {formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''} )}
{(place.description || place.address || cat?.name) && (
{place.description || place.address || cat?.name || ''}
)} {(() => { const res = reservations.find(r => r.assignment_id === assignment.id) if (!res) return null const confirmed = res.status === 'confirmed' const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2 const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false return (
{(() => { const RI = RES_ICONS[res.type] || Ticket; return })()} {confirmed ? t('planner.resConfirmed') : t('planner.resPending')} {(() => { const { time: st } = splitReservationDateTime(res.reservation_time) const { time: et } = splitReservationDateTime(res.reservation_end_time) if (!st && !et) return null return ( {st ? formatTime(st, locale, timeFormat) : ''} {et ? ` – ${formatTime(et, locale, timeFormat)}` : ''} ) })()} {(() => { const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) if (!meta) return null if (meta.airline && meta.flight_number) return {meta.airline} {meta.flight_number} if (meta.flight_number) return {meta.flight_number} if (meta.train_number) return {meta.train_number} return null })()}
{hasEndpoints && ( )} {canEditDays && (() => { const isTransport = TRANSPORT_TYPES.has(res.type) const handler = isTransport ? onEditTransport : onEditReservation if (!handler) return null return ( ) })()}
) })()} {assignment.participants?.length > 0 && (
{assignment.participants.slice(0, 5).map((p, pi) => (
0 ? -4 : 0, flexShrink: 0, overflow: 'hidden', }}> {p.avatar ? : p.username?.[0]?.toUpperCase()}
))} {assignment.participants.length > 5 && ( +{assignment.participants.length - 5} )}
)}
{canEditDays &&
} {canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && ( )}
{routeLegs[assignment.id] && }
) } // Transport booking (flight, train, bus, car, cruise) if (item.type === 'transport') { const res = item.data const spanPhase = getSpanPhase(res, day.id) // Car "active" (middle) days are shown in the day header, skip here if (res.type === 'car' && spanPhase === 'middle') return null const TransportIcon = RES_ICONS[res.type] || Ticket const color = '#3b82f6' const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) // Subtitle aus Metadaten zusammensetzen let subtitle = '' if (res.type === 'flight') { const parts = [meta.airline, meta.flight_number].filter(Boolean) if (meta.departure_airport || meta.arrival_airport) parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → ')) subtitle = parts.join(' · ') } else if (res.type === 'train') { subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ') } // Multi-day span phase const spanLabel = getSpanLabel(res, spanPhase) const displayTime = getDisplayTimeForDay(res, day.id) return (
{ if (!canEditDays) return if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res) else onEditReservation?.(res) }} onDragOver={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() const inBottom = e.clientY > rect.top + rect.height / 2 const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}` if (dropTargetRef.current !== key) setDropTargetKey(key) }} draggable={canEditDays && spanPhase !== 'middle'} onDragStart={e => { if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return } // setData is required for the drag to start reliably (Firefox) and // matches how place/note items initiate their drag. e.dataTransfer.setData('reservationId', String(res.id)) e.dataTransfer.setData('fromDayId', String(day.id)) e.dataTransfer.effectAllowed = 'move' dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase } setDraggingId(res.id) }} onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }} onDrop={e => { e.preventDefault(); e.stopPropagation() const rect = e.currentTarget.getBoundingClientRect() const insertAfter = e.clientY > rect.top + rect.height / 2 const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromReservationId && fromDayId !== day.id) { const r2 = reservations.find(x => x.id === Number(fromReservationId)) if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter) } else if (fromAssignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }} onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 10px', margin: '1px 8px', borderRadius: 6, border: `1px solid ${color}33`, borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, borderBottom: showDropLineAfter ? '2px solid var(--text-primary)' : undefined, background: `${color}08`, cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none', transition: 'background 0.1s', opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1, }} > {canEditDays && spanPhase !== 'middle' && (
)}
{spanLabel && ( {spanLabel} )} {res.title} {(() => { const { time: dispTime } = splitReservationDateTime(displayTime) const { time: endTime } = splitReservationDateTime(res.reservation_end_time) if (!dispTime && !endTime) return null return ( {dispTime ? formatTime(dispTime, locale, timeFormat) : ''} {spanPhase === 'single' && endTime ? ` – ${formatTime(endTime, locale, timeFormat)}` : ''} {meta.departure_timezone && spanPhase === 'start' && ` ${meta.departure_timezone}`} {meta.arrival_timezone && spanPhase === 'end' && ` ${meta.arrival_timezone}`} ) })()}
{subtitle && (
{subtitle}
)}
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => { const active = visibleConnectionIds.includes(res.id) return ( ) })()}
) } // Notizkarte const note = item.data const NoteIcon = getNoteIcon(note.icon) const noteIdx = idx return (
{ if (!canEditDays) { e.preventDefault(); return } e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }} onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }} onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) if (placeId) { // New place dropped onto a note: insert it among the // assignments at the note's position (after the places // above it), so it lands right where the note sits. const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const pos = tm.slice(0, noteIdx).filter(i => i.type === 'place').length onAssignToDay?.(parseInt(placeId), day.id, pos) setDropTargetKey(null); window.__dragData = null } else if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (fromReservationId) { handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id) } else if (fromNoteId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2 tripActions.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null) } else if (fromNoteId && fromNoteId !== String(note.id)) { handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id) } else if (fromAssignmentId && fromDayId !== day.id) { const tm = getMergedItems(day.id) const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id) const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) } }} onContextMenu={canEditDays ? e => ctxMenu.open(e, [ { label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) }, { divider: true }, { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) }, ]) : undefined} onMouseEnter={e => { const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '1' const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null if (editBtns) editBtns.style.opacity = '1' }} onMouseLeave={e => { const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null if (grip) grip.style.opacity = '0.3' const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null if (editBtns) editBtns.style.opacity = '0' }} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 2px', margin: '1px 8px', borderRadius: 6, border: '1px solid var(--border-faint)', borderTop: showDropLine ? '2px solid var(--text-primary)' : undefined, background: 'var(--bg-hover)', opacity: draggingId === `note-${note.id}` ? 0.4 : 1, transition: 'background 0.1s', cursor: 'grab', userSelect: 'none', }} > {canEditDays &&
}
{note.text} {note.time && (
{note.time}
)}
{canEditDays &&
} {canEditDays &&
}
) }) )} {/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
{ e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e) // Neuer Ort von der Orte-Liste if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (fromReservationId && fromDayId !== day.id) { const r = reservations.find(x => x.id === Number(fromReservationId)) if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return } if (!assignmentId && !noteId && !fromReservationId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } const m = getMergedItems(day.id) if (m.length === 0) return const lastItem = m[m.length - 1] if (assignmentId && String(lastItem?.data?.id) !== assignmentId) handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id, true) else if (noteId && String(lastItem?.data?.id) !== noteId) handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true) else if (fromReservationId && String(lastItem?.data?.id) !== fromReservationId) handleMergedDrop(day.id, 'transport', Number(fromReservationId), lastItem.type, lastItem.data.id, true) setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} > {dropTargetKey === `end-${day.id}` && (
)}
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {isSelected && getDayAssignments(day.id).length >= 2 && (
{(['driving', 'walking'] as const).map(p => { const ModeIcon = p === 'driving' ? Car : Footprints const active = routeProfile === p return ( ) })}
{routeInfo && (
{routeInfo.distance} · {routeInfo.duration}
)}
)} {/* Mobile: Add Place from list */}
)}
) })}
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */} {/* Confirm: remove time when reordering a timed place */} {/* Transport-Detail-Modal */} {/* Budget-Fußzeile */}
) }) export default DayPlanSidebar