import React from 'react' import ReactDOM from 'react-dom' import { useState, useMemo, useEffect, useRef, useCallback } from 'react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' import { useContextMenu, ContextMenu } from '../shared/ContextMenu' import { placesApi } from '../../api/client' import { useTripStore } from '../../store/tripStore' import { useCanDo } from '../../store/permissionsStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' import FileImportModal from './FileImportModal' import ConfirmDialog from '../shared/ConfirmDialog' import Tooltip from '../shared/Tooltip' 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 } interface MemoPlaceRowProps { place: Place category: Category | undefined isSelected: boolean isPlanned: boolean inDay: boolean isChecked: boolean selectMode: boolean selectedDayId: number | null canEditPlaces: boolean isMobile: boolean t: (key: string, params?: Record) => string onPlaceClick: (id: number | null) => void onContextMenu: (e: React.MouseEvent, place: Place) => void onAssignToDay: (placeId: number, dayId?: number) => void toggleSelected: (id: number) => void setDayPickerPlace: (place: any) => void } const MemoPlaceRow = React.memo(function MemoPlaceRow({ place, category: cat, isSelected, isPlanned, inDay, isChecked, selectMode, selectedDayId, canEditPlaces, isMobile, t, onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace, }: MemoPlaceRowProps) { const hasGeometry = Boolean(place.route_geometry) return (
{ e.dataTransfer.setData('placeId', String(place.id)) e.dataTransfer.effectAllowed = 'copy' window.__dragData = { placeId: String(place.id) } }} onClick={() => { if (selectMode) { toggleSelected(place.id) } else if (isMobile) { setDayPickerPlace(place) } else { onPlaceClick(isSelected ? null : place.id) } }} onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px 9px 16px', cursor: selectMode ? 'pointer' : 'grab', background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent', borderBottom: '1px solid var(--border-faint)', transition: 'background 0.1s', contentVisibility: 'auto', containIntrinsicSize: '0 52px', }} onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }} > {selectMode && (
{isChecked && }
)}
{hasGeometry && } {cat && (() => { const CatIcon = getCategoryIcon(cat.icon) return })()} {place.name}
{(place.description || place.address || cat?.name) && (
{place.description || place.address || cat?.name}
)}
{!selectMode && !inDay && selectedDayId && ( )}
) }) const PlacesSidebar = React.memo(function PlacesSidebar({ tripId, places, categories, assignments, selectedDayId, selectedPlaceId, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo, }: PlacesSidebarProps) { 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) const isNaverListImportEnabled = true const [fileImportOpen, setFileImportOpen] = useState(false) const [sidebarDropFile, setSidebarDropFile] = useState(null) const [sidebarDragOver, setSidebarDragOver] = useState(false) const sidebarDragCounter = useRef(0) 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 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 result = provider === 'google' ? await placesApi.importGoogleList(tripId, listImportUrl.trim()) : await placesApi.importNaverList(tripId, listImportUrl.trim()) 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: () => onEditPlace(place) }, selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => 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: () => onDeletePlace(place.id) }, ]) }, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace]) return (
{sidebarDragOver && (
{t('places.sidebarDrop')}
)} {/* Kopfbereich */}
{canEditPlaces && } {canEditPlaces && <>
} {/* Filter-Tabs */} {(() => { const baseFiltered = places.filter(p => { 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 }) const counts = { all: baseFiltered.length, unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length, tracks: baseFiltered.filter(p => p.route_geometry).length, } const tabs = ([ { id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null, ] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }> return (
{tabs.map(f => { const active = filter === f.id return ( ) })}
) })()} {/* Suchfeld */}
{ setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }} placeholder={t('places.search')} style={{ width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10, border: 'none', background: 'var(--bg-tertiary)', fontSize: 12, color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }} /> {search && ( )}
{/* Category multi-select dropdown */} {categories.length > 0 && (() => { const label = categoryFilters.size === 0 ? t('places.allCategories') : categoryFilters.size === 1 ? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')) : `${categoryFilters.size} ${t('places.categoriesSelected')}` return (
{canEditPlaces && ( )} {catDropOpen && (
{categories.map(c => { const active = categoryFilters.has(String(c.id)) const CatIcon = getCategoryIcon(c.icon) return ( ) })} {places.some(p => p.category_id == null) && (() => { const active = categoryFilters.has('uncategorized') return ( ) })()} {categoryFilters.size > 0 && ( )}
)}
) })()}
{/* Anzahl / Auswahl-Leiste */} {selectMode ? (
{t('places.selectionCount', { count: selectedIds.size })} 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
) : (
{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
)} {/* Liste */}
{filtered.length === 0 ? (
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')} {canEditPlaces && }
) : ( filtered.map(place => { const cat = categories.find(c => c.id === place.category_id) const isSelected = place.id === selectedPlaceId const isPlanned = plannedIds.has(place.id) const inDay = inDaySet.has(place.id) const isChecked = selectedIds.has(place.id) return ( ) }) )}
{dayPickerPlace && ReactDOM.createPortal(
{ setDayPickerPlace(null); setMobileShowDays(false) }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'flex-end', justifyContent: 'center' }} >
e.stopPropagation()} style={{ background: 'var(--bg-card)', borderRadius: '20px 20px 0 0', width: '100%', maxWidth: 500, maxHeight: '70vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', paddingBottom: 'var(--bottom-nav-h)' }} >
{dayPickerPlace.name}
{dayPickerPlace.address &&
{dayPickerPlace.address}
}
{/* View details */} {/* Edit */} {canEditPlaces && ( )} {/* Assign to day */} {days?.length > 0 && ( <> {mobileShowDays && (
{days.map((day, i) => ( ))}
)} )} {/* Delete */} {canEditPlaces && ( )}
, document.body )} {listImportOpen && ReactDOM.createPortal(
{ setListImportOpen(false); setListImportUrl('') }} style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }} >
e.stopPropagation()} style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 440, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)' }} >
{t('places.importList')}
{hasMultipleListImportProviders && (
{availableListImportProviders.map(provider => ( ))}
)}
{t(listImportProvider === 'google' ? 'places.googleListHint' : 'places.naverListHint')}
setListImportUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !listImportLoading) handleListImport() }} placeholder={listImportProvider === 'google' ? 'https://maps.app.goo.gl/...' : 'https://naver.me/...'} autoFocus style={{ width: '100%', padding: '10px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-tertiary)', fontSize: 13, color: 'var(--text-primary)', outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box', }} />
, document.body )} { setFileImportOpen(false); setSidebarDropFile(null) }} tripId={tripId} pushUndo={pushUndo} initialFile={sidebarDropFile} /> {isMobile && ( setPendingDeleteIds(null)} onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }} message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })} /> )}
) }) export default PlacesSidebar