-
-
{f.original_name}
-
-
-
+
+
+
{f.original_name}
+
{onFileDelete && (
-
onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
- onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
-
+ onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
+
)}
))}
{pendingFiles.map((f, i) => (
-
-
-
{f.name}
-
{t('reservations.pendingSave')}
+
+
+ {f.name}
setPendingFiles(prev => prev.filter((_, j) => j !== i))}
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
- onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
-
+ style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
+
))}
fileInputRef.current?.click()} disabled={uploadingFile} style={{
- display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
- border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)',
- fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer',
- fontFamily: 'inherit', transition: 'all 0.12s',
- }}
- onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-secondary)' } }}
- onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
-
+ display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
+ border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
+ fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
+ }}>
+
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
@@ -276,10 +272,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
{/* Actions */}
-
+
{t('common.cancel')}
-
+
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
@@ -288,8 +284,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
)
}
-function formatDate(dateStr) {
+function formatDate(dateStr, locale) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
- return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
+ return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
}
diff --git a/client/src/components/Planner/ReservationsPanel.jsx b/client/src/components/Planner/ReservationsPanel.jsx
index 908fc7f..fa3c3ce 100644
--- a/client/src/components/Planner/ReservationsPanel.jsx
+++ b/client/src/components/Planner/ReservationsPanel.jsx
@@ -1,160 +1,52 @@
import React, { useState, useMemo } from 'react'
-import ReactDOM from 'react-dom'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
-import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
-import CustomSelect from '../shared/CustomSelect'
import {
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
- Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, MapPinned, X, Users,
- ExternalLink, BookMarked, Lightbulb,
+ Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
+ ExternalLink, BookMarked, Lightbulb, Link2, Clock,
} from 'lucide-react'
const TYPE_OPTIONS = [
- { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
- { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
- { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
- { value: 'train', labelKey: 'reservations.type.train', Icon: Train },
- { value: 'car', labelKey: 'reservations.type.car', Icon: Car },
- { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
- { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
- { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
- { value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
+ { value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
+ { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
+ { value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
+ { value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
+ { value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
+ { value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
+ { value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
+ { value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
+ { value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
]
-function typeIcon(type) {
- return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).Icon
-}
-function typeLabelKey(type) {
- return (TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]).labelKey
+function getType(type) {
+ return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
}
-function formatDateTimeWithLocale(str, locale, timeFormat) {
- if (!str) return null
- const d = new Date(str)
- if (isNaN(d)) return str
- const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'long' })
- const h = d.getHours(), m = d.getMinutes()
- let timePart
- if (timeFormat === '12h') {
- const period = h >= 12 ? 'PM' : 'AM'
- const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
- timePart = `${h12}:${String(m).padStart(2, '0')} ${period}`
- } else {
- timePart = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
- if (locale?.startsWith('de')) timePart += ' Uhr'
- }
- return `${datePart} · ${timePart}`
-}
-
-const inputStyle = {
- width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
- padding: '8px 12px', fontSize: 13.5, fontFamily: 'inherit',
- outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-card)',
-}
-const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
-
-function PlaceReservationEditModal({ item, tripId, onClose }) {
- const { updatePlace } = useTripStore()
- const toast = useToast()
- const { t } = useTranslation()
- const [form, setForm] = useState({
- reservation_status: item.status === 'confirmed' ? 'confirmed' : 'pending',
- reservation_datetime: item.reservation_time ? item.reservation_time.slice(0, 16) : '',
- place_time: item.place_time || '',
- reservation_notes: item.notes || '',
- })
- const [saving, setSaving] = useState(false)
-
- const set = (f, v) => setForm(p => ({ ...p, [f]: v }))
-
- const handleSave = async () => {
- setSaving(true)
- try {
- await updatePlace(tripId, item.placeId, {
- reservation_status: form.reservation_status,
- reservation_datetime: form.reservation_datetime || null,
- place_time: form.place_time || null,
- reservation_notes: form.reservation_notes || null,
- })
- toast.success(t('reservations.toast.updated'))
- onClose()
- } catch {
- toast.error(t('reservations.toast.saveError'))
- } finally {
- setSaving(false)
+function buildAssignmentLookup(days, assignments) {
+ const map = {}
+ for (const day of (days || [])) {
+ const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
+ for (const a of da) {
+ if (!a.place) continue
+ map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time }
}
}
-
- return ReactDOM.createPortal(
-
-
e.stopPropagation()}>
-
-
-
{t('reservations.editTitle')}
-
{item.title}
-
-
-
-
-
-
-
-
- {t('reservations.status')}
- set('reservation_status', v)}
- options={[
- { value: 'pending', label: t('reservations.pending') },
- { value: 'confirmed', label: t('reservations.confirmed') },
- ]}
- />
-
-
-
- {t('reservations.datetime')}
- set('reservation_datetime', v)} />
-
-
-
-
- {t('reservations.notes')}
-
-
-
-
-
- {t('common.cancel')}
-
-
- {saving ? t('common.saving') : t('common.save')}
-
-
-
-
,
- document.body
- )
+ return map
}
-function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
+function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }) {
const { toggleReservationStatus } = useTripStore()
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
- const TypeIcon = typeIcon(r.type)
+ const typeInfo = getType(r.type)
+ const TypeIcon = typeInfo.Icon
const confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id)
+ const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) }
@@ -165,205 +57,137 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
- return (
-
-
-
-
-
-
-
-
-
-
{r.title}
-
{t(typeLabelKey(r.type))}
-
-
-
- {confirmed ? <> {t('reservations.confirmed')}> : <> {t('reservations.pending')}>}
-
-
onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}>
-
-
-
-
-
- {r.reservation_time && (
-
- {formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
-
- )}
- {r.location && (
-
-
- {r.location}
-
- )}
-
-
-
- {r.confirmation_number && (
-
- {r.confirmation_number}
-
- )}
- {r.day_number != null && {t('dayplan.dayN', { n: r.day_number })} }
- {r.place_name && {r.place_name} }
-
-
- {r.notes &&
{r.notes}
}
-
- {/* Attached files — read-only, upload only via edit modal */}
- {attachedFiles.length > 0 && (
-
- {attachedFiles.map(f => (
-
-
-
{f.original_name}
-
-
-
-
- ))}
-
- {t('reservations.showFiles')}
-
-
- )}
-
-
-
- )
-}
-
-function PlaceReservationCard({ item, tripId, files = [], onNavigateToFiles }) {
- const { updatePlace } = useTripStore()
- const toast = useToast()
- const { t, locale } = useTranslation()
- const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
- const [editing, setEditing] = useState(false)
- const confirmed = item.status === 'confirmed'
- const placeFiles = files.filter(f => f.place_id === item.placeId)
-
- const handleDelete = async () => {
- if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
- try {
- await updatePlace(tripId, item.placeId, {
- reservation_status: 'none',
- reservation_datetime: null,
- place_time: null,
- reservation_notes: null,
- })
- toast.success(t('reservations.toast.removed'))
- } catch { toast.error(t('reservations.toast.deleteError')) }
+ const fmtDate = (str) => {
+ const d = new Date(str)
+ return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
+ }
+ const fmtTime = (str) => {
+ const d = new Date(str)
+ return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
}
return (
- <>
- {editing &&
setEditing(false)} />}
-
-
-
-
-
+
+ {/* Header bar */}
+
+
+
+ {confirmed ? t('reservations.confirmed') : t('reservations.pending')}
+
+
+
+
{t(typeInfo.labelKey)}
+
+
{r.title}
+
onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
+ onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+
e.currentTarget.style.color = '#ef4444'}
+ onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
+
+
+
-
-
-
-
{item.title}
-
- {t('reservations.fromPlan')}
- {item.dayLabel && {item.dayLabel} }
-
-
-
-
- {confirmed ? <> {t('reservations.confirmed')}> : <> {t('reservations.pending')}>}
-
-
setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}>
-
-
-
-
-
- {item.reservation_time && (
-
-
{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
+ {/* Details */}
+ {(r.reservation_time || r.confirmation_number || r.location || linked) && (
+
+ {/* Row 1: Date, Time, Code */}
+ {(r.reservation_time || r.confirmation_number) && (
+
+ {r.reservation_time && (
+
+
{t('reservations.date')}
+
{fmtDate(r.reservation_time)}
)}
- {item.place_time && !item.reservation_time && (
-
-
{item.place_time}
+ {r.reservation_time && (
+
+
{t('reservations.time')}
+
{fmtTime(r.reservation_time)}
)}
- {item.location && (
-
-
-
{item.location}
+ {r.confirmation_number && (
+
+
{t('reservations.confirmationCode')}
+
{r.confirmation_number}
)}
-
- {item.notes &&
{item.notes}
}
-
- {/* Files attached to the place */}
- {placeFiles.length > 0 && (
-
- {placeFiles.map(f => (
-
-
-
{f.original_name}
-
-
-
+ )}
+ {/* Row 2: Location + Assignment */}
+ {(r.location || linked) && (
+
+ {r.location && (
+
+
{t('reservations.locationAddress')}
+
+
+ {r.location}
- ))}
- {onNavigateToFiles && (
-
- {t('reservations.showFiles')}
-
- )}
-
- )}
+
+ )}
+ {linked && (
+
+
{t('reservations.linkAssignment')}
+
+
+
+ {linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
+ {linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
+
+
+
+ )}
+
+ )}
+
+ )}
+
+ {/* Notes */}
+ {r.notes && (
+
+
{t('reservations.notes')}
+
+ {r.notes}
-
- >
+ )}
+
+ {/* Files */}
+ {attachedFiles.length > 0 && (
+
+ )}
+
)
}
function Section({ title, count, children, defaultOpen = true, accent }) {
const [open, setOpen] = useState(defaultOpen)
return (
-
+
setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
- background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
+ background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
}}>
- {open ? : }
- {title}
+ {open ? : }
+ {title}
{count}
{open &&
{children}
}
@@ -375,98 +199,66 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
const { t, locale } = useTranslation()
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
- const placeReservations = useMemo(() => {
- const result = []
- for (const day of (days || [])) {
- const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
- for (const assignment of da) {
- const place = assignment.place
- if (!place || !place.reservation_status || place.reservation_status === 'none') continue
- const dayLabel = day.title
- ? day.title
- : day.date
- ? `${t('dayplan.dayN', { n: day.day_number })} · ${new Date(day.date + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })}`
- : t('dayplan.dayN', { n: day.day_number })
- result.push({
- _placeRes: true,
- id: `place_${day.id}_${place.id}`,
- placeId: place.id,
- title: place.name,
- status: place.reservation_status === 'confirmed' ? 'confirmed' : 'pending',
- reservation_time: place.reservation_datetime || null,
- place_time: place.place_time || null,
- location: place.address || null,
- notes: place.reservation_notes || null,
- dayLabel,
- })
- }
- }
- return result
- }, [days, assignments, locale])
+ const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
- const allPending = [...reservations.filter(r => r.status !== 'confirmed'), ...placeReservations.filter(r => r.status !== 'confirmed')]
- const allConfirmed = [...reservations.filter(r => r.status === 'confirmed'), ...placeReservations.filter(r => r.status === 'confirmed')]
- const total = allPending.length + allConfirmed.length
-
- function renderCard(r) {
- if (r._placeRes) return
- return
- }
+ const allPending = reservations.filter(r => r.status !== 'confirmed')
+ const allConfirmed = reservations.filter(r => r.status === 'confirmed')
+ const total = reservations.length
return (
-
+ {/* Header */}
+
{t('reservations.title')}
-
+
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
{t('reservations.addManual')}
- {/* Hinweis — einmalig wegklickbar */}
+ {/* Hint */}
{showHint && (
-
-
-
- {t('reservations.placeHint')}
-
-
{ setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
- onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
- onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
- >×
+
+
+
{t('reservations.placeHint')}
+
{ setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 14, lineHeight: 1, flexShrink: 0 }}>×
)}
-
+ {/* Content */}
+
{total === 0 ? (
-
+
{t('reservations.empty')}
-
{t('reservations.emptyHint')}
+
{t('reservations.emptyHint')}
) : (
-
+ <>
{allPending.length > 0 && (
-
- {allPending.map(renderCard)}
+
+
+ {allPending.map(r => )}
+
)}
{allConfirmed.length > 0 && (
-
- {allConfirmed.map(renderCard)}
+
+
+ {allConfirmed.map(r => )}
+
)}
-
+ >
)}
diff --git a/client/src/components/Planner/RightPanel.jsx b/client/src/components/Planner/RightPanel.jsx
index de22608..a66e33f 100644
--- a/client/src/components/Planner/RightPanel.jsx
+++ b/client/src/components/Planner/RightPanel.jsx
@@ -6,19 +6,7 @@ import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
-
-const TABS = [
- { id: 'orte', label: 'Orte', icon: '📍' },
- { id: 'tagesplan', label: 'Tagesplan', icon: '📅' },
- { id: 'reservierungen', label: 'Reservierungen', icon: '🎫' },
- { id: 'packliste', label: 'Packliste', icon: '🎒' },
-]
-
-const TRANSPORT_MODES = [
- { value: 'driving', label: 'Auto', icon: '🚗' },
- { value: 'walking', label: 'Fuß', icon: '🚶' },
- { value: 'cycling', label: 'Rad', icon: '🚲' },
-]
+import { useTranslation } from '../../i18n'
export function RightPanel({
trip, days, places, categories, tags,
@@ -31,7 +19,6 @@ export function RightPanel({
const [activeTab, setActiveTab] = useState('orte')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
- const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
@@ -39,6 +26,14 @@ export function RightPanel({
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 => {
@@ -83,22 +78,22 @@ export function RightPanel({
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
- toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
+ toast.error(t('planner.minTwoPlaces'))
return
}
setIsCalculatingRoute(true)
try {
- const result = await calculateRoute(waypoints, transportMode)
+ const result = await calculateRoute(waypoints, 'walking')
if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
- toast.success('Route berechnet')
+ toast.success(t('planner.routeCalculated'))
} else {
- toast.error('Route konnte nicht berechnet werden')
+ toast.error(t('planner.routeCalcFailed'))
}
} catch (err) {
- toast.error('Fehler bei der Routenberechnung')
+ toast.error(t('planner.routeError'))
} finally {
setIsCalculatingRoute(false)
}
@@ -113,14 +108,14 @@ export function RightPanel({
return a?.id
}).filter(Boolean)
await onReorder(selectedDayId, optimizedIds)
- toast.success('Route optimiert')
+ 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('Keine Orte mit Koordinaten vorhanden')
+ else toast.error(t('planner.noGeoPlaces'))
}
const handleMoveUp = async (idx) => {
@@ -146,10 +141,10 @@ export function RightPanel({
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
- toast.success('Reservierung aktualisiert')
+ toast.success(t('planner.reservationUpdated'))
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
- toast.success('Reservierung hinzugefügt')
+ toast.success(t('planner.reservationAdded'))
}
setShowReservationModal(false)
} catch (err) {
@@ -158,10 +153,10 @@ export function RightPanel({
}
const handleDeleteReservation = async (id) => {
- if (!confirm('Reservierung löschen?')) return
+ if (!confirm(t('planner.confirmDeleteReservation'))) return
try {
await tripStore.deleteReservation(tripId, id)
- toast.success('Reservierung gelöscht')
+ toast.success(t('planner.reservationDeleted'))
} catch (err) {
toast.error(err.message)
}
@@ -226,7 +221,7 @@ export function RightPanel({
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
- placeholder="Orte suchen..."
+ 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 && (
@@ -241,7 +236,7 @@ export function RightPanel({
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"
>
-
Alle Kategorien
+
{t('planner.allCategories')}
{categories.map(c => (
{c.icon} {c.name}
))}
@@ -251,7 +246,7 @@ export function RightPanel({
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"
>
- Ort hinzufügen
+ {t('planner.addPlace')}
@@ -261,9 +256,9 @@ export function RightPanel({
{filteredPlaces.length === 0 ? (
📍
-
Keine Orte gefunden
+
{t('planner.noPlacesFound')}
- Ersten Ort hinzufügen
+ {t('planner.addFirstPlace')}
) : (
@@ -299,7 +294,7 @@ export function RightPanel({
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"
>
- + Tag
+ {t('planner.addToDay')}
)}
@@ -312,7 +307,7 @@ export function RightPanel({
)}
{place.place_time && (
-
🕐 {place.place_time}
+
🕐 {place.place_time}{place.end_time ? ` – ${place.end_time}` : ''}
)}
{place.price > 0 && (
@@ -337,7 +332,7 @@ export function RightPanel({
{!selectedDayId ? (
📅
-
Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen
+
{t('planner.selectDayHint')}
) : (
<>
@@ -352,39 +347,22 @@ export function RightPanel({
)}
- {dayAssignments.length} Ort{dayAssignments.length !== 1 ? 'e' : ''}
- {dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`}
+ {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')}`}
- {/* Transport mode */}
-
- {TRANSPORT_MODES.map(m => (
- setTransportMode(m.value)}
- className={`flex-1 py-1.5 text-xs rounded-lg flex items-center justify-center gap-1 transition-colors ${
- transportMode === m.value
- ? 'bg-slate-100 text-slate-900 font-medium'
- : 'text-gray-500 hover:bg-gray-100'
- }`}
- >
- {m.icon} {m.label}
-
- ))}
-
-
{/* Places list with order */}
{dayAssignments.length === 0 ? (
🗺️
-
Noch keine Orte für diesen Tag
+
{t('planner.noPlacesForDay')}
setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline"
>
- Orte hinzufügen →
+ {t('planner.addPlacesLink')}
) : (
@@ -475,14 +453,14 @@ export function RightPanel({
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"
>
- {isCalculatingRoute ? 'Berechne...' : 'Route berechnen'}
+ {isCalculatingRoute ? t('planner.calculating') : t('planner.route')}
- Optimieren
+ {t('planner.optimize')}
- In Google Maps öffnen
+ {t('planner.openGoogleMaps')}
)}
@@ -504,7 +482,7 @@ export function RightPanel({
- Reservierungen
+ {t('planner.reservations')}
{selectedDay && · Tag {selectedDay.day_number} }
- Hinzufügen
+ {t('common.add')}
@@ -520,9 +498,9 @@ export function RightPanel({
{filteredReservations.length === 0 ? (
🎫
-
Keine Reservierungen
+
{t('planner.noReservations')}
- Erste Reservierung hinzufügen
+ {t('planner.addFirstReservation')}
) : (
diff --git a/client/src/components/Trips/TripFormModal.jsx b/client/src/components/Trips/TripFormModal.jsx
index 280ca67..90d13c4 100644
--- a/client/src/components/Trips/TripFormModal.jsx
+++ b/client/src/components/Trips/TripFormModal.jsx
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
-import { Calendar, Camera, X } from 'lucide-react'
+import { Calendar, Camera, X, Clipboard } from 'lucide-react'
import { tripsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
@@ -21,6 +21,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
+ const [pendingCoverFile, setPendingCoverFile] = useState(null)
const [uploadingCover, setUploadingCover] = useState(false)
useEffect(() => {
@@ -36,6 +37,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setCoverPreview(null)
}
+ setPendingCoverFile(null)
setError('')
}, [trip, isOpen])
@@ -48,12 +50,23 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
setIsLoading(true)
try {
- await onSave({
+ const result = await onSave({
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
})
+ // Upload pending cover for newly created trips
+ if (pendingCoverFile && result?.trip?.id) {
+ try {
+ const fd = new FormData()
+ fd.append('cover', pendingCoverFile)
+ const data = await tripsApi.uploadCover(result.trip.id, fd)
+ onCoverUpdate?.(result.trip.id, data.cover_image)
+ } catch {
+ // Cover upload failed but trip was created
+ }
+ }
onClose()
} catch (err) {
setError(err.message || t('places.saveError'))
@@ -62,9 +75,24 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
}
- const handleCoverChange = async (e) => {
- const file = e.target.files?.[0]
- if (!file || !trip?.id) return
+ const handleCoverSelect = (file) => {
+ if (!file) return
+ if (isEditing && trip?.id) {
+ // Existing trip: upload immediately
+ uploadCoverNow(file)
+ } else {
+ // New trip: stage for upload after creation
+ setPendingCoverFile(file)
+ setCoverPreview(URL.createObjectURL(file))
+ }
+ }
+
+ const handleCoverChange = (e) => {
+ handleCoverSelect(e.target.files?.[0])
+ e.target.value = ''
+ }
+
+ const uploadCoverNow = async (file) => {
setUploadingCover(true)
try {
const fd = new FormData()
@@ -77,11 +105,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
toast.error(t('dashboard.coverUploadError'))
} finally {
setUploadingCover(false)
- e.target.value = ''
}
}
const handleRemoveCover = async () => {
+ if (pendingCoverFile) {
+ setPendingCoverFile(null)
+ setCoverPreview(null)
+ return
+ }
if (!trip?.id) return
try {
await tripsApi.update(trip.id, { cover_image: null })
@@ -92,15 +124,26 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
}
+ // Paste support for cover image
+ const handlePaste = (e) => {
+ const items = e.clipboardData?.items
+ if (!items) return
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ e.preventDefault()
+ const file = item.getAsFile()
+ if (file) handleCoverSelect(file)
+ return
+ }
+ }
+ }
+
const update = (field, value) => setFormData(prev => {
const next = { ...prev, [field]: value }
- // Auto-adjust end date when start date changes
if (field === 'start_date' && value) {
if (!prev.end_date || prev.end_date < value) {
- // If no end date or end date is before new start, set end = start
next.end_date = value
} else if (prev.start_date) {
- // Preserve trip duration: shift end date by same delta
const oldStart = new Date(prev.start_date + 'T00:00:00')
const oldEnd = new Date(prev.end_date + 'T00:00:00')
const duration = Math.round((oldEnd - oldStart) / 86400000)
@@ -135,40 +178,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
}
>
-
diff --git a/client/src/pages/RegisterPage.jsx b/client/src/pages/RegisterPage.jsx
index bd6e1d1..3089cc0 100644
--- a/client/src/pages/RegisterPage.jsx
+++ b/client/src/pages/RegisterPage.jsx
@@ -1,9 +1,11 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
+import { useTranslation } from '../i18n'
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
export default function RegisterPage() {
+ const { t } = useTranslation()
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@@ -20,12 +22,12 @@ export default function RegisterPage() {
setError('')
if (password !== confirmPassword) {
- setError('Passwörter stimmen nicht überein')
+ setError(t('register.passwordMismatch'))
return
}
if (password.length < 6) {
- setError('Passwort muss mindestens 6 Zeichen lang sein')
+ setError(t('register.passwordTooShort'))
return
}
@@ -34,7 +36,7 @@ export default function RegisterPage() {
await register(username, email, password)
navigate('/dashboard')
} catch (err) {
- setError(err.message || 'Registrierung fehlgeschlagen')
+ setError(err.message || t('register.failed'))
} finally {
setIsLoading(false)
}
@@ -48,19 +50,19 @@ export default function RegisterPage() {
-
Jetzt starten
+
{t('register.getStarted')}
- Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.
+ {t('register.subtitle')}
{[
- '✓ Unbegrenzte Reisepläne',
- '✓ Interaktive Kartenansicht',
- '✓ Orte und Kategorien verwalten',
- '✓ Reservierungen tracken',
- '✓ Packlisten erstellen',
- '✓ Fotos und Dateien speichern',
+ `✓ ${t('register.feature1')}`,
+ `✓ ${t('register.feature2')}`,
+ `✓ ${t('register.feature3')}`,
+ `✓ ${t('register.feature4')}`,
+ `✓ ${t('register.feature5')}`,
+ `✓ ${t('register.feature6')}`,
].map(item => (
{item}
))}
@@ -77,8 +79,8 @@ export default function RegisterPage() {
-
Konto erstellen
-
Beginnen Sie Ihre Reiseplanung
+
{t('register.createAccount')}
+
{t('register.startPlanning')}