/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */ interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string } declare global { interface Window { __dragData: DragDataPayload | null } } import React, { useState, useEffect, useRef, useMemo } from 'react' import ReactDOM from 'react-dom' import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2 } from 'lucide-react' const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText } import { assignmentsApi, reservationsApi } from '../../api/client' import { downloadTripPDF } from '../PDF/TripPDF' import { calculateRoute, generateGoogleMapsUrl, 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 { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters' import { useDayNotes } from '../../hooks/useDayNotes' import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types' const NOTE_ICONS = [ { id: 'FileText', Icon: FileText }, { id: 'Info', Icon: Info }, { id: 'Clock', Icon: Clock }, { id: 'MapPin', Icon: MapPin }, { id: 'Navigation', Icon: Navigation }, { id: 'Train', Icon: Train }, { id: 'Plane', Icon: Plane }, { id: 'Bus', Icon: Bus }, { id: 'Car', Icon: Car }, { id: 'Ship', Icon: Ship }, { id: 'Coffee', Icon: Coffee }, { id: 'Ticket', Icon: Ticket }, { id: 'Star', Icon: Star }, { id: 'Heart', Icon: Heart }, { id: 'Camera', Icon: Camera }, { id: 'Flag', Icon: Flag }, { id: 'Lightbulb', Icon: Lightbulb }, { id: 'AlertTriangle', Icon: AlertTriangle }, { id: 'ShoppingBag', Icon: ShoppingBag }, { id: 'Bookmark', Icon: Bookmark }, ] const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon])) function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText } const TYPE_ICONS = { flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆', car: '🚗', cruise: '🚢', event: '🎫', other: '📋', } 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) => void onPlaceClick: (placeId: number) => void onDayDetail: (day: Day) => void accommodations?: Assignment[] onReorder: (dayId: number, orderedIds: number[]) => void onUpdateDayTitle: (dayId: number, title: string) => void onRouteCalculated: (dayId: number, route: RouteResult | null) => void onAssignToDay: (placeId: number, dayId: number) => void onRemoveAssignment: (assignmentId: number, dayId: number) => void onEditPlace: (place: Place) => void onDeletePlace: (placeId: number) => void reservations?: Reservation[] onAddReservation: () => void onNavigateToFiles?: () => void onExpandedDaysChange?: (expandedDayIds: Set) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void canUndo?: boolean lastActionLabel?: string | null onUndo?: () => void } const DayPlanSidebar = React.memo(function DayPlanSidebar({ tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onReorder, onUpdateDayTitle, onRouteCalculated, onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace, reservations = [], onAddReservation, onNavigateToFiles, onExpandedDaysChange, pushUndo, canUndo = false, lastActionLabel = null, onUndo, }: DayPlanSidebarProps) { 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)) } 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 [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 [dropTargetKey, _setDropTargetKey] = useState(null) const dropTargetRef = useRef(null) const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) } const [dragOverDayId, setDragOverDayId] = useState(null) const [hoveredId, setHoveredId] = useState(null) const [transportDetail, setTransportDetail] = useState(null) 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 initedTransportIds = useRef(new Set()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren) 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 gesetzt) if (dragDataRef.current) { return { placeId: '', assignmentId: dragDataRef.current.assignmentId || '', noteId: dragDataRef.current.noteId || '', fromDayId: parseInt(dragDataRef.current.fromDayId) || 0, } } // Externer Drag (aus PlacesSidebar) const ext = window.__dragData || {} const placeId = dt?.getData('placeId') || ext.placeId || '' return { placeId, assignmentId: '', noteId: '', fromDayId: 0 } } // 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) }, []) 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 }) } const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise']) const getTransportForDay = (dayId: number) => { const day = days.find(d => d.id === dayId) if (!day?.date) return [] return reservations.filter(r => { if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false const resDate = r.reservation_time.split('T')[0] return resDate === day.date }) } const getDayAssignments = (dayId) => (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) // Helper: parse time string ("HH:MM" or ISO) to minutes since midnight, or null const parseTimeToMinutes = (time?: string | null): number | null => { if (!time) return null // ISO-Format "2025-03-30T09:00:00" if (time.includes('T')) { const [h, m] = time.split('T')[1].split(':').map(Number) return h * 60 + m } // Einfaches "HH:MM" Format const parts = time.split(':').map(Number) if (parts.length >= 2 && !isNaN(parts[0]) && !isNaN(parts[1])) return parts[0] * 60 + parts[1] return null } // 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) const res = reservations.find(x => x.id === p.id) if (res) res.day_plan_position = p.day_plan_position } // Persist to server (fire and forget) reservationsApi.updatePositions(tripId, positions).catch(() => {}) } const getMergedItems = (dayId) => { const da = getDayAssignments(dayId) const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order) const transport = getTransportForDay(dayId) // Initialize positions for transports that don't have one yet if (transport.some(r => r.day_plan_position == null)) { initTransportPositions(dayId) } // Build base list: untimed places + notes sorted by order_index/sort_order const timedPlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) !== null) const freePlaces = da.filter(a => parseTimeToMinutes(a.place?.place_time) === null) const baseItems = [ ...freePlaces.map(a => ({ type: 'place' as const, sortKey: a.order_index, data: a })), ...dn.map(n => ({ type: 'note' as const, sortKey: n.sort_order, data: n })), ].sort((a, b) => a.sortKey - b.sortKey) // Timed places + transports: compute sortKeys based on time, inserted among base items const allTimed = [ ...timedPlaces.map(a => ({ type: 'place' as const, data: a, minutes: parseTimeToMinutes(a.place?.place_time)! })), ...transport.map(r => ({ type: 'transport' as const, data: r, minutes: parseTimeToMinutes(r.reservation_time) ?? 0 })), ].sort((a, b) => a.minutes - b.minutes) if (allTimed.length === 0) return baseItems if (baseItems.length === 0) { return allTimed.map((item, i) => ({ ...item, sortKey: i })) } // Insert timed items among base items using time-to-position mapping. // Each timed item finds the last base place whose order_index corresponds // to a reasonable position, then gets a fractional sortKey after it. const result = [...baseItems] for (let ti = 0; ti < allTimed.length; ti++) { const timed = allTimed[ti] const minutes = timed.minutes // For transports, use persisted position if available if (timed.type === 'transport' && timed.data.day_plan_position != null) { result.push({ type: timed.type, sortKey: timed.data.day_plan_position, data: timed.data }) continue } // Find insertion position: after the last base item with time <= this item's time let insertAfterKey = -Infinity for (const item of result) { if (item.type === 'place') { const pm = parseTimeToMinutes(item.data?.place?.place_time) if (pm !== null && pm <= minutes) insertAfterKey = item.sortKey } else if (item.type === 'transport') { const tm = parseTimeToMinutes(item.data?.reservation_time) if (tm !== null && tm <= minutes) insertAfterKey = item.sortKey } } const lastKey = result.length > 0 ? Math.max(...result.map(i => i.sortKey)) : 0 const sortKey = insertAfterKey === -Infinity ? lastKey + 0.5 + ti * 0.01 : insertAfterKey + 0.01 + ti * 0.001 result.push({ type: timed.type, sortKey, data: timed.data }) } return result.sort((a, b) => a.sortKey - b.sortKey) } // 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]) 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, note, e) => { e?.stopPropagation() _openEditNote(dayId, note) } const deleteNote = async (dayId, noteId, e) => { 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 { if (assignmentIds.length) await onReorder(dayId, assignmentIds) for (const n of noteUpdates) { await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } if (transportUpdates.length) { for (const tu of transportUpdates) { const res = reservations.find(r => r.id === tu.id) if (res) res.day_plan_position = tu.day_plan_position } await reservationsApi.updatePositions(tripId, transportUpdates) } 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 : 'Unknown error') } } const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { // Transport bookings themselves cannot be dragged if (fromType === 'transport') { toast.error(t('dayplan.cannotReorderTransport')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null return } 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 : 'Unknown error') 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]) 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 handleGoogleMaps = () => { if (!selectedDayId) return const da = getDayAssignments(selectedDayId) const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng)) if (url) window.open(url, '_blank') else toast.error(t('dayplan.toast.noGeoPlaces')) } const handleDropOnDay = (e, dayId) => { e.preventDefault() e.stopPropagation() setDragOverDayId(null) const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) 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 : 'Unknown error')) } else if (noteId && fromDayId !== dayId) { tripActions.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } 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 + (parseFloat(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 (
{/* Reise-Titel */}
{trip?.title}
{(trip?.start_date || trip?.end_date) && (
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')} {days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
)}
{pdfHover && (
{t('dayplan.pdfTooltip')}
)}
{icsHover && (
{t('dayplan.icsTooltip')}
)}
{onUndo && (
{undoHover && (
{canUndo && lastActionLabel ? t('undo.tooltip', { action: lastActionLabel }) : t('undo.button')}
)}
)}
{/* Tagesliste */}
{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(); setDragOverDayId(day.id) }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) 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-tertiary)' : 'transparent'), transition: 'background 0.12s', userSelect: 'none', outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none', outlineOffset: -2, borderRadius: isDragTarget ? 8 : 0, }} 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 */}
{index + 1}
{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 })} {canEditDays && } {(() => { const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id) // 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 bg = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.08)' : isCheckIn ? 'rgba(34,197,94,0.08)' : 'var(--bg-secondary)' const border = isCheckOut && !isCheckIn ? 'rgba(239,68,68,0.2)' : isCheckIn ? 'rgba(34,197,94,0.2)' : 'var(--border-primary)' const iconColor = isCheckOut && !isCheckIn ? '#ef4444' : isCheckIn ? '#22c55e' : 'var(--text-muted)' return ( { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: bg, border: `1px solid ${border}`, flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> {acc.place_name} ) }) })()}
)}
{formattedDate && {formattedDate}} {cost && {cost}} {day.date && anyGeoPlace && } {day.date && anyGeoPlace && (() => { const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng return })()}
{canEditDays && }
{/* 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, fromDayId } = getDragData(e) // Drop on transport card (detected via dropTargetRef for sync accuracy) if (dropTargetRef.current?.startsWith('transport-')) { const transportId = Number(dropTargetRef.current.replace('transport-', '')) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (assignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (assignmentId) { handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = 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 : 'Unknown error')) 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 : 'Unknown error')) 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(); setDragOverDayId(day.id) }} onDrop={e => handleDropOnDay(e, day.id)} style={{ padding: '16px', textAlign: 'center', borderRadius: 8, background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent', 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}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`) const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey 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 isHovered = hoveredId === 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 ( {showDropLine &&
}
{ 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, fromDayId } = 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 (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 : 'Unknown error')) 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 : 'Unknown error')) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'place', 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.lat},${place.lng}`, '_blank') }, { divider: true }, canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} onMouseEnter={() => setHoveredId(assignment.id)} onMouseLeave={() => setHoveredId(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-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'), borderLeft: lockedIds.has(assignment.id) ? '3px solid #dc2626' : '3px solid transparent', 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' return (
{(() => { const RI = RES_ICONS[res.type] || Ticket; return })()} {confirmed ? t('planner.resConfirmed') : t('planner.resPending')} {res.reservation_time?.includes('T') && ( {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} {res.reservation_end_time && ` – ${res.reservation_end_time}`} )} {(() => { 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 })()}
) })()} {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 &&
}
) } // Transport booking (flight, train, bus, car, cruise) if (item.type === 'transport') { const res = item.data const TransportIcon = RES_ICONS[res.type] || Ticket const color = '#3b82f6' const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {}) const isTransportHovered = hoveredId === `transport-${res.id}` // 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(' · ') } return ( {showDropLine &&
}
setTransportDetail(res)} onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`transport-${res.id}`) }} onDrop={e => { e.preventDefault(); e.stopPropagation() const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e) if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) } else if (fromAssignmentId && fromDayId !== day.id) { tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id) } else if (noteId && fromDayId !== day.id) { tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error')) } else if (noteId) { handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id) } setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null }} onMouseEnter={() => setHoveredId(`transport-${res.id}`)} onMouseLeave={() => setHoveredId(null)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 10px', margin: '1px 8px', borderRadius: 6, border: `1px solid ${color}33`, background: isTransportHovered ? `${color}12` : `${color}08`, cursor: 'pointer', userSelect: 'none', transition: 'background 0.1s', }} >
{res.title} {res.reservation_time?.includes('T') && ( {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} {res.reservation_end_time?.includes('T') && ` – ${new Date(res.reservation_end_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}`} )}
{subtitle && (
{subtitle}
)}
) } // Notizkarte const note = item.data const isNoteHovered = hoveredId === `note-${note.id}` const NoteIcon = getNoteIcon(note.icon) const noteIdx = idx return ( {showDropLine &&
}
{ 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 { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e) 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 : 'Unknown error')) 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 : 'Unknown error')) 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={() => setHoveredId(`note-${note.id}`)} onMouseLeave={() => setHoveredId(null)} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 8px 7px 2px', margin: '1px 8px', borderRadius: 6, border: '1px solid var(--border-faint)', background: isNoteHovered ? 'var(--bg-hover)' : '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, fromDayId } = getDragData(e) // Neuer Ort von der Orte-Liste if (placeId) { onAssignToDay?.(parseInt(placeId), day.id) setDropTargetKey(null); window.__dragData = null; return } if (!assignmentId && !noteId) { 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 : 'Unknown error')) 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 : 'Unknown error')) 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) }} > {dropTargetKey === `end-${day.id}` && (
)}
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */} {isSelected && getDayAssignments(day.id).length >= 2 && (
{routeInfo && (
{routeInfo.distance} · {routeInfo.duration}
)}
)}
)}
) })}
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */} {Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
cancelNote(Number(dayId))}>
e.stopPropagation()}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
{/* Icon-Auswahl */}
{NOTE_ICONS.map(({ id, Icon }) => ( ))}
setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }} placeholder={t('dayplan.noteTitle')} style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }} />