mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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='© <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.`
|
||||
}
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user