import React from 'react' import ReactDOM from 'react-dom' import { useState, useMemo, useEffect, useRef } from 'react' import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } 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 { useAddonStore } from '../../store/addonStore' import type { Place, Category, Day, AssignmentsMap } from '../../types' import FileImportModal from './FileImportModal' 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 days: Day[] isMobile: boolean onCategoryFilterChange?: (categoryIds: Set) => void onPlacesFilterChange?: (filter: string) => void pushUndo?: (label: string, undoFn: () => Promise | void) => void } const PlacesSidebar = React.memo(function PlacesSidebar({ tripId, places, categories, assignments, selectedDayId, selectedPlaceId, onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, 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 = useAddonStore((s) => s.isEnabled('naver_list_import')) 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 () => { for (const id of importedIds) { try { await placesApi.delete(tripId, id) } 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 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 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 (categoryFilters.size > 0 && !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) return (
{sidebarDragOver && (
{t('places.sidebarDrop')}
)} {/* Kopfbereich */}
{canEditPlaces && } {canEditPlaces && <>
} {/* Filter-Tabs */}
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => ( ))}
{/* Suchfeld */}
setSearch(e.target.value)} 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 ? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories') : `${categoryFilters.size} ${t('places.categoriesSelected')}` return (
{catDropOpen && (
{categories.map(c => { const active = categoryFilters.has(String(c.id)) const CatIcon = getCategoryIcon(c.icon) return ( ) })} {categoryFilters.size > 0 && ( )}
)}
) })()}
{/* Anzahl */}
{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 inDay = isAssignedToSelectedDay(place.id) const isPlanned = plannedIds.has(place.id) return (
{ e.dataTransfer.setData('placeId', String(place.id)) e.dataTransfer.effectAllowed = 'copy' // Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren) window.__dragData = { placeId: String(place.id) } }} onClick={() => { if (isMobile) { setDayPickerPlace(place) } else { onPlaceClick(isSelected ? null : place.id) } }} onContextMenu={e => ctxMenu.open(e, [ canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) }, selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) }, 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.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') }, { divider: true }, canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) }, ])} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '9px 14px 9px 16px', cursor: 'grab', background: isSelected ? 'var(--border-faint)' : 'transparent', borderBottom: '1px solid var(--border-faint)', transition: 'background 0.1s', contentVisibility: 'auto', containIntrinsicSize: '0 52px', }} onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }} onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }} >
{cat && (() => { const CatIcon = getCategoryIcon(cat.icon) return })()} {place.name}
{(place.description || place.address || cat?.name) && (
{place.description || place.address || cat?.name}
)}
{!inDay && selectedDayId && ( )}
) }) )}
{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} />
) }) export default PlacesSidebar