Files
TREK/client/src/components/Planner/DayPlanSidebar.tsx
T
rossanorbr 3398da633b fix(planner): make route tools reachable in mobile day plan sheet (#1142)
* wiki: update dev env

* wiki: small precision in dev env

* fix(planner): make route tools reachable in mobile day plan sheet

On mobile, selecting a day closes the plan sheet immediately, so the
route tools footer (Route toggle / Optimize / routing profile) - gated
on the selected day - was never reachable. Desktop was unaffected.

- Add showRouteToolsWhenExpanded prop to DayPlanSidebar: when set,
  route tools render on any expanded day with 2+ assigned places
- Make handleOptimize accept an explicit dayId (defaulting to
  selectedDayId, preserving desktop behavior)
- Keep the distance/duration pill gated on the selected day, since
  routeInfo belongs to the selected day's calculated route
- Enable the prop on the mobile plan sheet in TripPlannerPage

* fix(planner): correct route-tools prop doc and dev-environment wiki

- Reword the showRouteToolsWhenExpanded JSDoc to list the controls the
  footer actually renders (Route toggle / Optimize / travel profile);
  there is no "Open in Google Maps" action in that block.
- Wiki: drop the non-existent server test:parity script, document the
  real shared i18n:parity checks, and fix the i18n note (the translation
  layer already lives in @trek/shared, it is not "upcoming").

---------

Co-authored-by: jubnl <jgunther021@gmail.com>
Co-authored-by: Maurice <mauriceboe@icloud.com>
2026-06-13 15:24:27 +02:00

2218 lines
120 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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 ConfirmDialog from '../shared/ConfirmDialog'
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, getAccommodationAnchors } 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
onReorderDays?: (orderedIds: number[]) => void
onAddDay?: (position?: 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<number>) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | 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
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
showRouteToolsWhenExpanded?: boolean
}
/**
* 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, onReorderDays, onAddDay, 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,
showRouteToolsWhenExpanded = false,
} = 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<number>(JSON.parse(saved) as number[])
} catch {}
return new Set<number>(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<Record<number, RouteSegment>>({})
const legsAbortRef = useRef<AbortController | null>(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<number | null>(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; toLegIndex?: number | null;
// For arrow reorder
reorderIds?: number[];
} | null>(null)
const inputRef = useRef(null)
const dragDataRef = useRef(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const initedTransportIds = useRef(new Set<number>()) // 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<number | null>(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<number, ReturnType<typeof getMergedItems>> = {}
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 epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
const e = (r.endpoints || []).find((x: any) => x.role === role)
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
}
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') {
const r = it.data
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
if (from || to) {
// Located transport: route to its departure point, break the run (the
// flight/train itself isn't driven), and let its arrival start the next.
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
if (cur.length >= 2) runs.push(cur)
cur = []
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
} else if (cur.length > 0) {
// No location: ignore for routing, but attribute the through-leg to the
// booking so its distance/duration shows under it (purely cosmetic).
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
}
}
}
if (cur.length >= 2) runs.push(cur)
if (runs.length === 0) { setRouteLegs({}); return }
const controller = new AbortController()
legsAbortRef.current = controller
;(async () => {
const map: Record<number, RouteSegment> = {}
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)
}
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
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 }[] = []
// Multi-leg flight legs share a reservation id, so their positions can't live in
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
const legPosUpdates: Record<number, Record<number, 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') {
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
else 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)
}
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
// (the single per-booking slot can't hold one position per leg).
const legResIds = Object.keys(legPosUpdates)
if (legResIds.length) {
for (const ridStr of legResIds) {
const rid = Number(ridStr)
const r = useTripStore.getState().reservations.find(x => x.id === rid)
if (!r) continue
let parsed: any = {}
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
if (!Array.isArray(parsed.legs)) continue
const legs = parsed.legs.map((leg: any, i: number) => {
const pos = legPosUpdates[rid][i]
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
})
// Send metadata as an OBJECT (like the form does) — passing a JSON string
// here double-encodes it on the server, which wipes metadata.legs on read
// and collapses the flight back to a single span.
const newMeta = { ...parsed, legs }
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
}
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, toLegIndex = null) => {
const m = getMergedItems(dayId)
// Multi-leg flights expose one item per leg sharing the same reservation id;
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
// 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(matchTo)
if (fromIdx !== -1 && toIdx !== -1) {
const simulated = [...m]
const [moved] = simulated.splice(fromIdx, 1)
let insertIdx = simulated.findIndex(matchTo)
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, toLegIndex, 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(matchTo)
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(matchTo)
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, toLegIndex } = 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 matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(matchTo)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
let adjustedTo = newOrder.findIndex(matchTo)
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 (dayId: number | null = selectedDayId) => {
if (!dayId) return
const da = getDayAssignments(dayId)
if (da.length < 3) return
const prevIds = da.map(a => a.id)
// Separate fixed (stay at their index) and movable assignments. A place is
// fixed if it's locked OR has a set time — timed places are anchored by their
// time, so the optimizer must not reshuffle them.
const locked = new Map() // index -> assignment
const unlocked = []
da.forEach((a, i) => {
if (lockedIds.has(a.id) || a.place?.place_time) 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)
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
const day = days.find(d => d.id === dayId)
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
? getAccommodationAnchors(day, days, accommodations)
: {}
const optimizedAssignments = unlockedWithCoords.length >= 2
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).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(dayId, result.map(a => a.id))
const usedHotel = !!(anchors.start || anchors.end)
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
const capturedDayId = dayId
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,
onReorderDays,
onAddDay,
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,
showRouteToolsWhenExpanded,
toast,
t,
language,
locale,
ctxMenu,
timeFormat,
tripActions,
can,
canEditDays,
noteUi,
setNoteUi,
noteInputRef,
dayNotes,
openAddNote,
openEditNote,
cancelNote,
saveNote,
deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
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,
onReorderDays,
onAddDay,
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,
showRouteToolsWhenExpanded,
toast,
t,
language,
locale,
ctxMenu,
timeFormat,
tripActions,
can,
canEditDays,
noteUi,
setNoteUi,
noteInputRef,
dayNotes,
openAddNote,
openEditNote,
cancelNote,
saveNote,
deleteNote,
pendingDeleteNote,
setPendingDeleteNote,
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "var(--font-system)" }}>
{/* Toolbar */}
<DayPlanSidebarToolbar
tripId={tripId}
trip={trip}
days={days}
places={places}
categories={categories}
assignments={assignments}
reservations={reservations}
dayNotes={dayNotes}
t={t}
locale={locale}
toast={toast}
pdfHover={pdfHover}
setPdfHover={setPdfHover}
icsHover={icsHover}
setIcsHover={setIcsHover}
expandedDays={expandedDays}
setExpandedDays={setExpandedDays}
onUndo={onUndo}
canUndo={canUndo}
undoHover={undoHover}
setUndoHover={setUndoHover}
lastActionLabel={lastActionLabel}
canEditDays={canEditDays}
onReorderDays={onReorderDays}
onAddDay={onAddDay}
/>
{/* Tagesliste */}
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => 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 (
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
className="dp-day-header"
data-selected={isSelected}
onClick={() => { 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 (
<div style={{
flexShrink: 0, alignSelf: 'flex-start',
width: hasWeather ? 34 : 26,
borderRadius: hasWeather ? 11 : '50%',
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
display: 'flex', flexDirection: 'column', alignItems: 'center', overflow: 'hidden',
}}>
<div style={{ width: '100%', height: 26, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 11, fontWeight: 700 }}>
{index + 1}
</div>
{hasWeather && (
<>
<div style={{ width: '64%', height: 1, background: 'currentColor', opacity: 0.25 }} />
<div style={{ padding: '3px 0 4px' }}>
<WeatherWidget lat={wLat} lng={wLng} date={day.date} stacked />
</div>
</>
)}
</div>
)
})()}
<div style={{ flex: 1, minWidth: 0 }}>
{editingDayId === day.id ? (
<input
ref={inputRef}
value={editTitle}
onChange={e => 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)',
}}
/>
) : (<>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }}>
<span style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
</span>
{formattedDate && (
<>
<span style={{ flexShrink: 0, width: 1, height: 11, background: 'var(--border-primary)' }} />
<span style={{ flexShrink: 0, fontSize: 11, fontWeight: 400, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>
{formattedDate}
</span>
</>
)}
</div>
{(() => {
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 <div style={{ height: 1, background: 'var(--border-faint)', margin: '5px 0 5px' }} />
})()}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'nowrap', minWidth: 0 }}>
{(() => {
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 (
<span key={acc.id} onClick={e => { 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' }}>
<Hotel size={11} strokeWidth={1.8} style={{ color: iconColor, flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 10.5, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{(acc as any).place_name || (acc as any).reservation_title}</span>
</span>
)
})
})()}
{/* Active rental car badges */}
{(() => {
const activeRentals = getActiveRentalsForDay(day.id)
if (activeRentals.length === 0) return null
return activeRentals.map(r => (
<span key={`rental-${r.id}`} onClick={e => { 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' }}>
<Car size={11} strokeWidth={1.8} className="text-content-faint" style={{ flexShrink: 0 }} />
<span className="text-content-muted" style={{ fontSize: 10.5, fontWeight: 400, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
</span>
))
})()}
</div>
</>
)}
{cost && (
<div style={{ marginTop: 2 }}>
<span className="text-[#059669]" style={{ fontSize: 11 }}>{cost}</span>
</div>
)}
</div>
{canEditDays ? (
(() => {
const cell = { padding: 7, cursor: 'pointer', display: 'grid', placeItems: 'center' } as const
const div = '1px solid var(--border-faint)'
return (
<div className="dp-day-actions" style={{ alignSelf: 'flex-start', flexShrink: 0, display: 'grid', gridTemplateColumns: '1fr 1fr', border: div, borderRadius: 9, overflow: 'hidden' }}>
<button onClick={e => startEditTitle(day, e)} aria-label={t('common.edit')} style={{ ...cell, border: 'none', borderRight: div, borderBottom: div }}>
<Pencil size={14} strokeWidth={1.8} />
</button>
{onAddTransport ? (
<button onClick={e => { e.stopPropagation(); onAddTransport(day.id) }} title={t('transport.addTransport')} style={{ ...cell, border: 'none', borderBottom: div }}>
<Plus size={14} strokeWidth={1.8} />
</button>
) : <div style={{ borderBottom: div }} />}
<button onClick={e => openAddNote(day.id, e)} aria-label={t('dayplan.addNote')} style={{ ...cell, border: 'none', borderRight: div }}>
<FileText size={14} strokeWidth={1.8} />
</button>
<button onClick={e => toggleDay(day.id, e)} title={isExpanded ? t('common.collapse') : t('common.expand')} style={{ ...cell, border: 'none' }}>
{isExpanded ? <ChevronDown size={15} strokeWidth={1.8} /> : <ChevronRight size={15} strokeWidth={1.8} />}
</button>
</div>
)
})()
) : (
<button onClick={e => toggleDay(day.id, e)} className="text-content-faint" style={{ alignSelf: 'flex-start', flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
{isExpanded ? <ChevronDown size={16} strokeWidth={1.8} /> : <ChevronRight size={16} strokeWidth={1.8} />}
</button>
)}
</div>
{/* Aufgeklappte Orte + Notizen */}
{isExpanded && (
<div
style={{ background: 'var(--bg-hover)', paddingTop: 6 }}
onDragOver={e => { 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])
const legPart = parts.find(p => /^leg\d+$/.test(p))
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
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, toLegIndex)
} 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, toLegIndex)
} 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, toLegIndex)
}
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 ? (
<div
onDragOver={e => { 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',
}}
>
<span className="text-content-faint" style={{ fontSize: 12 }}>{t('dayplan.emptyDay')}</span>
</div>
) : (
merged.map((item, idx) => {
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${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}${legSuffix}-${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 (
<React.Fragment key={`place-${assignment.id}`}>
<div
draggable={canEditDays}
onDragStart={e => {
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 && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div
onClick={e => { e.stopPropagation(); toggleLock(assignment.id) }}
onMouseEnter={e => { e.stopPropagation(); setLockHoverId(assignment.id) }}
onMouseLeave={() => setLockHoverId(null)}
style={{ position: 'relative', flexShrink: 0, cursor: 'pointer' }}
>
<PlaceAvatar place={place} category={cat} size={28} />
{/* Hover/locked overlay */}
{(lockHoverId === assignment.id || lockedIds.has(assignment.id)) && (
<div style={{
position: 'absolute', inset: 0, borderRadius: '50%',
background: lockedIds.has(assignment.id) ? 'rgba(220,38,38,0.6)' : 'rgba(220,38,38,0.4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background 0.15s',
}}>
<Lock size={14} strokeWidth={2.5} style={{ color: 'white', filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.3))' }} />
</div>
)}
{/* Custom tooltip */}
{lockHoverId === assignment.id && (
<div style={{
position: 'absolute', left: '100%', top: '50%', transform: 'translateY(-50%)',
marginLeft: 8, whiteSpace: 'nowrap', pointerEvents: 'none', zIndex: 50,
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
}}>
{lockedIds.has(assignment.id)
? t('planner.clickToUnlock')
: t('planner.keepPosition')}
</div>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <span title={cat.name} style={{ display: 'inline-flex', flexShrink: 0 }}><CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} /></span>
})()}
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
{place.place_time && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
<Clock size={9} strokeWidth={2} />
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` ${formatTime(place.end_time, locale, timeFormat)}` : ''}
</span>
)}
</div>
{(place.description || place.address || cat?.name) && (
<div className="collab-note-md" style={{ marginTop: 2, fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2, maxHeight: '1.2em' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.description || place.address || cat?.name || ''}</Markdown>
</div>
)}
{(() => {
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 (
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<div className={confirmed ? 'bg-[rgba(22,163,74,0.1)] text-[#16a34a]' : 'bg-[rgba(217,119,6,0.1)] text-[#d97706]'} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
}}>
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
{(() => {
const { time: st } = splitReservationDateTime(res.reservation_time)
const { time: et } = splitReservationDateTime(res.reservation_end_time)
if (!st && !et) return null
return (
<span style={{ fontWeight: 400 }}>
{st ? formatTime(st, locale, timeFormat) : ''}
{et ? ` ${formatTime(et, locale, timeFormat)}` : ''}
</span>
)
})()}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta) return null
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
return null
})()}
</div>
{hasEndpoints && (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
className={active ? 'bg-[#3b82f6] text-[#fff]' : 'bg-transparent text-content-faint'}
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={11} />
</button>
)}
{canEditDays && (() => {
const isTransport = TRANSPORT_TYPES.has(res.type)
const handler = isTransport ? onEditTransport : onEditReservation
if (!handler) return null
return (
<button
type="button"
onClick={e => { e.stopPropagation(); handler(res) }}
title={t('common.edit')}
className="bg-transparent text-content-faint"
style={{
flexShrink: 0, appearance: 'none',
width: 20, height: 20, borderRadius: 4,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
>
<Pencil size={11} />
</button>
)
})()}
</div>
)
})()}
{assignment.participants?.length > 0 && (
<div style={{ marginTop: 3, display: 'flex', alignItems: 'center', gap: -4 }}>
{assignment.participants.slice(0, 5).map((p, pi) => (
<div key={p.user_id} className="bg-surface-tertiary text-content-muted" style={{
width: 16, height: 16, borderRadius: '50%', border: '1.5px solid var(--bg-card)',
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700,
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
overflow: 'hidden',
}}>
{p.avatar ? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
</div>
))}
{assignment.participants.length > 5 && (
<span className="text-content-faint" style={{ fontSize: 8, marginLeft: 2 }}>+{assignment.participants.length - 5}</span>
)}
</div>
)}
</div>
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={idx === 0} className={idx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={moveDown} disabled={idx === merged.length - 1} className={idx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>}
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
<button
onClick={e => {
e.stopPropagation()
onAddBookingToAssignment(day.id, assignment.id)
}}
title={t('reservations.addBooking')}
style={{
flexShrink: 0,
background: 'none',
border: '1px solid var(--border-primary)',
borderRadius: 5,
padding: '2px 6px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
fontSize: 10,
fontWeight: 500,
color: 'var(--text-muted)',
fontFamily: 'inherit',
}}
>
<Plus size={11} strokeWidth={2} />
</button>
)}
</div>
{routeLegs[assignment.id] && <RouteConnector seg={routeLegs[assignment.id]} profile={routeProfile} />}
</React.Fragment>
)
}
// 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.__leg) {
// One leg of a multi-leg flight — show this segment's own route.
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
if (res.__leg.from || res.__leg.to)
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
subtitle = parts.join(' · ')
} else 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 (single-leg / non-flight only — a
// multi-leg flight is shown as one row per leg, see below).
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
const displayTime = getDisplayTimeForDay(res, day.id)
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
return (
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
<div
onClick={() => {
if (!canEditDays) return
const target = reservations.find(x => x.id === res.id) ?? res
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
else onEditReservation?.(target)
}}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const inBottom = e.clientY > rect.top + rect.height / 2
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
if (dropTargetRef.current !== key) setDropTargetKey(key)
}}
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
onDragStart={e => {
if (!canEditDays || spanPhase === 'middle' || res.__leg) { 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, res.__leg?.index ?? null)
} 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, res.__leg?.index ?? null)
} 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, res.__leg?.index ?? null)
}
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' && !res.__leg && (
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
)}
<div style={{
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: '50%', background: `${color}18`,
}}>
<TransportIcon size={14} strokeWidth={1.8} color={color} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{spanLabel && (
<span style={{
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4, flexShrink: 0,
background: `${color}20`, color: color, textTransform: 'uppercase', letterSpacing: '0.03em',
}}>
{spanLabel}
</span>
)}
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{res.title}
</span>
{(() => {
const { time: dispTime } = splitReservationDateTime(displayTime)
const { time: endTime } = splitReservationDateTime(res.reservation_end_time)
if (!dispTime && !endTime) return null
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
<Clock size={9} strokeWidth={2} />
{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}`}
</span>
)
})()}
</div>
{subtitle && (
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{subtitle}
</div>
)}
</div>
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
const active = visibleConnectionIds.includes(res.id)
return (
<button
type="button"
onClick={e => { e.stopPropagation(); onToggleConnection(res.id) }}
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
style={{
flexShrink: 0, appearance: 'none',
width: 26, height: 26, borderRadius: 6,
display: 'grid', placeItems: 'center', cursor: 'pointer',
border: 'none',
background: active ? color : 'transparent',
color: active ? '#fff' : 'var(--text-faint)',
transition: 'color 120ms cubic-bezier(0.23,1,0.32,1), background 120ms cubic-bezier(0.23,1,0.32,1)',
}}
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
>
<RouteIcon size={13} />
</button>
)
})()}
</div>
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
</React.Fragment>
)
}
// Notizkarte
const note = item.data
const NoteIcon = getNoteIcon(note.icon)
const noteIdx = idx
return (
<React.Fragment key={`note-${note.id}`}>
<div
draggable={canEditDays}
onDragStart={e => { 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: () => setPendingDeleteNote({ dayId: day.id, noteId: 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 && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>}
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
{note.text}
</span>
{note.time && (
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
)}
</div>
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
</div>}
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} className={noteIdx === merged.length - 1 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>}
</div>
</React.Fragment>
)
})
)}
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
<div
style={{ minHeight: 12, padding: '2px 8px' }}
onDragOver={e => { 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}` && (
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1 }} />
)}
</div>
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
<button
onClick={() => onToggleRoute?.()}
className={routeShown ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 600, borderRadius: 8,
border: routeShown ? 'none' : '1px solid var(--border-faint)',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<RouteIcon size={12} strokeWidth={2} />
{t('dayplan.route')}
</button>
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
cursor: 'pointer', fontFamily: 'inherit',
}}>
<RotateCcw size={12} strokeWidth={2} />
{t('dayplan.optimize')}
</button>
<div style={{ display: 'flex', borderRadius: 8, overflow: 'hidden', border: '1px solid var(--border-faint)', flexShrink: 0 }}>
{(['driving', 'walking'] as const).map(p => {
const ModeIcon = p === 'driving' ? Car : Footprints
const active = routeProfile === p
return (
<button
key={p}
onClick={() => onSetRouteProfile?.(p)}
aria-label={p === 'driving' ? 'Driving' : 'Walking'}
className={active ? 'bg-accent text-accent-text' : 'bg-transparent text-content-secondary'}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', border: 'none', cursor: 'pointer',
}}
>
<ModeIcon size={13} strokeWidth={2} />
</button>
)
})}
</div>
</div>
{isSelected && routeInfo && (
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span>
<span className="text-content-faint">·</span>
<span>{routeInfo.duration}</span>
</div>
)}
</div>
)}
{/* Mobile: Add Place from list */}
<MobileAddPlaceButton
dayId={day.id}
places={places}
assignments={assignments}
onAssign={onAssignToDay}
onAddNew={onAddPlace}
/>
</div>
)}
</div>
)
})}
</div>
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
<DayPlanSidebarNoteModal
noteUi={noteUi}
setNoteUi={setNoteUi}
noteInputRef={noteInputRef}
cancelNote={cancelNote}
saveNote={saveNote}
t={t}
/>
{/* Confirm: remove time when reordering a timed place */}
<DayPlanSidebarTimeConfirmModal
timeConfirm={timeConfirm}
setTimeConfirm={setTimeConfirm}
confirmTimeRemoval={confirmTimeRemoval}
t={t}
/>
{/* Confirm: delete a day note — guards against accidental taps on touch devices */}
<ConfirmDialog
isOpen={!!pendingDeleteNote}
onClose={() => setPendingDeleteNote(null)}
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
title={t('dayplan.confirmDeleteNoteTitle')}
message={t('dayplan.confirmDeleteNoteBody')}
/>
{/* Transport-Detail-Modal */}
<DayPlanSidebarTransportDetailModal
transportDetail={transportDetail}
setTransportDetail={setTransportDetail}
onNavigateToFiles={onNavigateToFiles}
t={t}
locale={locale}
timeFormat={timeFormat}
/>
{/* Budget-Fußzeile */}
<DayPlanSidebarFooter totalCost={totalCost} currency={currency} t={t} />
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
</div>
)
})
export default DayPlanSidebar