Initial commit — NOMAD (Navigation Organizer for Maps, Activities & Destinations)
Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
893
client/src/components/Planner/PlannerSidebar.jsx
Normal file
893
client/src/components/Planner/PlannerSidebar.jsx
Normal file
@@ -0,0 +1,893 @@
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
|
||||
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
|
||||
CalendarDays, FileText, Check, Pencil, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import FileManager from '../Files/FileManager'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
const SEGMENTS = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'orte', label: 'Orte' },
|
||||
{ id: 'reservierungen', label: 'Buchungen' },
|
||||
{ id: 'packliste', label: 'Packliste' },
|
||||
{ id: 'dokumente', label: 'Dokumente' },
|
||||
]
|
||||
|
||||
const TRANSPORT_MODES = [
|
||||
{ value: 'driving', label: 'Auto', icon: '🚗' },
|
||||
{ value: 'walking', label: 'Fuß', icon: '🚶' },
|
||||
{ value: 'cycling', label: 'Rad', icon: '🚲' },
|
||||
]
|
||||
|
||||
function formatShortDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch { return dt }
|
||||
}
|
||||
|
||||
export default function PlannerSidebar({
|
||||
trip, days, places, categories, tags,
|
||||
assignments, reservations, packingItems,
|
||||
selectedDayId, selectedPlaceId,
|
||||
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
|
||||
onAssignToDay, onRemoveAssignment, onReorder,
|
||||
onAddPlace, onEditTrip, onRouteCalculated, tripId,
|
||||
}) {
|
||||
const [activeSegment, setActiveSegment] = useState('plan')
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [transportMode, setTransportMode] = useState('driving')
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
const [expandedDays, setExpandedDays] = useState(new Set())
|
||||
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
|
||||
const [noteUi, setNoteUi] = useState({})
|
||||
const noteInputRef = useRef(null)
|
||||
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
|
||||
// Auto-expand selected day
|
||||
useEffect(() => {
|
||||
if (selectedDayId) {
|
||||
setExpandedDays(prev => new Set([...prev, selectedDayId]))
|
||||
}
|
||||
}, [selectedDayId])
|
||||
|
||||
const toggleDay = (dayId) => {
|
||||
setExpandedDays(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(dayId)) next.delete(dayId)
|
||||
else next.add(dayId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
|
||||
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
|
||||
|
||||
const filteredPlaces = places.filter(p => {
|
||||
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchSearch && matchCat
|
||||
})
|
||||
|
||||
const isAssignedToDay = (placeId) =>
|
||||
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
const totalCost = days.reduce((sum, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
// Get representative location for a day (first place with coords)
|
||||
const getDayLocation = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const p = da.find(a => a.place?.lat && a.place?.lng)
|
||||
return p ? { lat: p.place.lat, lng: p.place.lng } : null
|
||||
}
|
||||
|
||||
// Route handlers
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = selectedDayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
if (waypoints.length < 2) {
|
||||
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
|
||||
return
|
||||
}
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, transportMode)
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success('Route berechnet')
|
||||
} catch {
|
||||
toast.error('Route konnte nicht berechnet werden')
|
||||
} finally {
|
||||
setIsCalculatingRoute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimizeRoute = async () => {
|
||||
if (!selectedDayId || selectedDayAssignments.length < 3) return
|
||||
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(withCoords)
|
||||
const reorderedIds = optimized
|
||||
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
|
||||
.filter(Boolean)
|
||||
// Append assignments without coordinates at end
|
||||
for (const a of selectedDayAssignments) {
|
||||
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
|
||||
}
|
||||
await onReorder(selectedDayId, reorderedIds)
|
||||
toast.success('Route optimiert')
|
||||
}
|
||||
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const url = generateGoogleMapsUrl(ps)
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error('Keine Orte mit Koordinaten vorhanden')
|
||||
}
|
||||
|
||||
const handleMoveUp = async (dayId, idx) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (idx === 0) return
|
||||
const ids = da.map(a => a.id)
|
||||
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
|
||||
await onReorder(dayId, ids)
|
||||
}
|
||||
|
||||
const handleMoveDown = async (dayId, idx) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
if (idx === da.length - 1) return
|
||||
const ids = da.map(a => a.id)
|
||||
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
|
||||
await onReorder(dayId, ids)
|
||||
}
|
||||
|
||||
// Merge place assignments + day notes into a single sorted list
|
||||
const getMergedDayItems = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
return [
|
||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
||||
].sort((a, b) => a.sortKey - b.sortKey)
|
||||
}
|
||||
|
||||
const openAddNote = (dayId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note) => {
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const cancelNote = (dayId) => {
|
||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId) => {
|
||||
const ui = noteUi[dayId]
|
||||
if (!ui?.text?.trim()) return
|
||||
try {
|
||||
if (ui.mode === 'add') {
|
||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteNote = async (dayId, noteId) => {
|
||||
try {
|
||||
await tripStore.deleteDayNote(tripId, dayId, noteId)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteMoveUp = async (dayId, noteId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
|
||||
if (idx <= 0) return
|
||||
const newSortOrder = idx >= 2
|
||||
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
|
||||
: merged[idx - 1].sortKey - 1
|
||||
try {
|
||||
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleNoteMoveDown = async (dayId, noteId) => {
|
||||
const merged = getMergedDayItems(dayId)
|
||||
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
|
||||
if (idx === -1 || idx >= merged.length - 1) return
|
||||
const newSortOrder = idx < merged.length - 2
|
||||
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
|
||||
: merged[idx + 1].sortKey + 1
|
||||
try {
|
||||
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success('Reservierung aktualisiert')
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success('Reservierung hinzugefügt')
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm('Reservierung löschen?')) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success('Reservierung gelöscht')
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Inspector: show when a place is selected
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
|
||||
|
||||
{/* Trip header */}
|
||||
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<button onClick={onEditTrip} className="w-full text-left group">
|
||||
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
|
||||
{trip?.title}
|
||||
</h1>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{trip.start_date && formatShortDate(trip.start_date)}
|
||||
{trip.start_date && trip.end_date && ' – '}
|
||||
{trip.end_date && formatShortDate(trip.end_date)}
|
||||
{days.length > 0 && ` · ${days.length} Tage`}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Segmented control */}
|
||||
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
|
||||
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
|
||||
{SEGMENTS.map(seg => (
|
||||
<button
|
||||
key={seg.id}
|
||||
onClick={() => setActiveSegment(seg.id)}
|
||||
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
|
||||
activeSegment === seg.id
|
||||
? 'bg-white shadow-sm text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{/* ── PLAN ── */}
|
||||
{activeSegment === 'plan' && (
|
||||
<div className="pb-4">
|
||||
{/* Alle Orte */}
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
|
||||
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
|
||||
}`}>
|
||||
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
Alle Orte
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-10 text-center">
|
||||
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-400">Noch keine Tage geplant</p>
|
||||
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
|
||||
Reise bearbeiten →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const isExpanded = expandedDays.has(day.id)
|
||||
const da = getDayAssignments(day.id)
|
||||
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
const loc = getDayLocation(day.id)
|
||||
const merged = getMergedDayItems(day.id)
|
||||
const dayNoteUi = noteUi[day.id]
|
||||
const placeItems = merged.filter(i => i.type === 'place')
|
||||
|
||||
return (
|
||||
<div key={day.id} className="border-b border-gray-50">
|
||||
{/* Day header row */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
|
||||
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
onClick={() => {
|
||||
onSelectDay(day.id)
|
||||
if (!isExpanded) toggleDay(day.id)
|
||||
}}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
|
||||
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
|
||||
{day.title || `Tag ${index + 1}`}
|
||||
</p>
|
||||
{da.length > 0 && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
{da.length} {da.length === 1 ? 'Ort' : 'Orte'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
|
||||
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
|
||||
{day.date && loc && (
|
||||
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
|
||||
title="Notiz hinzufügen"
|
||||
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
|
||||
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="w-4 h-4" />
|
||||
: <ChevronRight className="w-4 h-4" />
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded items: places + notes interleaved */}
|
||||
{isExpanded && (
|
||||
<div className="bg-gray-50/40">
|
||||
{merged.length === 0 && !dayNoteUi ? (
|
||||
<div className="px-4 py-4 text-center">
|
||||
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p>
|
||||
<button
|
||||
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
|
||||
className="mt-1 text-xs text-slate-700"
|
||||
>
|
||||
+ Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-100/60">
|
||||
{merged.map((item, idx) => {
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = place.id === selectedPlaceId
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`place-${assignment.id}`}
|
||||
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
|
||||
}`}
|
||||
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
|
||||
{place.name}
|
||||
</p>
|
||||
{(place.description || place.notes) && (
|
||||
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
|
||||
{place.description || place.notes}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
{place.place_time && (
|
||||
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
|
||||
)}
|
||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
|
||||
place.reservation_status === 'confirmed'
|
||||
? 'bg-emerald-50 text-emerald-600'
|
||||
: 'bg-amber-50 text-amber-600'
|
||||
}`}>
|
||||
{place.reservation_status === 'confirmed' ? '✓ Bestätigt' : '⏳ Res. ausstehend'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
|
||||
disabled={placeIdx === 0}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
|
||||
disabled={placeIdx === placeItems.length - 1}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Note card
|
||||
const note = item.data
|
||||
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
|
||||
if (isEditingThis) {
|
||||
return (
|
||||
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={dayNoteUi.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||
placeholder="Zeit (optional)"
|
||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteInputRef}
|
||||
value={dayNoteUi.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||
placeholder="Notiz…"
|
||||
rows={2}
|
||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||
<Check className="w-3 h-3" /> Speichern
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
|
||||
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{note.time && (
|
||||
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
|
||||
)}
|
||||
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline "add note" form */}
|
||||
{dayNoteUi?.mode === 'add' && (
|
||||
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
|
||||
<div className="flex gap-2 mb-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={dayNoteUi.time}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
|
||||
placeholder="Zeit (optional)"
|
||||
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
ref={noteInputRef}
|
||||
value={dayNoteUi.text}
|
||||
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
|
||||
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…"
|
||||
rows={2}
|
||||
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
|
||||
<Check className="w-3 h-3" /> Hinzufügen
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add note button */}
|
||||
{!dayNoteUi && (
|
||||
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
|
||||
<button
|
||||
onClick={() => openAddNote(day.id)}
|
||||
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
|
||||
>
|
||||
<FileText className="w-3 h-3" />
|
||||
Notiz hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Route tools — only for the selected day */}
|
||||
{isSelected && da.length >= 2 && (
|
||||
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
|
||||
<div className="flex bg-gray-100 rounded-[8px] p-0.5 gap-0.5">
|
||||
{TRANSPORT_MODES.map(m => (
|
||||
<button
|
||||
key={m.value}
|
||||
onClick={() => setTransportMode(m.value)}
|
||||
className={`flex-1 py-1 text-[11px] rounded-[6px] transition-all ${
|
||||
transportMode === m.value
|
||||
? 'bg-white shadow-sm text-gray-900 font-medium'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{m.icon} {m.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{routeInfo && (
|
||||
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
|
||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="text-slate-900">⏱️ {routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
onClick={handleCalculateRoute}
|
||||
disabled={isCalculatingRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
{isCalculatingRoute ? 'Berechne...' : 'Route'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOptimizeRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Optimieren
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
In Google Maps öffnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Budget footer */}
|
||||
{totalCost > 0 && (
|
||||
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">Gesamtkosten</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ORTE ── */}
|
||||
{activeSegment === 'orte' && (
|
||||
<div>
|
||||
<div className="p-3 space-y-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Orte suchen…"
|
||||
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
|
||||
<X className="w-3.5 h-3.5 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
|
||||
>
|
||||
<option value="">Alle Kategorien</option>
|
||||
{categories.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Neu
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">📍</span>
|
||||
<p className="text-sm">Keine Orte gefunden</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
|
||||
Ersten Ort hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{filteredPlaces.map(place => {
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{inDay
|
||||
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">✓</span>
|
||||
: selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
+ Tag
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
|
||||
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── RESERVIERUNGEN ── */}
|
||||
{activeSegment === 'reservierungen' && (
|
||||
<div>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
Reservierungen
|
||||
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
|
||||
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
{filteredReservations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
|
||||
<span className="text-3xl mb-2">🎫</span>
|
||||
<p className="text-sm">Keine Reservierungen</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-2.5">
|
||||
{filteredReservations.map(r => (
|
||||
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
|
||||
{r.reservation_time && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDateTime(r.reservation_time)}
|
||||
</div>
|
||||
)}
|
||||
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
|
||||
{r.confirmation_number && (
|
||||
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
|
||||
# {r.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
>✏️</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReservation(r.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
|
||||
>🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PACKLISTE ── */}
|
||||
{activeSegment === 'packliste' && (
|
||||
<PackingListPanel tripId={tripId} items={packingItems} />
|
||||
)}
|
||||
|
||||
{/* ── DOKUMENTE ── */}
|
||||
{activeSegment === 'dokumente' && (
|
||||
<FileManager tripId={tripId} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── INSPECTOR OVERLAY ── */}
|
||||
{selectedPlace && (
|
||||
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
|
||||
<PlaceDetailPanel
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
selectedDayId={selectedDayId}
|
||||
dayAssignments={selectedDayAssignments}
|
||||
onClose={() => onPlaceClick(null)}
|
||||
onEdit={() => onPlaceEdit(selectedPlace)}
|
||||
onDelete={() => onPlaceDelete(selectedPlace.id)}
|
||||
onAssignToDay={onAssignToDay}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reservation modal */}
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user