import React from 'react' import ReactDOM from 'react-dom' import { useState, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' 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 CustomSelect from '../shared/CustomSelect' 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' 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?: (categoryId: 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, pushUndo, }: PlacesSidebarProps) { const { t } = useTranslation() const toast = useToast() const ctxMenu = useContextMenu() const gpxInputRef = useRef(null) const trip = useTripStore((s) => s.trip) const loadTrip = useTripStore((s) => s.loadTrip) const can = useCanDo() const canEditPlaces = can('place_edit', trip) const handleGpxImport = async (e: React.ChangeEvent) => { const file = e.target.files?.[0] if (!file) return e.target.value = '' try { const result = await placesApi.importGpx(tripId, file) await loadTrip(tripId) toast.success(t('places.gpxImported', { count: result.count })) if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t('undo.importGpx'), 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('places.gpxError')) } } const [googleListOpen, setGoogleListOpen] = useState(false) const [googleListUrl, setGoogleListUrl] = useState('') const [googleListLoading, setGoogleListLoading] = useState(false) const handleGoogleListImport = async () => { if (!googleListUrl.trim()) return setGoogleListLoading(true) try { const result = await placesApi.importGoogleList(tripId, googleListUrl.trim()) await loadTrip(tripId) toast.success(t('places.googleListImported', { count: result.count, list: result.listName })) setGoogleListOpen(false) setGoogleListUrl('') if (result.places?.length > 0) { const importedIds: number[] = result.places.map((p: { id: number }) => p.id) pushUndo?.(t('undo.importGoogleList'), 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('places.googleListError')) } finally { setGoogleListLoading(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) // Notify parent with first selected or empty onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '') 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 (
{/* 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.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: 'env(safe-area-inset-bottom)' }} >
{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 )} {googleListOpen && ReactDOM.createPortal(
{ setGoogleListOpen(false); setGoogleListUrl('') }} 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.importGoogleList')}
{t('places.googleListHint')}
setGoogleListUrl(e.target.value)} onKeyDown={e => { if (e.key === 'Enter' && !googleListLoading) handleGoogleListImport() }} placeholder="https://maps.app.goo.gl/..." 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 )}
) }) export default PlacesSidebar