import React, { useState, useEffect, useRef, useCallback } from 'react' import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } from 'lucide-react' import PlaceAvatar from '../shared/PlaceAvatar' import { mapsApi } from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { getCategoryIcon } from '../shared/categoryIcons' import { useTranslation } from '../../i18n' const detailsCache = new Map() function getSessionCache(key) { try { const raw = sessionStorage.getItem(key) return raw ? JSON.parse(raw) : undefined } catch { return undefined } } function setSessionCache(key, value) { try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {} } function useGoogleDetails(googlePlaceId, language) { const [details, setDetails] = useState(null) const cacheKey = `gdetails_${googlePlaceId}_${language}` useEffect(() => { if (!googlePlaceId) { setDetails(null); return } // In-memory cache (fastest) if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return } // sessionStorage cache (survives reload) const cached = getSessionCache(cacheKey) if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return } // Fetch from API mapsApi.details(googlePlaceId, language).then(data => { detailsCache.set(cacheKey, data.place) setSessionCache(cacheKey, data.place) setDetails(data.place) }).catch(() => {}) }, [googlePlaceId, language]) return details } function getWeekdayIndex(dateStr) { // weekdayDescriptions[0] = Monday … [6] = Sunday const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date() const jsDay = d.getDay() return jsDay === 0 ? 6 : jsDay - 1 } function convertHoursLine(line, timeFormat) { if (!line) return '' const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line) if (timeFormat === '12h' && !hasAmPm) { // 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => { const hour = parseInt(h) if (isNaN(hour)) return match const period = hour >= 12 ? 'PM' : 'AM' const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour return `${h12}:${m} ${period}` }) } if (timeFormat !== '12h' && hasAmPm) { // 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00" return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => { let hour = parseInt(h) if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12 if (p.toUpperCase() === 'AM' && hour === 12) hour = 0 return `${String(hour).padStart(2, '0')}:${m}` }) } return line } function formatTime(timeStr, locale, timeFormat) { if (!timeStr) return '' try { const parts = timeStr.split(':') const h = Number(parts[0]) || 0 const m = Number(parts[1]) || 0 if (isNaN(h)) return timeStr if (timeFormat === '12h') { const period = h >= 12 ? 'PM' : 'AM' const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h return `${h12}:${String(m).padStart(2, '0')} ${period}` } const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}` return locale?.startsWith('de') ? `${str} Uhr` : str } catch { return timeStr } } function formatFileSize(bytes) { if (!bytes) return '' if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / 1024 / 1024).toFixed(1)} MB` } export default function PlaceInspector({ place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [], onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment, files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace, }) { const { t, locale, language } = useTranslation() const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h' const [hoursExpanded, setHoursExpanded] = useState(false) const [filesExpanded, setFilesExpanded] = useState(false) const [isUploading, setIsUploading] = useState(false) const [editingName, setEditingName] = useState(false) const [nameValue, setNameValue] = useState('') const nameInputRef = useRef(null) const fileInputRef = useRef(null) const googleDetails = useGoogleDetails(place?.google_place_id, language) const startNameEdit = () => { if (!onUpdatePlace) return setNameValue(place.name || '') setEditingName(true) setTimeout(() => nameInputRef.current?.focus(), 0) } const commitNameEdit = () => { if (!editingName) return const trimmed = nameValue.trim() setEditingName(false) if (!trimmed || trimmed === place.name) return onUpdatePlace(place.id, { name: trimmed }) } const handleNameKeyDown = (e) => { if (e.key === 'Enter') { e.preventDefault(); commitNameEdit() } if (e.key === 'Escape') setEditingName(false) } if (!place) return null const category = categories?.find(c => c.id === place.category_id) const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : [] const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null const openingHours = googleDetails?.opening_hours || null const openNow = googleDetails?.open_now ?? null const selectedDay = days?.find(d => d.id === selectedDayId) const weekdayIndex = getWeekdayIndex(selectedDay?.date) const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id)) const handleFileUpload = useCallback(async (e) => { const selectedFiles = Array.from(e.target.files || []) if (!selectedFiles.length || !onFileUpload) return setIsUploading(true) try { for (const file of selectedFiles) { const fd = new FormData() fd.append('file', file) fd.append('place_id', place.id) await onFileUpload(fd) } setFilesExpanded(true) } catch (err) { console.error('Upload failed', err) } finally { setIsUploading(false) if (fileInputRef.current) fileInputRef.current.value = '' } }, [onFileUpload, place.id]) return (
{/* Header */}
{/* Avatar with open/closed ring + tag */}
{openNow !== null && ( {openNow ? t('inspector.opened') : t('inspector.closed')} )}
{editingName ? ( setNameValue(e.target.value)} onBlur={commitNameEdit} onKeyDown={handleNameKeyDown} style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }} /> ) : ( {place.name} )} {category && (() => { const CatIcon = getCategoryIcon(category.icon) return ( {category.name} ) })()}
{place.address && (
{place.address}
)} {place.place_time && (
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
)} {place.lat && place.lng && (
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
)}
{/* Content — scrollable */}
{/* Info-Chips — hidden on mobile, shown on desktop */}
{googleDetails?.rating && (() => { const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5) return ( } text={<> {googleDetails.rating.toFixed(1)} {googleDetails.rating_count ? ({googleDetails.rating_count.toLocaleString('de-DE')}) : ''} {shortReview && · „{shortReview.text}"} } color="var(--text-secondary)" bg="var(--bg-hover)" /> ) })()} {place.price > 0 && ( } text={`${place.price} ${place.currency || '€'}`} color="#059669" bg="#ecfdf5" /> )}
{/* Telefon */} {place.phone && (
{place.phone}
)} {/* Description */} {(place.description || place.notes) && (

{place.description || place.notes}

)} {/* Reservation + Participants — side by side */} {(() => { const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null const currentParticipants = assignment?.participants || [] const participantIds = currentParticipants.map(p => p.user_id) const allJoined = currentParticipants.length === 0 const showParticipants = selectedAssignmentId && tripMembers.length > 1 if (!res && !showParticipants) return null return (
{/* Reservation */} {res && (() => { const confirmed = res.status === 'confirmed' return (
{confirmed ? t('reservations.confirmed') : t('reservations.pending')} {res.title}
{res.reservation_time && (
{t('reservations.date')}
{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}
)} {res.reservation_time && (
{t('reservations.time')}
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
)} {res.confirmation_number && (
{t('reservations.confirmationCode')}
{res.confirmation_number}
)}
{res.notes &&
{res.notes}
}
) })()} {/* Participants */} {showParticipants && ( )}
) })()} {/* Opening hours + Files — side by side on desktop only if both exist */}
0 ? 'sm:grid-cols-2' : ''} gap-2`}> {openingHours && openingHours.length > 0 && (
{hoursExpanded && (
{openingHours.map((line, i) => (
{convertHoursLine(line, timeFormat)}
))}
)}
)} {/* Files section */} {(placeFiles.length > 0 || onFileUpload) && (
{onFileUpload && ( )}
{filesExpanded && placeFiles.length > 0 && ( )}
)}
{/* Footer actions */}
{selectedDayId && ( assignmentInDay ? ( onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={} label={<>{t('inspector.removeFromDay')}Remove} /> ) : ( onAssignToDay(place.id)} variant="primary" icon={} label={t('inspector.addToDay')} /> ) )} {googleDetails?.google_maps_url && ( window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={} label={{t('inspector.google')}} /> )} {place.website && ( window.open(place.website, '_blank')} variant="ghost" icon={} label={{t('inspector.website')}} /> )}
} label={{t('common.edit')}} /> } label={{t('common.delete')}} />
) } function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) { return (
{icon} {text}
) } function Row({ icon, children }) { return (
{icon}
{children}
) } function ActionButton({ onClick, variant, icon, label }) { const base = { primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' }, ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' }, danger: { background: 'rgba(239,68,68,0.08)', color: '#dc2626', border: 'none', hoverBg: 'rgba(239,68,68,0.16)' }, } const s = base[variant] || base.ghost return ( ) } function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) { const [showAdd, setShowAdd] = React.useState(false) const [hoveredId, setHoveredId] = React.useState(null) // Active participants: if allJoined, show all members; otherwise show only those in participantIds const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id)) const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id)) const handleRemove = (userId) => { if (!onSetParticipants) return let newIds if (allJoined) { newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id) } else { newIds = participantIds.filter(id => id !== userId) } if (newIds.length === tripMembers.length) newIds = [] onSetParticipants(selectedAssignmentId, selectedDayId, newIds) } const handleAdd = (userId) => { if (!onSetParticipants) return const newIds = [...participantIds, userId] if (newIds.length === tripMembers.length) { onSetParticipants(selectedAssignmentId, selectedDayId, []) } else { onSetParticipants(selectedAssignmentId, selectedDayId, newIds) } setShowAdd(false) } return (
{t('inspector.participants')}
{activeMembers.map(member => { const isHovered = hoveredId === member.id const canRemove = activeMembers.length > 1 return (
setHoveredId(member.id)} onMouseLeave={() => setHoveredId(null)} onClick={() => { if (canRemove) handleRemove(member.id) }} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px 2px 3px', borderRadius: 99, border: `1.5px solid ${isHovered && canRemove ? 'rgba(239,68,68,0.4)' : 'var(--accent)'}`, background: isHovered && canRemove ? 'rgba(239,68,68,0.06)' : 'var(--bg-hover)', fontSize: 10, fontWeight: 500, color: isHovered && canRemove ? '#ef4444' : 'var(--text-primary)', cursor: canRemove ? 'pointer' : 'default', transition: 'all 0.15s', }}>
{(member.avatar_url || member.avatar) ? : member.username?.[0]?.toUpperCase()}
{member.username}
) })} {/* Add button */} {availableToAdd.length > 0 && (
{showAdd && (
{availableToAdd.map(member => ( ))}
)}
)}
) }