import React, { useState, useEffect, useRef } 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 } 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 { downloadTripPDF } from '../PDF/TripPDF' import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator' import PlaceAvatar from '../shared/PlaceAvatar' import WeatherWidget from '../Weather/WeatherWidget' import { useToast } from '../shared/Toast' import { getCategoryIcon } from '../shared/categoryIcons' import { useTripStore } from '../../store/tripStore' import { useSettingsStore } from '../../store/settingsStore' import { useTranslation } from '../../i18n' function formatDate(dateStr, locale) { if (!dateStr) return null return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', }) } function formatTime(timeStr, locale, timeFormat) { if (!timeStr) return '' try { const parts = timeStr.split(':') const h = Number(parts[0]) || 0 const m = Number(parts[1]) || 0 if (isNaN(h)) return timeStr if (timeFormat === '12h') { const period = h >= 12 ? 'PM' : 'AM' const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h return `${h12}:${String(m).padStart(2, '0')} ${period}` } const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` return locale?.startsWith('de') ? `${str} Uhr` : str } catch { return timeStr } } function dayTotalCost(dayId, assignments, currency) { const da = assignments[String(dayId)] || [] const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0) return total > 0 ? `${total.toFixed(0)} ${currency}` : null } 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: '📋', } export default function DayPlanSidebar({ tripId, trip, days, places, categories, assignments, selectedDayId, selectedPlaceId, selectedAssignmentId, onSelectDay, onPlaceClick, onDayDetail, accommodations = [], onReorder, onUpdateDayTitle, onRouteCalculated, onAssignToDay, reservations = [], onAddReservation, }) { const toast = useToast() const { t, language, locale } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const tripStore = useTripStore() const dayNotes = tripStore.dayNotes || {} const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id))) 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 [dropTargetKey, setDropTargetKey] = useState(null) const [dragOverDayId, setDragOverDayId] = useState(null) const [hoveredId, setHoveredId] = useState(null) const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } } const inputRef = useRef(null) const noteInputRef = useRef(null) const dragDataRef = useRef(null) // 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 } } useEffect(() => { setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)])) }, [days.length]) 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) return n }) } const getDayAssignments = (dayId) => (assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index) const getMergedItems = (dayId) => { const da = getDayAssignments(dayId) const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order) return [ ...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })), ...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })), ].sort((a, b) => a.sortKey - b.sortKey) } const openAddNote = (dayId, e) => { e?.stopPropagation() const merged = getMergedItems(dayId) const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1 setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } })) if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId])) setTimeout(() => noteInputRef.current?.focus(), 50) } const openEditNote = (dayId, note, e) => { e?.stopPropagation() setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } })) setTimeout(() => noteInputRef.current?.focus(), 50) } const cancelNote = (dayId) => { setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n }) } const saveNote = async (dayId) => { const ui = noteUi[dayId] if (!ui?.text?.trim()) return try { if (ui.mode === 'add') { await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder }) } else { await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' }) } cancelNote(dayId) } catch (err) { toast.error(err.message) } } const deleteNote = async (dayId, noteId, e) => { e?.stopPropagation() try { await tripStore.deleteDayNote(tripId, dayId, noteId) } catch (err) { toast.error(err.message) } } const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => { const m = getMergedItems(dayId) 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 // Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standard), oder NACH dem Ziel wenn insertAfter const newOrder = [...m] const [moved] = newOrder.splice(fromIdx, 1) let adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx if (insertAfter) adjustedTo += 1 newOrder.splice(adjustedTo, 0, moved) // Orte: neuer order_index über onReorder const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id) // Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein. // Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1) // z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2) const groups = {} let pc = 0 newOrder.forEach(item => { if (item.type === 'place') { pc++ } else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) } }) const noteChanges = [] Object.entries(groups).forEach(([pb, ids]) => { ids.forEach((id, i) => { noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) }) }) }) try { if (assignmentIds.length) await onReorder(dayId, assignmentIds) for (const n of noteChanges) { await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order }) } } catch (err) { toast.error(err.message) } setDraggingId(null) setDropTargetKey(null) dragDataRef.current = null } const moveNote = async (dayId, noteId, direction) => { const merged = getMergedItems(dayId) const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId) if (idx === -1) return let newSortOrder if (direction === 'up') { if (idx === 0) return newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1 } else { if (idx >= merged.length - 1) return newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1 } try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) } catch (err) { toast.error(err.message) } } 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) => { setLockedIds(prev => { const next = new Set(prev) if (next.has(assignmentId)) next.delete(assignmentId) else next.add(assignmentId) return next }) } const handleOptimize = async () => { if (!selectedDayId) return const da = getDayAssignments(selectedDayId) if (da.length < 3) return // 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 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() setDragOverDayId(null) const { placeId, assignmentId, noteId, fromDayId } = getDragData(e) if (placeId) { onAssignToDay?.(parseInt(placeId), dayId) } else if (assignmentId && fromDayId !== dayId) { tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message)) } else if (noteId && fromDayId !== dayId) { tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message)) } 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 = days.reduce((s, d) => { const da = assignments[String(d.id)] || [] return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0) }, 0) // 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')}`}
)}
{/* 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 = getMergedItems(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-hover)' : '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 })} {(() => { const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id) return acc ? ( { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}> {acc.place_name} ) : null })()}
)}
{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 })()}
{/* Aufgeklappte Orte + Notizen */} {isExpanded && (
{ e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }} onDrop={e => { e.preventDefault() const { assignmentId, noteId, fromDayId } = getDragData(e) if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return } if (assignmentId && fromDayId !== day.id) { tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message)) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message)) 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 === '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 moveUp = (e) => { e.stopPropagation() if (placeIdx === 0) return const ids = placeItems.map(i => i.data.id) ;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]] onReorder(day.id, ids) } const moveDown = (e) => { e.stopPropagation() if (placeIdx === placeItems.length - 1) return const ids = placeItems.map(i => i.data.id) ;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]] onReorder(day.id, ids) } return ( {showDropLine &&
}
{ 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) tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message)) 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 tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message)) 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) }} 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, }} >
{ 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 && ( {new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })} )}
) })()}
) } // Notizkarte const note = item.data const isNoteHovered = hoveredId === `note-${note.id}` const NoteIcon = getNoteIcon(note.icon) const noteIdx = idx return ( {showDropLine &&
}
{ 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 tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message)) 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 tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message)) setDraggingId(null); setDropTargetKey(null) } else if (fromAssignmentId) { handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id) } }} 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', }} >
{note.text} {note.time && (
{note.time}
)}
) }) )} {/* 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) { tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message)) setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return } if (noteId && fromDayId !== day.id) { tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message)) 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)' }} /> setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }} placeholder={t('dayplan.noteSubtitle')} style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }} />
, document.body ))} {/* Budget-Fußzeile */} {totalCost > 0 && (
{t('dayplan.totalCost')} {totalCost.toFixed(2)} {currency}
)}
) }