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:
Maurice
2026-03-21 16:12:13 +01:00
parent 5b6e3d6c1a
commit 544ac796d5
3 changed files with 112 additions and 36 deletions

View File

@@ -97,7 +97,7 @@ function SelectionController({ places, selectedPlaceId }) {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId)
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

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
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 { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
@@ -79,7 +79,7 @@ export default function DayPlanSidebar({
onAddReservation,
}) {
const toast = useToast()
const { t, locale } = useTranslation()
const { t, language, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
@@ -97,6 +97,8 @@ export default function DayPlanSidebar({
const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null)
const [draggingId, setDraggingId] = useState(null)
const [lockedIds, setLockedIds] = useState(new Set())
const [lockHoverId, setLockHoverId] = useState(null)
const [dropTargetKey, setDropTargetKey] = useState(null)
const [dragOverDayId, setDragOverDayId] = useState(null)
const [hoveredId, setHoveredId] = useState(null)
@@ -291,15 +293,44 @@ export default function DayPlanSidebar({
finally { setIsCalculating(false) }
}
const toggleLock = (assignmentId) => {
setLockedIds(prev => {
const next = new Set(prev)
if (next.has(assignmentId)) next.delete(assignmentId)
else next.add(assignmentId)
return next
})
}
const handleOptimize = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
if (da.length < 3) return
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
await onReorder(selectedDayId, reorderedIds)
// Separate locked (stay at their index) and unlocked assignments
const locked = new Map() // index -> assignment
const unlocked = []
da.forEach((a, i) => {
if (lockedIds.has(a.id)) locked.set(i, a)
else unlocked.push(a)
})
// Optimize only unlocked 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'))
}
@@ -608,25 +639,61 @@ export default function DayPlanSidebar({
}
}}
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)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
cursor: 'pointer',
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: hasReservation
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
: '3px solid transparent',
transition: 'background 0.1s',
background: lockedIds.has(assignment.id)
? 'rgba(220,38,38,0.08)'
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: lockedIds.has(assignment.id)
? '3px solid #dc2626'
: hasReservation
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
: '3px solid transparent',
transition: 'background 0.15s, border-color 0.15s',
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' }}>
<GripVertical size={13} strokeWidth={1.8} />
</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={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
@@ -810,15 +877,6 @@ export default function DayPlanSidebar({
)}
<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={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',

View File

@@ -133,13 +133,8 @@ export default function TripPlannerPage() {
return places.filter(p => p.lat && p.lng)
}, [places])
const handleSelectDay = useCallback((dayId) => {
tripStore.setSelectedDay(dayId)
setRouteInfo(null)
setFitKey(k => k + 1)
setMobileSidebarOpen(null)
// Auto-show Luftlinien for the selected day
const updateRouteForDay = useCallback((dayId) => {
if (!dayId) { setRoute(null); setRouteInfo(null); return }
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)
if (waypoints.length >= 2) {
@@ -147,12 +142,22 @@ export default function TripPlannerPage() {
} else {
setRoute(null)
}
setRouteInfo(null)
}, [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) => {
setSelectedPlaceId(placeId)
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
}, [])
updateRouteForDay(selectedDayId)
}, [selectedDayId, updateRouteForDay])
const handleMarkerClick = useCallback((placeId) => {
const opening = placeId !== undefined
@@ -189,16 +194,29 @@ export default function TripPlannerPage() {
try {
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
updateRouteForDay(target)
} catch (err) { toast.error(err.message) }
}, [selectedDayId, tripId, tripStore, toast])
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
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) }
}, [tripId, tripStore, toast])
}, [tripId, tripStore, toast, updateRouteForDay])
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')) }
}, [tripId, tripStore, toast])