import type React from 'react' import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react' import { Pencil, Trash2, ExternalLink, Navigation, CalendarDays } from 'lucide-react' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { useContextMenu } from '../shared/ContextMenu' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import { useAuthStore } from '../../store/authStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' export interface PlacesSidebarProps { tripId: number places: Place[] 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 days: Day[] isMobile: boolean onCategoryFilterChange?: (categoryIds: Set) => void onPlacesFilterChange?: (filter: string) => void pushUndo?: (label: string, undoFn: () => Promise | 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, onCategoryFilterChange, onPlacesFilterChange, pushUndo, initialScrollTop, onScrollTopChange, } = props const { t } = useTranslation() const toast = useToast() const ctxMenu = useContextMenu() const trip = useTripStore((s) => s.trip) const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) // Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it. const canEnrichImport = useAuthStore((s) => s.hasMapsKey) const isNaverListImportEnabled = true const [fileImportOpen, setFileImportOpen] = useState(false) const [sidebarDropFile, setSidebarDropFile] = useState(null) const [sidebarDragOver, setSidebarDragOver] = useState(false) const sidebarDragCounter = useRef(0) const scrollContainerRef = useRef(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'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google'] const hasMultipleListImportProviders = availableListImportProviders.length > 1 useEffect(() => { if (!isNaverListImportEnabled && listImportProvider === 'naver') { setListImportProvider('google') } }, [isNaverListImportEnabled, listImportProvider]) const handleListImport = async () => { if (!listImportUrl.trim()) return setListImportLoading(true) const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google' 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('') const [filter, setFilter] = useState('all') const [categoryFilters, setCategoryFiltersLocal] = useState>(new Set()) const [selectMode, setSelectMode] = useState(false) const [selectedIds, setSelectedIds] = useState>(new Set()) const [pendingDeleteIds, setPendingDeleteIds] = useState(null) const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) } // 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) => { setCategoryFiltersLocal(prev => { const next = new Set(prev) if (next.has(catId)) next.delete(catId); else next.add(catId) onCategoryFilterChange?.(next) return next }) } const [dayPickerPlace, setDayPickerPlace] = useState(null) const [catDropOpen, setCatDropOpen] = 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(() => new Set( Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean)) ), [assignments]) const filtered = useMemo(() => places.filter(p => { if (filter === 'unplanned' && 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 return true }), [places, filter, categoryFilters, search, plannedIds]) const isAssignedToSelectedDay = (placeId) => selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId) const selectedDayIdRef = useRef(selectedDayId) useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId]) const inDaySet = useMemo(() => { if (!selectedDayId) return new Set() return new Set((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 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) }, 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 as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') }, { divider: true }, canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => props.onDeletePlace(place.id) }, ]) }, [ctxMenu.open, canEditPlaces, 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, setCategoryFiltersLocal, selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds, exitSelectMode, toggleSelected, toggleCategoryFilter, dayPickerPlace, setDayPickerPlace, catDropOpen, setCatDropOpen, mobileShowDays, setMobileShowDays, hasTracks, plannedIds, filtered, isAssignedToSelectedDay, inDaySet, openContextMenu, } } export type SidebarState = ReturnType