diff --git a/client/src/components/Map/MapView.jsx b/client/src/components/Map/MapView.jsx index 08d2241..7e561f9 100644 --- a/client/src/components/Map/MapView.jsx +++ b/client/src/components/Map/MapView.jsx @@ -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 diff --git a/client/src/components/Planner/DayPlanSidebar.jsx b/client/src/components/Planner/DayPlanSidebar.jsx index d60eaeb..e36a15c 100644 --- a/client/src/components/Planner/DayPlanSidebar.jsx +++ b/client/src/components/Planner/DayPlanSidebar.jsx @@ -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, }} >