refactoring: TypeScript migration, security fixes,
This commit is contained in:
@@ -1,138 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useSortable } from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { GripVertical, X, Edit2, Clock, DollarSign, MapPin } from 'lucide-react'
|
||||
|
||||
export default function AssignedPlaceItem({ assignment, dayId, onRemove, onEdit }) {
|
||||
const { place } = assignment
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: `assignment-${assignment.id}`,
|
||||
data: {
|
||||
type: 'assignment',
|
||||
dayId: dayId,
|
||||
assignment,
|
||||
},
|
||||
})
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`
|
||||
group bg-white border rounded-lg p-2.5 transition-all
|
||||
${isDragging
|
||||
? 'opacity-40 border-slate-300 shadow-lg'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="drag-handle mt-0.5 p-0.5 text-slate-300 hover:text-slate-500 flex-shrink-0 rounded touch-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Name row */}
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
{place.category && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
<span className="text-sm font-medium text-slate-800 truncate">{place.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Time & price row */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{place.place_time && (
|
||||
<span className="flex items-center gap-1 text-xs text-slate-600 bg-slate-50 px-1.5 py-0.5 rounded">
|
||||
<Clock className="w-3 h-3" />
|
||||
{place.place_time}
|
||||
</span>
|
||||
)}
|
||||
{place.price != null && (
|
||||
<span className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block mt-1 text-xs px-1.5 py-0.5 rounded text-white text-[10px] font-medium"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{place.tags.map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={() => onEdit(place, assignment.id)}
|
||||
className="p-1 text-slate-400 hover:text-slate-700 hover:bg-slate-100 rounded transition-colors"
|
||||
title="Edit place"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onRemove(assignment.id)}
|
||||
className="p-1 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded transition-colors"
|
||||
title="Remove from day"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import AssignedPlaceItem from './AssignedPlaceItem'
|
||||
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
|
||||
|
||||
export default function DayColumn({
|
||||
day,
|
||||
assignments,
|
||||
tripId,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
onQuickAdd,
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
const [notes, setNotes] = useState(day.notes || '')
|
||||
const [notesEditing, setNotesEditing] = useState(false)
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `day-${day.id}`,
|
||||
data: {
|
||||
type: 'day',
|
||||
dayId: day.id,
|
||||
},
|
||||
})
|
||||
|
||||
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
|
||||
|
||||
const totalCost = (assignments || []).reduce((sum, a) => {
|
||||
return sum + (a.place?.price ? Number(a.place.price) : 0)
|
||||
}, 0)
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return null
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
|
||||
: 'border-transparent bg-white shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
|
||||
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
|
||||
{assignments?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{day.date && (
|
||||
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{totalCost > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{totalCost.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
title="Notes"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes area */}
|
||||
{showNotes && (
|
||||
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => setNotesEditing(false)}
|
||||
onFocus={() => setNotesEditing(true)}
|
||||
placeholder="Add notes for this day..."
|
||||
rows={2}
|
||||
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
|
||||
/>
|
||||
{notesEditing && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
// Parent will handle save via onUpdateNotes if passed
|
||||
}}
|
||||
className="text-xs text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignments list */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`
|
||||
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
|
||||
${isOver ? 'bg-slate-50' : 'bg-transparent'}
|
||||
`}
|
||||
>
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{assignments.map(assignment => (
|
||||
<AssignedPlaceItem
|
||||
key={assignment.id}
|
||||
assignment={assignment}
|
||||
dayId={day.id}
|
||||
onRemove={(id) => onRemoveAssignment(day.id, id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<div className={`
|
||||
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
|
||||
text-xs text-center transition-colors
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-100 text-slate-500'
|
||||
: 'border-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
<Package className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="font-medium">Drop places here</p>
|
||||
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick add button */}
|
||||
<button
|
||||
onClick={() => onQuickAdd(day)}
|
||||
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add place
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCollapsed && (
|
||||
<div
|
||||
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
>
|
||||
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} — click to expand
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,13 +9,19 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
|
||||
}
|
||||
|
||||
function WIcon({ main, size = 14 }) {
|
||||
interface WIconProps {
|
||||
main: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function WIcon({ main, size = 14 }: WIconProps) {
|
||||
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
@@ -32,7 +38,21 @@ function formatTime12(val, is12h) {
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }) {
|
||||
interface DayDetailPanelProps {
|
||||
day: Day
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories?: Category[]
|
||||
tripId: number
|
||||
assignments: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
onClose: () => void
|
||||
onAccommodationChange: () => void
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -504,7 +524,12 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ icon: Icon, value }) {
|
||||
interface ChipProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
value: string
|
||||
}
|
||||
|
||||
function Chip({ icon: Icon, value }: ChipProps) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
@@ -513,7 +538,16 @@ function Chip({ icon: Icon, value }) {
|
||||
)
|
||||
}
|
||||
|
||||
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }) {
|
||||
interface InfoChipProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
value: string
|
||||
placeholder: string
|
||||
onEdit: (value: string) => void
|
||||
type: 'text' | 'time'
|
||||
}
|
||||
|
||||
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
|
||||
const [editing, setEditing] = React.useState(false)
|
||||
const [val, setVal] = React.useState(value || '')
|
||||
const inputRef = React.useRef(null)
|
||||
@@ -1,3 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
@@ -13,36 +17,9 @@ import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
||||
weekday: 'short', day: 'numeric', month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
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 dayTotalCost(dayId, assignments, currency) {
|
||||
const da = assignments[String(dayId)] || []
|
||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
|
||||
}
|
||||
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
|
||||
const NOTE_ICONS = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
@@ -74,6 +51,31 @@ const TYPE_ICONS = {
|
||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
selectedPlaceId: number | null
|
||||
selectedAssignmentId: number | null
|
||||
onSelectDay: (dayId: number | null) => void
|
||||
onPlaceClick: (placeId: number) => void
|
||||
onDayDetail: (day: Day) => void
|
||||
accommodations?: Assignment[]
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
@@ -83,14 +85,14 @@ export default function DayPlanSidebar({
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
}) {
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
const { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
@@ -109,9 +111,7 @@ export default function DayPlanSidebar({
|
||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
|
||||
const inputRef = useRef(null)
|
||||
const noteInputRef = useRef(null)
|
||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
@@ -190,40 +190,19 @@ export default function DayPlanSidebar({
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
const merged = getMergedItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
|
||||
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id]))
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note, e) => {
|
||||
e?.stopPropagation()
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
|
||||
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, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) { toast.error(err.message) }
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
|
||||
const deleteNote = async (dayId, noteId, e) => {
|
||||
e?.stopPropagation()
|
||||
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
@@ -263,26 +242,14 @@ export default function DayPlanSidebar({
|
||||
for (const n of noteChanges) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
dragDataRef.current = null
|
||||
}
|
||||
|
||||
const moveNote = async (dayId, noteId, direction) => {
|
||||
const merged = getMergedItems(dayId)
|
||||
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
|
||||
if (idx === -1) return
|
||||
let newSortOrder
|
||||
if (direction === 'up') {
|
||||
if (idx === 0) return
|
||||
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
|
||||
} else {
|
||||
if (idx >= merged.length - 1) return
|
||||
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) }
|
||||
await _moveNote(dayId, noteId, direction, getMergedItems)
|
||||
}
|
||||
|
||||
const startEditTitle = (day, e) => {
|
||||
@@ -369,9 +336,9 @@ export default function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -573,11 +540,11 @@ export default function DayPlanSidebar({
|
||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -652,7 +619,7 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -660,7 +627,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
@@ -822,7 +789,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -830,7 +797,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
@@ -895,11 +862,11 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -1,138 +0,0 @@
|
||||
import React from 'react'
|
||||
import { CalendarDays, MapPin, Plus } from 'lucide-react'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
function dayTotal(dayId, assignments) {
|
||||
const dayAssignments = assignments[String(dayId)] || []
|
||||
return dayAssignments.reduce((sum, a) => {
|
||||
const cost = parseFloat(a.place?.cost) || 0
|
||||
return sum + cost
|
||||
}, 0)
|
||||
}
|
||||
|
||||
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
|
||||
const { t } = useTranslation()
|
||||
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
|
||||
<h2 className="text-sm font-semibold text-gray-700">{t('planner.dayPlan')}</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">{t('planner.dayCount', { n: days.length })}</p>
|
||||
</div>
|
||||
|
||||
{/* All places overview option */}
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
|
||||
selectedDayId === null
|
||||
? 'bg-slate-50 border-l-2 border-l-slate-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
{t('planner.allPlaces')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{t('planner.overview')}</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Day list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{days.length === 0 ? (
|
||||
<div className="px-4 py-6 text-center">
|
||||
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-400">{t('planner.noDays')}</p>
|
||||
<p className="text-xs text-gray-300 mt-1">{t('planner.editTripToAddDays')}</p>
|
||||
</div>
|
||||
) : (
|
||||
days.map((day, index) => {
|
||||
const isSelected = selectedDayId === day.id
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
const cost = dayTotal(day.id, assignments)
|
||||
const placeCount = dayAssignments.length
|
||||
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
onClick={() => onSelectDay(day.id)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
|
||||
isSelected
|
||||
? 'bg-slate-50 border-l-2 border-l-slate-900'
|
||||
: 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
|
||||
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
{day.title || `Tag ${index + 1}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{day.date && (
|
||||
<p className="text-xs text-gray-400 mt-1 ml-0.5">
|
||||
{formatDate(day.date)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5">
|
||||
{placeCount > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{placeCount === 1 ? t('planner.placeOne') : t('planner.placeN', { n: placeCount })}
|
||||
</span>
|
||||
)}
|
||||
{cost > 0 && (
|
||||
<span className="text-xs text-emerald-600 font-medium">
|
||||
{cost.toFixed(0)} {currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather for this day */}
|
||||
{day.date && isSelected && (
|
||||
<div className="mt-2">
|
||||
<WeatherWidget date={day.date} compact />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget summary footer */}
|
||||
{totalCost > 0 && (
|
||||
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">{t('planner.totalCost')}</span>
|
||||
<span className="text-sm font-semibold text-gray-800">
|
||||
{totalCost.toFixed(2)} {currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { MapPin, DollarSign, Check } from 'lucide-react'
|
||||
|
||||
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `place-${place.id}`,
|
||||
data: {
|
||||
type: 'place',
|
||||
place,
|
||||
},
|
||||
})
|
||||
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: isDragging ? 999 : undefined,
|
||||
} : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
|
||||
transition-all select-none
|
||||
${isDragging
|
||||
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
|
||||
}
|
||||
`}
|
||||
onClick={e => {
|
||||
if (!isDragging && onEdit) {
|
||||
e.stopPropagation()
|
||||
onEdit(place)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Category left border accent */}
|
||||
{place.category && (
|
||||
<div
|
||||
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pl-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
|
||||
{place.name}
|
||||
</p>
|
||||
{isAssigned && (
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
|
||||
<Check className="w-3 h-3 text-emerald-600" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{place.price != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-2.5 h-2.5" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{place.tags.slice(0, 3).map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{place.tags.length > 3 && (
|
||||
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function PlaceDetailPanel({
|
||||
place, categories, tags, selectedDayId, dayAssignments,
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const [googlePhoto, setGooglePhoto] = useState(null)
|
||||
const [photoAttribution, setPhotoAttribution] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!place?.google_place_id || place?.image_url) {
|
||||
setGooglePhoto(null)
|
||||
return
|
||||
}
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
setGooglePhoto(data.photoUrl || null)
|
||||
setPhotoAttribution(data.attribution || null)
|
||||
})
|
||||
.catch(() => setGooglePhoto(null))
|
||||
}, [place?.google_place_id, place?.image_url])
|
||||
|
||||
if (!place) return null
|
||||
|
||||
const displayPhoto = place.image_url || googlePhoto
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
const placeTags = (place.tags || []).map(t =>
|
||||
tags?.find(tg => tg.id === (t.id || t)) || t
|
||||
).filter(Boolean)
|
||||
|
||||
const assignmentInDay = selectedDayId
|
||||
? dayAssignments?.find(a => a.place?.id === place.id)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
{/* Image */}
|
||||
{displayPhoto ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={displayPhoto}
|
||||
alt={place.name}
|
||||
className="w-full h-40 object-cover"
|
||||
onError={e => { e.target.style.display = 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
{photoAttribution && !place.image_url && (
|
||||
<div className="absolute bottom-1 right-2 text-[10px] text-white/70">
|
||||
© {photoAttribution}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-24 flex items-center justify-center relative"
|
||||
style={{ backgroundColor: category?.color ? `${category.color}20` : '#f0f0ff' }}
|
||||
>
|
||||
<span className="text-4xl">{category?.icon || '📍'}</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 bg-white/90 rounded-full p-1.5 shadow"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
{/* Name + category */}
|
||||
<div>
|
||||
<h3 className="font-bold text-gray-900 text-base leading-snug">{place.name}</h3>
|
||||
{category && (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded-full mt-1"
|
||||
style={{ backgroundColor: `${category.color}20`, color: category.color }}
|
||||
>
|
||||
{category.icon} {category.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick info row */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{place.place_time && (
|
||||
<div className="flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-lg">
|
||||
<Clock className="w-3 h-3" />
|
||||
{place.place_time}
|
||||
</div>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<div className="flex items-center gap-1 text-xs text-emerald-700 bg-emerald-50 px-2 py-1 rounded-lg">
|
||||
<Euro className="w-3 h-3" />
|
||||
{place.price} {place.currency}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<div className="flex items-start gap-1.5 text-xs text-gray-600">
|
||||
<MapPin className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-gray-400" />
|
||||
<span>{place.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Coordinates */}
|
||||
{place.lat && place.lng && (
|
||||
<div className="text-xs text-gray-400">
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex gap-2">
|
||||
{place.website && (
|
||||
<a
|
||||
href={place.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
Website
|
||||
</a>
|
||||
)}
|
||||
{place.phone && (
|
||||
<a
|
||||
href={`tel:${place.phone}`}
|
||||
className="flex items-center gap-1 text-xs text-slate-700 hover:underline"
|
||||
>
|
||||
<Phone className="w-3 h-3" />
|
||||
{place.phone}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{place.description && (
|
||||
<p className="text-xs text-gray-600 leading-relaxed">{place.description}</p>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{place.notes && (
|
||||
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
|
||||
<p className="text-xs text-amber-800 leading-relaxed">📝 {place.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{placeTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{placeTags.map((tag, i) => (
|
||||
<span
|
||||
key={tag.id || i}
|
||||
className="text-xs px-2 py-0.5 rounded-full"
|
||||
style={{ backgroundColor: `${tag.color || '#6366f1'}20`, color: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Day assignment actions */}
|
||||
{selectedDayId && (
|
||||
<div className="pt-1">
|
||||
{assignmentInDay ? (
|
||||
<button
|
||||
onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
{t('planner.removeFromDay')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onAssignToDay(place.id)}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-white bg-slate-900 rounded-lg hover:bg-slate-700"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('planner.addToThisDay')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit / Delete */}
|
||||
<div className="flex gap-2 pt-1">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-700 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Edit2 className="w-3.5 h-3.5" />
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex items-center justify-center gap-1.5 py-2 px-3 text-xs text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
@@ -7,8 +7,23 @@ import { useToast } from '../shared/Toast'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import type { Place, Category, Assignment } from '../../types'
|
||||
|
||||
const DEFAULT_FORM = {
|
||||
interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
address: string
|
||||
lat: string
|
||||
lng: string
|
||||
category_id: string
|
||||
place_time: string
|
||||
end_time: string
|
||||
notes: string
|
||||
transport_mode: string
|
||||
website: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM: PlaceFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
@@ -22,10 +37,22 @@ const DEFAULT_FORM = {
|
||||
website: '',
|
||||
}
|
||||
|
||||
interface PlaceFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
|
||||
place: Place | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: Category) => void
|
||||
assignmentId: number | null
|
||||
dayAssignments?: Assignment[]
|
||||
}
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen, onClose, onSave, place, tripId, categories,
|
||||
onCategoryCreated, assignmentId, dayAssignments = [],
|
||||
}) {
|
||||
}: PlaceFormModalProps) {
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
@@ -70,7 +97,7 @@ export default function PlaceFormModal({
|
||||
try {
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
@@ -97,13 +124,13 @@ export default function PlaceFormModal({
|
||||
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
|
||||
setNewCategoryName('')
|
||||
setShowNewCategory(false)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('places.categoryCreateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileAdd = (e) => {
|
||||
const files = Array.from(e.target.files || [])
|
||||
const files = Array.from((e.target as HTMLInputElement).files || [])
|
||||
setPendingFiles(prev => [...prev, ...files])
|
||||
e.target.value = ''
|
||||
}
|
||||
@@ -116,7 +143,7 @@ export default function PlaceFormModal({
|
||||
const handlePaste = (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
@@ -144,8 +171,8 @@ export default function PlaceFormModal({
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('places.saveError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -371,7 +398,16 @@ export default function PlaceFormModal({
|
||||
)
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }) {
|
||||
interface TimeSectionProps {
|
||||
form: PlaceFormData
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
|
||||
assignmentId: number | null
|
||||
dayAssignments: Assignment[]
|
||||
hasTimeError: boolean
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
|
||||
|
||||
const collisions = useMemo(() => {
|
||||
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
||||
@@ -5,6 +5,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -97,11 +98,37 @@ function formatFileSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
place: Place | null
|
||||
categories: Category[]
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
selectedAssignmentId: number | null
|
||||
assignments: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
}
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||
}) {
|
||||
}: PlaceInspectorProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||
@@ -147,7 +174,7 @@ export default function PlaceInspector({
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from(e.target.files || [])
|
||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||
if (!selectedFiles.length || !onFileUpload) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
@@ -158,7 +185,7 @@ export default function PlaceInspector({
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
setFilesExpanded(true)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
@@ -488,7 +515,14 @@ export default function PlaceInspector({
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
|
||||
interface ChipProps {
|
||||
icon: React.ReactNode
|
||||
text: React.ReactNode
|
||||
color?: string
|
||||
bg?: string
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }: ChipProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
@@ -497,7 +531,12 @@ function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hove
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon, children }) {
|
||||
interface RowProps {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Row({ icon, children }: RowProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flexShrink: 0 }}>{icon}</div>
|
||||
@@ -506,7 +545,14 @@ function Row({ icon, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }) {
|
||||
interface ActionButtonProps {
|
||||
onClick: () => void
|
||||
variant: 'primary' | 'ghost' | 'danger'
|
||||
icon: React.ReactNode
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
|
||||
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)' },
|
||||
@@ -531,7 +577,17 @@ function ActionButton({ onClick, variant, icon, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }) {
|
||||
interface ParticipantsBoxProps {
|
||||
tripMembers: TripMember[]
|
||||
participantIds: number[]
|
||||
allJoined: boolean
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
selectedAssignmentId: number | null
|
||||
selectedDayId: number | null
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }: ParticipantsBoxProps) {
|
||||
const [showAdd, setShowAdd] = React.useState(false)
|
||||
const [hoveredId, setHoveredId] = React.useState(null)
|
||||
|
||||
@@ -1,223 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import DraggablePlaceCard from './DraggablePlaceCard'
|
||||
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
export default function PlacesPanel({
|
||||
places,
|
||||
categories,
|
||||
tags,
|
||||
assignments,
|
||||
tripId,
|
||||
onAddPlace,
|
||||
onEditPlace,
|
||||
hasMapKey,
|
||||
onSearchMaps,
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState([])
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Get set of assigned place IDs (for any day)
|
||||
const assignedPlaceIds = useMemo(() => {
|
||||
const ids = new Set()
|
||||
Object.values(assignments || {}).forEach(dayAssignments => {
|
||||
dayAssignments.forEach(a => {
|
||||
if (a.place?.id) ids.add(a.place.id)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
}, [assignments])
|
||||
|
||||
const filteredPlaces = useMemo(() => {
|
||||
return places.filter(place => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
if (!place.name.toLowerCase().includes(q) &&
|
||||
!place.address?.toLowerCase().includes(q) &&
|
||||
!place.description?.toLowerCase().includes(q)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
|
||||
return false
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
const placeTags = (place.tags || []).map(t => t.id)
|
||||
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [places, search, selectedCategory, selectedTags])
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||
)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setSelectedCategory('')
|
||||
setSelectedTags([])
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border-r border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-slate-800">
|
||||
Places
|
||||
<span className="ml-1.5 text-xs font-normal text-slate-400">
|
||||
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{hasMapKey && (
|
||||
<button
|
||||
onClick={onSearchMaps}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
|
||||
title="Search Google Maps"
|
||||
>
|
||||
<Map className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'text-slate-700 bg-slate-50'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Filters"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search places..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Tag filters */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||
selectedTags.includes(tag.id)
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-white opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add place button */}
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Place
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
{places.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No places yet</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
|
||||
>
|
||||
+ Add your first place
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No matches found</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredPlaces.map(place => (
|
||||
<DraggablePlaceCard
|
||||
key={place.id}
|
||||
place={place}
|
||||
isAssigned={assignedPlaceIds.has(place.id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
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
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
||||
}) {
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -1,876 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
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'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
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 [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 { t } = useTranslation()
|
||||
|
||||
const SEGMENTS = [
|
||||
{ id: 'plan', label: 'Plan' },
|
||||
{ id: 'orte', label: t('planner.places') },
|
||||
{ id: 'reservierungen', label: t('planner.bookings') },
|
||||
{ id: 'packliste', label: t('planner.packingList') },
|
||||
{ id: 'dokumente', label: t('planner.documents') },
|
||||
]
|
||||
|
||||
const dayNotes = tripStore.dayNotes || {}
|
||||
const placesListRef = useRef(null)
|
||||
const [placesListHeight, setPlacesListHeight] = useState(400)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!placesListRef.current) return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPlacesListHeight(entry.contentRect.height)
|
||||
})
|
||||
ro.observe(placesListRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [activeSegment])
|
||||
|
||||
// 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 = useMemo(() => 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
|
||||
}), [places, search, categoryFilter])
|
||||
|
||||
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(t('planner.minTwoPlaces'))
|
||||
return
|
||||
}
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, 'walking')
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success(t('planner.routeCalculated'))
|
||||
} catch {
|
||||
toast.error(t('planner.routeCalcFailed'))
|
||||
} 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(t('planner.routeOptimized'))
|
||||
}
|
||||
|
||||
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(t('planner.noGeoPlaces'))
|
||||
}
|
||||
|
||||
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(t('planner.reservationUpdated'))
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('planner.reservationAdded'))
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm(t('planner.confirmDeleteReservation'))) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success(t('planner.reservationDeleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
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" }}>
|
||||
|
||||
<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} ${t('planner.days')}`}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{/* ── PLAN ── */}
|
||||
{activeSegment === 'plan' && (
|
||||
<div className="pb-4">
|
||||
<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'}`}>
|
||||
{t('planner.allPlaces')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">{t('planner.totalPlaces', { n: places.length })}</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">{t('planner.noDaysPlanned')}</p>
|
||||
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
|
||||
{t('planner.editTrip')}
|
||||
</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">
|
||||
<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 === 1 ? t('planner.placeOne') : t('planner.placeN', { n: da.length })}
|
||||
</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={t('planner.addNote')}
|
||||
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>
|
||||
|
||||
{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">{t('planner.noEntries')}</p>
|
||||
<button
|
||||
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
|
||||
className="mt-1 text-xs text-slate-700"
|
||||
>
|
||||
{t('planner.addPlaceShort')}
|
||||
</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}{place.end_time ? ` – ${place.end_time}` : ''}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</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>
|
||||
)
|
||||
}
|
||||
|
||||
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={t('planner.noteTimePlaceholder')}
|
||||
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={t('planner.notePlaceholder')}
|
||||
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" /> {t('common.save')}
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
{t('common.cancel')}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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={t('planner.noteTimePlaceholder')}
|
||||
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={t('planner.noteExamplePlaceholder')}
|
||||
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" /> {t('common.add')}
|
||||
</button>
|
||||
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!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" />
|
||||
{t('planner.addNote')}
|
||||
</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">
|
||||
{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 ? t('planner.calculating') : t('planner.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" />
|
||||
{t('planner.optimize')}
|
||||
</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" />
|
||||
{t('planner.openGoogleMaps')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{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">{t('planner.totalCost')}</span>
|
||||
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── ORTE ── */}
|
||||
{activeSegment === 'orte' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<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={t('planner.searchPlaces')}
|
||||
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="">{t('planner.allCategories')}</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" />
|
||||
{t('planner.new')}
|
||||
</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">{t('planner.noPlacesFound')}</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
|
||||
{t('planner.addFirstPlace')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
|
||||
<FixedSizeList
|
||||
height={placesListHeight}
|
||||
itemCount={filteredPlaces.length}
|
||||
itemSize={68}
|
||||
overscanCount={10}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const place = filteredPlaces[index]
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
|
||||
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"
|
||||
>
|
||||
{t('planner.addToDay')}
|
||||
</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>
|
||||
)
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</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">
|
||||
{t('planner.reservations')}
|
||||
{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" />
|
||||
{t('common.add')}
|
||||
</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">{t('planner.noReservations')}</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)}
|
||||
{r.reservation_end_time && ` – ${r.reservation_end_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>
|
||||
)}
|
||||
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
@@ -6,6 +6,7 @@ import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
@@ -45,7 +46,21 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
return options
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
||||
interface ReservationModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) {
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
@@ -113,7 +128,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
if (reservation?.id) {
|
||||
setUploadingFile(true)
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
@@ -8,6 +8,16 @@ import {
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
dayNumber: number
|
||||
dayTitle: string | null
|
||||
dayDate: string
|
||||
placeName: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
|
||||
@@ -37,7 +47,17 @@ function buildAssignmentLookup(days, assignments) {
|
||||
return map
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
|
||||
interface ReservationCardProps {
|
||||
r: Reservation
|
||||
tripId: number
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
files?: TripFile[]
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -176,7 +196,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
interface SectionProps {
|
||||
title: string
|
||||
count: number
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
accent: 'green' | string
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -197,7 +225,19 @@ function Section({ title, count, children, defaultOpen = true, accent }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
|
||||
interface ReservationsPanelProps {
|
||||
tripId: number
|
||||
reservations: Reservation[]
|
||||
days: Day[]
|
||||
assignments: AssignmentsMap
|
||||
files?: TripFile[]
|
||||
onAdd: () => void
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
@@ -1,591 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
export function RightPanel({
|
||||
trip, days, places, categories, tags,
|
||||
assignments, reservations, packingItems,
|
||||
selectedDay, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onPlaceEdit, onPlaceDelete,
|
||||
onAssignToDay, onRemoveAssignment, onReorder,
|
||||
onAddPlace, onEditTrip, onRouteCalculated, tripId,
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('orte')
|
||||
const [search, setSearch] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState(false)
|
||||
const [editingReservation, setEditingReservation] = useState(null)
|
||||
const [routeInfo, setRouteInfo] = useState(null)
|
||||
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const TABS = [
|
||||
{ id: 'orte', label: t('planner.places'), icon: '📍' },
|
||||
{ id: 'tagesplan', label: t('planner.dayPlan'), icon: '📅' },
|
||||
{ id: 'reservierungen', label: t('planner.reservations'), icon: '🎫' },
|
||||
{ id: 'packliste', label: t('planner.packingList'), icon: '🎒' },
|
||||
]
|
||||
|
||||
// Filtered places for Orte tab
|
||||
const filteredPlaces = places.filter(p => {
|
||||
const matchesSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchesCategory = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchesSearch && matchesCategory
|
||||
})
|
||||
|
||||
// Ordered assignments for selected day
|
||||
const dayAssignments = selectedDayId
|
||||
? (assignments[String(selectedDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
: []
|
||||
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && dayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
// Calculate schedule with times
|
||||
const getSchedule = () => {
|
||||
if (!dayAssignments.length) return []
|
||||
let currentTime = null
|
||||
return dayAssignments.map((assignment, idx) => {
|
||||
const place = assignment.place
|
||||
const startTime = place?.place_time || (currentTime ? currentTime : null)
|
||||
const duration = place?.duration_minutes || 60
|
||||
if (startTime) {
|
||||
const [h, m] = startTime.split(':').map(Number)
|
||||
const endMinutes = h * 60 + m + duration
|
||||
const endH = Math.floor(endMinutes / 60) % 24
|
||||
const endM = endMinutes % 60
|
||||
currentTime = `${String(endH).padStart(2, '0')}:${String(endM).padStart(2, '0')}`
|
||||
}
|
||||
return { assignment, startTime, endTime: currentTime }
|
||||
})
|
||||
}
|
||||
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = dayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
|
||||
if (waypoints.length < 2) {
|
||||
toast.error(t('planner.minTwoPlaces'))
|
||||
return
|
||||
}
|
||||
|
||||
setIsCalculatingRoute(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, 'walking')
|
||||
if (result) {
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
onRouteCalculated?.(result)
|
||||
toast.success(t('planner.routeCalculated'))
|
||||
} else {
|
||||
toast.error(t('planner.routeCalcFailed'))
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(t('planner.routeError'))
|
||||
} finally {
|
||||
setIsCalculatingRoute(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleOptimizeRoute = async () => {
|
||||
if (!selectedDayId || dayAssignments.length < 3) return
|
||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = optimizeRoute(places)
|
||||
const optimizedIds = optimized.map(p => {
|
||||
const a = dayAssignments.find(a => a.place?.id === p.id)
|
||||
return a?.id
|
||||
}).filter(Boolean)
|
||||
await onReorder(selectedDayId, optimizedIds)
|
||||
toast.success(t('planner.routeOptimized'))
|
||||
}
|
||||
|
||||
const handleOpenGoogleMaps = () => {
|
||||
const places = dayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const url = generateGoogleMapsUrl(places)
|
||||
if (url) window.open(url, '_blank')
|
||||
else toast.error(t('planner.noGeoPlaces'))
|
||||
}
|
||||
|
||||
const handleMoveUp = async (idx) => {
|
||||
if (idx === 0) return
|
||||
const ids = dayAssignments.map(a => a.id)
|
||||
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
|
||||
await onReorder(selectedDayId, ids)
|
||||
}
|
||||
|
||||
const handleMoveDown = async (idx) => {
|
||||
if (idx === dayAssignments.length - 1) return
|
||||
const ids = dayAssignments.map(a => a.id)
|
||||
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
|
||||
await onReorder(selectedDayId, ids)
|
||||
}
|
||||
|
||||
const handleAddReservation = () => {
|
||||
setEditingReservation(null)
|
||||
setShowReservationModal(true)
|
||||
}
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
await tripStore.updateReservation(tripId, editingReservation.id, data)
|
||||
toast.success(t('planner.reservationUpdated'))
|
||||
} else {
|
||||
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
|
||||
toast.success(t('planner.reservationAdded'))
|
||||
}
|
||||
setShowReservationModal(false)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
if (!confirm(t('planner.confirmDeleteReservation'))) return
|
||||
try {
|
||||
await tripStore.deleteReservation(tripId, id)
|
||||
toast.success(t('planner.reservationDeleted'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// Reservations for selected day (or all if no day selected)
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 flex-shrink-0">
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex-1 py-2.5 text-xs font-medium transition-colors flex flex-col items-center gap-0.5 ${
|
||||
activeTab === tab.id
|
||||
? 'text-slate-700 border-b-2 border-slate-700'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base leading-none">{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
|
||||
{/* ORTE TAB */}
|
||||
{activeTab === 'orte' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Place detail (when selected) */}
|
||||
{selectedPlace && (
|
||||
<div className="border-b border-gray-100">
|
||||
<PlaceDetailPanel
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
tags={tags}
|
||||
selectedDayId={selectedDayId}
|
||||
dayAssignments={dayAssignments}
|
||||
onClose={() => onPlaceClick(null)}
|
||||
onEdit={() => onPlaceEdit(selectedPlace)}
|
||||
onDelete={() => onPlaceDelete(selectedPlace.id)}
|
||||
onAssignToDay={onAssignToDay}
|
||||
onRemoveAssignment={onRemoveAssignment}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search & filter */}
|
||||
<div className="p-3 space-y-2 border-b border-gray-100 flex-shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t('planner.searchPlaces')}
|
||||
className="w-full pl-8 pr-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
|
||||
/>
|
||||
{search && (
|
||||
<button onClick={() => setSearch('')} className="absolute right-2.5 top-2.5">
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={e => setCategoryFilter(e.target.value)}
|
||||
className="flex-1 border border-gray-200 rounded-lg text-xs py-1.5 px-2 focus:outline-none focus:ring-1 focus:ring-slate-900 text-gray-600"
|
||||
>
|
||||
<option value="">{t('planner.allCategories')}</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-700 text-white text-xs px-3 py-1.5 rounded-lg hover:bg-slate-900 whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('planner.addPlace')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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">{t('planner.noPlacesFound')}</p>
|
||||
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||
{t('planner.addFirstPlace')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{filteredPlaces.map(place => {
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const isInDay = isAssignedToSelectedDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`px-3 py-2.5 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Category color bar */}
|
||||
<div
|
||||
className="w-1 rounded-full flex-shrink-0 mt-1 self-stretch"
|
||||
style={{ backgroundColor: category?.color || '#6366f1', minHeight: 16 }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-sm text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isInDay && (
|
||||
<span className="text-xs text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded">✓</span>
|
||||
)}
|
||||
{!isInDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-xs text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100"
|
||||
>
|
||||
{t('planner.addToDay')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{category && (
|
||||
<span className="text-xs text-gray-500">{category.icon} {category.name}</span>
|
||||
)}
|
||||
{place.address && (
|
||||
<p className="text-xs text-gray-400 truncate mt-0.5">{place.address}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{place.place_time && (
|
||||
<span className="text-xs text-gray-500">🕐 {place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}</span>
|
||||
)}
|
||||
{place.price > 0 && (
|
||||
<span className="text-xs text-gray-500">
|
||||
{place.price} {place.currency || trip?.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAGESPLAN TAB */}
|
||||
{activeTab === 'tagesplan' && (
|
||||
<div className="flex flex-col h-full">
|
||||
{!selectedDayId ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-gray-400 px-6">
|
||||
<span className="text-4xl mb-3">📅</span>
|
||||
<p className="text-sm text-center">{t('planner.selectDayHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Day header */}
|
||||
<div className="px-4 py-3 bg-slate-50 border-b border-slate-100 flex-shrink-0">
|
||||
<h3 className="font-semibold text-slate-900 text-sm">
|
||||
Tag {selectedDay?.day_number}
|
||||
{selectedDay?.date && (
|
||||
<span className="font-normal text-slate-700 ml-2">
|
||||
{formatGermanDate(selectedDay.date)}
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-700 mt-0.5">
|
||||
{dayAssignments.length === 1 ? t('planner.placeOne') : t('planner.placeN', { n: dayAssignments.length })}
|
||||
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} ${t('planner.minTotal')}`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Places list with order */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{dayAssignments.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">{t('planner.noPlacesForDay')}</p>
|
||||
<button
|
||||
onClick={() => setActiveTab('orte')}
|
||||
className="mt-3 text-slate-700 text-sm hover:underline"
|
||||
>
|
||||
{t('planner.addPlacesLink')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gray-50">
|
||||
{getSchedule().map(({ assignment, startTime, endTime }, idx) => {
|
||||
const place = assignment.place
|
||||
if (!place) return null
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
|
||||
return (
|
||||
<div key={assignment.id} className="px-3 py-3 flex items-start gap-2">
|
||||
{/* Order number */}
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0 mt-0.5"
|
||||
style={{ backgroundColor: category?.color || '#6366f1' }}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
|
||||
{/* Place info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm text-gray-900 truncate">{place.name}</div>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
{startTime && (
|
||||
<span className="text-xs text-slate-700">🕐 {startTime}</span>
|
||||
)}
|
||||
<span className="text-xs text-gray-400">
|
||||
{place.duration_minutes || 60} Min.
|
||||
</span>
|
||||
{place.price > 0 && (
|
||||
<span className="text-xs text-gray-400">
|
||||
{place.price} {place.currency || trip?.currency}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{place.address && (
|
||||
<p className="text-xs text-gray-400 mt-0.5 truncate">{place.address}</p>
|
||||
)}
|
||||
{assignment.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1 bg-gray-50 rounded px-2 py-1">{assignment.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col items-center gap-0.5 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleMoveUp(idx)}
|
||||
disabled={idx === 0}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMoveDown(idx)}
|
||||
disabled={idx === dayAssignments.length - 1}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveAssignment(selectedDayId, assignment.id)}
|
||||
className="p-1 text-red-400 hover:text-red-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Route buttons */}
|
||||
{dayAssignments.length >= 2 && (
|
||||
<div className="p-3 border-t border-gray-100 flex-shrink-0 space-y-2">
|
||||
{routeInfo && (
|
||||
<div className="flex items-center justify-center gap-3 text-sm bg-slate-50 rounded-lg px-3 py-2">
|
||||
<span className="text-slate-900">🛣️ {routeInfo.distance}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-900">⏱️ {routeInfo.duration}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={handleCalculateRoute}
|
||||
disabled={isCalculatingRoute}
|
||||
className="flex items-center justify-center gap-1.5 bg-slate-700 text-white text-xs py-2 rounded-lg hover:bg-slate-900 disabled:opacity-60"
|
||||
>
|
||||
<Navigation className="w-3.5 h-3.5" />
|
||||
{isCalculatingRoute ? t('planner.calculating') : t('planner.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"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t('planner.optimize')}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenGoogleMaps}
|
||||
className="w-full flex items-center justify-center gap-1.5 bg-white border border-gray-200 text-gray-700 text-xs py-2 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
{t('planner.openGoogleMaps')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* RESERVIERUNGEN TAB */}
|
||||
{activeTab === 'reservierungen' && (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-3 flex items-center justify-between border-b border-gray-100 flex-shrink-0">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
{t('planner.reservations')}
|
||||
{selectedDay && <span className="text-gray-500 font-normal"> · Tag {selectedDay.day_number}</span>}
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAddReservation}
|
||||
className="flex items-center gap-1 bg-slate-700 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-900"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
{t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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">{t('planner.noReservations')}</p>
|
||||
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
|
||||
{t('planner.addFirstReservation')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-3 space-y-3">
|
||||
{filteredReservations.map(reservation => (
|
||||
<div key={reservation.id} className="bg-white border border-gray-200 rounded-xl p-3 shadow-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-sm text-gray-900">{reservation.title}</div>
|
||||
{reservation.reservation_time && (
|
||||
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatDateTime(reservation.reservation_time)}
|
||||
{reservation.reservation_end_time && ` – ${reservation.reservation_end_time}`}
|
||||
</div>
|
||||
)}
|
||||
{reservation.location && (
|
||||
<div className="text-xs text-gray-500 mt-0.5">📍 {reservation.location}</div>
|
||||
)}
|
||||
{reservation.confirmation_number && (
|
||||
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded px-2 py-0.5 inline-block">
|
||||
# {reservation.confirmation_number}
|
||||
</div>
|
||||
)}
|
||||
{reservation.notes && (
|
||||
<p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{reservation.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => { setEditingReservation(reservation); setShowReservationModal(true) }}
|
||||
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteReservation(reservation.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PACKLISTE TAB */}
|
||||
{activeTab === 'packliste' && (
|
||||
<PackingListPanel
|
||||
tripId={tripId}
|
||||
items={packingItems}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reservation Modal */}
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function formatGermanDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr + 'T00:00:00')
|
||||
return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return ''
|
||||
try {
|
||||
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return dt
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user