Auto-route on reorder/assign/remove, lock places for optimization, fix zoom
- Route line auto-updates on day select, reorder, assign, remove (no manual button) - Remove manual route calculation button (keep optimize + Google Maps) - Lock places at their position during route optimization (click avatar to toggle) - Locked places shown with red background, border and lock overlay - Custom tooltip for lock feature (DE/EN, dark mode) - Fix map zoom: panTo instead of setView keeps current zoom level - Fix fitBounds only on actual day change, not on place click - Missing translations: needTwoPlaces, routeOptimized, noGeoPlaces
This commit is contained in:
@@ -97,7 +97,7 @@ function SelectionController({ places, selectedPlaceId }) {
|
|||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
const place = places.find(p => p.id === selectedPlaceId)
|
const place = places.find(p => p.id === selectedPlaceId)
|
||||||
if (place?.lat && place?.lng) {
|
if (place?.lat && place?.lng) {
|
||||||
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
|
map.panTo([place.lat, place.lng], { animate: true, duration: 0.5 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
|
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock } from 'lucide-react'
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
@@ -79,7 +79,7 @@ export default function DayPlanSidebar({
|
|||||||
onAddReservation,
|
onAddReservation,
|
||||||
}) {
|
}) {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||||
const tripStore = useTripStore()
|
const tripStore = useTripStore()
|
||||||
|
|
||||||
@@ -97,6 +97,8 @@ export default function DayPlanSidebar({
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
|
const [lockHoverId, setLockHoverId] = useState(null)
|
||||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
const [hoveredId, setHoveredId] = useState(null)
|
||||||
@@ -291,15 +293,44 @@ export default function DayPlanSidebar({
|
|||||||
finally { setIsCalculating(false) }
|
finally { setIsCalculating(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleLock = (assignmentId) => {
|
||||||
|
setLockedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(assignmentId)) next.delete(assignmentId)
|
||||||
|
else next.add(assignmentId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
const handleOptimize = async () => {
|
||||||
if (!selectedDayId) return
|
if (!selectedDayId) return
|
||||||
const da = getDayAssignments(selectedDayId)
|
const da = getDayAssignments(selectedDayId)
|
||||||
if (da.length < 3) return
|
if (da.length < 3) return
|
||||||
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
|
||||||
const optimized = optimizeRoute(withCoords)
|
// Separate locked (stay at their index) and unlocked assignments
|
||||||
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
|
const locked = new Map() // index -> assignment
|
||||||
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
|
const unlocked = []
|
||||||
await onReorder(selectedDayId, reorderedIds)
|
da.forEach((a, i) => {
|
||||||
|
if (lockedIds.has(a.id)) locked.set(i, a)
|
||||||
|
else unlocked.push(a)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Optimize only unlocked places
|
||||||
|
const unlockedWithCoords = unlocked.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
const optimized = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords) : unlockedWithCoords
|
||||||
|
const optimizedQueue = optimized.map(p => unlocked.find(a => a.place?.id === p.id)).filter(Boolean)
|
||||||
|
// Add unlocked without coords at the end
|
||||||
|
for (const a of unlocked) { if (!optimizedQueue.includes(a)) optimizedQueue.push(a) }
|
||||||
|
|
||||||
|
// Merge: locked stay at their index, fill gaps with optimized
|
||||||
|
const result = new Array(da.length)
|
||||||
|
locked.forEach((a, i) => { result[i] = a })
|
||||||
|
let qi = 0
|
||||||
|
for (let i = 0; i < result.length; i++) {
|
||||||
|
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||||
|
}
|
||||||
|
|
||||||
|
await onReorder(selectedDayId, result.map(a => a.id))
|
||||||
toast.success(t('dayplan.toast.routeOptimized'))
|
toast.success(t('dayplan.toast.routeOptimized'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -608,25 +639,61 @@ export default function DayPlanSidebar({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
|
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
onMouseLeave={() => setHoveredId(null)}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '7px 8px 7px 10px',
|
padding: '7px 8px 7px 10px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
background: lockedIds.has(assignment.id)
|
||||||
borderLeft: hasReservation
|
? 'rgba(220,38,38,0.08)'
|
||||||
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||||
: '3px solid transparent',
|
borderLeft: lockedIds.has(assignment.id)
|
||||||
transition: 'background 0.1s',
|
? '3px solid #dc2626'
|
||||||
|
: hasReservation
|
||||||
|
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
||||||
|
: '3px solid transparent',
|
||||||
|
transition: 'background 0.15s, border-color 0.15s',
|
||||||
opacity: isDraggingThis ? 0.4 : 1,
|
opacity: isDraggingThis ? 0.4 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
<PlaceAvatar place={place} category={cat} size={28} />
|
<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)
|
||||||
|
? (language === 'de' ? 'Klicken zum Entsperren' : 'Click to unlock')
|
||||||
|
: (language === 'de' ? 'Position bei Routenoptimierung beibehalten' : 'Keep position during route optimization')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||||
{cat && (() => {
|
{cat && (() => {
|
||||||
@@ -810,15 +877,6 @@ export default function DayPlanSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
|
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
opacity: isCalculating ? 0.6 : 1,
|
|
||||||
}}>
|
|
||||||
<Navigation size={12} strokeWidth={2} />
|
|
||||||
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleOptimize} style={{
|
<button onClick={handleOptimize} style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
|
|||||||
@@ -133,13 +133,8 @@ export default function TripPlannerPage() {
|
|||||||
return places.filter(p => p.lat && p.lng)
|
return places.filter(p => p.lat && p.lng)
|
||||||
}, [places])
|
}, [places])
|
||||||
|
|
||||||
const handleSelectDay = useCallback((dayId) => {
|
const updateRouteForDay = useCallback((dayId) => {
|
||||||
tripStore.setSelectedDay(dayId)
|
if (!dayId) { setRoute(null); setRouteInfo(null); return }
|
||||||
setRouteInfo(null)
|
|
||||||
setFitKey(k => k + 1)
|
|
||||||
setMobileSidebarOpen(null)
|
|
||||||
|
|
||||||
// Auto-show Luftlinien for the selected day
|
|
||||||
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
if (waypoints.length >= 2) {
|
if (waypoints.length >= 2) {
|
||||||
@@ -147,12 +142,22 @@ export default function TripPlannerPage() {
|
|||||||
} else {
|
} else {
|
||||||
setRoute(null)
|
setRoute(null)
|
||||||
}
|
}
|
||||||
|
setRouteInfo(null)
|
||||||
}, [tripStore])
|
}, [tripStore])
|
||||||
|
|
||||||
|
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||||
|
const changed = dayId !== selectedDayId
|
||||||
|
tripStore.setSelectedDay(dayId)
|
||||||
|
if (changed && !skipFit) setFitKey(k => k + 1)
|
||||||
|
setMobileSidebarOpen(null)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}, [tripStore, updateRouteForDay, selectedDayId])
|
||||||
|
|
||||||
const handlePlaceClick = useCallback((placeId) => {
|
const handlePlaceClick = useCallback((placeId) => {
|
||||||
setSelectedPlaceId(placeId)
|
setSelectedPlaceId(placeId)
|
||||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||||
}, [])
|
updateRouteForDay(selectedDayId)
|
||||||
|
}, [selectedDayId, updateRouteForDay])
|
||||||
|
|
||||||
const handleMarkerClick = useCallback((placeId) => {
|
const handleMarkerClick = useCallback((placeId) => {
|
||||||
const opening = placeId !== undefined
|
const opening = placeId !== undefined
|
||||||
@@ -189,16 +194,29 @@ export default function TripPlannerPage() {
|
|||||||
try {
|
try {
|
||||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||||
toast.success(t('trip.toast.assignedToDay'))
|
toast.success(t('trip.toast.assignedToDay'))
|
||||||
|
updateRouteForDay(target)
|
||||||
} catch (err) { toast.error(err.message) }
|
} catch (err) { toast.error(err.message) }
|
||||||
}, [selectedDayId, tripId, tripStore, toast])
|
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||||
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) }
|
try {
|
||||||
|
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
||||||
|
updateRouteForDay(dayId)
|
||||||
|
}
|
||||||
catch (err) { toast.error(err.message) }
|
catch (err) { toast.error(err.message) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, tripStore, toast, updateRouteForDay])
|
||||||
|
|
||||||
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
||||||
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
|
try {
|
||||||
|
await tripStore.reorderAssignments(tripId, dayId, orderedIds)
|
||||||
|
// Build route directly from orderedIds to avoid stale closure
|
||||||
|
const dayItems = tripStore.assignments[String(dayId)] || []
|
||||||
|
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||||
|
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||||
|
if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng]))
|
||||||
|
else setRoute(null)
|
||||||
|
setRouteInfo(null)
|
||||||
|
}
|
||||||
catch { toast.error(t('trip.toast.reorderError')) }
|
catch { toast.error(t('trip.toast.reorderError')) }
|
||||||
}, [tripId, tripStore, toast])
|
}, [tripId, tripStore, toast])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user