Spaces:
Sleeping
Sleeping
| /* 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, getDayBookendHotels } from '../../utils/dayOrder' | |
| import { | |
| TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints, | |
| 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, HotelRouteConnector } 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 [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({}) | |
| const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation) | |
| 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({}); setHotelLegs({}); return } | |
| const merged = mergedItemsMap[selectedDayId] || [] | |
| const runs: { id: number; lat: number; lng: number }[][] = [] | |
| let cur: { id: number; lat: number; lng: number }[] = [] | |
| for (const it of merged) { | |
| if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { | |
| cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng }) | |
| } else if (it.type === 'transport') { | |
| const r = it.data | |
| const { from, to } = getTransportRouteEndpoints(r, selectedDayId) | |
| 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) | |
| // Hotel bookend legs: the drive from the day's accommodation to the first located | |
| // waypoint of the day (morning) and from the last one back to it (evening). Only when | |
| // the "optimize from accommodation" setting is on and the day has a hotel. | |
| const day = days.find(d => d.id === selectedDayId) | |
| const { morning: startHotel, evening: endHotel } = | |
| day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {} | |
| const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || '' | |
| // Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel | |
| // legs connect even when the day starts or ends with a booking rather than a place. | |
| const wayPts: { lat: number; lng: number }[] = [] | |
| for (const it of merged) { | |
| if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) { | |
| wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng }) | |
| } else if (it.type === 'transport') { | |
| const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId) | |
| if (from) wayPts.push({ lat: from.lat, lng: from.lng }) | |
| if (to) wayPts.push({ lat: to.lat, lng: to.lng }) | |
| } | |
| } | |
| const firstWay = wayPts[0] | |
| const lastWay = wayPts[wayPts.length - 1] | |
| const wantTop = !!(startHotel && firstWay) | |
| const wantBottom = !!(endHotel && lastWay) | |
| if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); 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 | |
| } | |
| } | |
| // One extra cached OSRM call per bookend; shares RouteCalculator's cache. | |
| const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => { | |
| try { | |
| const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile }) | |
| return r.legs[0] | |
| } catch { return undefined } | |
| } | |
| const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {} | |
| if (wantTop) { | |
| const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng }) | |
| if (seg) hotel.top = { seg, name: hotelName(startHotel!) } | |
| } | |
| if (wantBottom) { | |
| const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number }) | |
| if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) } | |
| } | |
| if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) } | |
| })() | |
| }, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation]) | |
| 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, | |
| hotelLegs, | |
| setHotelLegs, | |
| 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, | |
| hotelLegs, | |
| setHotelLegs, | |
| 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) | |
| }} | |
| > | |
| {isSelected && hotelLegs.top && ( | |
| <HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" /> | |
| )} | |
| {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> | |
| ) | |
| }) | |
| )} | |
| {isSelected && hotelLegs.bottom && ( | |
| <HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" /> | |
| )} | |
| {/* 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 |