mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 06:26:26 +00:00
4d6820a4d4
fix(planner): route a layover flight per leg, and count linked places as planned Two v4.0.0 reports, both a surface answering a question with less than it had. The map built its own waypoint order. It read the BOOKING position and never expanded metadata.legs, while a multi-leg booking stores its position per leg — so a Frankfurt to Vancouver flight via Reykjavik was dropped from the list and the airport before it was joined to the airport after it in one driving run across the Atlantic. It now takes the order from getMergedItems, the same one the day plan renders, which brings the span filter and the leg expansion with it. getMergedItems in turn had never read day_plan_position, the one slot the map did read, which is how the two could disagree at all. getTransportRouteEndpoints handed back the booking outermost airports for every leg. A stop between two legs was therefore routed from the arrival airport back to the departure one — a drive from Vancouver to Frankfurt with a cafe in Reykjavik on the way. A leg now spans its own pair, when the endpoints line up one per stop with the legs; anything ambiguous keeps the role lookup. Being planned meant one thing: a day_assignments row. A hotel is linked through its stay and a venue through its booking, and neither is ever dragged onto a day, so both sat under Unplanned while the day header printed their names. The three places that asked the question — desktop sidebar, map markers, phone browser — now ask one helper that also counts a stay and a day-anchored booking. A booking that names no day still counts as unplanned, because nothing says when it happens, and the day plan renders nothing for it either. @
321 lines
15 KiB
TypeScript
321 lines
15 KiB
TypeScript
import type React from 'react'
|
|
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'
|
|
import { Pencil, Trash2, ExternalLink, Navigation, CalendarDays, Bookmark } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
import { useToast } from '../shared/Toast'
|
|
import { useContextMenu } from '../shared/ContextMenu'
|
|
import { placesApi } from '../../api/client'
|
|
import { collectionsApi } from '../../api/collections'
|
|
import { useTripStore } from '../../store/tripStore'
|
|
import { useCanDo } from '../../store/permissionsStore'
|
|
import { useAuthStore } from '../../store/authStore'
|
|
import { useAddonStore } from '../../store/addonStore'
|
|
import { useSaveToCollectionStore } from '../../store/saveToCollectionStore'
|
|
import { placeToSaveTarget } from '../Collections/saveTarget'
|
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
|
import { getGoogleMapsUrlForPlace } from './placeGoogleMaps'
|
|
import { safeHttpUrl } from '../../utils/safeUrl'
|
|
import { plannedPlaceIds, type PlannedAccommodation } from '../../utils/plannedPlaces'
|
|
|
|
/** Stable identity — a fresh [] default would invalidate the planned memo on every render. */
|
|
const NO_ACCOMMODATIONS: PlannedAccommodation[] = []
|
|
|
|
export interface PlacesSidebarProps {
|
|
tripId: number
|
|
places: Place[]
|
|
/** The trip's stays — hook-local state in useTripPlanner, so it arrives as a prop. */
|
|
accommodations?: PlannedAccommodation[]
|
|
categories: Category[]
|
|
assignments: AssignmentsMap
|
|
selectedDayId: number | null
|
|
selectedPlaceId: number | null
|
|
onPlaceClick: (placeId: number | null) => void
|
|
onAddPlace: () => void
|
|
onAssignToDay: (placeId: number, dayId: number) => void
|
|
onEditPlace: (place: Place) => void
|
|
onDeletePlace: (placeId: number) => void
|
|
onBulkDeletePlaces?: (ids: number[]) => void
|
|
onBulkDeleteConfirm?: (ids: number[]) => void
|
|
onBulkChangeCategory?: (ids: number[], categoryId: number | null) => void
|
|
days: Day[]
|
|
isMobile: boolean
|
|
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
|
initialScrollTop?: number
|
|
onScrollTopChange?: (top: number) => void
|
|
}
|
|
|
|
/**
|
|
* Sidebar state: file/list import, search + filter + category multi-select,
|
|
* multi-select/bulk-delete and the mobile day-picker sheet. Kept in one hook so
|
|
* PlacesSidebar stays a thin layout shell over the sub-sections below.
|
|
*/
|
|
export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|
const {
|
|
tripId, places, assignments, selectedDayId, accommodations = NO_ACCOMMODATIONS,
|
|
pushUndo, initialScrollTop, onScrollTopChange,
|
|
} = props
|
|
const { t } = useTranslation()
|
|
const toast = useToast()
|
|
const ctxMenu = useContextMenu()
|
|
const trip = useTripStore((s) => s.trip)
|
|
// A booking plans the place it points at, so the pool has to see them (#2072).
|
|
const reservations = useTripStore((s) => s.reservations)
|
|
const loadTrip = useTripStore((s) => s.loadTrip)
|
|
const can = useCanDo()
|
|
const canEditPlaces = can('place_edit', trip)
|
|
const collectionsEnabled = useAddonStore((s) => s.isEnabled('collections'))
|
|
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
|
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
|
|
|
const [fileImportOpen, setFileImportOpen] = useState(false)
|
|
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
|
|
const [sidebarDragOver, setSidebarDragOver] = useState(false)
|
|
const sidebarDragCounter = useRef(0)
|
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
|
const placeRowRefs = useRef(new Map<number, HTMLDivElement>())
|
|
const lastAutoScrolledPlaceIdRef = useRef<number | null>(null)
|
|
useLayoutEffect(() => {
|
|
if (scrollContainerRef.current && initialScrollTop) {
|
|
scrollContainerRef.current.scrollTop = initialScrollTop
|
|
}
|
|
}, [])
|
|
|
|
const handleSidebarDragEnter = (e: React.DragEvent) => {
|
|
if (!canEditPlaces) return
|
|
e.preventDefault()
|
|
sidebarDragCounter.current++
|
|
setSidebarDragOver(true)
|
|
}
|
|
|
|
const handleSidebarDragOver = (e: React.DragEvent) => {
|
|
if (!canEditPlaces) return
|
|
e.preventDefault()
|
|
}
|
|
|
|
const handleSidebarDragLeave = () => {
|
|
sidebarDragCounter.current--
|
|
if (sidebarDragCounter.current === 0) setSidebarDragOver(false)
|
|
}
|
|
|
|
const handleSidebarDrop = (e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
sidebarDragCounter.current = 0
|
|
setSidebarDragOver(false)
|
|
if (!canEditPlaces) return
|
|
const f = e.dataTransfer.files[0]
|
|
if (!f) return
|
|
setSidebarDropFile(f)
|
|
setFileImportOpen(true)
|
|
}
|
|
|
|
const [listImportOpen, setListImportOpen] = useState(false)
|
|
const [listImportUrl, setListImportUrl] = useState('')
|
|
const [listImportLoading, setListImportLoading] = useState(false)
|
|
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
|
const [listImportEnrich, setListImportEnrich] = useState(false)
|
|
const availableListImportProviders: Array<'google' | 'naver'> = ['google', 'naver']
|
|
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
|
|
|
const handleListImport = async () => {
|
|
if (!listImportUrl.trim()) return
|
|
setListImportLoading(true)
|
|
const provider = listImportProvider
|
|
try {
|
|
const enrich = listImportEnrich && canEnrichImport
|
|
const result = provider === 'google'
|
|
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
|
|
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
|
|
await loadTrip(tripId)
|
|
if (result.count === 0 && result.skipped > 0) {
|
|
toast.warning(t('places.importAllSkipped'))
|
|
} else {
|
|
toast.success(t(provider === 'google' ? 'places.googleListImported' : 'places.naverListImported', { count: result.count, list: result.listName }))
|
|
}
|
|
setListImportOpen(false)
|
|
setListImportUrl('')
|
|
if (result.places?.length > 0) {
|
|
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
|
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
|
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
|
await loadTrip(tripId)
|
|
})
|
|
}
|
|
} catch (err: any) {
|
|
toast.error(err?.response?.data?.error || t(provider === 'google' ? 'places.googleListError' : 'places.naverListError'))
|
|
} finally {
|
|
setListImportLoading(false)
|
|
}
|
|
}
|
|
|
|
const [search, setSearch] = useState('')
|
|
// Filter state lives in the trip store so it survives the Plan tab
|
|
// unmounting (tab switch, mobile sheet close) and stays in lockstep with the
|
|
// map markers, which filter on the same values (#1541).
|
|
const filter = useTripStore((s) => s.placesFilter)
|
|
const setFilter = useTripStore((s) => s.setPlacesFilter)
|
|
const categoryFilters = useTripStore((s) => s.placesCategoryFilter)
|
|
const setCategoryFilters = useTripStore((s) => s.setPlacesCategoryFilter)
|
|
const [selectMode, setSelectMode] = useState(false)
|
|
// Star sort (#1435): list-only toggle, so it stays local (the map keeps its order).
|
|
// Minimum average stars, matching the collections filter (#1435): 'all', or a
|
|
// floor of 1..5 that unrated places fall through. It replaced a sort toggle,
|
|
// which put the best first but still left everything else on the list — no
|
|
// help at all when the point is to see only what the group actually rated.
|
|
const [ratingFilter, setRatingFilter] = useState<number | 'all'>('all')
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
|
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
|
const [categoryPickerOpen, setCategoryPickerOpen] = useState(false)
|
|
const [saveToListOpen, setSaveToListOpen] = useState(false)
|
|
|
|
const [markVisitedBusy, setMarkVisitedBusy] = useState(false)
|
|
|
|
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
|
|
|
/**
|
|
* "I have been to these" for the selection, applied wherever the places are
|
|
* saved in the library (#1469). The server does the matching, so a place saved
|
|
* under a different name in a list is still found.
|
|
*/
|
|
const markSelectionVisited = useCallback(async () => {
|
|
const ids = Array.from(selectedIds)
|
|
if (ids.length === 0 || markVisitedBusy) return
|
|
setMarkVisitedBusy(true)
|
|
try {
|
|
const { updated, places: matchedPlaces } = await collectionsApi.setStatusFromTrip(props.tripId, ids, 'visited')
|
|
if (updated === 0) toast.info(t('collections.markVisitedNone'))
|
|
else toast.success(t('collections.markedVisitedTrip', { count: matchedPlaces ?? 0 }))
|
|
exitSelectMode()
|
|
} catch {
|
|
toast.error(t('common.error'))
|
|
} finally {
|
|
setMarkVisitedBusy(false)
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [selectedIds, markVisitedBusy, props.tripId, t])
|
|
|
|
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
|
useEffect(() => {
|
|
if (!selectMode || selectedIds.size === 0) return
|
|
const placeIdSet = new Set(places.map(p => p.id))
|
|
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
|
|
setSelectMode(false)
|
|
setSelectedIds(new Set())
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [places])
|
|
|
|
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id); else next.add(id)
|
|
return next
|
|
}), [])
|
|
|
|
const toggleCategoryFilter = (catId: string) => {
|
|
const next = new Set(categoryFilters)
|
|
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
|
setCategoryFilters(next)
|
|
}
|
|
const [dayPickerPlace, setDayPickerPlace] = useState<Place | null>(null)
|
|
const [catDropOpen, setCatDropOpen] = useState(false)
|
|
const [starDropOpen, setStarDropOpen] = useState(false)
|
|
const [mobileShowDays, setMobileShowDays] = useState(false)
|
|
|
|
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
|
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
|
|
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
|
|
|
const plannedIds = useMemo(
|
|
() => plannedPlaceIds({ assignments, accommodations, reservations }),
|
|
[assignments, accommodations, reservations],
|
|
)
|
|
|
|
const filtered = useMemo(() => {
|
|
const list = places.filter(p => {
|
|
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
|
if (filter === 'planned' && !plannedIds.has(p.id)) return false
|
|
if (filter === 'tracks' && !p.route_geometry) return false
|
|
if (categoryFilters.size > 0) {
|
|
if (p.category_id == null) {
|
|
if (!categoryFilters.has('uncategorized')) return false
|
|
} else if (!categoryFilters.has(String(p.category_id))) return false
|
|
}
|
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
|
if (ratingFilter !== 'all' && (p.rating_avg == null || p.rating_avg < ratingFilter)) return false
|
|
return true
|
|
})
|
|
return list
|
|
}, [places, filter, categoryFilters, search, plannedIds, ratingFilter])
|
|
|
|
const registerPlaceRow = useCallback((placeId: number, element: HTMLDivElement | null) => {
|
|
if (element) {
|
|
placeRowRefs.current.set(placeId, element)
|
|
} else {
|
|
placeRowRefs.current.delete(placeId)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!props.selectedPlaceId) {
|
|
lastAutoScrolledPlaceIdRef.current = null
|
|
return
|
|
}
|
|
if (lastAutoScrolledPlaceIdRef.current === props.selectedPlaceId) return
|
|
if (!filtered.some(place => place.id === props.selectedPlaceId)) return
|
|
|
|
const selectedRow = placeRowRefs.current.get(props.selectedPlaceId)
|
|
if (!selectedRow) return
|
|
selectedRow.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
lastAutoScrolledPlaceIdRef.current = props.selectedPlaceId
|
|
}, [filtered, props.selectedPlaceId])
|
|
|
|
const isAssignedToSelectedDay = (placeId) =>
|
|
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
|
|
|
const selectedDayIdRef = useRef<number | null>(selectedDayId)
|
|
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
|
|
|
|
const inDaySet = useMemo(() => {
|
|
if (!selectedDayId) return new Set<number>()
|
|
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
|
}, [assignments, selectedDayId])
|
|
|
|
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
|
const selDayId = selectedDayIdRef.current
|
|
const googleMapsUrl = getGoogleMapsUrlForPlace(place)
|
|
ctxMenu.open(e, [
|
|
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => props.onEditPlace(place) },
|
|
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => props.onAssignToDay(place.id, selDayId) },
|
|
safeHttpUrl(place.website) && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(safeHttpUrl(place.website)!, '_blank', 'noopener,noreferrer') },
|
|
googleMapsUrl && { label: t('inspector.google'), icon: Navigation, onClick: () => window.open(googleMapsUrl, '_blank') },
|
|
collectionsEnabled && { label: t('inspector.saveToCollection'), icon: Bookmark, onClick: () => useSaveToCollectionStore.getState().open(placeToSaveTarget(place)) },
|
|
{ divider: true },
|
|
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) },
|
|
])
|
|
}, [ctxMenu.open, canEditPlaces, collectionsEnabled, t, props.onEditPlace, props.onAssignToDay, props.onDeletePlace])
|
|
|
|
return {
|
|
...props,
|
|
t, toast, ctxMenu, trip, canEditPlaces,
|
|
fileImportOpen, setFileImportOpen, sidebarDropFile, setSidebarDropFile,
|
|
sidebarDragOver, handleSidebarDragEnter, handleSidebarDragOver, handleSidebarDragLeave, handleSidebarDrop,
|
|
scrollContainerRef, onScrollTopChange,
|
|
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
|
listImportLoading, listImportProvider, setListImportProvider,
|
|
listImportEnrich, setListImportEnrich, canEnrichImport,
|
|
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
|
search, setSearch, filter, setFilter, categoryFilters, setCategoryFilters,
|
|
ratingFilter, setRatingFilter,
|
|
starDropOpen, setStarDropOpen,
|
|
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
|
categoryPickerOpen, setCategoryPickerOpen,
|
|
saveToListOpen, setSaveToListOpen, collectionsEnabled, tripId,
|
|
markSelectionVisited, markVisitedBusy,
|
|
exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace,
|
|
catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays,
|
|
hasTracks, plannedIds, filtered, registerPlaceRow, isAssignedToSelectedDay, inDaySet, openContextMenu,
|
|
}
|
|
}
|
|
|
|
export type SidebarState = ReturnType<typeof usePlacesSidebar>
|