Initial commit — NOMAD (Navigation Organizer for Maps, Activities & Destinations)

Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
Maurice
2026-03-18 23:58:08 +01:00
commit cb1e217bbe
100 changed files with 25545 additions and 0 deletions
+360
View File
@@ -0,0 +1,360 @@
import React, { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
import { useTranslation } from '../../i18n'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
]
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
]
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const fileInputRef = useRef(null)
const toast = useToast()
const { t, locale } = useTranslation()
const loadBackups = async () => {
setIsLoading(true)
try {
const data = await backupApi.list()
setBackups(data.backups || [])
} catch {
toast.error(t('backup.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
} catch {}
}
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
const handleCreate = async () => {
setIsCreating(true)
try {
await backupApi.create()
toast.success(t('backup.toast.created'))
await loadBackups()
} catch {
toast.error(t('backup.toast.createError'))
} finally {
setIsCreating(false)
}
}
const handleRestore = async (filename) => {
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
setRestoringFile(null)
}
}
const handleUploadRestore = async (e) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false)
}
}
const handleDelete = async (filename) => {
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
try {
await backupApi.delete(filename)
toast.success(t('backup.toast.deleted'))
setBackups(prev => prev.filter(b => b.filename !== filename))
} catch {
toast.error(t('backup.toast.deleteError'))
}
}
const handleAutoSettingsChange = (key, value) => {
setAutoSettings(prev => ({ ...prev, [key]: value }))
setAutoSettingsDirty(true)
}
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true)
try {
const data = await backupApi.setAutoSettings(autoSettings)
setAutoSettings(data.settings)
setAutoSettingsDirty(false)
toast.success(t('backup.toast.settingsSaved'))
} catch {
toast.error(t('backup.toast.settingsError'))
} finally {
setAutoSettingsSaving(false)
}
}
const formatSize = (bytes) => {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch { return dateStr }
}
const isAuto = (filename) => filename.startsWith('auto-backup-')
return (
<div className="flex flex-col gap-6">
{/* Manual Backups */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
title={t('backup.refresh')}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{/* Upload & Restore */}
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleUploadRestore}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? t('backup.uploading') : t('backup.upload')}
</button>
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
{isCreating ? t('backup.creating') : t('backup.create')}
</button>
</div>
</div>
{isLoading && backups.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
{t('common.loading')}
</div>
) : backups.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
{t('backup.createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{backups.map(backup => (
<div key={backup.filename} className="flex items-center gap-4 py-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
{isAuto(backup.filename)
? <RefreshCw className="w-4 h-4 text-blue-500" />
: <HardDrive className="w-4 h-4 text-gray-500" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
{isAuto(backup.filename) && (
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
{t('backup.download')}
</button>
<button
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
>
{restoringFile === backup.filename
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
: <RotateCcw className="w-3.5 h-3.5" />
}
{t('backup.restore')}
</button>
<button
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Auto-Backup Settings */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
{autoSettings.enabled && (
<>
{/* Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.interval === opt.value
? 'bg-slate-700 text-white border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
{/* Keep duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.keep_days === opt.value
? 'bg-slate-700 text-white border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
>
{autoSettingsSaving
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Check className="w-4 h-4" />
}
{autoSettingsSaving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,255 @@
import React, { useState, useEffect, useRef } from 'react'
import { categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
const PRESET_COLORS = [
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
'#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#84cc16',
'#6b7280', '#1f2937',
]
const ICON_NAMES = Object.keys(CATEGORY_ICON_MAP)
export default function CategoryManager() {
const [categories, setCategories] = useState([])
const [showForm, setShowForm] = useState(false)
const [editingId, setEditingId] = useState(null)
const [form, setForm] = useState({ name: '', color: '#6366f1', icon: 'MapPin' })
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const colorInputRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
useEffect(() => { loadCategories() }, [])
const loadCategories = async () => {
setIsLoading(true)
try {
const data = await categoriesApi.list()
setCategories(data.categories || [])
} catch (err) {
toast.error(t('categories.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const handleStartEdit = (cat) => {
setEditingId(cat.id)
setForm({ name: cat.name, color: cat.color || '#6366f1', icon: cat.icon || 'MapPin' })
setShowForm(false)
}
const handleStartCreate = () => {
setEditingId(null)
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
setShowForm(true)
}
const handleCancel = () => {
setShowForm(false)
setEditingId(null)
}
const handleSave = async () => {
if (!form.name.trim()) { toast.error(t('categories.toast.nameRequired')); return }
setIsSaving(true)
try {
if (editingId) {
const result = await categoriesApi.update(editingId, form)
setCategories(prev => prev.map(c => c.id === editingId ? result.category : c))
setEditingId(null)
toast.success(t('categories.toast.updated'))
} else {
const result = await categoriesApi.create(form)
setCategories(prev => [...prev, result.category])
setShowForm(false)
toast.success(t('categories.toast.created'))
}
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
} catch (err) {
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
} finally {
setIsSaving(false)
}
}
const handleDelete = async (id) => {
if (!confirm(t('categories.confirm.delete'))) return
try {
await categoriesApi.delete(id)
setCategories(prev => prev.filter(c => c.id !== id))
toast.success(t('categories.toast.deleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('categories.toast.deleteError'))
}
}
const isPresetColor = PRESET_COLORS.includes(form.color)
const PreviewIcon = getCategoryIcon(form.icon)
const categoryForm = (
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-200">
<input
type="text"
value={form.name}
onChange={e => setForm(prev => ({ ...prev, name: e.target.value }))}
placeholder={t('categories.namePlaceholder')}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
autoFocus
/>
<div>
<label className="block text-xs font-medium text-gray-600 mb-2">{t('categories.icon')}</label>
<div className="max-h-48 overflow-y-auto">
<div className="flex flex-wrap gap-1.5 px-1.5 py-1.5">
{ICON_NAMES.map(name => {
const Icon = CATEGORY_ICON_MAP[name]
const isSelected = form.icon === name
return (
<button
key={name}
type="button"
title={ICON_LABELS[name] || name}
onClick={() => setForm(prev => ({ ...prev, icon: name }))}
className={`w-9 h-9 flex items-center justify-center rounded-lg transition-all ${
isSelected
? 'ring-2 ring-offset-1 ring-slate-700'
: 'hover:bg-gray-200'
}`}
style={{ background: isSelected ? `${form.color}18` : undefined }}
>
<Icon size={17} strokeWidth={1.8} color={isSelected ? form.color : '#374151'} />
</button>
)
})}
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1.5">{t('categories.color')}</label>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map(color => (
<button key={color} type="button" onClick={() => setForm(prev => ({ ...prev, color }))}
className={`w-7 h-7 rounded-full transition-transform hover:scale-110 ${form.color === color ? 'ring-2 ring-offset-2 ring-gray-400 scale-110' : ''}`}
style={{ backgroundColor: color }} />
))}
{/* Custom color button */}
<input
ref={colorInputRef}
type="color"
value={form.color}
onChange={e => setForm(prev => ({ ...prev, color: e.target.value }))}
className="sr-only"
/>
<button
type="button"
title={t('categories.customColor')}
onClick={() => colorInputRef.current?.click()}
className={`w-7 h-7 rounded-full flex items-center justify-center border-2 transition-transform hover:scale-110 ${
!isPresetColor
? 'ring-2 ring-offset-2 ring-gray-400 scale-110 border-transparent'
: 'border-dashed border-gray-300 hover:border-gray-400'
}`}
style={!isPresetColor ? { backgroundColor: form.color } : undefined}
>
{isPresetColor && <Pipette className="w-3 h-3 text-gray-400" />}
</button>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{t('categories.preview')}:</span>
<span className="inline-flex items-center gap-1.5 text-sm px-2.5 py-1 rounded-full font-medium"
style={{ backgroundColor: `${form.color}20`, color: form.color }}>
<PreviewIcon size={14} strokeWidth={1.8} />
{form.name || t('categories.defaultName')}
</span>
</div>
<div className="flex justify-end gap-2">
<button type="button" onClick={handleCancel}
className="px-3 py-1.5 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50">
{t('common.cancel')}
</button>
<button type="button" onClick={handleSave} disabled={isSaving || !form.name.trim()}
className="px-4 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium">
{isSaving ? t('common.saving') : editingId ? t('categories.update') : t('categories.create')}
</button>
</div>
</div>
)
return (
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
</div>
<button onClick={handleStartCreate}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
<Plus className="w-4 h-4" />
{t('categories.new')}
</button>
</div>
{showForm && <div className="mb-4">{categoryForm}</div>}
{isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-600 rounded-full animate-spin" />
</div>
) : categories.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="text-sm">{t('categories.empty')}</p>
</div>
) : (
<div className="space-y-2">
{categories.map(cat => {
const Icon = getCategoryIcon(cat.icon)
return (
<div key={cat.id}>
{editingId === cat.id ? (
<div className="mb-2">{categoryForm}</div>
) : (
<div className="flex items-center gap-3 p-3 border border-gray-100 rounded-xl hover:border-gray-200 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: `${cat.color}20` }}>
<Icon size={18} strokeWidth={1.8} color={cat.color} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 text-sm">{cat.name}</span>
<span className="text-xs px-2 py-0.5 rounded-full"
style={{ backgroundColor: `${cat.color}20`, color: cat.color }}>
{cat.color}
</span>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleStartEdit(cat)}
className="p-1.5 text-gray-400 hover:text-slate-700 hover:bg-slate-100 rounded-lg">
<Edit2 className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(cat.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
}
@@ -0,0 +1,407 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useTranslation } from '../../i18n'
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
import CustomSelect from '../shared/CustomSelect'
// ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
const fmtNum = (v, locale, cur) => {
if (v == null || isNaN(v)) return '-'
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
}
const calcPP = (p, n) => (n > 0 ? p / n : null)
const calcPD = (p, d) => (d > 0 ? p / d : null)
const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip }) {
const [editing, setEditing] = useState(false)
const [editValue, setEditValue] = useState(value ?? '')
const inputRef = useRef(null)
useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
const save = () => {
setEditing(false)
let v = editValue
if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
if (v !== value) onSave(v)
}
if (editing) {
return <input ref={inputRef} type="text" inputMode={type === 'number' ? 'decimal' : 'text'} value={editValue}
onChange={e => setEditValue(e.target.value)} onBlur={save}
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
style={{ width: '100%', border: '1px solid #6366f1', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
placeholder={placeholder} />
}
const display = type === 'number' && value != null
? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
: (value || '')
return (
<div onClick={() => { setEditValue(value ?? ''); setEditing(true) }} title={editTooltip}
style={{ cursor: 'pointer', padding: '4px 6px', borderRadius: 4, minHeight: 28, display: 'flex', alignItems: 'center',
justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(99,102,241,0.06)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{display || placeholder || '-'}
</div>
)
}
// ── Add Item Row ─────────────────────────────────────────────────────────────
function AddItemRow({ onAdd, t }) {
const [name, setName] = useState('')
const [price, setPrice] = useState('')
const [persons, setPersons] = useState('')
const [days, setDays] = useState('')
const [note, setNote] = useState('')
const nameRef = useRef(null)
const handleAdd = () => {
if (!name.trim()) return
onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null })
setName(''); setPrice(''); setPersons(''); setDays(''); setNote('')
setTimeout(() => nameRef.current?.focus(), 50)
}
const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
return (
<tr style={{ background: 'var(--bg-secondary)' }}>
<td style={{ padding: '4px 6px' }}>
<input ref={nameRef} value={name} onChange={e => setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder={t('budget.newEntry')} style={inp} />
</td>
<td style={{ padding: '4px 6px' }}>
<input value={price} onChange={e => setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={persons} onChange={e => setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px', textAlign: 'center' }}>
<input value={days} onChange={e => setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 50, margin: '0 auto' }} />
</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden md:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden lg:table-cell" style={{ padding: '4px 6px', color: 'var(--text-faint)', fontSize: 12, textAlign: 'center' }}>-</td>
<td className="hidden sm:table-cell" style={{ padding: '4px 6px' }}>
<input value={note} onChange={e => setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
</td>
<td style={{ padding: '4px 6px', textAlign: 'center' }}>
<button onClick={handleAdd} disabled={!name.trim()} title={t('reservations.add')}
style={{ background: name.trim() ? '#6366f1' : 'var(--border-primary)', border: 'none', borderRadius: 4, color: '#fff',
cursor: name.trim() ? 'pointer' : 'default', padding: '4px 8px', display: 'inline-flex', alignItems: 'center' }}>
<Plus size={14} />
</button>
</td>
</tr>
)
}
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
function PieChart({ segments, size = 200, totalLabel }) {
if (!segments.length) return null
const total = segments.reduce((s, x) => s + x.value, 0)
if (total === 0) return null
let cumDeg = 0
const stops = segments.map(seg => {
const start = cumDeg
const deg = (seg.value / total) * 360
cumDeg += deg
return `${seg.color} ${start}deg ${start + deg}deg`
}).join(', ')
return (
<div style={{ position: 'relative', width: size, height: size, margin: '0 auto' }}>
<div style={{
width: size, height: size, borderRadius: '50%',
background: `conic-gradient(${stops})`,
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
}} />
{/* Center hole */}
<div style={{
position: 'absolute', top: '50%', left: '50%',
transform: 'translate(-50%, -50%)',
width: size * 0.55, height: size * 0.55,
borderRadius: '50%', background: 'var(--bg-card)',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
boxShadow: 'inset 0 0 12px rgba(0,0,0,0.04)',
}}>
<Wallet size={18} color="var(--text-faint)" style={{ marginBottom: 2 }} />
<span style={{ fontSize: 10, color: 'var(--text-faint)', fontWeight: 500 }}>{totalLabel}</span>
</div>
</div>
)
}
// ── Main Component ───────────────────────────────────────────────────────────
export default function BudgetPanel({ tripId }) {
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
const { t, locale } = useTranslation()
const [newCategoryName, setNewCategoryName] = useState('')
const currency = trip?.currency || 'EUR'
const fmt = (v, cur) => fmtNum(v, locale, cur)
const setCurrency = (cur) => {
if (tripId) updateTrip(tripId, { currency: cur })
}
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
const cat = item.category || 'Sonstiges'
if (!acc[cat]) acc[cat] = []
acc[cat].push(item)
return acc
}, {}), [budgetItems])
const categoryNames = Object.keys(grouped)
const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
const pieSegments = useMemo(() =>
categoryNames.map((cat, i) => ({
name: cat,
value: grouped[cat].reduce((s, x) => s + (x.total_price || 0), 0),
color: PIE_COLORS[i % PIE_COLORS.length],
})).filter(s => s.value > 0)
, [grouped, categoryNames])
const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
const handleDeleteCategory = async (cat) => {
const items = grouped[cat] || []
for (const item of items) await deleteBudgetItem(tripId, item.id)
}
const handleAddCategory = () => {
if (!newCategoryName.trim()) return
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
setNewCategoryName(''); setShowAddCategory(false)
}
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
// ── Empty State ──────────────────────────────────────────────────────────
if (!budgetItems || budgetItems.length === 0) {
return (
<div style={{ padding: 24, maxWidth: 600, margin: '60px auto', textAlign: 'center' }}>
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Calculator size={28} color="#6b7280" />
</div>
<h2 style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', margin: '0 0 8px' }}>{t('budget.emptyTitle')}</h2>
<p style={{ fontSize: 14, color: 'var(--text-muted)', margin: '0 0 24px', lineHeight: 1.5 }}>{t('budget.emptyText')}</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', alignItems: 'center' }}>
<input value={newCategoryName} onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
style={{ padding: '10px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 14, fontFamily: 'inherit', width: 260, outline: 'none' }} />
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '10px 20px', fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', display: 'inline-flex', alignItems: 'center', gap: 6, opacity: newCategoryName.trim() ? 1 : 0.5 }}>
<Plus size={16} /> {t('budget.createCategory')}
</button>
</div>
</div>
)
}
// ── Main Layout ──────────────────────────────────────────────────────────
return (
<div style={{ fontFamily: "'Poppins', -apple-system, BlinkMacSystemFont, system-ui, sans-serif" }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '16px 16px 12px', flexWrap: 'wrap', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Calculator size={20} color="var(--text-primary)" />
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('budget.title')}</h2>
</div>
</div>
{/* Main: table + sidebar */}
<div style={{ display: 'flex', gap: 20, padding: '0 16px 40px', alignItems: 'flex-start', flexWrap: 'wrap' }}>
{/* Left: Tables */}
<div style={{ flex: 1, minWidth: 0 }}>
{categoryNames.map((cat, ci) => {
const items = grouped[cat]
const subtotal = items.reduce((s, x) => s + (x.total_price || 0), 0)
const color = PIE_COLORS[ci % PIE_COLORS.length]
return (
<div key={cat} style={{ marginBottom: 16 }}>
{/* Category header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', background: '#000000', color: '#fff', borderRadius: '10px 10px 0 0', padding: '9px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
<button onClick={() => handleDeleteCategory(cat)} title={t('budget.deleteCategory')}
style={{ background: 'rgba(255,255,255,0.1)', border: 'none', borderRadius: 4, color: '#fff', cursor: 'pointer', padding: '3px 6px', display: 'flex', alignItems: 'center', opacity: 0.6 }}
onMouseEnter={e => e.currentTarget.style.opacity = '1'} onMouseLeave={e => e.currentTarget.style.opacity = '0.6'}>
<Trash2 size={13} />
</button>
</div>
</div>
{/* Table */}
<div style={{ overflowX: 'auto', border: '1px solid var(--border-primary)', borderTop: 'none', borderRadius: '0 0 10px 10px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
<th className="hidden lg:table-cell" style={{ ...th, minWidth: 95 }}>{t('budget.table.perPersonDay')}</th>
<th className="hidden sm:table-cell" style={{ ...th, textAlign: 'left', minWidth: 80 }}>{t('budget.table.note')}</th>
<th style={{ ...th, width: 36 }}></th>
</tr>
</thead>
<tbody>
{items.map(item => {
const pp = calcPP(item.total_price, item.persons)
const pd = calcPD(item.total_price, item.days)
const ppd = calcPPD(item.total_price, item.persons, item.days)
return (
<tr key={item.id} style={{ transition: 'background 0.1s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<td style={td}><InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
<td style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pp != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pp != null ? fmt(pp, currency) : '-'}</td>
<td className="hidden md:table-cell" style={{ ...td, textAlign: 'center', color: pd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{pd != null ? fmt(pd, currency) : '-'}</td>
<td className="hidden lg:table-cell" style={{ ...td, textAlign: 'center', color: ppd != null ? 'var(--text-secondary)' : 'var(--text-faint)' }}>{ppd != null ? fmt(ppd, currency) : '-'}</td>
<td className="hidden sm:table-cell" style={td}><InlineEditCell value={item.note} onSave={v => handleUpdateField(item.id, 'note', v)} placeholder={t('budget.table.note')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
<td style={{ ...td, textAlign: 'center' }}>
<button onClick={() => handleDeleteItem(item.id)} title={t('common.delete')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, color: 'var(--text-faint)', borderRadius: 4, display: 'inline-flex', transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#d1d5db'}>
<Trash2 size={14} />
</button>
</td>
</tr>
)
})}
<AddItemRow onAdd={data => handleAddItem(cat, data)} t={t} />
</tbody>
</table>
</div>
</div>
)
})}
</div>
{/* Right: Sidebar */}
<div className="w-full md:w-[280px]" style={{ flexShrink: 0, position: 'sticky', top: 16, alignSelf: 'flex-start' }}>
{/* Currency selector */}
<div style={{ marginBottom: 12 }}>
<CustomSelect
value={currency}
onChange={setCurrency}
options={CURRENCIES.map(c => ({ value: c, label: `${c} (${SYMBOLS[c] || c})` }))}
searchable
/>
</div>
{/* Add category */}
<div style={{ display: 'flex', gap: 6, marginBottom: 12 }}>
<input
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory() }}
placeholder={t('budget.categoryName')}
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '9px 14px', fontSize: 13, outline: 'none', fontFamily: 'inherit', background: 'var(--bg-input)', color: 'var(--text-primary)' }}
/>
<button onClick={handleAddCategory} disabled={!newCategoryName.trim()}
style={{ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10, padding: '9px 12px', cursor: 'pointer', display: 'flex', alignItems: 'center', opacity: newCategoryName.trim() ? 1 : 0.4, flexShrink: 0 }}>
<Plus size={16} />
</button>
</div>
{/* Grand total card */}
<div style={{
background: 'linear-gradient(135deg, #000000 0%, #18181b 100%)',
borderRadius: 16, padding: '24px 20px', color: '#fff', marginBottom: 16,
boxShadow: '0 8px 32px rgba(15,23,42,0.18)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Wallet size={18} color="rgba(255,255,255,0.8)" />
</div>
<div>
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', fontWeight: 500, letterSpacing: 0.5 }}>{t('budget.totalBudget')}</div>
</div>
</div>
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</div>
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
</div>
{/* Pie chart card */}
{pieSegments.length > 0 && (
<div style={{
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
}}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
{/* Legend */}
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
{pieSegments.map(seg => {
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
return (
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
</div>
)
})}
</div>
{/* Category amounts */}
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
{pieSegments.map(seg => (
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,193 @@
import React, { useState, useEffect, useMemo, useRef } from 'react'
import { Globe, MapPin, Plane } from 'lucide-react'
import { authApi } from '../../api/client'
import { useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
// Numeric ISO → country name lookup (countries-110m uses numeric IDs)
const NUMERIC_TO_NAME = {"004":"Afghanistan","008":"Albania","012":"Algeria","024":"Angola","032":"Argentina","036":"Australia","040":"Austria","050":"Bangladesh","056":"Belgium","064":"Bhutan","068":"Bolivia","070":"Bosnia and Herzegovina","072":"Botswana","076":"Brazil","100":"Bulgaria","104":"Myanmar","108":"Burundi","112":"Belarus","116":"Cambodia","120":"Cameroon","124":"Canada","140":"Central African Republic","144":"Sri Lanka","148":"Chad","152":"Chile","156":"China","170":"Colombia","178":"Congo","180":"Democratic Republic of the Congo","188":"Costa Rica","191":"Croatia","192":"Cuba","196":"Cyprus","203":"Czech Republic","204":"Benin","208":"Denmark","214":"Dominican Republic","218":"Ecuador","818":"Egypt","222":"El Salvador","226":"Equatorial Guinea","232":"Eritrea","233":"Estonia","231":"Ethiopia","238":"Falkland Islands","246":"Finland","250":"France","266":"Gabon","270":"Gambia","268":"Georgia","276":"Germany","288":"Ghana","300":"Greece","320":"Guatemala","324":"Guinea","328":"Guyana","332":"Haiti","340":"Honduras","348":"Hungary","352":"Iceland","356":"India","360":"Indonesia","364":"Iran","368":"Iraq","372":"Ireland","376":"Israel","380":"Italy","384":"Ivory Coast","388":"Jamaica","392":"Japan","400":"Jordan","398":"Kazakhstan","404":"Kenya","408":"North Korea","410":"South Korea","414":"Kuwait","417":"Kyrgyzstan","418":"Laos","422":"Lebanon","426":"Lesotho","430":"Liberia","434":"Libya","440":"Lithuania","442":"Luxembourg","450":"Madagascar","454":"Malawi","458":"Malaysia","466":"Mali","478":"Mauritania","484":"Mexico","496":"Mongolia","498":"Moldova","504":"Morocco","508":"Mozambique","516":"Namibia","524":"Nepal","528":"Netherlands","540":"New Caledonia","554":"New Zealand","558":"Nicaragua","562":"Niger","566":"Nigeria","578":"Norway","512":"Oman","586":"Pakistan","591":"Panama","598":"Papua New Guinea","600":"Paraguay","604":"Peru","608":"Philippines","616":"Poland","620":"Portugal","630":"Puerto Rico","634":"Qatar","642":"Romania","643":"Russia","646":"Rwanda","682":"Saudi Arabia","686":"Senegal","688":"Serbia","694":"Sierra Leone","703":"Slovakia","705":"Slovenia","706":"Somalia","710":"South Africa","724":"Spain","144":"Sri Lanka","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
// Our country names from addresses → match against GeoJSON names
function isCountryMatch(geoName, visitedCountries) {
if (!geoName) return false
const lower = geoName.toLowerCase()
return visitedCountries.some(c => {
const cl = c.toLowerCase()
return lower === cl || lower.includes(cl) || cl.includes(lower)
// Handle common mismatches
|| (cl === 'usa' && lower.includes('united states'))
|| (cl === 'uk' && lower === 'united kingdom')
|| (cl === 'south korea' && lower === 'korea' || lower === 'south korea')
|| (cl === 'deutschland' && lower === 'germany')
|| (cl === 'frankreich' && lower === 'france')
|| (cl === 'italien' && lower === 'italy')
|| (cl === 'spanien' && lower === 'spain')
|| (cl === 'österreich' && lower === 'austria')
|| (cl === 'schweiz' && lower === 'switzerland')
|| (cl === 'niederlande' && lower === 'netherlands')
|| (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye'))
|| (cl === 'griechenland' && lower === 'greece')
|| (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia'))
|| (cl === 'ägypten' && lower === 'egypt')
|| (cl === 'südkorea' && lower.includes('korea'))
|| (cl === 'indien' && lower === 'india')
|| (cl === 'brasilien' && lower === 'brazil')
|| (cl === 'argentinien' && lower === 'argentina')
|| (cl === 'russland' && lower === 'russia')
|| (cl === 'australien' && lower === 'australia')
|| (cl === 'kanada' && lower === 'canada')
|| (cl === 'mexiko' && lower === 'mexico')
|| (cl === 'neuseeland' && lower === 'new zealand')
|| (cl === 'singapur' && lower === 'singapore')
|| (cl === 'kroatien' && lower === 'croatia')
|| (cl === 'ungarn' && lower === 'hungary')
|| (cl === 'rumänien' && lower === 'romania')
|| (cl === 'polen' && lower === 'poland')
|| (cl === 'schweden' && lower === 'sweden')
|| (cl === 'norwegen' && lower === 'norway')
|| (cl === 'dänemark' && lower === 'denmark')
|| (cl === 'finnland' && lower === 'finland')
|| (cl === 'irland' && lower === 'ireland')
|| (cl === 'portugal' && lower === 'portugal')
|| (cl === 'belgien' && lower === 'belgium')
})
}
const TOTAL_COUNTRIES = 195
// Simple Mercator projection for SVG
function project(lon, lat, width, height) {
const clampedLat = Math.max(-75, Math.min(83, lat))
const x = ((lon + 180) / 360) * width
const latRad = (clampedLat * Math.PI) / 180
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
const y = (height / 2) - (width * mercN) / (2 * Math.PI)
return [x, y]
}
function geoToPath(coords, width, height) {
return coords.map((ring) => {
// Split ring at dateline crossings to avoid horizontal stripes
const segments = [[]]
for (let i = 0; i < ring.length; i++) {
const [lon, lat] = ring[i]
if (i > 0) {
const prevLon = ring[i - 1][0]
if (Math.abs(lon - prevLon) > 180) {
// Dateline crossing — start new segment
segments.push([])
}
}
const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height)
segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`)
}
return segments
.filter(s => s.length > 2)
.map(s => 'M' + s.join('L') + 'Z')
.join(' ')
}).join(' ')
}
let geoJsonCache = null
async function loadGeoJson() {
if (geoJsonCache) return geoJsonCache
try {
const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
const topo = await res.json()
const { feature } = await import('topojson-client')
const geo = feature(topo, topo.objects.countries)
geo.features.forEach(f => {
f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || ''
})
geoJsonCache = geo
return geo
} catch { return null }
}
export default function TravelStats() {
const { t } = useTranslation()
const dark = useSettingsStore(s => s.settings.dark_mode)
const [stats, setStats] = useState(null)
const [geoData, setGeoData] = useState(null)
useEffect(() => {
authApi.travelStats().then(setStats).catch(() => {})
loadGeoJson().then(setGeoData)
}, [])
const countryCount = stats?.countries?.length || 0
const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1)
if (!stats || stats.totalPlaces === 0) return null
return (
<div style={{ width: 340 }}>
{/* Stats Card */}
<div style={{
borderRadius: 20, overflow: 'hidden', height: 300,
display: 'flex', flexDirection: 'column', justifyContent: 'center',
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
padding: 16,
}}>
{/* Progress bar */}
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
</div>
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99,
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
width: `${Math.max(1, parseFloat(worldPercent))}%`,
transition: 'width 0.5s ease',
}} />
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
</div>
</div>
{/* Stat grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
</div>
{/* Country tags */}
{stats.countries.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{stats.countries.map(c => (
<span key={c} style={{
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
}}>{c}</span>
))}
</div>
</>
)}
</div>
</div>
)
}
function StatBox({ icon: Icon, value, label }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
borderRadius: 10, background: 'var(--bg-hover)',
}}>
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<div>
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
</div>
</div>
)
}
+331
View File
@@ -0,0 +1,331 @@
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
function isImage(mimeType) {
if (!mimeType) return false
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
}
function getFileIcon(mimeType) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
// Image lightbox
function ImageLightbox({ file, onClose }) {
const { t } = useTranslation()
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={file.url}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<ExternalLink size={16} />
</a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
</div>
</div>
</div>
)
}
// Source badge — unified style for both place and reservation
function SourceBadge({ icon: Icon, label }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px',
fontWeight: 500, whiteSpace: 'nowrap',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
{label}
</span>
)
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
const toast = useToast()
const { t, locale } = useTranslation()
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
try {
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append('file', file)
await onUpload(formData)
}
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
} catch {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
}, [onUpload, toast, t])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 50 * 1024 * 1024,
noClick: false,
})
const filteredFiles = files.filter(f => {
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
if (filterType === 'image') return isImage(f.mime_type)
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
return true
})
const handleDelete = async (id) => {
if (!confirm(t('files.confirm.delete'))) return
try {
await onDelete(id)
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
const openFile = (file) => {
if (isImage(file.mime_type)) {
setLightboxFile(file)
} else {
setPreviewFile(file)
}
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{/* Datei-Vorschau Modal */}
{previewFile && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<iframe
src={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
/>
</div>
</div>
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
</p>
</div>
</div>
{/* Upload zone */}
<div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')}
</div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
</>
)}
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => {
const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id)
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null
const fileUrl = file.url || `/uploads/files/${file.filename}`
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
>
{file.original_name}
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlace && (
<SourceBadge
icon={MapPin}
label={`${t('files.sourcePlan')} · ${linkedPlace.name}`}
/>
)}
{linkedReservation && (
<SourceBadge
icon={Ticket}
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
/>
)}
</div>
{file.description && !linkedReservation && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<Trash2 size={14} />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
<style>{`
div:hover > .file-actions { opacity: 1 !important; }
`}</style>
</div>
)
}
+157
View File
@@ -0,0 +1,157 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const dark = settings.dark_mode
const handleLogout = () => {
logout()
navigate('/login')
}
const toggleDark = () => {
updateSetting('dark_mode', !dark).catch(() => {})
}
return (
<nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
<button onClick={onBack}
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.back')}</span>
</button>
)}
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
style={{ color: 'var(--text-primary)' }}>
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<span className="font-bold text-sm hidden sm:inline">{t('nav.trip')}</span>
</Link>
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
{tripTitle}
</span>
</>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Share button */}
{onShare && (
<button onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Dark mode toggle */}
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* User menu */}
{user && (
<div className="relative">
<button onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg transition-colors"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#e2e8f0' : '#111827', color: dark ? '#0f172a' : '#ffffff' }}>
{user.username?.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}>
{user.username}
</span>
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
</button>
{userMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
{user.role === 'admin' && (
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}>
<Shield className="w-3 h-3" /> {t('nav.administrator')}
</span>
)}
</div>
<div className="py-1">
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Settings className="w-4 h-4" />
{t('nav.settings')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Shield className="w-4 h-4" />
{t('nav.admin')}
</Link>
)}
</div>
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<button onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
<LogOut className="w-4 h-4" />
{t('nav.logout')}
</button>
</div>
</div>
</>
)}
</div>
)}
</nav>
)
}
+258
View File
@@ -0,0 +1,258 @@
import React, { useEffect, useRef, useState } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import L from 'leaflet'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from '../shared/categoryIcons'
// Fix default marker icons for vite
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
})
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumber, isSelected) {
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = place.category_color || '#6b7280'
const icon = place.category_icon || '📍'
// White semi-transparent number badge (bottom-right), only when orderNumber is set
const badgeHtml = orderNumber != null ? `
<span style="
position:absolute;bottom:-3px;right:-3px;
min-width:18px;height:18px;border-radius:9px;
padding:0 3px;
background:rgba(255,255,255,0.92);
border:1.5px solid rgba(0,0,0,0.18);
box-shadow:0 1px 4px rgba(0,0,0,0.18);
display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:800;color:#111827;
font-family:-apple-system,system-ui,sans-serif;line-height:1;
box-sizing:border-box;
">${orderNumber}</span>` : ''
if (place.image_url) {
return L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:visible;background:${bgColor};
cursor:pointer;flex-shrink:0;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
}
return L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
background:${bgColor};
display:flex;align-items:center;justify-content:center;
cursor:pointer;position:relative;
">
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
}
// Pan/zoom to selected place
function SelectionController({ places, selectedPlaceId }) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId)
if (place?.lat && place?.lng) {
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, map])
return null
}
// Recenter map when default center changes
function MapController({ center, zoom }) {
const map = useMap()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
prevCenter.current = center
}
}, [center, zoom, map])
return null
}
// Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey }) {
const map = useMap()
const prevFitKey = useRef(-1)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
if (places.length === 0) return
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
} catch {}
}, [fitKey, places, map])
return null
}
function MapClickHandler({ onClick }) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => map.off('click', onClick)
}, [map, onClick])
return null
}
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
export function MapView({
places = [],
route = null,
selectedPlaceId = null,
onMarkerClick,
onMapClick,
center = [48.8566, 2.3522],
zoom = 10,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
fitKey = 0,
dayOrderMap = {},
}) {
const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url
useEffect(() => {
places.forEach(place => {
if (place.image_url || !place.google_place_id) return
if (mapPhotoCache.has(place.google_place_id)) {
const cached = mapPhotoCache.get(place.google_place_id)
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
mapPhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
}
})
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
})
}, [places])
return (
<MapContainer
center={center}
zoom={zoom}
zoomControl={false}
className="w-full h-full"
style={{ background: '#e5e7eb' }}
>
<TileLayer
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={places} fitKey={fitKey} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
<MapClickHandler onClick={onMapClick} />
{places.map((place) => {
const isSelected = place.id === selectedPlaceId
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
const orderNumber = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
})}
{route && route.length > 1 && (
<Polyline
positions={route}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
)}
</MapContainer>
)
}
@@ -0,0 +1,109 @@
// OSRM routing utility - free, no API key required
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
/**
* Calculate a route between multiple waypoints using OSRM
* @param {Array<{lat: number, lng: number}>} waypoints
* @param {string} profile - 'driving' | 'walking' | 'cycling'
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
*/
export async function calculateRoute(waypoints, profile = 'driving') {
if (!waypoints || waypoints.length < 2) {
throw new Error('Mindestens 2 Wegpunkte erforderlich')
}
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
// OSRM public API only supports driving; we override duration for other modes
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
const response = await fetch(url)
if (!response.ok) {
throw new Error('Route konnte nicht berechnet werden')
}
const data = await response.json()
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
throw new Error('Keine Route gefunden')
}
const route = data.routes[0]
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
const distance = route.distance // meters
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
let duration
if (profile === 'walking') {
duration = distance / (5000 / 3600)
} else if (profile === 'cycling') {
duration = distance / (15000 / 3600)
} else {
duration = route.duration // driving: use OSRM value
}
return {
coordinates,
distance,
duration,
distanceText: formatDistance(distance),
durationText: formatDuration(duration),
}
}
/**
* Generate a Google Maps directions URL for the given places
*/
export function generateGoogleMapsUrl(places) {
const valid = places.filter(p => p.lat && p.lng)
if (valid.length === 0) return null
if (valid.length === 1) {
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
}
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
return `https://www.google.com/maps/dir/${stops}`
}
/**
* Simple nearest-neighbor route optimization
*/
export function optimizeRoute(places) {
const valid = places.filter(p => p.lat && p.lng)
if (valid.length <= 2) return places
const visited = new Set()
const result = []
let current = valid[0]
visited.add(current.id)
result.push(current)
while (result.length < valid.length) {
let nearest = null
let minDist = Infinity
for (const place of valid) {
if (visited.has(place.id)) continue
const d = Math.sqrt(
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2)
)
if (d < minDist) { minDist = d; nearest = place }
}
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest }
}
return result
}
function formatDistance(meters) {
if (meters < 1000) {
return `${Math.round(meters)} m`
}
return `${(meters / 1000).toFixed(1)} km`
}
function formatDuration(seconds) {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) {
return `${h} Std. ${m} Min.`
}
return `${m} Min.`
}
+395
View File
@@ -0,0 +1,395 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { mapsApi } from '../../api/client'
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`
function escHtml(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function absUrl(url) {
if (!url) return null
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function safeImg(url) {
if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
}
// Generate SVG string from Lucide icon name (for category thumbnails)
let _renderToStaticMarkup = null
async function ensureRenderer() {
if (!_renderToStaticMarkup) {
const mod = await import('react-dom/server')
_renderToStaticMarkup = mod.renderToStaticMarkup
}
}
function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
if (!_renderToStaticMarkup) return ''
const Icon = getCategoryIcon(iconName)
return _renderToStaticMarkup(
createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' })
)
}
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
}
function dayCost(assignments, dayId, locale) {
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments) {
const photoMap = {} // placeId → photoUrl
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled(
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(place.google_place_id)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {}
})
)
return photoMap
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
await ensureRenderer()
const loc = _locale || 'de-DE'
const tr = _t || (k => k)
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc)
const coverImg = safeImg(trip?.cover_image)
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
merged.sort((a, b) => a.k - b.k)
let pi = 0
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'note') {
const note = item.data
return `
<div class="note-card">
<div class="note-line"></div>
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
<rect x="4" y="3" width="16" height="18" rx="2"/>
<line x1="8" y1="8" x2="16" y2="8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="13" y2="16"/>
</svg>
<div class="note-body">
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
</div>
</div>`
}
pi++
const place = item.data.place
if (!place) return ''
const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon
const directImg = safeImg(place.image_url)
const googleImg = photoMap[place.id] || null
const img = directImg || googleImg
const confirmed = place.reservation_status === 'confirmed'
const pending = place.reservation_status === 'pending'
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />`
: `<div class="place-thumb-fallback" style="background:${color}">
${iconSvg}
</div>`
const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
confirmed ? `<span class="chip chip-green">${svgCheck}${escHtml(tr('reservations.confirmed'))}</span>` : '',
pending ? `<span class="chip chip-amber">${svgClock2}${escHtml(tr('reservations.pending'))}</span>` : '',
].filter(Boolean).join('')
return `
<div class="place-card">
<div class="place-bar" style="background:${color}"></div>
${thumbHtml}
<div class="place-info">
<div class="place-name-row">
<span class="place-num">${pi}</span>
<span class="place-name">${escHtml(place.name)}</span>
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
return `
<div class="day-section${di > 0 ? ' page-break' : ''}">
<div class="day-header">
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
<div class="day-body">${itemsHtml}</div>
</div>`
}).join('')
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.name || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* ── Cover ─────────────────────────────────────── */
.cover {
width: 100%; min-height: 100vh;
background: #0f172a;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 52px; position: relative; overflow: hidden;
}
.cover-bg {
position: absolute; inset: 0;
background-size: cover; background-position: center;
opacity: 0.28;
}
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
.cover-brand {
position: absolute; top: 36px; right: 52px;
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
color: rgba(255,255,255,0.3); text-transform: uppercase;
}
.cover-body { position: relative; z-index: 1; }
.cover-circle {
width: 100px; height: 100px; border-radius: 50%;
overflow: hidden; border: 2.5px solid rgba(255,255,255,0.25);
margin-bottom: 26px; flex-shrink: 0;
}
.cover-circle img { width: 100%; height: 100%; object-fit: cover; }
.cover-circle-ph {
width: 100px; height: 100px; border-radius: 50%;
background: rgba(255,255,255,0.07);
margin-bottom: 26px;
}
.cover-label { font-size: 9px; font-weight: 600; letter-spacing: 2.5px; color: rgba(255,255,255,0.4); text-transform: uppercase; margin-bottom: 8px; }
.cover-title { font-size: 42px; font-weight: 700; color: #fff; line-height: 1.1; margin-bottom: 8px; }
.cover-desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.6; margin-bottom: 18px; max-width: 420px; }
.cover-dates { font-size: 12px; color: rgba(255,255,255,0.45); margin-bottom: 30px; }
.cover-line { height: 1px; background: rgba(255,255,255,0.1); margin-bottom: 24px; }
.cover-stats { display: flex; gap: 36px; }
.cover-stat-num { font-size: 28px; font-weight: 700; color: #fff; line-height: 1; }
.cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; }
/* ── Day ───────────────────────────────────────── */
.page-break { page-break-before: always; }
.day-header {
background: #0f172a; padding: 11px 28px;
display: flex; align-items: center; gap: 8px;
}
.day-tag { font-size: 8px; font-weight: 700; color: #fff; letter-spacing: 0.8px; background: rgba(255,255,255,0.12); border-radius: 4px; padding: 3px 8px; flex-shrink: 0; }
.day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
.day-date { font-size: 9px; color: rgba(255,255,255,0.45); }
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
.day-body { padding: 12px 28px 6px; }
/* ── Place card ────────────────────────────────── */
.place-card {
display: flex; align-items: stretch;
border: 1px solid #e2e8f0; border-radius: 8px;
margin-bottom: 8px; overflow: hidden;
background: #fff; page-break-inside: avoid;
}
.place-bar { width: 4px; flex-shrink: 0; }
.place-thumb {
width: 52px; height: 52px; object-fit: cover;
margin: 8px; border-radius: 6px; flex-shrink: 0;
}
.place-thumb-fallback {
width: 52px; height: 52px; margin: 8px; border-radius: 8px;
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
}
.place-thumb-fallback svg { width: 24px; height: 24px; }
.place-info { flex: 1; padding: 9px 10px 8px 0; min-width: 0; }
.place-name-row { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; }
.place-num {
width: 16px; height: 16px; border-radius: 50%;
background: #1e293b; color: #fff; font-size: 8px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.place-name { font-size: 11.5px; font-weight: 600; color: #1e293b; flex: 1; }
.cat-badge { font-size: 7.5px; font-weight: 600; color: #fff; border-radius: 99px; padding: 2px 7px; flex-shrink: 0; white-space: nowrap; }
.info-row { display: flex; align-items: flex-start; gap: 4px; margin-bottom: 2px; padding-left: 21px; }
.info-row svg { flex-shrink: 0; margin-top: 1px; }
.info-spacer { width: 13px; flex-shrink: 0; }
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
.info-text.muted { color: #94a3b8; }
.info-text.italic { font-style: italic; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
.chip svg { flex-shrink: 0; }
.chip-green { background: #ecfdf5; color: #059669; }
.chip-amber { background: #fffbeb; color: #d97706; }
/* ── Note card ─────────────────────────────────── */
.note-card {
display: flex; align-items: center; gap: 8px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
padding: 8px 10px; margin-bottom: 7px; page-break-inside: avoid;
}
.note-line { width: 3px; border-radius: 99px; background: #94a3b8; align-self: stretch; flex-shrink: 0; }
.note-icon { flex-shrink: 0; }
.note-body { flex: 1; min-width: 0; }
.note-text { font-size: 9.5px; color: #334155; line-height: 1.55; }
.note-time { font-size: 8px; color: #94a3b8; margin-top: 2px; }
.empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; }
/* ── Print ─────────────────────────────────────── */
@media print {
body { margin: 0; }
.cover { min-height: 100vh; page-break-after: always; }
@page { margin: 0; }
}
</style>
</head>
<body>
<!-- Cover -->
<div class="cover">
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-brand">NOMAD</div>
<div class="cover-body">
${coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`}
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
<div class="cover-title">${escHtml(trip?.name || 'Meine Reise')}</div>
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
${range ? `<div class="cover-dates">${range}</div>` : ''}
<div class="cover-line"></div>
<div class="cover-stats">
<div>
<div class="cover-stat-num">${sorted.length}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
</div>
<div>
<div class="cover-stat-num">${places?.length || 0}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
</div>
<div>
<div class="cover-stat-num">${totalAssigned}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalCost > 0 ? `<div>
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
</div>` : ''}
</div>
</div>
</div>
<!-- Days -->
${daysHtml}
</body></html>`
// Open print window
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
// Modal in die App einfügen
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); URL.revokeObjectURL(url) } }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.name || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.src = url
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector('#pdf-close-btn').onclick = () => { overlay.remove(); URL.revokeObjectURL(url) }
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
}
@@ -0,0 +1,529 @@
import React, { useState, useMemo, useRef } from 'react'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import {
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
} from 'lucide-react'
const VORSCHLAEGE = [
{ name: 'Reisepass', kategorie: 'Dokumente' },
{ name: 'Reiseversicherung', kategorie: 'Dokumente' },
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' },
{ name: 'Flugtickets', kategorie: 'Dokumente' },
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' },
{ name: 'Impfpass', kategorie: 'Dokumente' },
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' },
{ name: 'Hosen (2×)', kategorie: 'Kleidung' },
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' },
{ name: 'Socken (7×)', kategorie: 'Kleidung' },
{ name: 'Jacke', kategorie: 'Kleidung' },
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' },
{ name: 'Sportschuhe', kategorie: 'Kleidung' },
{ name: 'Zahnbürste', kategorie: 'Körperpflege' },
{ name: 'Zahnpasta', kategorie: 'Körperpflege' },
{ name: 'Shampoo', kategorie: 'Körperpflege' },
{ name: 'Sonnencreme', kategorie: 'Körperpflege' },
{ name: 'Deo', kategorie: 'Körperpflege' },
{ name: 'Rasierer', kategorie: 'Körperpflege' },
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' },
{ name: 'Reiseadapter', kategorie: 'Elektronik' },
{ name: 'Kopfhörer', kategorie: 'Elektronik' },
{ name: 'Kamera', kategorie: 'Elektronik' },
{ name: 'Powerbank', kategorie: 'Elektronik' },
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' },
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' },
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' },
{ name: 'Mückenschutz', kategorie: 'Gesundheit' },
{ name: 'Bargeld', kategorie: 'Finanzen' },
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
]
const KAT_DOTS = {
'Dokumente': '#3b82f6',
'Kleidung': '#a855f7',
'Körperpflege': '#ec4899',
'Elektronik': '#22c55e',
'Gesundheit': '#f97316',
'Finanzen': '#16a34a',
}
function katDot(kat) { return KAT_DOTS[kat] || '#9ca3af' }
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
const [editing, setEditing] = useState(false)
const [editName, setEditName] = useState(item.name)
const [hovered, setHovered] = useState(false)
const [showCatPicker, setShowCatPicker] = useState(false)
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
const handleSaveName = async () => {
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
catch { toast.error(t('packing.toast.saveError')) }
}
const handleDelete = async () => {
try { await deletePackingItem(tripId, item.id) }
catch { toast.error(t('packing.toast.deleteError')) }
}
const handleCatChange = async (cat) => {
setShowCatPicker(false)
if (cat === item.category) return
try { await updatePackingItem(tripId, item.id, { category: cat }) }
catch { toast.error(t('common.error')) }
}
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '6px 10px', borderRadius: 10, position: 'relative',
background: hovered ? 'var(--bg-secondary)' : 'transparent',
transition: 'background 0.1s',
}}
>
{/* Checkbox */}
<button onClick={handleToggle} style={{
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
}}>
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
</button>
{/* Name */}
{editing ? (
<input
type="text" value={editName} autoFocus
onChange={e => setEditName(e.target.value)}
onBlur={handleSaveName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
/>
) : (
<span
onClick={() => !item.checked && setEditing(true)}
style={{
flex: 1, fontSize: 13.5,
cursor: item.checked ? 'default' : 'text',
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
}}
>
{item.name}
</span>
)}
{/* Actions — always in DOM, visible on hover */}
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
{/* Category change */}
<div style={{ position: 'relative' }}>
<button
onClick={() => setShowCatPicker(p => !p)}
title={t('packing.changeCategory')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }}
>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katDot(item.category || t('packing.defaultCategory')), display: 'inline-block' }} />
</button>
{showCatPicker && (
<div style={{
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
padding: 4, minWidth: 140,
}}>
{categories.map(cat => (
<button key={cat} onClick={() => handleCatChange(cat)} style={{
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katDot(cat), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
{/* Edit */}
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Pencil size={13} />
</button>
{/* Delete */}
<button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Trash2 size={13} />
</button>
</div>
</div>
)
}
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
const [offen, setOffen] = useState(true)
const [editingName, setEditingName] = useState(false)
const [editKatName, setEditKatName] = useState(kategorie)
const [showMenu, setShowMenu] = useState(false)
const { togglePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const abgehakt = items.filter(i => i.checked).length
const alleAbgehakt = abgehakt === items.length
const dot = katDot(kategorie)
const handleSaveKatName = async () => {
const neu = editKatName.trim()
if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return }
try { await onRename(kategorie, neu); setEditingName(false) }
catch { toast.error(t('packing.toast.renameError')) }
}
const handleCheckAll = async () => {
for (const item of items) {
if (!item.checked) await togglePackingItem(tripId, item.id, true)
}
}
const handleUncheckAll = async () => {
for (const item of items) {
if (item.checked) await togglePackingItem(tripId, item.id, false)
}
}
const handleDeleteAll = async () => {
await onDeleteAll(items)
setShowMenu(false)
}
return (
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
<button onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
</button>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
{editingName ? (
<input
autoFocus value={editKatName}
onChange={e => setEditKatName(e.target.value)}
onBlur={handleSaveKatName}
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
/>
) : (
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', flex: 1 }}>
{kategorie}
</span>
)}
{/* Progress pill */}
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: alleAbgehakt ? '#dcfce7' : 'var(--bg-tertiary)',
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
}}>
{abgehakt}/{items.length}
</span>
{/* Kategorie-Menü */}
<div style={{ position: 'relative' }}>
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<MoreHorizontal size={15} />
</button>
{showMenu && (
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
onMouseLeave={() => setShowMenu(false)}>
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
</div>
)}
</div>
</div>
{/* Items */}
{offen && (
<div style={{ padding: '4px 4px 6px' }}>
{items.map(item => (
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
))}
</div>
)}
</div>
)
}
function MenuItem({ icon, label, onClick, danger }) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
color: danger ? '#ef4444' : 'var(--text-secondary)',
}}
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{icon}{label}
</button>
)
}
// ── Haupt-Panel ────────────────────────────────────────────────────────────
export default function PackingListPanel({ tripId, items }) {
const [neuerName, setNeuerName] = useState('')
const [neueKategorie, setNeueKategorie] = useState('')
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
const [showKatDropdown, setShowKatDropdown] = useState(false)
const katInputRef = useRef(null)
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
const toast = useToast()
const { t } = useTranslation()
const allCategories = useMemo(() => {
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
return Array.from(cats).sort()
}, [items, t])
const gruppiert = useMemo(() => {
const filtered = items.filter(i => {
if (filter === 'offen') return !i.checked
if (filter === 'erledigt') return i.checked
return true
})
const groups = {}
for (const item of filtered) {
const kat = item.category || t('packing.defaultCategory')
if (!groups[kat]) groups[kat] = []
groups[kat].push(item)
}
return groups
}, [items, filter, t])
const abgehakt = items.filter(i => i.checked).length
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
const handleAdd = async (e) => {
e.preventDefault()
if (!neuerName.trim()) return
const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory'))
try {
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
setNeuerName('')
} catch { toast.error(t('packing.toast.addError')) }
}
const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE
const handleVorschlag = async (v) => {
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
catch { toast.error(t('packing.toast.addError')) }
}
// Rename all items in a category
const handleRenameCategory = async (oldName, newName) => {
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
for (const item of toUpdate) {
await updatePackingItem(tripId, item.id, { category: newName })
}
}
// Delete all items in a category
const handleDeleteCategory = async (catItems) => {
for (const item of catItems) {
try { await deletePackingItem(tripId, item.id) } catch {}
}
}
// Clear all checked items
const handleClearChecked = async () => {
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
for (const item of items.filter(i => i.checked)) {
try { await deletePackingItem(tripId, item.id) } catch {}
}
}
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
{/* ── Header ── */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
</p>
</div>
<div style={{ display: 'flex', gap: 6 }}>
{abgehakt > 0 && (
<button onClick={handleClearChecked} style={{
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
}}>
{t('packing.clearChecked', { count: abgehakt })}
</button>
)}
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
background: zeigeVorschlaege ? '#111827' : 'var(--bg-card)',
borderColor: zeigeVorschlaege ? '#111827' : 'var(--border-primary)',
color: zeigeVorschlaege ? 'white' : 'var(--text-muted)',
}}>
<Sparkles size={12} /> {t('packing.suggestions')}
</button>
</div>
</div>
{/* Fortschrittsbalken */}
{items.length > 0 && (
<div style={{ marginBottom: 14 }}>
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
<div style={{
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
width: `${fortschritt}%`,
}} />
</div>
{fortschritt === 100 && (
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
)}
</div>
)}
{/* Artikel hinzufügen */}
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
<input
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
placeholder={t('packing.addPlaceholder')}
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
/>
{/* Kategorie-Auswahl */}
<div style={{ position: 'relative' }}>
<input
ref={katInputRef}
type="text" value={neueKategorie}
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
onFocus={() => setShowKatDropdown(true)}
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
placeholder={allCategories[0] || t('packing.categoryPlaceholder')}
style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }}
/>
{showKatDropdown && allCategories.length > 0 && (
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', zIndex: 50, padding: 4, marginTop: 2 }}>
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12.5, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katDot(cat), flexShrink: 0 }} />
{cat}
</button>
))}
</div>
)}
</div>
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: '#111827', color: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
<Plus size={16} />
</button>
</form>
</div>
{/* ── Vorschläge ── */}
{zeigeVorschlaege && (
<div style={{ borderBottom: '1px solid rgba(0,0,0,0.06)', background: 'var(--bg-secondary)', padding: '10px 20px', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('packing.suggestionsTitle')}</span>
<button onClick={() => setZeigeVorschlaege(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
<X size={14} style={{ color: 'var(--text-faint)' }} />
</button>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
{verfuegbareVorschlaege.map((v, i) => (
<button key={i} onClick={() => handleVorschlag(v)} style={{
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
+ {v.name}
</button>
))}
{verfuegbareVorschlaege.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('packing.allSuggested')}</p>}
</div>
</div>
)}
{/* ── Filter-Tabs ── */}
{items.length > 0 && (
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
<button key={id} onClick={() => setFilter(id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
background: filter === id ? '#111827' : 'transparent',
color: filter === id ? 'white' : 'var(--text-muted)',
}}>{label}</button>
))}
</div>
)}
{/* ── Liste ── */}
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
{items.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
</div>
) : Object.keys(gruppiert).length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{Object.entries(gruppiert).map(([kat, katItems]) => (
<KategorieGruppe
key={kat}
kategorie={kat}
items={katItems}
tripId={tripId}
allCategories={allCategories}
onRename={handleRenameCategory}
onDeleteAll={handleDeleteCategory}
/>
))}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,197 @@
import React, { useState, useMemo } from 'react'
import { PhotoLightbox } from './PhotoLightbox'
import { PhotoUpload } from './PhotoUpload'
import { Upload, Camera } from 'lucide-react'
import Modal from '../shared/Modal'
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
const [lightboxIndex, setLightboxIndex] = useState(null)
const [showUpload, setShowUpload] = useState(false)
const [filterDayId, setFilterDayId] = useState('')
const filteredPhotos = useMemo(() => {
return photos.filter(photo => {
if (filterDayId && String(photo.day_id) !== String(filterDayId)) return false
return true
})
}, [photos, filterDayId])
const handlePhotoClick = (photo) => {
const idx = filteredPhotos.findIndex(p => p.id === photo.id)
setLightboxIndex(idx)
}
const handleDelete = async (photoId) => {
await onDelete(photoId)
if (lightboxIndex !== null) {
const newPhotos = filteredPhotos.filter(p => p.id !== photoId)
if (newPhotos.length === 0) {
setLightboxIndex(null)
} else if (lightboxIndex >= newPhotos.length) {
setLightboxIndex(newPhotos.length - 1)
}
}
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Header */}
<div style={{ padding: '16px 24px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0, flexWrap: 'wrap' }}>
<div style={{ marginRight: 'auto' }}>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
</p>
</div>
<select
value={filterDayId}
onChange={e => setFilterDayId(e.target.value)}
className="border border-gray-200 rounded-lg px-3 py-1.5 text-sm text-gray-600 focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Alle Tage</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
</option>
))}
</select>
{filterDayId && (
<button
onClick={() => setFilterDayId('')}
className="text-xs text-gray-500 hover:text-gray-700 underline"
>
Zurücksetzen
</button>
)}
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
>
<Upload className="w-4 h-4" />
Fotos hochladen
</button>
</div>
{/* Gallery Grid */}
<div className="flex-1 overflow-y-auto p-4">
{filteredPhotos.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>Noch keine Fotos</p>
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</p>
<button
onClick={() => setShowUpload(true)}
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
style={{ display: 'inline-flex', margin: '0 auto' }}
>
<Upload className="w-4 h-4" />
Fotos hochladen
</button>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
{filteredPhotos.map(photo => (
<PhotoThumbnail
key={photo.id}
photo={photo}
days={days}
places={places}
onClick={() => handlePhotoClick(photo)}
/>
))}
{/* Upload tile */}
<button
onClick={() => setShowUpload(true)}
className="aspect-square rounded-xl border-2 border-dashed border-gray-200 hover:border-slate-400 flex flex-col items-center justify-center gap-2 text-gray-400 hover:text-slate-700 transition-colors"
>
<Upload className="w-6 h-6" />
<span className="text-xs">Hinzufügen</span>
</button>
</div>
)}
</div>
{/* Lightbox */}
{lightboxIndex !== null && (
<PhotoLightbox
photos={filteredPhotos}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
onUpdate={onUpdate}
onDelete={handleDelete}
days={days}
places={places}
tripId={tripId}
/>
)}
{/* Upload Modal */}
<Modal
isOpen={showUpload}
onClose={() => setShowUpload(false)}
title="Fotos hochladen"
size="lg"
>
<PhotoUpload
tripId={tripId}
days={days}
places={places}
onUpload={async (formData) => {
await onUpload(formData)
setShowUpload(false)
}}
onClose={() => setShowUpload(false)}
/>
</Modal>
</div>
)
}
function PhotoThumbnail({ photo, days, places, onClick }) {
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
return (
<div
className="aspect-square rounded-xl overflow-hidden cursor-pointer relative group bg-gray-100"
onClick={onClick}
>
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
onError={e => {
e.target.style.display = 'none'
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
}}
/>
{/* Fallback */}
<div className="hidden absolute inset-0 items-center justify-center text-gray-400 text-2xl">
🖼
</div>
{/* Hover overlay */}
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-all duration-200 flex flex-col justify-end p-2 opacity-0 group-hover:opacity-100">
{photo.caption && (
<p className="text-white text-xs font-medium truncate">{photo.caption}</p>
)}
{(day || place) && (
<p className="text-white/70 text-xs truncate">
{day ? `Tag ${day.day_number}` : ''}{day && place ? ' · ' : ''}{place?.name || ''}
</p>
)}
</div>
</div>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
}
@@ -0,0 +1,227 @@
import React, { useState, useEffect, useCallback } from 'react'
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
const [index, setIndex] = useState(initialIndex || 0)
const [editCaption, setEditCaption] = useState(false)
const [caption, setCaption] = useState('')
const [isSaving, setIsSaving] = useState(false)
const photo = photos[index]
useEffect(() => {
setIndex(initialIndex || 0)
}, [initialIndex])
useEffect(() => {
if (photo) setCaption(photo.caption || '')
}, [photo])
const prev = useCallback(() => {
setIndex(i => Math.max(0, i - 1))
setEditCaption(false)
}, [])
const next = useCallback(() => {
setIndex(i => Math.min(photos.length - 1, i + 1))
setEditCaption(false)
}, [photos.length])
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') onClose()
if (e.key === 'ArrowLeft') prev()
if (e.key === 'ArrowRight') next()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [onClose, prev, next])
const handleSaveCaption = async () => {
setIsSaving(true)
try {
await onUpdate(photo.id, { caption })
setEditCaption(false)
} finally {
setIsSaving(false)
}
}
const handleDelete = async () => {
if (!confirm('Foto löschen?')) return
await onDelete(photo.id)
if (photos.length <= 1) {
onClose()
} else {
setIndex(i => Math.min(i, photos.length - 2))
}
}
if (!photo) return null
const day = days?.find(d => d.id === photo.day_id)
const place = places?.find(p => p.id === photo.place_id)
return (
<div
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={onClose}
>
{/* Main area */}
<div
className="relative flex flex-col w-full h-full max-w-5xl mx-auto"
onClick={e => e.stopPropagation()}
>
{/* Top bar */}
<div className="flex items-center justify-between p-4 flex-shrink-0">
<div className="text-white/60 text-sm">
{index + 1} / {photos.length}
</div>
<div className="flex items-center gap-2">
<button
onClick={handleDelete}
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
title="Löschen"
>
<Trash2 className="w-4 h-4" />
</button>
<button
onClick={onClose}
className="p-2 text-white/60 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
{/* Image area */}
<div className="flex-1 flex items-center justify-center relative min-h-0 px-16">
{/* Prev button */}
{index > 0 && (
<button
onClick={prev}
className="absolute left-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
>
<ChevronLeft className="w-6 h-6" />
</button>
)}
<img
src={photo.url}
alt={photo.caption || photo.original_name}
className="max-h-full max-w-full object-contain rounded-lg select-none"
draggable={false}
/>
{/* Next button */}
{index < photos.length - 1 && (
<button
onClick={next}
className="absolute right-4 top-1/2 -translate-y-1/2 p-3 bg-white/10 hover:bg-white/20 text-white rounded-full transition-colors z-10"
>
<ChevronRight className="w-6 h-6" />
</button>
)}
</div>
{/* Bottom info */}
<div className="flex-shrink-0 p-4">
{/* Caption */}
<div className="flex items-center gap-2 mb-2">
{editCaption ? (
<>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleSaveCaption()}
placeholder="Beschriftung hinzufügen..."
className="flex-1 bg-white/10 text-white border border-white/20 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:border-white/40"
autoFocus
/>
<button
onClick={handleSaveCaption}
disabled={isSaving}
className="p-1.5 bg-slate-900 text-white rounded-lg hover:bg-slate-700"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => { setEditCaption(false); setCaption(photo.caption || '') }}
className="p-1.5 text-white/60 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</>
) : (
<>
<p
className="text-white text-sm flex-1 cursor-pointer hover:text-white/80"
onClick={() => setEditCaption(true)}
>
{photo.caption || <span className="text-white/40 italic">Beschriftung hinzufügen...</span>}
</p>
<button
onClick={() => setEditCaption(true)}
className="p-1.5 text-white/40 hover:text-white/70"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
</>
)}
</div>
{/* Metadata */}
<div className="flex items-center gap-4 text-white/40 text-xs">
<span>{photo.original_name}</span>
{photo.created_at && (
<span>{formatDate(photo.created_at)}</span>
)}
{day && <span>📅 Tag {day.day_number}</span>}
{place && <span>📍 {place.name}</span>}
{photo.file_size && <span>{formatSize(photo.file_size)}</span>}
</div>
</div>
{/* Thumbnail strip */}
{photos.length > 1 && (
<div className="flex-shrink-0 px-4 pb-4">
<div className="flex gap-1.5 overflow-x-auto pb-1">
{photos.map((p, i) => (
<button
key={p.id}
onClick={() => { setIndex(i); setEditCaption(false) }}
className={`flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden transition-all ${
i === index
? 'ring-2 ring-white scale-105'
: 'opacity-50 hover:opacity-75'
}`}
>
<img
src={p.url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
))}
</div>
</div>
)}
</div>
</div>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
} catch { return '' }
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
@@ -0,0 +1,191 @@
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, X, Image } from 'lucide-react'
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
const [files, setFiles] = useState([])
const [dayId, setDayId] = useState('')
const [placeId, setPlaceId] = useState('')
const [caption, setCaption] = useState('')
const [uploading, setUploading] = useState(false)
const [progress, setProgress] = useState(0)
const onDrop = useCallback((acceptedFiles) => {
const withPreview = acceptedFiles.map(file =>
Object.assign(file, { preview: URL.createObjectURL(file) })
)
setFiles(prev => [...prev, ...withPreview])
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.heic'] },
maxFiles: 30,
maxSize: 10 * 1024 * 1024,
})
const removeFile = (index) => {
setFiles(prev => {
URL.revokeObjectURL(prev[index].preview)
return prev.filter((_, i) => i !== index)
})
}
const handleUpload = async () => {
if (files.length === 0) return
setUploading(true)
setProgress(0)
try {
const formData = new FormData()
files.forEach(file => formData.append('photos', file))
if (dayId) formData.append('day_id', dayId)
if (placeId) formData.append('place_id', placeId)
if (caption) formData.append('caption', caption)
await onUpload(formData)
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
} catch (err) {
console.error('Upload failed:', err)
} finally {
setUploading(false)
setProgress(0)
}
}
const formatSize = (bytes) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
return (
<div className="space-y-4">
{/* Dropzone */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? 'border-slate-900 bg-slate-50'
: 'border-gray-300 hover:border-slate-400 hover:bg-gray-50'
}`}
>
<input {...getInputProps()} />
<Upload className={`w-10 h-10 mx-auto mb-3 ${isDragActive ? 'text-slate-900' : 'text-gray-400'}`} />
{isDragActive ? (
<p className="text-slate-700 font-medium">Fotos hier ablegen...</p>
) : (
<>
<p className="text-gray-600 font-medium">Fotos hier ablegen</p>
<p className="text-gray-400 text-sm mt-1">oder klicken zum Auswählen</p>
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
</>
)}
</div>
{/* Preview grid */}
{files.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">{files.length} Foto{files.length !== 1 ? 's' : ''} ausgewählt</p>
<div className="grid grid-cols-4 sm:grid-cols-6 gap-2 max-h-48 overflow-y-auto">
{files.map((file, idx) => (
<div key={idx} className="relative aspect-square group">
<img
src={file.preview}
alt={file.name}
className="w-full h-full object-cover rounded-lg"
/>
<button
onClick={() => removeFile(idx)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
>
<X className="w-3 h-3" />
</button>
<div className="absolute bottom-0 left-0 right-0 bg-black/50 text-white text-xs p-1 rounded-b-lg opacity-0 group-hover:opacity-100 transition-opacity truncate">
{formatSize(file.size)}
</div>
</div>
))}
</div>
</div>
)}
{/* Options */}
{files.length > 0 && (
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Tag verknüpfen</label>
<select
value={dayId}
onChange={e => setDayId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Kein Tag</option>
{(days || []).map(day => (
<option key={day.id} value={day.id}>Tag {day.day_number}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">Ort verknüpfen</label>
<select
value={placeId}
onChange={e => setPlaceId(e.target.value)}
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
>
<option value="">Kein Ort</option>
{(places || []).map(place => (
<option key={place.id} value={place.id}>{place.name}</option>
))}
</select>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-gray-700 mb-1">Beschriftung (für alle)</label>
<input
type="text"
value={caption}
onChange={e => setCaption(e.target.value)}
placeholder="Optionale Beschriftung..."
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-900"
/>
</div>
</div>
)}
{/* Upload progress */}
{uploading && (
<div className="bg-slate-50 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<div className="w-4 h-4 border-2 border-slate-900 border-t-transparent rounded-full animate-spin" />
<span className="text-sm text-slate-900">Wird hochgeladen...</span>
</div>
<div className="w-full bg-slate-200 rounded-full h-1.5">
<div
className="bg-slate-900 h-1.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
>
Abbrechen
</button>
<button
onClick={handleUpload}
disabled={files.length === 0 || uploading}
className="flex items-center gap-2 px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
<Upload className="w-4 h-4" />
{uploading ? 'Hochladen...' : `${files.length} Foto${files.length !== 1 ? 's' : ''} hochladen`}
</button>
</div>
</div>
)
}
@@ -0,0 +1,504 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { mapsApi, tagsApi, categoriesApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Search, Plus, MapPin, Loader } from 'lucide-react'
const STATUSES = [
{ value: 'none', label: 'None' },
{ value: 'pending', label: 'Pending' },
{ value: 'confirmed', label: 'Confirmed' },
]
export default function PlaceFormModal({
isOpen,
onClose,
onSave,
place,
tripId,
categories: initialCategories = [],
tags: initialTags = [],
onCategoryCreated,
onTagCreated,
}) {
const isEditing = !!place
const { user } = useAuthStore()
const toast = useToast()
const [categories, setCategories] = useState(initialCategories)
const [tags, setTags] = useState(initialTags)
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
useEffect(() => { setTags(initialTags) }, [initialTags])
const emptyForm = {
name: '',
description: '',
address: '',
lat: '',
lng: '',
category_id: '',
place_time: '',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
google_place_id: '',
website: '',
tags: [],
}
const [formData, setFormData] = useState(emptyForm)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
// Maps search state
const [mapQuery, setMapQuery] = useState('')
const [mapResults, setMapResults] = useState([])
const [mapSearching, setMapSearching] = useState(false)
// New category/tag
const [newCategoryName, setNewCategoryName] = useState('')
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
const [showNewCategory, setShowNewCategory] = useState(false)
const [newTagName, setNewTagName] = useState('')
const [newTagColor, setNewTagColor] = useState('#374151')
const [showNewTag, setShowNewTag] = useState(false)
useEffect(() => {
if (place && isOpen) {
setFormData({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat ?? '',
lng: place.lng ?? '',
category_id: place.category_id || '',
place_time: place.place_time || '',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
google_place_id: place.google_place_id || '',
website: place.website || '',
tags: (place.tags || []).map(t => t.id),
})
} else if (!place && isOpen) {
setFormData(emptyForm)
}
setError('')
setMapResults([])
setMapQuery('')
}, [place, isOpen])
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
const toggleTag = (tagId) => {
setFormData(prev => ({
...prev,
tags: prev.tags.includes(tagId)
? prev.tags.filter(id => id !== tagId)
: [...prev.tags, tagId]
}))
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!formData.name.trim()) {
setError('Place name is required')
return
}
setIsLoading(true)
setError('')
try {
await onSave({
...formData,
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
category_id: formData.category_id || null,
})
onClose()
} catch (err) {
setError(err.message || 'Failed to save place')
} finally {
setIsLoading(false)
}
}
const handleMapSearch = async () => {
if (!mapQuery.trim()) return
setMapSearching(true)
try {
const data = await mapsApi.search(mapQuery)
setMapResults(data.places || [])
} catch (err) {
toast.error(err.response?.data?.error || 'Maps search failed')
} finally {
setMapSearching(false)
}
}
const selectMapPlace = (p) => {
setFormData(prev => ({
...prev,
name: p.name || prev.name,
address: p.address || prev.address,
lat: p.lat ?? prev.lat,
lng: p.lng ?? prev.lng,
google_place_id: p.google_place_id || prev.google_place_id,
website: p.website || prev.website,
}))
setMapResults([])
setMapQuery('')
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
setCategories(prev => [...prev, data.category])
if (onCategoryCreated) onCategoryCreated(data.category)
setFormData(prev => ({ ...prev, category_id: data.category.id }))
setNewCategoryName('')
setShowNewCategory(false)
toast.success('Category created')
} catch (err) {
toast.error('Failed to create category')
}
}
const handleCreateTag = async () => {
if (!newTagName.trim()) return
try {
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
setTags(prev => [...prev, data.tag])
if (onTagCreated) onTagCreated(data.tag)
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
setNewTagName('')
setShowNewTag(false)
toast.success('Tag created')
} catch (err) {
toast.error('Failed to create tag')
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? 'Edit Place' : 'Add Place'}
size="xl"
footer={
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={isLoading}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Saving...
</>
) : isEditing ? 'Save Changes' : 'Add Place'}
</button>
</div>
}
>
<div className="space-y-5">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
{error}
</div>
)}
{/* Google Maps search — always visible when API key is set */}
{user?.maps_api_key && (
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="flex gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
value={mapQuery}
onChange={e => setMapQuery(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
placeholder="Google Maps suchen..."
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
/>
</div>
<button
onClick={handleMapSearch}
disabled={mapSearching}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
>
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : 'Suchen'}
</button>
</div>
{mapResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
{mapResults.map((p, i) => (
<button
key={p.google_place_id || i}
onClick={() => selectMapPlace(p)}
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
>
<p className="text-sm font-medium text-slate-900">{p.name}</p>
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
<MapPin className="w-3 h-3" />
{p.address}
</p>
{p.rating && (
<p className="text-xs text-amber-600 mt-0.5"> {p.rating}</p>
)}
</button>
))}
</div>
)}
</div>
)}
{/* Name */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={e => update('name', e.target.value)}
required
placeholder="e.g. Eiffel Tower"
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
<textarea
value={formData.description}
onChange={e => update('description', e.target.value)}
placeholder="Notes about this place..."
rows={2}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none"
/>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
<input
type="text"
value={formData.address}
onChange={e => update('address', e.target.value)}
placeholder="Street address"
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
{/* Lat / Lng */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
<input
type="number"
step="any"
value={formData.lat}
onChange={e => update('lat', e.target.value)}
placeholder="e.g. 48.8584"
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
<input
type="number"
step="any"
value={formData.lng}
onChange={e => update('lng', e.target.value)}
placeholder="e.g. 2.2945"
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
<div className="flex gap-2">
<select
value={formData.category_id}
onChange={e => update('category_id', e.target.value)}
className="flex-1 px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
>
<option value="">No category</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>{cat.name}</option>
))}
</select>
<button
type="button"
onClick={() => setShowNewCategory(!showNewCategory)}
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
title="Create new category"
>
<Plus className="w-4 h-4" />
</button>
</div>
{showNewCategory && (
<div className="mt-2 flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder="Category name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newCategoryColor}
onChange={e => setNewCategoryColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
title="Category color"
/>
<button
type="button"
onClick={handleCreateCategory}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Tags */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
<div className="flex flex-wrap gap-1.5 mb-2">
{tags.map(tag => (
<button
key={tag.id}
type="button"
onClick={() => toggleTag(tag.id)}
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
formData.tags.includes(tag.id)
? 'text-white shadow-sm ring-2 ring-offset-1'
: 'text-white opacity-50 hover:opacity-80'
}`}
style={{
backgroundColor: tag.color || '#374151',
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
}}
>
{tag.name}
</button>
))}
<button
type="button"
onClick={() => setShowNewTag(!showNewTag)}
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
>
<Plus className="inline w-3 h-3 mr-0.5" />
New tag
</button>
</div>
{showNewTag && (
<div className="flex gap-2">
<input
type="text"
value={newTagName}
onChange={e => setNewTagName(e.target.value)}
placeholder="Tag name"
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<input
type="color"
value={newTagColor}
onChange={e => setNewTagColor(e.target.value)}
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
/>
<button
type="button"
onClick={handleCreateTag}
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
>
Add
</button>
</div>
)}
</div>
{/* Time & Reservation */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
<input
type="time"
value={formData.place_time}
onChange={e => update('place_time', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
<select
value={formData.reservation_status}
onChange={e => update('reservation_status', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
>
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
</div>
</div>
{/* Reservation details */}
{formData.reservation_status !== 'none' && (
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
<input
type="datetime-local"
value={formData.reservation_datetime}
onChange={e => update('reservation_datetime', e.target.value)}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
<textarea
value={formData.reservation_notes}
onChange={e => update('reservation_notes', e.target.value)}
placeholder="Confirmation number, special requests..."
rows={2}
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none bg-white"
/>
</div>
</div>
)}
{/* Website */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
<input
type="url"
value={formData.website}
onChange={e => update('website', e.target.value)}
placeholder="https://..."
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
</div>
</Modal>
)
}
@@ -0,0 +1,149 @@
import React from 'react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, X, Edit2, Clock, DollarSign, CheckCircle, Clock3, 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,
}
const reservationIcon = () => {
if (place.reservation_status === 'confirmed') {
return <CheckCircle className="w-3.5 h-3.5 text-emerald-500" title="Confirmed" />
}
if (place.reservation_status === 'pending') {
return <Clock3 className="w-3.5 h-3.5 text-amber-500" title="Pending" />
}
return null
}
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>
{reservationIcon()}
</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)}
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>
)
}
+177
View File
@@ -0,0 +1,177 @@
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>
)
}
@@ -0,0 +1,877 @@
import React, { useState, useEffect, useRef } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
import { downloadTripPDF } from '../PDF/TripPDF'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PlaceAvatar from '../shared/PlaceAvatar'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
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 [h, m] = timeStr.split(':').map(Number)
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
}
const NOTE_ICONS = [
{ id: 'FileText', Icon: FileText },
{ id: 'Info', Icon: Info },
{ id: 'Clock', Icon: Clock },
{ id: 'MapPin', Icon: MapPin },
{ id: 'Navigation', Icon: Navigation },
{ id: 'Train', Icon: Train },
{ id: 'Plane', Icon: Plane },
{ id: 'Bus', Icon: Bus },
{ id: 'Car', Icon: Car },
{ id: 'Ship', Icon: Ship },
{ id: 'Coffee', Icon: Coffee },
{ id: 'Ticket', Icon: Ticket },
{ id: 'Star', Icon: Star },
{ id: 'Heart', Icon: Heart },
{ id: 'Camera', Icon: Camera },
{ id: 'Flag', Icon: Flag },
{ id: 'Lightbulb', Icon: Lightbulb },
{ id: 'AlertTriangle', Icon: AlertTriangle },
{ id: 'ShoppingBag', Icon: ShoppingBag },
{ id: 'Bookmark', Icon: Bookmark },
]
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
const TYPE_ICONS = {
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
}
export default function DayPlanSidebar({
tripId,
trip, days, places, categories, assignments,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick,
onReorder, onUpdateDayTitle, onRouteCalculated,
onAssignToDay,
reservations = [],
onAddReservation,
}) {
const toast = useToast()
const { t, locale } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const tripStore = useTripStore()
const TRANSPORT_MODES = [
{ value: 'driving', label: t('dayplan.transport.car') },
{ value: 'walking', label: t('dayplan.transport.walk') },
{ value: 'cycling', label: t('dayplan.transport.bike') },
]
const dayNotes = tripStore.dayNotes || {}
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
const [editingDayId, setEditingDayId] = useState(null)
const [editTitle, setEditTitle] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculating, setIsCalculating] = useState(false)
const [routeInfo, setRouteInfo] = useState(null)
const [draggingId, setDraggingId] = useState(null)
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'
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
const getDragData = (e) => {
const dt = e?.dataTransfer
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
if (dragDataRef.current) {
return {
placeId: '',
assignmentId: dragDataRef.current.assignmentId || '',
noteId: dragDataRef.current.noteId || '',
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
}
}
// Externer Drag (aus PlacesSidebar)
const ext = window.__dragData || {}
const placeId = dt?.getData('placeId') || ext.placeId || ''
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
}
useEffect(() => {
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
}, [days.length])
useEffect(() => {
if (editingDayId && inputRef.current) inputRef.current.focus()
}, [editingDayId])
// Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen
useEffect(() => {
const cleanup = () => {
setDraggingId(null)
setDropTargetKey(null)
setDragOverDayId(null)
dragDataRef.current = null
window.__dragData = null
}
document.addEventListener('dragend', cleanup)
return () => document.removeEventListener('dragend', cleanup)
}, [])
const toggleDay = (dayId, e) => {
e.stopPropagation()
setExpandedDays(prev => {
const n = new Set(prev)
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
return n
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const getMergedItems = (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, 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)
}
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) }
}
const deleteNote = async (dayId, noteId, e) => {
e?.stopPropagation()
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
catch (err) { toast.error(err.message) }
}
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
const m = getMergedItems(dayId)
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
const newOrder = [...m]
const [moved] = newOrder.splice(fromIdx, 1)
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
newOrder.splice(adjustedTo, 0, moved)
// Orte: neuer order_index über onReorder
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
const groups = {}
let pc = 0
newOrder.forEach(item => {
if (item.type === 'place') { pc++ }
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
})
const noteChanges = []
Object.entries(groups).forEach(([pb, ids]) => {
ids.forEach((id, i) => {
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
})
})
try {
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
for (const n of noteChanges) {
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
}
} catch (err) { toast.error(err.message) }
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) }
}
const startEditTitle = (day, e) => {
e.stopPropagation()
setEditTitle(day.title || '')
setEditingDayId(day.id)
}
const saveTitle = async (dayId) => {
setEditingDayId(null)
await onUpdateDayTitle?.(dayId, editTitle.trim())
}
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const waypoints = da.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('dayplan.toast.needTwoPlaces')); return }
setIsCalculating(true)
try {
const result = await calculateRoute(waypoints, transportMode)
// Luftlinien zwischen Wegpunkten anzeigen
const lineCoords = waypoints.map(p => [p.lat, p.lng])
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.({ ...result, coordinates: lineCoords })
} catch { toast.error(t('dayplan.toast.routeError')) }
finally { setIsCalculating(false) }
}
const handleOptimize = async () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
if (da.length < 3) return
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
await onReorder(selectedDayId, reorderedIds)
toast.success(t('dayplan.toast.routeOptimized'))
}
const handleGoogleMaps = () => {
if (!selectedDayId) return
const da = getDayAssignments(selectedDayId)
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
if (url) window.open(url, '_blank')
else toast.error(t('dayplan.toast.noGeoPlaces'))
}
const handleDropOnDay = (e, dayId) => {
e.preventDefault()
setDragOverDayId(null)
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (assignmentId && fromDayId !== dayId) {
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
} else if (noteId && fromDayId !== dayId) {
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
}
setDraggingId(null)
setDropTargetKey(null)
dragDataRef.current = null
window.__dragData = null
}
const handleDropOnRow = (e, dayId, toIdx) => {
e.preventDefault()
e.stopPropagation()
setDragOverDayId(null)
const placeId = e.dataTransfer.getData('placeId')
const fromAssignmentId = e.dataTransfer.getData('assignmentId')
if (placeId) {
onAssignToDay?.(parseInt(placeId), dayId)
} else if (fromAssignmentId) {
const da = getDayAssignments(dayId)
const fromIdx = da.findIndex(a => String(a.id) === fromAssignmentId)
if (fromIdx === -1 || fromIdx === toIdx) { setDraggingId(null); dragDataRef.current = null; return }
const ids = da.map(a => a.id)
const [removed] = ids.splice(fromIdx, 1)
ids.splice(toIdx, 0, removed)
onReorder(dayId, ids)
}
setDraggingId(null)
}
const totalCost = days.reduce((s, d) => {
const da = assignments[String(d.id)] || []
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
}, 0)
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Reise-Titel */}
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
{(trip?.start_date || trip?.end_date) && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' ')}
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
</div>
)}
</div>
<button
onClick={async () => {
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
notes.map(n => ({ ...n, day_id: Number(dayId) }))
)
try {
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
} catch (e) {
console.error('PDF error:', e)
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
}
}}
title={t('dayplan.pdfTooltip')}
style={{
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
padding: '5px 10px', borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit',
}}
>
<FileDown size={13} strokeWidth={2} />
{t('dayplan.pdf')}
</button>
</div>
</div>
{/* Tagesliste */}
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = dayTotalCost(day.id, assignments, currency)
const formattedDate = formatDate(day.date, locale)
const loc = da.find(a => a.place?.lat && a.place?.lng)
const isDragTarget = dragOverDayId === day.id
const merged = getMergedItems(day.id)
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
<div
onClick={() => onSelectDay(isSelected ? null : day.id)}
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '11px 14px 11px 16px',
cursor: 'pointer',
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'),
transition: 'background 0.12s',
userSelect: 'none',
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
outlineOffset: -2,
borderRadius: isDragTarget ? 8 : 0,
}}
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
>
{/* Tages-Badge */}
<div style={{
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 11, fontWeight: 700,
}}>
{index + 1}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{editingDayId === day.id ? (
<input
ref={inputRef}
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
onBlur={() => saveTitle(day.id)}
onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }}
onClick={e => e.stopPropagation()}
style={{
width: '100%', border: 'none', outline: 'none',
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)',
background: 'transparent', padding: 0, fontFamily: 'inherit',
borderBottom: '1.5px solid var(--text-primary)',
}}
/>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{day.title || t('dayplan.dayN', { n: index + 1 })}
</span>
<button
onClick={e => startEditTitle(day, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
>
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
</button>
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
{day.date && anyGeoPlace && (() => {
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
})()}
</div>
</div>
<button
onClick={e => openAddNote(day.id, e)}
title={t('dayplan.addNote')}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
>
<FileText size={13} strokeWidth={2} />
</button>
<button
onClick={e => toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
>
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
</button>
</div>
{/* Aufgeklappte Orte + Notizen */}
{isExpanded && (
<div
style={{ background: 'var(--bg-hover)' }}
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
onDrop={e => {
e.preventDefault()
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))
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))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
}
const m = getMergedItems(day.id)
if (m.length === 0) return
const lastItem = m[m.length - 1]
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
else if (noteId && String(lastItem?.data?.id) !== noteId)
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
}}
>
{merged.length === 0 && !dayNoteUi ? (
<div
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
onDrop={e => handleDropOnDay(e, day.id)}
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
}}
>
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('dayplan.emptyDay')}</span>
</div>
) : (
merged.map((item, idx) => {
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
if (item.type === 'place') {
const assignment = item.data
const place = assignment.place
if (!place) return null
const cat = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
const isConfirmed = place.reservation_status === 'confirmed'
const isDraggingThis = draggingId === assignment.id
const isHovered = hoveredId === assignment.id
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
const moveUp = (e) => {
e.stopPropagation()
if (placeIdx === 0) return
const ids = placeItems.map(i => i.data.id)
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
onReorder(day.id, ids)
}
const moveDown = (e) => {
e.stopPropagation()
if (placeIdx === placeItems.length - 1) return
const ids = placeItems.map(i => i.data.id)
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
onReorder(day.id, ids)
}
return (
<React.Fragment key={`place-${assignment.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
onDragStart={e => {
e.dataTransfer.setData('assignmentId', String(assignment.id))
e.dataTransfer.setData('fromDayId', String(day.id))
e.dataTransfer.effectAllowed = 'move'
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
setDraggingId(assignment.id)
}}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
if (placeId) {
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
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))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
} else if (noteId && fromDayId !== day.id) {
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))
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
} else if (noteId) {
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
}
}}
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
onMouseEnter={() => setHoveredId(assignment.id)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 10px',
cursor: 'pointer',
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
borderLeft: hasReservation
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
: '3px solid transparent',
transition: 'background 0.1s',
opacity: isDraggingThis ? 0.4 : 1,
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
<PlaceAvatar place={place} category={cat} size={28} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} title={cat.name} style={{ flexShrink: 0 }} />
})()}
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
{place.place_time && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
<Clock size={9} strokeWidth={2} />
{formatTime(place.place_time, locale, timeFormat)}
</span>
)}
</div>
{(place.description || place.address || cat?.name) && !hasReservation && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
{hasReservation && (
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}>
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}>
{isConfirmed ? <><CheckCircle2 size={10} />
{place.reservation_datetime
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}`
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')}
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1, opacity: isHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronUp size={12} strokeWidth={2} />
</button>
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
<ChevronDown size={12} strokeWidth={2} />
</button>
</div>
</div>
</React.Fragment>
)
}
// Notizkarte
const note = item.data
const isNoteHovered = hoveredId === `note-${note.id}`
const NoteIcon = getNoteIcon(note.icon)
const noteIdx = idx
return (
<React.Fragment key={`note-${note.id}`}>
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
<div
draggable
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
onDrop={e => {
e.preventDefault(); e.stopPropagation()
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
if (fromNoteId && fromDayId !== day.id) {
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))
setDraggingId(null); setDropTargetKey(null)
} else if (fromNoteId && fromNoteId !== String(note.id)) {
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
} else if (fromAssignmentId && fromDayId !== day.id) {
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))
setDraggingId(null); setDropTargetKey(null)
} else if (fromAssignmentId) {
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
}
}}
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
onMouseLeave={() => setHoveredId(null)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 8px 7px 2px',
margin: '1px 8px',
borderRadius: 6,
border: '1px solid var(--border-faint)',
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
}}
>
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
<GripVertical size={13} strokeWidth={1.8} />
</div>
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{note.text}
</span>
{note.time && (
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 1, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
</div>
<div style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
</div>
</div>
</React.Fragment>
)
})
)}
{/* Drop-Indikator am Listenende */}
{!!draggingId && dropTargetKey === `end-${day.id}` && (
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
)}
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
{isSelected && getDayAssignments(day.id).length >= 2 && (
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
{TRANSPORT_MODES.map(m => (
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
fontFamily: 'inherit',
}}>{m.label}</button>
))}
</div>
{routeInfo && (
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
<span>{routeInfo.distance}</span>
<span style={{ color: 'var(--text-faint)' }}>·</span>
<span>{routeInfo.duration}</span>
</div>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
opacity: isCalculating ? 0.6 : 1,
}}>
<Navigation size={12} strokeWidth={2} />
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
</button>
<button onClick={handleOptimize} style={{
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
background: 'var(--bg-hover)', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<RotateCcw size={12} strokeWidth={2} />
{t('dayplan.optimize')}
</button>
<button onClick={handleGoogleMaps} style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<ExternalLink size={12} strokeWidth={2} />
</button>
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
<div key={dayId} style={{
position: 'fixed', inset: 0, zIndex: 1000,
display: 'flex', alignItems: 'center', justifyContent: 'center',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
}} onClick={() => cancelNote(Number(dayId))}>
<div style={{
width: 340, background: 'var(--bg-card)', borderRadius: 16,
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
display: 'flex', flexDirection: 'column', gap: 12,
}} onClick={e => e.stopPropagation()}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
</div>
{/* Icon-Auswahl */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
{NOTE_ICONS.map(({ id, Icon }) => (
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
title={id}
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
</button>
))}
</div>
<input
ref={noteInputRef}
type="text"
value={ui.text}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteTitle')}
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/>
<input
type="text"
value={ui.time}
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
placeholder={t('dayplan.noteSubtitle')}
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
{ui.mode === 'add' ? t('common.add') : t('common.save')}
</button>
</div>
</div>
</div>,
document.body
))}
{/* Budget-Fußzeile */}
{totalCost > 0 && (
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
</div>
)}
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget'
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 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">Tagesplan</h2>
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</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'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">Gesamtübersicht</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">Noch keine Tage</p>
<p className="text-xs text-gray-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</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} {placeCount === 1 ? 'Ort' : 'Orte'}
</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">Gesamtkosten</span>
<span className="text-sm font-semibold text-gray-800">
{totalCost.toFixed(2)} {currency}
</span>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,107 @@
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>
)
}
@@ -0,0 +1,254 @@
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'
const RESERVATION_STATUS = {
none: { label: 'Keine Reservierung', color: 'gray' },
pending: { label: 'Res. ausstehend', color: 'yellow' },
confirmed: { label: 'Bestätigt', color: 'green' },
}
export function PlaceDetailPanel({
place, categories, tags, selectedDayId, dayAssignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
}) {
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
const status = RESERVATION_STATUS[place.reservation_status] || RESERVATION_STATUS.none
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>
)}
{/* Reservation status */}
{place.reservation_status && place.reservation_status !== 'none' && (
<div className={`rounded-lg px-3 py-2 border ${
place.reservation_status === 'confirmed'
? 'bg-emerald-50 border-emerald-200'
: 'bg-yellow-50 border-yellow-200'
}`}>
<div className={`text-xs font-semibold ${
place.reservation_status === 'confirmed' ? 'text-emerald-700' : 'text-yellow-700'
}`}>
{place.reservation_status === 'confirmed' ? '✅' : '⏳'} {status.label}
</div>
{place.reservation_datetime && (
<div className="text-xs text-gray-500 mt-0.5">
{formatDateTime(place.reservation_datetime)}
</div>
)}
{place.reservation_notes && (
<p className="text-xs text-gray-600 mt-1">{place.reservation_notes}</p>
)}
</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" />
Aus Tag entfernen
</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" />
Zum Tag hinzufügen
</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" />
Bearbeiten
</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
}
}
@@ -0,0 +1,354 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { mapsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Search } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomTimePicker from '../shared/CustomTimePicker'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
const TRANSPORT_MODES = [
{ value: 'walking', labelKey: 'places.transport.walking' },
{ value: 'driving', labelKey: 'places.transport.driving' },
{ value: 'cycling', labelKey: 'places.transport.cycling' },
{ value: 'transit', labelKey: 'places.transport.transit' },
]
const DEFAULT_FORM = {
name: '',
description: '',
address: '',
lat: '',
lng: '',
category_id: '',
place_time: '',
notes: '',
transport_mode: 'walking',
reservation_status: 'none',
reservation_notes: '',
reservation_datetime: '',
website: '',
}
export default function PlaceFormModal({
isOpen, onClose, onSave, place, tripId, categories,
onCategoryCreated,
}) {
const [form, setForm] = useState(DEFAULT_FORM)
const [mapsSearch, setMapsSearch] = useState('')
const [mapsResults, setMapsResults] = useState([])
const [isSearchingMaps, setIsSearchingMaps] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showNewCategory, setShowNewCategory] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const toast = useToast()
const { t, language } = useTranslation()
useEffect(() => {
if (place) {
setForm({
name: place.name || '',
description: place.description || '',
address: place.address || '',
lat: place.lat || '',
lng: place.lng || '',
category_id: place.category_id || '',
place_time: place.place_time || '',
notes: place.notes || '',
transport_mode: place.transport_mode || 'walking',
reservation_status: place.reservation_status || 'none',
reservation_notes: place.reservation_notes || '',
reservation_datetime: place.reservation_datetime || '',
website: place.website || '',
})
} else {
setForm(DEFAULT_FORM)
}
}, [place, isOpen])
const handleChange = (field, value) => {
setForm(prev => ({ ...prev, [field]: value }))
}
const handleMapsSearch = async () => {
if (!mapsSearch.trim()) return
setIsSearchingMaps(true)
try {
const result = await mapsApi.search(mapsSearch, language)
setMapsResults(result.places || [])
} catch (err) {
toast.error(t('places.mapsSearchError'))
} finally {
setIsSearchingMaps(false)
}
}
const handleSelectMapsResult = (result) => {
setForm(prev => ({
...prev,
name: result.name || prev.name,
address: result.address || prev.address,
lat: result.lat || prev.lat,
lng: result.lng || prev.lng,
google_place_id: result.google_place_id || prev.google_place_id,
}))
setMapsResults([])
setMapsSearch('')
}
const handleCreateCategory = async () => {
if (!newCategoryName.trim()) return
try {
const cat = await onCategoryCreated?.({ name: newCategoryName, color: '#6366f1', icon: 'MapPin' })
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
setNewCategoryName('')
setShowNewCategory(false)
} catch (err) {
toast.error(t('places.categoryCreateError'))
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.name.trim()) {
toast.error(t('places.nameRequired'))
return
}
setIsSaving(true)
try {
await onSave({
...form,
lat: form.lat ? parseFloat(form.lat) : null,
lng: form.lng ? parseFloat(form.lng) : null,
category_id: form.category_id || null,
})
onClose()
} catch (err) {
toast.error(err.message || t('places.saveError'))
} finally {
setIsSaving(false)
}
}
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={place ? t('places.editPlace') : t('places.addPlace')}
size="lg"
>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Google Maps Search */}
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
<div className="flex gap-2">
<input
type="text"
value={mapsSearch}
onChange={e => setMapsSearch(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), handleMapsSearch())}
placeholder={t('places.mapsSearchPlaceholder')}
className="flex-1 border border-slate-200 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-slate-400 bg-white"
/>
<button
type="button"
onClick={handleMapsSearch}
disabled={isSearchingMaps}
className="bg-slate-900 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-slate-700 disabled:opacity-60"
>
{isSearchingMaps ? '...' : <Search className="w-4 h-4" />}
</button>
</div>
{mapsResults.length > 0 && (
<div className="bg-white rounded-lg border border-slate-200 overflow-hidden max-h-40 overflow-y-auto mt-2">
{mapsResults.map((result, idx) => (
<button
key={idx}
type="button"
onClick={() => handleSelectMapsResult(result)}
className="w-full text-left px-3 py-2 hover:bg-slate-50 border-b border-slate-100 last:border-0"
>
<div className="font-medium text-sm">{result.name}</div>
<div className="text-xs text-slate-500 truncate">{result.address}</div>
</button>
))}
</div>
)}
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formName')} *</label>
<input
type="text"
value={form.name}
onChange={e => handleChange('name', e.target.value)}
required
placeholder={t('places.formNamePlaceholder')}
className="form-input"
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formDescription')}</label>
<textarea
value={form.description}
onChange={e => handleChange('description', e.target.value)}
rows={2}
placeholder={t('places.formDescriptionPlaceholder')}
className="form-input" style={{ resize: 'none' }}
/>
</div>
{/* Address + Coordinates */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formAddress')}</label>
<input
type="text"
value={form.address}
onChange={e => handleChange('address', e.target.value)}
placeholder={t('places.formAddressPlaceholder')}
className="form-input"
/>
<div className="grid grid-cols-2 gap-2 mt-2">
<input
type="number"
step="any"
value={form.lat}
onChange={e => handleChange('lat', e.target.value)}
placeholder={t('places.formLat')}
className="form-input"
/>
<input
type="number"
step="any"
value={form.lng}
onChange={e => handleChange('lng', e.target.value)}
placeholder={t('places.formLng')}
className="form-input"
/>
</div>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formCategory')}</label>
{!showNewCategory ? (
<div className="flex gap-2">
<CustomSelect
value={form.category_id}
onChange={value => handleChange('category_id', value)}
placeholder={t('places.noCategory')}
options={[
{ value: '', label: t('places.noCategory') },
...(categories || []).map(c => ({
value: c.id,
label: c.name,
})),
]}
style={{ flex: 1 }}
size="sm"
/>
</div>
) : (
<div className="flex gap-2">
<input
type="text"
value={newCategoryName}
onChange={e => setNewCategoryName(e.target.value)}
placeholder={t('places.categoryNamePlaceholder')}
className="form-input" style={{ flex: 1 }}
/>
<button type="button" onClick={handleCreateCategory} className="bg-slate-900 text-white px-3 rounded-lg hover:bg-slate-700 text-sm">
OK
</button>
<button type="button" onClick={() => setShowNewCategory(false)} className="text-gray-500 px-2 text-sm">
{t('common.cancel')}
</button>
</div>
)}
</div>
{/* Time */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label>
<CustomTimePicker
value={form.place_time}
onChange={v => handleChange('place_time', v)}
/>
</div>
{/* Website */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formWebsite')}</label>
<input
type="url"
value={form.website}
onChange={e => handleChange('website', e.target.value)}
placeholder="https://..."
className="form-input"
/>
</div>
{/* Reservation */}
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
<div className="flex items-center gap-3">
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
<div className="flex gap-2">
{['none', 'pending', 'confirmed'].map(status => (
<button
key={status}
type="button"
onClick={() => handleChange('reservation_status', status)}
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
form.reservation_status === status
? status === 'confirmed' ? 'bg-emerald-600 text-white'
: status === 'pending' ? 'bg-yellow-500 text-white'
: 'bg-gray-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{status === 'none' ? t('common.none') : status === 'pending' ? t('reservations.pending') : t('reservations.confirmed')}
</button>
))}
</div>
</div>
{form.reservation_status !== 'none' && (
<>
<CustomDateTimePicker
value={form.reservation_datetime}
onChange={v => handleChange('reservation_datetime', v)}
/>
<textarea
value={form.reservation_notes}
onChange={e => handleChange('reservation_notes', e.target.value)}
rows={2}
placeholder={t('places.reservationNotesPlaceholder')}
className="form-input" style={{ resize: 'none' }}
/>
</>
)}
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-900 border border-gray-200 rounded-lg hover:bg-gray-50"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={isSaving}
className="px-6 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-60 font-medium"
>
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
}
@@ -0,0 +1,455 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
const detailsCache = new Map()
function useGoogleDetails(googlePlaceId, language) {
const [details, setDetails] = useState(null)
const cacheKey = `${googlePlaceId}_${language}`
useEffect(() => {
if (!googlePlaceId) { setDetails(null); return }
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
mapsApi.details(googlePlaceId, language).then(data => {
detailsCache.set(cacheKey, data.place)
setDetails(data.place)
}).catch(() => {})
}, [googlePlaceId, language])
return details
}
function getWeekdayIndex(dateStr) {
// weekdayDescriptions[0] = Monday … [6] = Sunday
const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date()
const jsDay = d.getDay()
return jsDay === 0 ? 6 : jsDay - 1
}
function convertHoursLine(line, timeFormat) {
if (!line) return ''
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
if (timeFormat === '12h' && !hasAmPm) {
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
const hour = parseInt(h)
if (isNaN(hour)) return match
const period = hour >= 12 ? 'PM' : 'AM'
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
return `${h12}:${m} ${period}`
})
}
if (timeFormat !== '12h' && hasAmPm) {
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
let hour = parseInt(h)
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
return `${String(hour).padStart(2, '0')}:${m}`
})
}
return line
}
function formatTime(timeStr, locale, timeFormat) {
if (!timeStr) return ''
try {
const [h, m] = timeStr.split(':').map(Number)
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 formatReservationDatetime(dt, locale, timeFormat) {
if (!dt) return null
try {
const d = new Date(dt)
if (isNaN(d)) return dt
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
const timePart = formatTime(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, locale, timeFormat)
return `${datePart}, ${timePart}`
} catch { return dt }
}
function formatFileSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
export default function PlaceInspector({
place, categories, days, selectedDayId, assignments,
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
files, onFileUpload,
}) {
const { t, locale, language } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
const [hoursExpanded, setHoursExpanded] = useState(false)
const [filesExpanded, setFilesExpanded] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const fileInputRef = useRef(null)
const googleDetails = useGoogleDetails(place?.google_place_id, language)
if (!place) return null
const category = categories?.find(c => c.id === place.category_id)
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
const openingHours = googleDetails?.opening_hours || null
const openNow = googleDetails?.open_now ?? null
const selectedDay = days?.find(d => d.id === selectedDayId)
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
const handleFileUpload = useCallback(async (e) => {
const selectedFiles = Array.from(e.target.files || [])
if (!selectedFiles.length || !onFileUpload) return
setIsUploading(true)
try {
for (const file of selectedFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('place_id', place.id)
await onFileUpload(fd)
}
setFilesExpanded(true)
} catch (err) {
console.error('Upload failed', err)
} finally {
setIsUploading(false)
if (fileInputRef.current) fileInputRef.current.value = ''
}
}, [onFileUpload, place.id])
return (
<div
style={{
position: 'absolute',
bottom: 20,
left: '50%',
transform: 'translateX(-50%)',
width: 'min(800px, calc(100vw - 32px))',
zIndex: 50,
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}}
>
<div style={{
background: 'var(--bg-elevated)',
backdropFilter: 'blur(40px) saturate(180%)',
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
borderRadius: 20,
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
overflow: 'hidden',
maxHeight: '60vh',
display: 'flex',
flexDirection: 'column',
}}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
{/* Avatar with open/closed ring + tag */}
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
<div style={{
borderRadius: '50%', padding: 2.5,
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
}}>
<PlaceAvatar place={place} category={category} size={52} />
</div>
{openNow !== null && (
<span style={{
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
color: 'white',
background: openNow ? '#16a34a' : '#dc2626',
padding: '1.5px 7px', borderRadius: 99,
whiteSpace: 'nowrap',
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
}}>
{openNow ? t('inspector.opened') : t('inspector.closed')}
</span>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
{category && (() => {
const CatIcon = getCategoryIcon(category.icon)
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 11, fontWeight: 500,
color: category.color || '#6b7280',
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
padding: '2px 8px', borderRadius: 99,
}}>
<CatIcon size={10} />
{category.name}
</span>
)
})()}
</div>
{place.address && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span>
</div>
)}
{place.place_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}</span>
</div>
)}
{place.lat && place.lng && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
</div>
)}
</div>
<button
onClick={onClose}
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
>
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
</button>
</div>
{/* Content — scrollable */}
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Info-Chips */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
{googleDetails?.rating && (() => {
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
return (
<Chip
icon={<Star size={12} fill="#facc15" color="#facc15" />}
text={<>
{googleDetails.rating.toFixed(1)}
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · {shortReview.text}"</span>}
</>}
color="var(--text-secondary)" bg="var(--bg-hover)"
/>
)
})()}
{place.price > 0 && (
<Chip icon={<Euro size={12} />} text={`${place.price} ${place.currency || '€'}`} color="#059669" bg="#ecfdf5" />
)}
</div>
{/* Telefon */}
{place.phone && (
<div style={{ display: 'flex', gap: 12 }}>
<a href={`tel:${place.phone}`}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
<Phone size={12} /> {place.phone}
</a>
</div>
)}
{/* Description + Reservation in one box */}
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
{(place.description || place.notes) && (
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px',
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none'
}}>
{place.description || place.notes}
</p>
)}
{place.reservation_status && place.reservation_status !== 'none' && (
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 3 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
{place.reservation_status === 'confirmed'
? <CheckCircle2 size={12} color="#059669" />
: <AlertCircle size={12} color="#d97706" />
}
<span style={{ fontSize: 12, fontWeight: 600, color: place.reservation_status === 'confirmed' ? '#059669' : '#d97706' }}>
{place.reservation_status === 'confirmed' ? t('inspector.confirmedRes') : t('inspector.pendingRes')}
</span>
{(place.reservation_datetime || place.place_time) && (
<>
<span style={{ fontSize: 11, color: '#d1d5db' }}>·</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
{place.reservation_datetime
? formatReservationDatetime(place.reservation_datetime, locale, timeFormat)
: formatTime(place.place_time, locale, timeFormat)}
</span>
</>
)}
</div>
{place.reservation_notes && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: '1.5', paddingLeft: 17 }}>{place.reservation_notes}</p>
)}
</div>
)}
</div>
)}
{/* Opening hours */}
{openingHours && openingHours.length > 0 && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<button
onClick={() => setHoursExpanded(h => !h)}
style={{
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
fontFamily: 'inherit',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Clock size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
</span>
</div>
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
</button>
{hoursExpanded && (
<div style={{ padding: '0 12px 10px' }}>
{openingHours.map((line, i) => (
<div key={i} style={{
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
fontWeight: i === weekdayIndex ? 600 : 400,
padding: '2px 0',
}}>{convertHoursLine(line, timeFormat)}</div>
))}
</div>
)}
</div>
)}
{/* Files section */}
{(placeFiles.length > 0 || onFileUpload) && (
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
<button
onClick={() => setFilesExpanded(f => !f)}
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
>
<FileText size={13} color="#9ca3af" />
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
</span>
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
</button>
{onFileUpload && (
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
{isUploading ? (
<span style={{ fontSize: 11 }}>…</span>
) : (
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
)}
</label>
)}
</div>
{filesExpanded && placeFiles.length > 0 && (
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{placeFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
<a
href={`/uploads/files/${f.filename}`}
target="_blank"
rel="noopener noreferrer"
style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex' }}
>
<ExternalLink size={11} />
</a>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Footer actions */}
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
{selectedDayId && (
assignmentInDay ? (
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} />
) : (
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
)
)}
{googleDetails?.google_maps_url && (
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
)}
{place.website && (
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
)}
<div style={{ flex: 1 }} />
<ActionButton onClick={onEdit} variant="ghost" icon={<Edit2 size={13} />} label={<span className="hidden sm:inline">{t('common.edit')}</span>} />
<ActionButton onClick={onDelete} variant="danger" icon={<Trash2 size={13} />} label={<span className="hidden sm:inline">{t('common.delete')}</span>} />
</div>
</div>
</div>
)
}
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
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>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text}</span>
</div>
)
}
function Row({ icon, children }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ flexShrink: 0 }}>{icon}</div>
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
</div>
)
}
function ActionButton({ onClick, variant, icon, label }) {
const base = {
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
danger: { background: 'rgba(239,68,68,0.08)', color: '#dc2626', border: 'none', hoverBg: 'rgba(239,68,68,0.16)' },
}
const s = base[variant] || base.ghost
return (
<button
onClick={onClick}
style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 12px', borderRadius: 10, minHeight: 30,
fontSize: 12, fontWeight: 500, cursor: 'pointer',
fontFamily: 'inherit', transition: 'background 0.15s, opacity 0.15s',
background: s.background, color: s.color, border: s.border,
}}
onMouseEnter={e => e.currentTarget.style.background = s.hoverBg}
onMouseLeave={e => e.currentTarget.style.background = s.background}
>
{icon}{label}
</button>
)
}
@@ -0,0 +1,223 @@
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>
)
}
@@ -0,0 +1,185 @@
import React, { useState } from 'react'
import { Search, Plus, X } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
export default function PlacesSidebar({
places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay,
}) {
const { t } = useTranslation()
const [search, setSearch] = useState('')
const [filter, setFilter] = useState('all') // 'all' | 'ungeplant'
const [categoryFilter, setCategoryFilter] = useState('')
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
const plannedIds = new Set(
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
)
const filtered = places.filter(p => {
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
return true
})
const isAssignedToSelectedDay = (placeId) =>
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Kopfbereich */}
<div style={{ padding: '14px 16px 10px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
<button
onClick={onAddPlace}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
width: '100%', padding: '8px 12px', borderRadius: 12, border: 'none',
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 13, fontWeight: 500,
cursor: 'pointer', fontFamily: 'inherit', marginBottom: 10,
}}
>
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
</button>
{/* Filter-Tabs */}
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
<button key={f.id} onClick={() => setFilter(f.id)} style={{
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
}}>{f.label}</button>
))}
</div>
{/* Suchfeld */}
<div style={{ position: 'relative' }}>
<Search size={13} strokeWidth={1.8} color="var(--text-faint)" style={{ position: 'absolute', left: 10, top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={t('places.search')}
style={{
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
border: 'none', background: 'var(--bg-tertiary)', fontSize: 12, color: 'var(--text-primary)',
outline: 'none', fontFamily: 'inherit', boxSizing: 'border-box',
}}
/>
{search && (
<button onClick={() => setSearch('')} style={{ position: 'absolute', right: 8, top: '50%', transform: 'translateY(-50%)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex' }}>
<X size={12} strokeWidth={2} color="var(--text-faint)" />
</button>
)}
</div>
{/* Kategoriefilter */}
{categories.length > 0 && (
<div style={{ marginTop: 6 }}>
<CustomSelect
value={categoryFilter}
onChange={setCategoryFilter}
placeholder={t('places.allCategories')}
size="sm"
options={[
{ value: '', label: t('places.allCategories') },
...categories.map(c => ({ value: String(c.id), label: c.name }))
]}
/>
</div>
)}
</div>
{/* Anzahl */}
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
</div>
{/* Liste */}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
{filter === 'unplanned' ? t('places.allPlanned') : t('places.noneFound')}
</span>
<button onClick={onAddPlace} style={{ fontSize: 12, color: 'var(--text-primary)', background: 'none', border: 'none', cursor: 'pointer', textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('places.addPlace')}
</button>
</div>
) : (
filtered.map(place => {
const cat = categories.find(c => c.id === place.category_id)
const isSelected = place.id === selectedPlaceId
const inDay = isAssignedToSelectedDay(place.id)
const isPlanned = plannedIds.has(place.id)
return (
<div
key={place.id}
draggable
onDragStart={e => {
e.dataTransfer.setData('placeId', String(place.id))
e.dataTransfer.effectAllowed = 'copy'
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
window.__dragData = { placeId: String(place.id) }
}}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '9px 14px 9px 16px',
cursor: 'grab',
background: isSelected ? 'var(--border-faint)' : 'transparent',
borderBottom: '1px solid var(--border-faint)',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
>
<PlaceAvatar place={place} category={cat} size={34} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
{cat && (() => {
const CatIcon = getCategoryIcon(cat.icon)
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
})()}
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
{place.name}
</span>
</div>
{(place.description || place.address || cat?.name) && (
<div style={{ marginTop: 2 }}>
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
{place.description || place.address || cat?.name}
</span>
</div>
)}
</div>
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
{!inDay && selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 20, height: 20, borderRadius: 6,
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
}}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
><Plus size={12} strokeWidth={2.5} /></button>
)}
</div>
</div>
)
})
)}
</div>
</div>
)
}
@@ -0,0 +1,893 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'
import {
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
CalendarDays, FileText, Check, Pencil, Trash2,
} from 'lucide-react'
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
import PackingListPanel from '../Packing/PackingListPanel'
import FileManager from '../Files/FileManager'
import { ReservationModal } from './ReservationModal'
import { PlaceDetailPanel } from './PlaceDetailPanel'
import WeatherWidget from '../Weather/WeatherWidget'
import { useTripStore } from '../../store/tripStore'
import { useToast } from '../shared/Toast'
const SEGMENTS = [
{ id: 'plan', label: 'Plan' },
{ id: 'orte', label: 'Orte' },
{ id: 'reservierungen', label: 'Buchungen' },
{ id: 'packliste', label: 'Packliste' },
{ id: 'dokumente', label: 'Dokumente' },
]
const TRANSPORT_MODES = [
{ value: 'driving', label: 'Auto', icon: '🚗' },
{ value: 'walking', label: 'Fuß', icon: '🚶' },
{ value: 'cycling', label: 'Rad', icon: '🚲' },
]
function formatShortDate(dateStr) {
if (!dateStr) return ''
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
day: 'numeric', month: 'short',
})
}
function formatDateTime(dt) {
if (!dt) return ''
try {
return new Date(dt).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
} catch { return dt }
}
export default function PlannerSidebar({
trip, days, places, categories, tags,
assignments, reservations, packingItems,
selectedDayId, selectedPlaceId,
onSelectDay, onPlaceClick, onPlaceEdit, onPlaceDelete,
onAssignToDay, onRemoveAssignment, onReorder,
onAddPlace, onEditTrip, onRouteCalculated, tripId,
}) {
const [activeSegment, setActiveSegment] = useState('plan')
const [search, setSearch] = useState('')
const [categoryFilter, setCategoryFilter] = useState('')
const [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const [expandedDays, setExpandedDays] = useState(new Set())
// Day notes inline UI state: { [dayId]: { mode: 'add'|'edit', noteId?, text, time } }
const [noteUi, setNoteUi] = useState({})
const noteInputRef = useRef(null)
const tripStore = useTripStore()
const toast = useToast()
const dayNotes = tripStore.dayNotes || {}
// Auto-expand selected day
useEffect(() => {
if (selectedDayId) {
setExpandedDays(prev => new Set([...prev, selectedDayId]))
}
}, [selectedDayId])
const toggleDay = (dayId) => {
setExpandedDays(prev => {
const next = new Set(prev)
if (next.has(dayId)) next.delete(dayId)
else next.add(dayId)
return next
})
}
const getDayAssignments = (dayId) =>
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
const filteredPlaces = places.filter(p => {
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.address || '').toLowerCase().includes(search.toLowerCase())
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
return matchSearch && matchCat
})
const isAssignedToDay = (placeId) =>
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
const totalCost = days.reduce((sum, d) => {
const da = assignments[String(d.id)] || []
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
}, 0)
const currency = trip?.currency || 'EUR'
const filteredReservations = selectedDayId
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
: reservations
// Get representative location for a day (first place with coords)
const getDayLocation = (dayId) => {
const da = getDayAssignments(dayId)
const p = da.find(a => a.place?.lat && a.place?.lng)
return p ? { lat: p.place.lat, lng: p.place.lng } : null
}
// Route handlers
const handleCalculateRoute = async () => {
if (!selectedDayId) return
const waypoints = selectedDayAssignments
.map(a => a.place)
.filter(p => p?.lat && p?.lng)
.map(p => ({ lat: p.lat, lng: p.lng }))
if (waypoints.length < 2) {
toast.error('Mindestens 2 Orte mit Koordinaten benötigt')
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, transportMode)
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success('Route berechnet')
} catch {
toast.error('Route konnte nicht berechnet werden')
} finally {
setIsCalculatingRoute(false)
}
}
const handleOptimizeRoute = async () => {
if (!selectedDayId || selectedDayAssignments.length < 3) return
const withCoords = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const optimized = optimizeRoute(withCoords)
const reorderedIds = optimized
.map(p => selectedDayAssignments.find(a => a.place?.id === p.id)?.id)
.filter(Boolean)
// Append assignments without coordinates at end
for (const a of selectedDayAssignments) {
if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id)
}
await onReorder(selectedDayId, reorderedIds)
toast.success('Route optimiert')
}
const handleOpenGoogleMaps = () => {
const ps = selectedDayAssignments.map(a => a.place).filter(p => p?.lat && p?.lng)
const url = generateGoogleMapsUrl(ps)
if (url) window.open(url, '_blank')
else toast.error('Keine Orte mit Koordinaten vorhanden')
}
const handleMoveUp = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === 0) return
const ids = da.map(a => a.id)
;[ids[idx - 1], ids[idx]] = [ids[idx], ids[idx - 1]]
await onReorder(dayId, ids)
}
const handleMoveDown = async (dayId, idx) => {
const da = getDayAssignments(dayId)
if (idx === da.length - 1) return
const ids = da.map(a => a.id)
;[ids[idx], ids[idx + 1]] = [ids[idx + 1], ids[idx]]
await onReorder(dayId, ids)
}
// Merge place assignments + day notes into a single sorted list
const getMergedDayItems = (dayId) => {
const da = getDayAssignments(dayId)
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
return [
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
].sort((a, b) => a.sortKey - b.sortKey)
}
const openAddNote = (dayId) => {
const merged = getMergedDayItems(dayId)
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', sortOrder: maxKey + 1 } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const openEditNote = (dayId, note) => {
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '' } }))
setTimeout(() => noteInputRef.current?.focus(), 50)
}
const cancelNote = (dayId) => {
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
}
const saveNote = async (dayId) => {
const ui = noteUi[dayId]
if (!ui?.text?.trim()) return
try {
if (ui.mode === 'add') {
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, sort_order: ui.sortOrder })
} else {
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null })
}
cancelNote(dayId)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteNote = async (dayId, noteId) => {
try {
await tripStore.deleteDayNote(tripId, dayId, noteId)
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveUp = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx <= 0) return
const newSortOrder = idx >= 2
? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2
: merged[idx - 1].sortKey - 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleNoteMoveDown = async (dayId, noteId) => {
const merged = getMergedDayItems(dayId)
const idx = merged.findIndex(item => item.type === 'note' && item.data.id === noteId)
if (idx === -1 || idx >= merged.length - 1) return
const newSortOrder = idx < merged.length - 2
? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2
: merged[idx + 1].sortKey + 1
try {
await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder })
} catch (err) {
toast.error(err.message)
}
}
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success('Reservierung aktualisiert')
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt')
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht')
} catch (err) {
toast.error(err.message)
}
}
// Inspector: show when a place is selected
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
return (
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
{/* Trip header */}
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
<button onClick={onEditTrip} className="w-full text-left group">
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
{trip?.title}
</h1>
{(trip?.start_date || trip?.end_date) && (
<p className="text-xs text-gray-400 mt-0.5">
{trip.start_date && formatShortDate(trip.start_date)}
{trip.start_date && trip.end_date && ' '}
{trip.end_date && formatShortDate(trip.end_date)}
{days.length > 0 && ` · ${days.length} Tage`}
</p>
)}
</button>
</div>
{/* Segmented control */}
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
{SEGMENTS.map(seg => (
<button
key={seg.id}
onClick={() => setActiveSegment(seg.id)}
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
activeSegment === seg.id
? 'bg-white shadow-sm text-gray-900'
: 'text-gray-500 hover:text-gray-700'
}`}
>
{seg.label}
</button>
))}
</div>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto min-h-0">
{/* ── PLAN ── */}
{activeSegment === 'plan' && (
<div className="pb-4">
{/* Alle Orte */}
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
}`}>
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">{places.length} Orte gesamt</p>
</div>
</button>
{days.length === 0 ? (
<div className="px-4 py-10 text-center">
<CalendarDays className="w-10 h-10 text-gray-200 mx-auto mb-3" />
<p className="text-sm text-gray-400">Noch keine Tage geplant</p>
<button onClick={onEditTrip} className="mt-2 text-slate-700 text-sm">
Reise bearbeiten
</button>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
const da = getDayAssignments(day.id)
const cost = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
const loc = getDayLocation(day.id)
const merged = getMergedDayItems(day.id)
const dayNoteUi = noteUi[day.id]
const placeItems = merged.filter(i => i.type === 'place')
return (
<div key={day.id} className="border-b border-gray-50">
{/* Day header row */}
<div
className={`flex items-center gap-3 px-4 py-3 cursor-pointer select-none transition-colors ${
isSelected ? 'bg-slate-100/60' : 'hover:bg-gray-50/80'
}`}
onClick={() => {
onSelectDay(day.id)
if (!isExpanded) toggleDay(day.id)
}}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-100 text-gray-500'
}`}>
{index + 1}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{day.title || `Tag ${index + 1}`}
</p>
{da.length > 0 && (
<span className="text-xs text-gray-400 flex-shrink-0">
{da.length} {da.length === 1 ? 'Ort' : 'Orte'}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{day.date && <span className="text-xs text-gray-400">{formatShortDate(day.date)}</span>}
{cost > 0 && <span className="text-xs text-emerald-600">{cost.toFixed(0)} {currency}</span>}
{day.date && loc && (
<WeatherWidget lat={loc.lat} lng={loc.lng} date={day.date} compact />
)}
</div>
</div>
<button
onClick={e => { e.stopPropagation(); openAddNote(day.id); if (!isExpanded) toggleDay(day.id) }}
title="Notiz hinzufügen"
className="p-1 text-gray-300 hover:text-amber-500 flex-shrink-0 transition-colors"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={e => { e.stopPropagation(); toggleDay(day.id) }}
className="p-1 text-gray-300 hover:text-gray-500 flex-shrink-0"
>
{isExpanded
? <ChevronDown className="w-4 h-4" />
: <ChevronRight className="w-4 h-4" />
}
</button>
</div>
{/* Expanded items: places + notes interleaved */}
{isExpanded && (
<div className="bg-gray-50/40">
{merged.length === 0 && !dayNoteUi ? (
<div className="px-4 py-4 text-center">
<p className="text-xs text-gray-400">Keine Einträge für diesen Tag</p>
<button
onClick={() => { onSelectDay(day.id); setActiveSegment('orte') }}
className="mt-1 text-xs text-slate-700"
>
+ Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-100/60">
{merged.map((item, idx) => {
if (item.type === 'place') {
const assignment = item.data
const place = assignment.place
if (!place) return null
const category = categories.find(c => c.id === place.category_id)
const isPlaceSelected = place.id === selectedPlaceId
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
return (
<div
key={`place-${assignment.id}`}
className={`group flex items-center gap-2.5 pl-4 pr-3 py-2.5 cursor-pointer transition-colors ${
isPlaceSelected ? 'bg-slate-50' : 'hover:bg-white/80'
}`}
onClick={() => onPlaceClick(isPlaceSelected ? null : place.id)}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-[13px] font-medium truncate leading-snug ${isPlaceSelected ? 'text-slate-900' : 'text-gray-800'}`}>
{place.name}
</p>
{(place.description || place.notes) && (
<p className="text-[11px] text-gray-400 mt-0.5 leading-snug line-clamp-2">
{place.description || place.notes}
</p>
)}
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
{place.place_time && (
<span className="text-[11px] text-slate-600 font-medium">{place.place_time}</span>
)}
{place.price > 0 && (
<span className="text-[11px] text-gray-400">{place.price} {place.currency || currency}</span>
)}
{place.reservation_status && place.reservation_status !== 'none' && (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
place.reservation_status === 'confirmed'
? 'bg-emerald-50 text-emerald-600'
: 'bg-amber-50 text-amber-600'
}`}>
{place.reservation_status === 'confirmed' ? '✓ Bestätigt' : '⏳ Res. ausstehend'}
</span>
)}
</div>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={e => { e.stopPropagation(); handleMoveUp(day.id, placeIdx) }}
disabled={placeIdx === 0}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button
onClick={e => { e.stopPropagation(); handleMoveDown(day.id, placeIdx) }}
disabled={placeIdx === placeItems.length - 1}
className="p-0.5 text-gray-300 hover:text-gray-600 disabled:opacity-20"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
}
// Note card
const note = item.data
const isEditingThis = dayNoteUi?.mode === 'edit' && dayNoteUi.noteId === note.id
if (isEditingThis) {
return (
<div key={`note-edit-${note.id}`} className="px-3 py-2 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)"
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</div>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder="Notiz…"
rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/>
<div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> Speichern
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen
</button>
</div>
</div>
)
}
return (
<div key={`note-${note.id}`} className="group flex items-start gap-2 pl-4 pr-3 py-2 bg-amber-50/40 hover:bg-amber-50/70 transition-colors">
<FileText className="w-3.5 h-3.5 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
{note.time && (
<span className="text-[11px] font-semibold text-amber-600 mr-1.5">{note.time}</span>
)}
<span className="text-[12px] text-gray-700 leading-snug">{note.text}</span>
</div>
<div className="flex flex-col gap-0 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); handleNoteMoveUp(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronUp className="w-3.5 h-3.5" />
</button>
<button onClick={e => { e.stopPropagation(); handleNoteMoveDown(day.id, note.id) }} className="p-0.5 text-gray-300 hover:text-gray-600">
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={e => { e.stopPropagation(); openEditNote(day.id, note) }} className="p-1 text-gray-300 hover:text-amber-500 rounded">
<Pencil className="w-3 h-3" />
</button>
<button onClick={e => { e.stopPropagation(); handleDeleteNote(day.id, note.id) }} className="p-1 text-gray-300 hover:text-red-500 rounded">
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
)
})}
</div>
)}
{/* Inline "add note" form */}
{dayNoteUi?.mode === 'add' && (
<div className="px-3 py-2 border-t border-amber-100 bg-amber-50/60">
<div className="flex gap-2 mb-1.5">
<input
type="text"
value={dayNoteUi.time}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], time: e.target.value } }))}
placeholder="Zeit (optional)"
className="w-24 text-[11px] border border-amber-200 rounded-lg px-2 py-1 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300"
/>
</div>
<textarea
ref={noteInputRef}
value={dayNoteUi.text}
onChange={e => setNoteUi(prev => ({ ...prev, [day.id]: { ...prev[day.id], text: e.target.value } }))}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); saveNote(day.id) } if (e.key === 'Escape') cancelNote(day.id) }}
placeholder="z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause…"
rows={2}
className="w-full text-[12px] border border-amber-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-amber-300 resize-none"
/>
<div className="flex gap-1.5 mt-1.5">
<button onClick={() => saveNote(day.id)} className="flex items-center gap-1 text-[11px] bg-amber-500 text-white px-2.5 py-1 rounded-lg hover:bg-amber-600">
<Check className="w-3 h-3" /> Hinzufügen
</button>
<button onClick={() => cancelNote(day.id)} className="text-[11px] text-gray-500 px-2.5 py-1 rounded-lg hover:bg-gray-100">
Abbrechen
</button>
</div>
</div>
)}
{/* Add note button */}
{!dayNoteUi && (
<div className="px-4 py-2 border-t border-gray-100/60 flex gap-2">
<button
onClick={() => openAddNote(day.id)}
className="flex items-center gap-1 text-[11px] text-amber-600 hover:text-amber-700 py-1"
>
<FileText className="w-3 h-3" />
Notiz hinzufügen
</button>
</div>
)}
{/* Route tools — only for the selected day */}
{isSelected && da.length >= 2 && (
<div className="px-4 py-3 space-y-2 border-t border-gray-100/60">
<div className="flex bg-gray-100 rounded-[8px] p-0.5 gap-0.5">
{TRANSPORT_MODES.map(m => (
<button
key={m.value}
onClick={() => setTransportMode(m.value)}
className={`flex-1 py-1 text-[11px] rounded-[6px] transition-all ${
transportMode === m.value
? 'bg-white shadow-sm text-gray-900 font-medium'
: 'text-gray-500'
}`}
>
{m.icon} {m.label}
</button>
))}
</div>
{routeInfo && (
<div className="flex items-center justify-center gap-3 text-xs bg-slate-50 rounded-lg px-3 py-2">
<span className="text-slate-900">🛣 {routeInfo.distance}</span>
<span className="text-slate-300">·</span>
<span className="text-slate-900"> {routeInfo.duration}</span>
</div>
)}
<div className="grid grid-cols-2 gap-1.5">
<button
onClick={handleCalculateRoute}
disabled={isCalculatingRoute}
className="flex items-center justify-center gap-1.5 bg-slate-900 text-white text-xs py-2 rounded-lg hover:bg-slate-700 disabled:opacity-60 transition-colors"
>
<Navigation className="w-3.5 h-3.5" />
{isCalculatingRoute ? 'Berechne...' : 'Route'}
</button>
<button
onClick={handleOptimizeRoute}
className="flex items-center justify-center gap-1.5 bg-emerald-600 text-white text-xs py-2 rounded-lg hover:bg-emerald-700 transition-colors"
>
<RotateCcw className="w-3.5 h-3.5" />
Optimieren
</button>
</div>
<button
onClick={handleOpenGoogleMaps}
className="w-full flex items-center justify-center gap-1.5 border border-gray-200 text-gray-600 text-xs py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
<ExternalLink className="w-3.5 h-3.5" />
In Google Maps öffnen
</button>
</div>
)}
</div>
)}
</div>
)
})
)}
{/* Budget footer */}
{totalCost > 0 && (
<div className="px-4 py-3 border-t border-gray-100 flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span>
<span className="text-sm font-semibold text-gray-800">{totalCost.toFixed(2)} {currency}</span>
</div>
)}
</div>
)}
{/* ── ORTE ── */}
{activeSegment === 'orte' && (
<div>
<div className="p-3 space-y-2 border-b border-gray-100">
<div className="relative">
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Orte suchen…"
className="w-full pl-8 pr-8 py-2 bg-gray-100 rounded-[10px] text-sm focus:outline-none focus:bg-white focus:ring-2 focus:ring-slate-400 transition-colors"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-[9px]">
<X className="w-3.5 h-3.5 text-gray-400" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<select
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
className="flex-1 bg-gray-100 rounded-lg text-xs py-2 px-2 focus:outline-none text-gray-600"
>
<option value="">Alle Kategorien</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-3 py-2 rounded-lg hover:bg-slate-700 whitespace-nowrap transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Neu
</button>
</div>
</div>
{filteredPlaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">📍</span>
<p className="text-sm">Keine Orte gefunden</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm">
Ersten Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{filteredPlaces.map(place => {
const category = categories.find(c => c.id === place.category_id)
const inDay = isAssignedToDay(place.id)
const isSelected = place.id === selectedPlaceId
return (
<div
key={place.id}
onClick={() => onPlaceClick(isSelected ? null : place.id)}
className={`flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors ${
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
}`}
>
<div
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
>
{place.image_url ? (
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
) : (
<span className="text-lg">{category?.icon || '📍'}</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
<div className="flex items-center gap-1 flex-shrink-0">
{inDay
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full"></span>
: selectedDayId && (
<button
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
>
+ Tag
</button>
)
}
</div>
</div>
{category && <p className="text-xs text-gray-500 mt-0.5">{category.icon} {category.name}</p>}
{place.address && <p className="text-xs text-gray-400 truncate">{place.address}</p>}
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* ── RESERVIERUNGEN ── */}
{activeSegment === 'reservierungen' && (
<div>
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
<h3 className="font-medium text-sm text-gray-900">
Reservierungen
{selectedDay && <span className="text-gray-400 font-normal"> · Tag {selectedDay.day_number}</span>}
</h3>
<button
onClick={() => { setEditingReservation(null); setShowReservationModal(true) }}
className="flex items-center gap-1 bg-slate-900 text-white text-xs px-2.5 py-1.5 rounded-lg hover:bg-slate-700 transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Hinzufügen
</button>
</div>
{filteredReservations.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<span className="text-3xl mb-2">🎫</span>
<p className="text-sm">Keine Reservierungen</p>
</div>
) : (
<div className="p-3 space-y-2.5">
{filteredReservations.map(r => (
<div key={r.id} className="bg-white border border-gray-100 rounded-2xl p-3.5 shadow-sm">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-semibold text-[13px] text-gray-900">{r.title}</div>
{r.reservation_time && (
<div className="flex items-center gap-1 mt-1 text-xs text-slate-700">
<Clock className="w-3 h-3" />
{formatDateTime(r.reservation_time)}
</div>
)}
{r.location && <div className="text-xs text-gray-500 mt-0.5">📍 {r.location}</div>}
{r.confirmation_number && (
<div className="text-xs text-emerald-600 mt-1 bg-emerald-50 rounded-lg px-2 py-0.5 inline-block">
# {r.confirmation_number}
</div>
)}
{r.notes && <p className="text-xs text-gray-500 mt-1.5 leading-relaxed">{r.notes}</p>}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
onClick={() => { setEditingReservation(r); setShowReservationModal(true) }}
className="p-1.5 text-gray-400 hover:text-slate-700 rounded-lg hover:bg-slate-50 transition-colors"
></button>
<button
onClick={() => handleDeleteReservation(r.id)}
className="p-1.5 text-gray-400 hover:text-red-600 rounded-lg hover:bg-red-50 transition-colors"
>🗑</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── PACKLISTE ── */}
{activeSegment === 'packliste' && (
<PackingListPanel tripId={tripId} items={packingItems} />
)}
{/* ── DOKUMENTE ── */}
{activeSegment === 'dokumente' && (
<FileManager tripId={tripId} />
)}
</div>
{/* ── INSPECTOR OVERLAY ── */}
{selectedPlace && (
<div className="absolute inset-0 bg-white z-10 overflow-y-auto">
<PlaceDetailPanel
place={selectedPlace}
categories={categories}
tags={tags}
selectedDayId={selectedDayId}
dayAssignments={selectedDayAssignments}
onClose={() => onPlaceClick(null)}
onEdit={() => onPlaceEdit(selectedPlace)}
onDelete={() => onPlaceDelete(selectedPlace.id)}
onAssignToDay={onAssignToDay}
onRemoveAssignment={onRemoveAssignment}
/>
</div>
)}
{/* Reservation modal */}
<ReservationModal
isOpen={showReservationModal}
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
onSave={handleSaveReservation}
reservation={editingReservation}
days={days}
places={places}
selectedDayId={selectedDayId}
/>
</div>
)
}
@@ -0,0 +1,295 @@
import React, { useState, useEffect, useRef } 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 } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
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 },
]
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) {
const toast = useToast()
const { t } = useTranslation()
const fileInputRef = useRef(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: '', place_id: '',
})
const [isSaving, setIsSaving] = useState(false)
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations
useEffect(() => {
if (reservation) {
setForm({
title: reservation.title || '',
type: reservation.type || 'other',
status: reservation.status || 'pending',
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
location: reservation.location || '',
confirmation_number: reservation.confirmation_number || '',
notes: reservation.notes || '',
day_id: reservation.day_id || '',
place_id: reservation.place_id || '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', location: '', confirmation_number: '',
notes: '', day_id: selectedDayId || '', place_id: '',
})
setPendingFiles([])
}
}, [reservation, isOpen, selectedDayId])
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e) => {
e.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
const saved = await onSave({
...form,
day_id: form.day_id || null,
place_id: form.place_id || null,
})
// Upload pending files for newly created reservations
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', saved.id)
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} finally {
setIsSaving(false)
}
}
const handleFileChange = async (e) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
// Existing reservation — upload immediately
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', reservation.id)
fd.append('description', reservation.title)
await onFileUpload(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
// New reservation — stage locally
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
}
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: '#374151', marginBottom: 5 }
return (
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* Type selector */}
<div>
<label style={labelStyle}>{t('reservations.bookingType')}</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
<button key={value} type="button" onClick={() => set('type', value)} style={{
display: 'flex', alignItems: 'center', gap: 5,
padding: '6px 11px', borderRadius: 99, border: '1px solid',
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
background: form.type === value ? '#111827' : 'white',
borderColor: form.type === value ? '#111827' : '#e5e7eb',
color: form.type === value ? 'white' : '#6b7280',
}}>
<Icon size={12} /> {t(labelKey)}
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
</div>
{/* Date/Time + Status */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.status}
onChange={value => set('status', value)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
size="sm"
/>
</div>
</div>
{/* Location */}
<div>
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
</div>
{/* Confirmation number */}
<div>
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
</div>
{/* Linked day + place */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
<div>
<label style={labelStyle}>{t('reservations.day')}</label>
<CustomSelect
value={form.day_id}
onChange={value => set('day_id', value)}
placeholder={t('reservations.noDay')}
options={[
{ value: '', label: t('reservations.noDay') },
...(days || []).map(day => ({
value: day.id,
label: `${t('reservations.day')} ${day.day_number}${day.date ? ` · ${formatDate(day.date)}` : ''}`,
})),
]}
size="sm"
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.place')}</label>
<CustomSelect
value={form.place_id}
onChange={value => set('place_id', value)}
placeholder={t('reservations.noPlace')}
options={[
{ value: '', label: t('reservations.noPlace') },
...(places || []).map(place => ({
value: place.id,
label: place.name,
})),
]}
searchable
size="sm"
/>
</div>
</div>
{/* Notes */}
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3}
placeholder={t('reservations.notesPlaceholder')}
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* File upload — always visible */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
<FileText size={13} style={{ color: '#6b7280', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12.5, color: '#374151', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: '#9ca3af', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={12} />
</a>
{onFileDelete && (
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', display: 'flex', padding: 0, flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
</button>
)}
</div>
))}
{pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
<FileText size={13} style={{ color: '#6b7280', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12.5, color: '#374151', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<span style={{ fontSize: 11, color: '#9ca3af', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#9ca3af', display: 'flex', padding: 0, flexShrink: 0 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<X size={12} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 12px',
border: '1px dashed #d1d5db', borderRadius: 8, background: 'white',
fontSize: 12.5, color: '#6b7280', cursor: uploadingFile ? 'default' : 'pointer',
fontFamily: 'inherit', transition: 'all 0.12s',
}}
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = '#9ca3af'; e.currentTarget.style.color = '#374151' } }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}>
<Paperclip size={13} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>
</div>
</div>
{/* Actions */}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid #f3f4f6' }}>
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid #e5e7eb', background: 'white', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: '#374151' }}>
{t('common.cancel')}
</button>
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: '#111827', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
</button>
</div>
</form>
</Modal>
)
}
function formatDate(dateStr) {
if (!dateStr) return ''
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
}
@@ -0,0 +1,456 @@
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,
} 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 },
]
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 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 }
// Inline modal for editing place reservation fields
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)
}
}
return ReactDOM.createPortal(
<div style={{
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 9000,
display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16,
}} onClick={onClose}>
<div style={{
background: 'var(--bg-card)', borderRadius: 18, padding: 24, width: '100%', maxWidth: 420,
boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
}} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
<div>
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.editTitle')}</h3>
<p style={{ margin: '3px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>{item.title}</p>
</div>
<button onClick={onClose} style={{ background: 'var(--bg-tertiary)', border: 'none', borderRadius: '50%', width: 30, height: 30, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<X size={14} />
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={labelStyle}>{t('reservations.status')}</label>
<CustomSelect
value={form.reservation_status}
onChange={v => set('reservation_status', v)}
options={[
{ value: 'pending', label: t('reservations.pending') },
{ value: 'confirmed', label: t('reservations.confirmed') },
]}
/>
</div>
<div>
<label style={labelStyle}>{t('reservations.datetime')}</label>
<CustomDateTimePicker value={form.reservation_datetime} onChange={v => set('reservation_datetime', v)} />
</div>
<div>
<label style={labelStyle}>{t('reservations.notes')}</label>
<textarea value={form.reservation_notes} onChange={e => set('reservation_notes', e.target.value)} rows={3} placeholder={t('reservations.notesPlaceholder')} style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 20, paddingTop: 16, borderTop: '1px solid var(--border-secondary)' }}>
<button onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-secondary)' }}>
{t('common.cancel')}
</button>
<button onClick={handleSave} disabled={saving} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--accent)', color: 'white', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: saving ? 0.6 : 1 }}>
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>,
document.body
)
}
// Card for real reservations (reservations table)
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles }) {
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 confirmed = r.status === 'confirmed'
const attachedFiles = files.filter(f => f.reservation_id === r.id)
const handleToggle = async () => {
try { await toggleReservationStatus(tripId, r.id) }
catch { toast.error(t('reservations.toast.updateError')) }
}
const handleDelete = async () => {
if (!confirm(t('reservations.confirm.delete', { name: r.title }))) return
try { await onDelete(r.id) } catch { toast.error(t('reservations.toast.deleteError')) }
}
return (
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'stretch' }}>
<div style={{
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: confirmed ? '#f0fdf4' : '#fefce8',
borderRight: `1px solid ${confirmed ? '#bbf7d0' : '#fef08a'}`,
}}>
<TypeIcon size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
</div>
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{r.title}</div>
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 1 }}>{t(typeLabelKey(r.type))}</div>
</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<button onClick={handleToggle} style={{
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
border: 'none', cursor: 'pointer', fontSize: 11, fontWeight: 500,
background: confirmed ? '#dcfce7' : '#fef9c3',
color: confirmed ? '#16a34a' : '#a16207',
}}>
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
</button>
<button onClick={() => onEdit(r)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
</div>
</div>
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
{r.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(r.reservation_time, locale, timeFormat)}
</div>
)}
{r.location && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
</div>
)}
</div>
<div style={{ marginTop: 5, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{r.confirmation_number && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10.5, color: '#15803d', background: '#f0fdf4', border: '1px solid #bbf7d0', borderRadius: 99, padding: '1px 7px', fontWeight: 600 }}>
<Hash size={8} />{r.confirmation_number}
</span>
)}
{r.day_number != null && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{t('dayplan.dayN', { n: r.day_number })}</span>}
{r.place_name && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '1px 7px' }}>{r.place_name}</span>}
</div>
{r.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{r.notes}</p>}
{/* Attached files — read-only, upload only via edit modal */}
{attachedFiles.length > 0 && (
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<FileText size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ fontSize: 11.5, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }} title={t('common.open')}>
<ExternalLink size={11} />
</a>
</div>
))}
<button onClick={onNavigateToFiles} style={{ alignSelf: 'flex-start', fontSize: 11, color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: 0, textDecoration: 'underline', fontFamily: 'inherit' }}>
{t('reservations.showFiles')}
</button>
</div>
)}
</div>
</div>
</div>
)
}
// Card for place-level reservations (from day plan)
function PlaceReservationCard({ item, tripId }) {
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 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')) }
}
return (
<>
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'stretch' }}>
<div style={{
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
background: confirmed ? '#f0fdf4' : '#fefce8',
borderRight: `1px solid ${confirmed ? '#bbf7d0' : '#fef08a'}`,
}}>
<MapPinned size={16} style={{ color: confirmed ? '#16a34a' : '#a16207' }} />
</div>
<div style={{ flex: 1, padding: '11px 13px', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 13.5, color: 'var(--text-primary)', lineHeight: 1.3 }}>{item.title}</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 2, flexWrap: 'nowrap', overflow: 'hidden' }}>
<span className="hidden sm:inline" style={{ fontSize: 10.5, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.fromPlan')}</span>
{item.dayLabel && <span style={{ fontSize: 10.5, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 99, padding: '0 6px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{item.dayLabel}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: 3, flexShrink: 0 }}>
<span style={{
display: 'flex', alignItems: 'center', gap: 3, padding: '3px 8px', borderRadius: 99,
fontSize: 11, fontWeight: 500,
background: confirmed ? '#dcfce7' : '#fef9c3',
color: confirmed ? '#16a34a' : '#a16207',
}}>
{confirmed ? <><CheckCircle2 size={11} /> {t('reservations.confirmed')}</> : <><Circle size={11} /> {t('reservations.pending')}</>}
</span>
<button onClick={() => setEditing(true)} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Pencil size={12} /></button>
<button onClick={handleDelete} style={{ padding: 5, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}><Trash2 size={12} /></button>
</div>
</div>
<div style={{ marginTop: 7, display: 'flex', flexWrap: 'wrap', gap: '3px 10px' }}>
{item.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{formatDateTimeWithLocale(item.reservation_time, locale, timeFormat)}
</div>
)}
{item.place_time && !item.reservation_time && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<Calendar size={10} style={{ color: 'var(--text-faint)' }} />{item.place_time}
</div>
)}
{item.location && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 11.5, color: 'var(--text-secondary)' }}>
<MapPin size={10} style={{ color: 'var(--text-faint)' }} />
<span style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.location}</span>
</div>
)}
</div>
{item.notes && <p style={{ margin: '7px 0 0', fontSize: 11.5, color: 'var(--text-muted)', lineHeight: 1.5, borderTop: '1px solid var(--border-secondary)', paddingTop: 7 }}>{item.notes}</p>}
</div>
</div>
</div>
</>
)
}
function Section({ title, count, children, defaultOpen = true, accent }) {
const [open, setOpen] = useState(defaultOpen)
return (
<div style={{ marginBottom: 24 }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 10,
}}>
{open ? <ChevronDown size={15} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={15} style={{ color: 'var(--text-faint)' }} />}
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--text-primary)' }}>{title}</span>
<span style={{
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
background: accent === 'green' ? '#dcfce7' : 'var(--bg-tertiary)',
color: accent === 'green' ? '#16a34a' : 'var(--text-muted)',
}}>{count}</span>
</button>
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
</div>
)
}
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }) {
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 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 <PlaceReservationCard key={r.id} item={r} tripId={tripId} />
return <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} />
}
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('reservations.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{total === 0 ? t('reservations.empty') : t('reservations.summary', { confirmed: allConfirmed.length, pending: allPending.length })}
</p>
</div>
<button onClick={onAdd} style={{
display: 'flex', alignItems: 'center', gap: 6, padding: '7px 14px', borderRadius: 99,
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
fontSize: 12.5, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
}}>
<Plus size={13} /> {t('reservations.addManual')}
</button>
</div>
{/* Hinweis — einmalig wegklickbar */}
{showHint && (
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
{t('reservations.placeHint')}
</p>
<button
onClick={() => { 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)'}
>×</button>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
{total === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{allPending.length > 0 && (
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
{allPending.map(renderCard)}
</Section>
)}
{allConfirmed.length > 0 && (
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
{allConfirmed.map(renderCard)}
</Section>
)}
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,612 @@
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'
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: '🚲' },
]
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 [transportMode, setTransportMode] = useState('driving')
const [isCalculatingRoute, setIsCalculatingRoute] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const tripStore = useTripStore()
const toast = useToast()
// 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('Mindestens 2 Orte mit Koordinaten benötigt')
return
}
setIsCalculatingRoute(true)
try {
const result = await calculateRoute(waypoints, transportMode)
if (result) {
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
onRouteCalculated?.(result)
toast.success('Route berechnet')
} else {
toast.error('Route konnte nicht berechnet werden')
}
} catch (err) {
toast.error('Fehler bei der Routenberechnung')
} 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('Route optimiert')
}
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')
}
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('Reservierung aktualisiert')
} else {
await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success('Reservierung hinzugefügt')
}
setShowReservationModal(false)
} catch (err) {
toast.error(err.message)
}
}
const handleDeleteReservation = async (id) => {
if (!confirm('Reservierung löschen?')) return
try {
await tripStore.deleteReservation(tripId, id)
toast.success('Reservierung gelöscht')
} catch (err) {
toast.error(err.message)
}
}
// 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="Orte suchen..."
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="">Alle Kategorien</option>
{categories.map(c => (
<option key={c.id} value={c.id}>{c.icon} {c.name}</option>
))}
</select>
<button
onClick={onAddPlace}
className="flex items-center gap-1 bg-slate-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" />
Ort hinzufügen
</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">Keine Orte gefunden</p>
<button onClick={onAddPlace} className="mt-3 text-slate-700 text-sm hover:underline">
Ersten Ort hinzufügen
</button>
</div>
) : (
<div className="divide-y divide-gray-50">
{filteredPlaces.map(place => {
const category = categories.find(c => c.id === place.category_id)
const 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"
>
+ Tag
</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}</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">Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen</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} Ort{dayAssignments.length !== 1 ? 'e' : ''}
{dayAssignments.length > 0 && ` · ${dayAssignments.reduce((s, a) => s + (a.place?.duration_minutes || 60), 0)} Min. gesamt`}
</p>
</div>
{/* Transport mode */}
<div className="px-3 py-2 border-b border-gray-100 flex items-center gap-1 flex-shrink-0">
{TRANSPORT_MODES.map(m => (
<button
key={m.value}
onClick={() => 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}
</button>
))}
</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">Noch keine Orte für diesen Tag</p>
<button
onClick={() => setActiveTab('orte')}
className="mt-3 text-slate-700 text-sm hover:underline"
>
Orte hinzufügen
</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 ? 'Berechne...' : 'Route berechnen'}
</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" />
Optimieren
</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" />
In Google Maps öffnen
</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">
Reservierungen
{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" />
Hinzufügen
</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">Keine Reservierungen</p>
<button onClick={handleAddReservation} className="mt-3 text-slate-700 text-sm hover:underline">
Erste Reservierung hinzufügen
</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)}
</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
}
}
@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react'
import Modal from '../shared/Modal'
import { Calendar, Camera, X } from 'lucide-react'
import { tripsApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
const isEditing = !!trip
const fileRef = useRef(null)
const toast = useToast()
const { t } = useTranslation()
const [formData, setFormData] = useState({
title: '',
description: '',
start_date: '',
end_date: '',
})
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [coverPreview, setCoverPreview] = useState(null)
const [uploadingCover, setUploadingCover] = useState(false)
useEffect(() => {
if (trip) {
setFormData({
title: trip.title || '',
description: trip.description || '',
start_date: trip.start_date || '',
end_date: trip.end_date || '',
})
setCoverPreview(trip.cover_image || null)
} else {
setFormData({ title: '', description: '', start_date: '', end_date: '' })
setCoverPreview(null)
}
setError('')
}, [trip, isOpen])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
setError(t('dashboard.endDateError')); return
}
setIsLoading(true)
try {
await onSave({
title: formData.title.trim(),
description: formData.description.trim() || null,
start_date: formData.start_date || null,
end_date: formData.end_date || null,
})
onClose()
} catch (err) {
setError(err.message || t('places.saveError'))
} finally {
setIsLoading(false)
}
}
const handleCoverChange = async (e) => {
const file = e.target.files?.[0]
if (!file || !trip?.id) return
setUploadingCover(true)
try {
const fd = new FormData()
fd.append('cover', file)
const data = await tripsApi.uploadCover(trip.id, fd)
setCoverPreview(data.cover_image)
onCoverUpdate?.(trip.id, data.cover_image)
toast.success(t('dashboard.coverSaved'))
} catch {
toast.error(t('dashboard.coverUploadError'))
} finally {
setUploadingCover(false)
e.target.value = ''
}
}
const handleRemoveCover = async () => {
if (!trip?.id) return
try {
await tripsApi.update(trip.id, { cover_image: null })
setCoverPreview(null)
onCoverUpdate?.(trip.id, null)
} catch {
toast.error(t('dashboard.coverRemoveError'))
}
}
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm"
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
size="md"
footer={
<div className="flex gap-3 justify-end">
<button type="button" onClick={onClose}
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
{t('common.cancel')}
</button>
<button onClick={handleSubmit} disabled={isLoading}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg transition-colors flex items-center gap-2">
{isLoading
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('common.saving')}</>
: isEditing ? t('common.update') : t('dashboard.createTrip')}
</button>
</div>
}
>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
)}
{/* Cover image — only for existing trips */}
{isEditing && (
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
{coverPreview ? (
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
</button>
<button type="button" onClick={handleRemoveCover}
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
<X size={12} />
</button>
</div>
</div>
) : (
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
</button>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
</label>
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
className={`${inputCls} resize-none`} />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.startDate')}
</label>
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder="Start" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.endDate')}
</label>
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder="End" />
</div>
</div>
{!formData.start_date && !formData.end_date && (
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
{t('dashboard.noDateHint')}
</p>
)}
</form>
</Modal>
)
}
@@ -0,0 +1,221 @@
import React, { useState, useEffect } from 'react'
import Modal from '../shared/Modal'
import { tripsApi, authApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { useAuthStore } from '../../store/authStore'
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
import { useTranslation } from '../../i18n'
import CustomSelect from '../shared/CustomSelect'
function Avatar({ username, avatarUrl, size = 32 }) {
if (avatarUrl) {
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
}
const letter = (username || '?')[0].toUpperCase()
const colors = ['#3b82f6', '#8b5cf6', '#ec4899', '#10b981', '#f59e0b', '#ef4444', '#06b6d4']
const color = colors[letter.charCodeAt(0) % colors.length]
return (
<div style={{
width: size, height: size, borderRadius: '50%', background: color,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: size * 0.4, fontWeight: 700, color: 'white', flexShrink: 0,
}}>
{letter}
</div>
)
}
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
const [data, setData] = useState(null)
const [allUsers, setAllUsers] = useState([])
const [loading, setLoading] = useState(false)
const [selectedUserId, setSelectedUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingId, setRemovingId] = useState(null)
const toast = useToast()
const { user } = useAuthStore()
const { t } = useTranslation()
useEffect(() => {
if (isOpen && tripId) {
loadMembers()
loadAllUsers()
}
}, [isOpen, tripId])
const loadMembers = async () => {
setLoading(true)
try {
const d = await tripsApi.getMembers(tripId)
setData(d)
} catch {
toast.error(t('members.loadError'))
} finally {
setLoading(false)
}
}
const loadAllUsers = async () => {
try {
const d = await authApi.listUsers()
setAllUsers(d.users)
} catch {}
}
const handleAdd = async () => {
if (!selectedUserId) return
setAdding(true)
try {
const target = allUsers.find(u => String(u.id) === String(selectedUserId))
await tripsApi.addMember(tripId, target.username)
setSelectedUserId('')
await loadMembers()
toast.success(`${target.username} ${t('members.added')}`)
} catch (err) {
toast.error(err.response?.data?.error || t('members.addError'))
} finally {
setAdding(false)
}
}
const handleRemove = async (userId, isSelf) => {
const msg = isSelf
? t('members.confirmLeave')
: t('members.confirmRemove')
if (!confirm(msg)) return
setRemovingId(userId)
try {
await tripsApi.removeMember(tripId, userId)
if (isSelf) { onClose(); window.location.reload() }
else { await loadMembers(); toast.success(t('members.removed')) }
} catch {
toast.error(t('members.removeError'))
} finally {
setRemovingId(null)
}
}
// Users not yet in the trip
const existingIds = new Set([
data?.owner?.id,
...(data?.members?.map(m => m.id) || []),
])
const availableUsers = allUsers.filter(u => !existingIds.has(u.id))
const isCurrentOwner = data?.owner?.id === user?.id
const allMembers = data ? [
{ ...data.owner, role: 'owner' },
...data.members,
] : []
return (
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Trip name */}
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 2 }}>{t('nav.trip')}</div>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>{tripTitle}</div>
</div>
{/* Add member dropdown */}
<div>
<label style={{ display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 8 }}>
{t('members.inviteUser')}
</label>
<div style={{ display: 'flex', gap: 8 }}>
<CustomSelect
value={selectedUserId}
onChange={value => setSelectedUserId(value)}
placeholder={t('members.selectUser')}
options={[
{ value: '', label: t('members.selectUser') },
...availableUsers.map(u => ({
value: u.id,
label: u.username,
})),
]}
searchable
style={{ flex: 1 }}
size="sm"
/>
<button
onClick={handleAdd}
disabled={adding || !selectedUserId}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10,
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
}}
>
<UserPlus size={13} /> {adding ? '…' : t('members.invite')}
</button>
</div>
{availableUsers.length === 0 && allUsers.length > 0 && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '6px 0 0' }}>{t('members.allHaveAccess')}</p>
)}
</div>
{/* Members list */}
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
<Users size={13} style={{ color: 'var(--text-faint)' }} />
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>
{t('members.access')} ({allMembers.length} {allMembers.length === 1 ? t('members.person') : t('members.persons')})
</span>
</div>
{loading ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{[1, 2].map(i => (
<div key={i} style={{ height: 48, background: 'var(--bg-tertiary)', borderRadius: 10, animation: 'pulse 1.5s ease-in-out infinite' }} />
))}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{allMembers.map(member => {
const isSelf = member.id === user?.id
const canRemove = isCurrentOwner ? member.role !== 'owner' : isSelf
return (
<div key={member.id} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '8px 12px', borderRadius: 10, background: 'var(--bg-secondary)',
border: '1px solid var(--border-secondary)',
}}>
<Avatar username={member.username} avatarUrl={member.avatar_url} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{member.username}</span>
{isSelf && <span style={{ fontSize: 10, color: 'var(--text-faint)' }}>({t('members.you')})</span>}
{member.role === 'owner' && (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 10, fontWeight: 700, color: '#d97706', background: '#fef9c3', padding: '1px 6px', borderRadius: 99 }}>
<Crown size={9} /> {t('members.owner')}
</span>
)}
</div>
</div>
{canRemove && (
<button
onClick={() => handleRemove(member.id, isSelf)}
disabled={removingId === member.id}
title={isSelf ? t('members.leaveTrip') : t('members.removeAccess')}
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)', opacity: removingId === member.id ? 0.4 : 1 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}
>
{isSelf ? <LogOut size={14} /> : <UserMinus size={14} />}
</button>
)}
</div>
)
})}
</div>
)}
</div>
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
</div>
</Modal>
)
}
@@ -0,0 +1,90 @@
import React, { useState, useEffect } from 'react'
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
import { weatherApi } from '../../api/client'
import { useSettingsStore } from '../../store/settingsStore'
const WEATHER_ICON_MAP = {
Clear: Sun,
Clouds: Cloud,
Rain: CloudRain,
Drizzle: CloudDrizzle,
Thunderstorm: CloudLightning,
Snow: CloudSnow,
Mist: Wind,
Fog: Wind,
Haze: Wind,
}
function WeatherIcon({ main, size = 13 }) {
const Icon = WEATHER_ICON_MAP[main] || Cloud
return <Icon size={size} strokeWidth={1.8} />
}
const weatherCache = {}
export default function WeatherWidget({ lat, lng, date, compact = false }) {
const [weather, setWeather] = useState(null)
const [loading, setLoading] = useState(false)
const [failed, setFailed] = useState(false)
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
useEffect(() => {
if (!lat || !lng || !date) return
const cacheKey = `${lat},${lng},${date}`
if (weatherCache[cacheKey] !== undefined) {
if (weatherCache[cacheKey] === null) setFailed(true)
else setWeather(weatherCache[cacheKey])
return
}
setLoading(true)
weatherApi.get(lat, lng, date)
.then(data => {
if (data.error || data.temp === undefined) {
weatherCache[cacheKey] = null
setFailed(true)
} else {
weatherCache[cacheKey] = data
setWeather(data)
}
})
.catch(() => { weatherCache[cacheKey] = null; setFailed(true) })
.finally(() => setLoading(false))
}, [lat, lng, date])
if (!lat || !lng) return null
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
if (loading) {
return (
<span style={{ fontSize: 11, color: '#d1d5db', ...fontStyle }}></span>
)
}
if (failed || !weather) {
return (
<span style={{ fontSize: 11, color: '#9ca3af', ...fontStyle }}></span>
)
}
const rawTemp = weather.temp
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
const unit = isFahrenheit ? '°F' : '°C'
if (compact) {
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
<WeatherIcon main={weather.main} size={12} />
{temp !== null && <span>{temp}{unit}</span>}
</span>
)
}
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: '#374151', background: 'rgba(0,0,0,0.04)', borderRadius: 8, padding: '5px 10px', ...fontStyle }}>
<WeatherIcon main={weather.main} size={15} />
{temp !== null && <span style={{ fontWeight: 500 }}>{temp}{unit}</span>}
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
</div>
)
}
@@ -0,0 +1,174 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
import { useTranslation } from '../../i18n'
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
// ── Datum-Only Picker ────────────────────────────────────────────────────────
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
const { locale } = useTranslation()
const [open, setOpen] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const parsed = value ? new Date(value + 'T00:00:00') : null
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
useEffect(() => {
if (open && parsed) { setViewYear(parsed.getFullYear()); setViewMonth(parsed.getMonth()) }
}, [open])
const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) } else setViewMonth(m => m - 1) }
const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) } else setViewMonth(m => m + 1) }
const monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
const days = daysInMonth(viewYear, viewMonth)
const startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7 // Mo=0
const weekdays = Array.from({ length: 7 }, (_, i) => new Date(2024, 0, i + 1).toLocaleDateString(locale, { weekday: 'narrow' }))
const displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
const selectDay = (day) => {
const y = String(viewYear)
const m = String(viewMonth + 1).padStart(2, '0')
const d = String(day).padStart(2, '0')
onChange(`${y}-${m}-${d}`)
setOpen(false)
}
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
const today = new Date()
const isToday = (d) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<button type="button" onClick={() => setOpen(o => !o)}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: displayValue ? 'var(--text-primary)' : 'var(--text-faint)',
fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', outline: 'none',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span>{displayValue || placeholder || 'Datum'}</span>
</button>
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 14, boxShadow: '0 8px 32px rgba(0,0,0,0.12)', padding: 12, width: 268,
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Month nav */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<button type="button" onClick={prevMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronLeft size={16} />
</button>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{monthLabel}</span>
<button type="button" onClick={nextMonth} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronRight size={16} />
</button>
</div>
{/* Weekday headers */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2, marginBottom: 4 }}>
{weekdays.map((d, i) => (
<div key={i} style={{ textAlign: 'center', fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', padding: '2px 0' }}>{d}</div>
))}
</div>
{/* Days grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 2 }}>
{Array.from({ length: startDay }, (_, i) => <div key={`e-${i}`} />)}
{Array.from({ length: days }, (_, i) => {
const d = i + 1
const sel = d === selectedDay
const td = isToday(d)
return (
<button key={d} type="button" onClick={() => selectDay(d)}
style={{
width: 32, height: 32, borderRadius: 8, border: 'none',
background: sel ? 'var(--accent)' : 'transparent',
color: sel ? 'var(--accent-text)' : 'var(--text-primary)',
fontSize: 12, fontWeight: sel ? 700 : td ? 600 : 400,
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
outline: td && !sel ? '2px solid var(--border-primary)' : 'none', outlineOffset: -2,
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = 'var(--bg-hover)' }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = 'transparent' }}>
{d}
</button>
)
})}
</div>
{/* Clear */}
{value && (
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'center' }}>
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--text-faint)', padding: '3px 8px', borderRadius: 6 }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
</button>
</div>
)}
</div>,
document.body
)}
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
}
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
const { locale } = useTranslation()
// value = "2024-03-15T14:30" oder ""
const [datePart, timePart] = (value || '').split('T')
const handleDateChange = (d) => {
onChange(d ? `${d}T${timePart || '12:00'}` : '')
}
const handleTimeChange = (t) => {
const d = datePart || new Date().toISOString().split('T')[0]
onChange(t ? `${d}T${t}` : `${d}T00:00`)
}
return (
<div style={{ display: 'flex', gap: 8, ...style }}>
<CustomDatePicker value={datePart || ''} onChange={handleDateChange} style={{ flex: 1, minWidth: 0 }} />
<div style={{ width: 110, flexShrink: 0 }}>
<CustomTimePicker value={timePart || ''} onChange={handleTimeChange} />
</div>
</div>
)
}
// Inline re-export for convenience
import CustomTimePicker from './CustomTimePicker'
@@ -0,0 +1,144 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
export default function CustomSelect({
value,
onChange,
options = [], // [{ value, label, icon? }]
placeholder = '',
searchable = false,
style = {},
size = 'md', // 'sm' | 'md'
}) {
const [open, setOpen] = useState(false)
const [search, setSearch] = useState('')
const ref = useRef(null)
const dropRef = useRef(null)
const searchRef = useRef(null)
useEffect(() => {
if (open && searchable && searchRef.current) searchRef.current.focus()
}, [open, searchable])
useEffect(() => {
const handleClick = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const selected = options.find(o => o.value === value)
const filtered = searchable && search
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
: options
const sm = size === 'sm'
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
{/* Trigger */}
<button
type="button"
onClick={() => { setOpen(o => !o); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: sm ? '8px 12px' : '8px 14px', borderRadius: 10,
border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', color: 'var(--text-primary)',
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
cursor: 'pointer', outline: 'none', textAlign: 'left',
transition: 'border-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
>
{selected?.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{selected.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: selected ? 'var(--text-primary)' : 'var(--text-faint)' }}>
{selected ? selected.label : placeholder}
</span>
<ChevronDown size={sm ? 12 : 14} style={{ flexShrink: 0, color: 'var(--text-faint)', transition: 'transform 0.2s', transform: open ? 'rotate(180deg)' : 'none' }} />
</button>
{/* Dropdown */}
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
zIndex: 99999,
background: 'var(--bg-card)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
border: '1px solid var(--border-primary)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
overflow: 'hidden',
animation: 'selectIn 0.15s ease-out',
}}>
{/* Search */}
{searchable && (
<div style={{ padding: '6px 6px 2px' }}>
<input
ref={searchRef}
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="..."
style={{
width: '100%', border: '1px solid var(--border-secondary)', borderRadius: 6,
padding: '5px 8px', fontSize: 12, outline: 'none', fontFamily: 'inherit',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
boxSizing: 'border-box',
}}
/>
</div>
)}
{/* Options */}
<div style={{ maxHeight: 220, overflowY: 'auto', padding: '4px' }}>
{filtered.length === 0 ? (
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}></div>
) : (
filtered.map(option => {
const isSelected = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => { onChange(option.value); setOpen(false); setSearch('') }}
style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 10px', borderRadius: 6,
border: 'none', background: isSelected ? 'var(--bg-hover)' : 'transparent',
color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit',
cursor: 'pointer', textAlign: 'left', transition: 'background 0.1s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isSelected ? 'var(--bg-hover)' : 'transparent'}
>
{option.icon && <span style={{ display: 'flex', flexShrink: 0 }}>{option.icon}</span>}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{option.label}</span>
{isSelected && <Check size={13} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />}
</button>
)
})
)}
</div>
</div>,
document.body
)}
<style>{`
@keyframes selectIn {
from { opacity: 0; transform: translateY(-4px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
)
}
@@ -0,0 +1,224 @@
import React, { useState, useRef, useEffect } from 'react'
import ReactDOM from 'react-dom'
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
import { useSettingsStore } from '../../store/settingsStore'
function formatDisplay(val, is12h) {
if (!val) return ''
const [h, m] = val.split(':').map(Number)
if (isNaN(h) || isNaN(m)) return val
if (!is12h) return val
const period = h >= 12 ? 'PM' : 'AM'
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
return `${h12}:${String(m).padStart(2, '0')} ${period}`
}
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
const [open, setOpen] = useState(false)
const [inputFocused, setInputFocused] = useState(false)
const ref = useRef(null)
const dropRef = useRef(null)
const [h, m] = (value || '').split(':').map(Number)
const hour = isNaN(h) ? null : h
const minute = isNaN(m) ? null : m
useEffect(() => {
const handler = (e) => {
if (ref.current?.contains(e.target)) return
if (dropRef.current?.contains(e.target)) return
setOpen(false)
}
if (open) document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
const update = (newH, newM) => {
const hh = String(Math.max(0, Math.min(23, newH))).padStart(2, '0')
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
onChange(`${hh}:${mm}`)
}
const incHour = () => update(((hour ?? -1) + 1) % 24, minute ?? 0)
const decHour = () => update(((hour ?? 1) - 1 + 24) % 24, minute ?? 0)
const incMin = () => {
const newM = ((minute ?? -5) + 5) % 60
const newH = newM < (minute ?? 0) ? ((hour ?? 0) + 1) % 24 : (hour ?? 0)
update(newH, newM)
}
const decMin = () => {
const newM = ((minute ?? 5) - 5 + 60) % 60
const newH = newM > (minute ?? 0) ? ((hour ?? 0) - 1 + 24) % 24 : (hour ?? 0)
update(newH, newM)
}
const btnStyle = {
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
transition: 'color 0.15s',
}
const handleInput = (e) => {
const raw = e.target.value
onChange(raw)
// Auto-format: wenn "1430" → "14:30"
const clean = raw.replace(/[^0-9:]/g, '')
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
else if (/^\d{1,2}:\d{2}$/.test(clean)) {
const [hh, mm] = clean.split(':')
onChange(hh.padStart(2, '0') + ':' + mm)
}
}
const handleBlur = () => {
if (!value) return
const clean = value.replace(/[^0-9:]/g, '')
if (/^\d{1,2}:\d{2}$/.test(clean)) {
const [hh, mm] = clean.split(':')
const h = Math.min(23, Math.max(0, parseInt(hh)))
const m = Math.min(59, Math.max(0, parseInt(mm)))
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
} else if (/^\d{3,4}$/.test(clean)) {
const s = clean.padStart(4, '0')
const h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
}
}
return (
<div ref={ref} style={{ position: 'relative', ...style }}>
<div style={{
width: '100%', display: 'flex', alignItems: 'center', gap: 0,
borderRadius: 10, border: '1px solid var(--border-primary)',
background: 'var(--bg-input)', overflow: 'hidden',
transition: 'border-color 0.15s',
}}>
<input
type="text"
value={inputFocused ? value : formatDisplay(value, is12h)}
onChange={handleInput}
onFocus={() => setInputFocused(true)}
onBlur={() => { setInputFocused(false); handleBlur() }}
placeholder={is12h ? '2:30 PM' : placeholder}
style={{
flex: 1, border: 'none', outline: 'none', background: 'transparent',
padding: '8px 10px 8px 14px', fontSize: 13, fontFamily: 'inherit',
color: value ? 'var(--text-primary)' : 'var(--text-faint)',
minWidth: 0,
}}
/>
<button
type="button"
onClick={() => setOpen(o => !o)}
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '8px 10px',
display: 'flex', alignItems: 'center', color: 'var(--text-faint)',
transition: 'color 0.15s', flexShrink: 0,
}}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<Clock size={14} />
</button>
</div>
{open && ReactDOM.createPortal(
<div ref={dropRef} style={{
position: 'fixed',
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
zIndex: 99999,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)',
borderRadius: 12, boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
padding: 12, display: 'flex', alignItems: 'center', gap: 6,
animation: 'selectIn 0.15s ease-out',
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
}}>
{/* Stunden */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronUp size={16} />
</button>
<div style={{
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
background: 'var(--bg-hover)', borderRadius: 8,
fontVariantNumeric: 'tabular-nums',
}}>
{hour !== null ? (is12h ? String(hour === 0 ? 12 : hour > 12 ? hour - 12 : hour) : String(hour).padStart(2, '0')) : '--'}
</div>
<button type="button" onClick={decHour} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronDown size={16} />
</button>
</div>
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
{/* Minuten */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
<button type="button" onClick={incMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronUp size={16} />
</button>
<div style={{
width: 44, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 22, fontWeight: 700, color: 'var(--text-primary)',
background: 'var(--bg-hover)', borderRadius: 8,
fontVariantNumeric: 'tabular-nums',
}}>
{minute !== null ? String(minute).padStart(2, '0') : '--'}
</div>
<button type="button" onClick={decMin} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronDown size={16} />
</button>
</div>
{/* AM/PM Toggle */}
{is12h && hour !== null && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, marginLeft: 4 }}>
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronUp size={16} />
</button>
<div style={{
width: 36, height: 40, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 14, fontWeight: 700, color: 'var(--text-primary)',
background: 'var(--bg-hover)', borderRadius: 8,
}}>
{hour >= 12 ? 'PM' : 'AM'}
</div>
<button type="button" onClick={() => { if (hour < 12) update(hour + 12, minute ?? 0); else update(hour - 12, minute ?? 0) }} style={btnStyle}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ChevronDown size={16} />
</button>
</div>
)}
{/* Clear */}
{value && (
<button type="button" onClick={() => { onChange(''); setOpen(false) }}
style={{ ...btnStyle, marginLeft: 4, fontSize: 11, color: 'var(--text-faint)', padding: '4px 6px' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
</button>
)}
</div>,
document.body
)}
<style>{`@keyframes selectIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }`}</style>
</div>
)
}
+96
View File
@@ -0,0 +1,96 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { X } from 'lucide-react'
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
}
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
footer,
hideCloseButton = false,
}) {
const handleEsc = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
mouseDownTarget.current = null
}}
>
<div
className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[90vh]
animate-in fade-in zoom-in-95 duration-200
`}
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && (
<button
onClick={onClose}
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
{/* Footer */}
{footer && (
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer}
</div>
)}
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}
@@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from './categoryIcons'
const googlePhotoCache = new Map()
export default function PlaceAvatar({ place, size = 32, category }) {
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
useEffect(() => {
if (place.image_url) { setPhotoSrc(place.image_url); return }
if (!place.google_place_id) return
if (googlePhotoCache.has(place.google_place_id)) {
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
googlePhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoSrc(data.photoUrl)
}
})
.catch(() => {})
}, [place.id, place.image_url, place.google_place_id])
const bgColor = category?.color || '#6366f1'
const IconComp = getCategoryIcon(category?.icon)
const iconSize = Math.round(size * 0.46)
const containerStyle = {
width: size, height: size,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: bgColor,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}
if (photoSrc) {
return (
<div style={containerStyle}>
<img
src={photoSrc}
alt={place.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={() => setPhotoSrc(null)}
/>
</div>
)
}
return (
<div style={containerStyle}>
<IconComp size={iconSize} strokeWidth={1.8} color="rgba(255,255,255,0.92)" />
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, duration)
}
return id
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, [])
// Make addToast globally accessible
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
info: 'bg-white border-l-4 border-blue-500',
}
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`
${bgColors[toast.type] || bgColors.info}
${toast.removing ? 'toast-exit' : 'toast-enter'}
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
min-w-0
`}
>
{icons[toast.type] || icons.info}
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message, duration) => show(message, 'success', duration),
error: (message, duration) => show(message, 'error', duration),
warning: (message, duration) => show(message, 'warning', duration),
info: (message, duration) => show(message, 'info', duration),
}
}
export default useToast
@@ -0,0 +1,43 @@
import {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
} from 'lucide-react'
export const CATEGORY_ICON_MAP = {
MapPin, Building2, BedDouble, UtensilsCrossed, Landmark, ShoppingBag,
Bus, Train, Car, Plane, Ship, Bike,
Activity, Dumbbell, Mountain, Tent, Anchor,
Coffee, Beer, Wine, Utensils,
Camera, Music, Theater, Ticket,
TreePine, Waves, Leaf, Flower2, Sun,
Globe, Compass, Flag, Navigation, Map,
Church, Library, Store, Home, Cross,
Heart, Star, CreditCard, Wifi,
Luggage, Backpack, Zap,
}
export const ICON_LABELS = {
MapPin: 'Pin', Building2: 'Gebäude', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
Landmark: 'Sehenswürdigkeit', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Zug',
Car: 'Auto', Plane: 'Flugzeug', Ship: 'Schiff', Bike: 'Fahrrad',
Activity: 'Aktivität', Dumbbell: 'Fitness', Mountain: 'Berg', Tent: 'Camping',
Anchor: 'Hafen', Coffee: 'Café', Beer: 'Bar', Wine: 'Wein', Utensils: 'Essen',
Camera: 'Foto', Music: 'Musik', Theater: 'Theater', Ticket: 'Events',
TreePine: 'Natur', Waves: 'Strand', Leaf: 'Grün', Flower2: 'Garten', Sun: 'Sonne',
Globe: 'Welt', Compass: 'Erkundung', Flag: 'Flagge', Navigation: 'Navigation', Map: 'Karte',
Church: 'Kirche', Library: 'Museum', Store: 'Markt', Home: 'Unterkunft', Cross: 'Medizin',
Heart: 'Favorit', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
Luggage: 'Gepäck', Backpack: 'Rucksack', Zap: 'Abenteuer',
}
export function getCategoryIcon(iconName) {
return CATEGORY_ICON_MAP[iconName] || MapPin
}