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

Self-hosted travel planner with Express.js, SQLite, React & Tailwind CSS.
This commit is contained in:
Maurice
2026-03-18 23:58:08 +01:00
commit cb1e217bbe
100 changed files with 25545 additions and 0 deletions

14
client/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>NOMAD</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3664
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
client/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "nomad-client",
"version": "2.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
"leaflet": "^1.9.4",
"lucide-react": "^0.344.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.4.1",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.22.2",
"topojson-client": "^3.1.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/leaflet": "^1.9.8",
"@types/react": "^18.2.61",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vite": "^5.1.4"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

133
client/src/App.jsx Normal file
View File

@@ -0,0 +1,133 @@
import React, { useEffect } from 'react'
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from './store/authStore'
import { useSettingsStore } from './store/settingsStore'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage'
import DashboardPage from './pages/DashboardPage'
import TripPlannerPage from './pages/TripPlannerPage'
// PhotosPage removed - replaced by Finanzplan
import FilesPage from './pages/FilesPage'
import AdminPage from './pages/AdminPage'
import SettingsPage from './pages/SettingsPage'
import { ToastContainer } from './components/shared/Toast'
import { TranslationProvider } from './i18n'
function ProtectedRoute({ children, adminRequired = false }) {
const { isAuthenticated, user, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="flex flex-col items-center gap-3">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
<p className="text-slate-500 text-sm">Wird geladen...</p>
</div>
</div>
)
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />
}
if (adminRequired && user?.role !== 'admin') {
return <Navigate to="/dashboard" replace />
}
return children
}
function RootRedirect() {
const { isAuthenticated, isLoading } = useAuthStore()
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin"></div>
</div>
)
}
return <Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />
}
export default function App() {
const { loadUser, token, isAuthenticated } = useAuthStore()
const { loadSettings } = useSettingsStore()
useEffect(() => {
if (token) {
loadUser()
}
}, [])
const { settings } = useSettingsStore()
useEffect(() => {
if (isAuthenticated) {
loadSettings()
}
}, [isAuthenticated])
// Apply dark mode class to <html>
useEffect(() => {
if (settings.dark_mode) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}, [settings.dark_mode])
return (
<TranslationProvider>
<ToastContainer />
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<Navigate to="/login" replace />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<DashboardPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id"
element={
<ProtectedRoute>
<TripPlannerPage />
</ProtectedRoute>
}
/>
<Route
path="/trips/:id/files"
element={
<ProtectedRoute>
<FilesPage />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute adminRequired>
<AdminPage />
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute>
<SettingsPage />
</ProtectedRoute>
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TranslationProvider>
)
}

194
client/src/api/client.js Normal file
View File

@@ -0,0 +1,194 @@
import axios from 'axios'
const apiClient = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor - add auth token
apiClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('auth_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => Promise.reject(error)
)
// Response interceptor - handle 401
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token')
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
window.location.href = '/login'
}
}
return Promise.reject(error)
}
)
export const authApi = {
register: (data) => apiClient.post('/auth/register', data).then(r => r.data),
login: (data) => apiClient.post('/auth/login', data).then(r => r.data),
me: () => apiClient.get('/auth/me').then(r => r.data),
updateMapsKey: (key) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
updateApiKeys: (data) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
updateSettings: (data) => apiClient.put('/auth/me/settings', data).then(r => r.data),
getSettings: () => apiClient.get('/auth/me/settings').then(r => r.data),
listUsers: () => apiClient.get('/auth/users').then(r => r.data),
uploadAvatar: (formData) => apiClient.post('/auth/avatar', formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
deleteAvatar: () => apiClient.delete('/auth/avatar').then(r => r.data),
getAppConfig: () => apiClient.get('/auth/app-config').then(r => r.data),
updateAppSettings: (data) => apiClient.put('/auth/app-settings', data).then(r => r.data),
validateKeys: () => apiClient.get('/auth/validate-keys').then(r => r.data),
travelStats: () => apiClient.get('/auth/travel-stats').then(r => r.data),
}
export const tripsApi = {
list: (params) => apiClient.get('/trips', { params }).then(r => r.data),
create: (data) => apiClient.post('/trips', data).then(r => r.data),
get: (id) => apiClient.get(`/trips/${id}`).then(r => r.data),
update: (id, data) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/trips/${id}`).then(r => r.data),
uploadCover: (id, formData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
archive: (id) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
unarchive: (id) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
getMembers: (id) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
addMember: (id, identifier) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
removeMember: (id, userId) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
}
export const daysApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
update: (tripId, dayId, data) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
delete: (tripId, dayId) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
}
export const placesApi = {
list: (tripId, params) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
get: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
searchImage: (tripId, id) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
}
export const assignmentsApi = {
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
reorder: (tripId, dayId, orderedIds) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
move: (tripId, assignmentId, newDayId, orderIndex) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
}
export const packingApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
reorder: (tripId, orderedIds) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
}
export const tagsApi = {
list: () => apiClient.get('/tags').then(r => r.data),
create: (data) => apiClient.post('/tags', data).then(r => r.data),
update: (id, data) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/tags/${id}`).then(r => r.data),
}
export const categoriesApi = {
list: () => apiClient.get('/categories').then(r => r.data),
create: (data) => apiClient.post('/categories', data).then(r => r.data),
update: (id, data) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
delete: (id) => apiClient.delete(`/categories/${id}`).then(r => r.data),
}
export const adminApi = {
users: () => apiClient.get('/admin/users').then(r => r.data),
updateUser: (id, data) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
deleteUser: (id) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
stats: () => apiClient.get('/admin/stats').then(r => r.data),
}
export const mapsApi = {
search: (query, lang) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
details: (placeId, lang) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
placePhoto: (placeId) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
}
export const budgetApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
}
export const filesApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
upload: (tripId, formData) => apiClient.post(`/trips/${tripId}/files`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
}
export const reservationsApi = {
list: (tripId) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
create: (tripId, data) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
}
export const weatherApi = {
get: (lat, lng, date) => apiClient.get('/weather', { params: { lat, lng, date, units: 'metric' } }).then(r => r.data),
}
export const settingsApi = {
get: () => apiClient.get('/settings').then(r => r.data),
set: (key, value) => apiClient.put('/settings', { key, value }).then(r => r.data),
setBulk: (settings) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
}
export const dayNotesApi = {
list: (tripId, dayId) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
create: (tripId, dayId, data) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
update: (tripId, dayId, id, data) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
delete: (tripId, dayId, id) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
}
export const backupApi = {
list: () => apiClient.get('/backup/list').then(r => r.data),
create: () => apiClient.post('/backup/create').then(r => r.data),
download: async (filename) => {
const token = localStorage.getItem('auth_token')
const res = await fetch(`/api/backup/download/${filename}`, {
headers: { Authorization: `Bearer ${token}` },
})
if (!res.ok) throw new Error('Download fehlgeschlagen')
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
},
delete: (filename) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
restore: (filename) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
uploadRestore: (file) => {
const form = new FormData()
form.append('backup', file)
return apiClient.post('/backup/upload-restore', form, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
},
getAutoSettings: () => apiClient.get('/backup/auto-settings').then(r => r.data),
setAutoSettings: (settings) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
}
export default apiClient

View File

@@ -0,0 +1,360 @@
import React, { useState, useEffect, useRef } from 'react'
import { backupApi } from '../../api/client'
import { useToast } from '../shared/Toast'
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
import { useTranslation } from '../../i18n'
const INTERVAL_OPTIONS = [
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
{ value: 'daily', labelKey: 'backup.interval.daily' },
{ value: 'weekly', labelKey: 'backup.interval.weekly' },
{ value: 'monthly', labelKey: 'backup.interval.monthly' },
]
const KEEP_OPTIONS = [
{ value: 1, labelKey: 'backup.keep.1day' },
{ value: 3, labelKey: 'backup.keep.3days' },
{ value: 7, labelKey: 'backup.keep.7days' },
{ value: 14, labelKey: 'backup.keep.14days' },
{ value: 30, labelKey: 'backup.keep.30days' },
{ value: 0, labelKey: 'backup.keep.forever' },
]
export default function BackupPanel() {
const [backups, setBackups] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [isCreating, setIsCreating] = useState(false)
const [restoringFile, setRestoringFile] = useState(null)
const [isUploading, setIsUploading] = useState(false)
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
const fileInputRef = useRef(null)
const toast = useToast()
const { t, locale } = useTranslation()
const loadBackups = async () => {
setIsLoading(true)
try {
const data = await backupApi.list()
setBackups(data.backups || [])
} catch {
toast.error(t('backup.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAutoSettings = async () => {
try {
const data = await backupApi.getAutoSettings()
setAutoSettings(data.settings)
} catch {}
}
useEffect(() => { loadBackups(); loadAutoSettings() }, [])
const handleCreate = async () => {
setIsCreating(true)
try {
await backupApi.create()
toast.success(t('backup.toast.created'))
await loadBackups()
} catch {
toast.error(t('backup.toast.createError'))
} finally {
setIsCreating(false)
}
}
const handleRestore = async (filename) => {
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
setRestoringFile(filename)
try {
await backupApi.restore(filename)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
setRestoringFile(null)
}
}
const handleUploadRestore = async (e) => {
const file = e.target.files?.[0]
if (!file) return
e.target.value = ''
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
setIsUploading(true)
try {
await backupApi.uploadRestore(file)
toast.success(t('backup.toast.restored'))
setTimeout(() => window.location.reload(), 1500)
} catch (err) {
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
setIsUploading(false)
}
}
const handleDelete = async (filename) => {
if (!confirm(t('backup.confirm.delete', { name: filename }))) return
try {
await backupApi.delete(filename)
toast.success(t('backup.toast.deleted'))
setBackups(prev => prev.filter(b => b.filename !== filename))
} catch {
toast.error(t('backup.toast.deleteError'))
}
}
const handleAutoSettingsChange = (key, value) => {
setAutoSettings(prev => ({ ...prev, [key]: value }))
setAutoSettingsDirty(true)
}
const handleSaveAutoSettings = async () => {
setAutoSettingsSaving(true)
try {
const data = await backupApi.setAutoSettings(autoSettings)
setAutoSettings(data.settings)
setAutoSettingsDirty(false)
toast.success(t('backup.toast.settingsSaved'))
} catch {
toast.error(t('backup.toast.settingsError'))
} finally {
setAutoSettingsSaving(false)
}
}
const formatSize = (bytes) => {
if (!bytes) return '-'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
return new Date(dateStr).toLocaleString(locale, {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
} catch { return dateStr }
}
const isAuto = (filename) => filename.startsWith('auto-backup-')
return (
<div className="flex flex-col gap-6">
{/* Manual Backups */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<HardDrive className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={loadBackups}
disabled={isLoading}
className="p-2 text-gray-500 hover:bg-gray-100 rounded-lg transition-colors"
title={t('backup.refresh')}
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
{/* Upload & Restore */}
<input
ref={fileInputRef}
type="file"
accept=".zip"
className="hidden"
onChange={handleUploadRestore}
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
>
{isUploading ? (
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
) : (
<Upload className="w-4 h-4" />
)}
{isUploading ? t('backup.uploading') : t('backup.upload')}
</button>
<button
onClick={handleCreate}
disabled={isCreating}
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
) : (
<Plus className="w-4 h-4" />
)}
{isCreating ? t('backup.creating') : t('backup.create')}
</button>
</div>
</div>
{isLoading && backups.length === 0 ? (
<div className="flex items-center justify-center py-12 text-gray-400">
<div className="w-6 h-6 border-2 border-gray-300 border-t-slate-700 rounded-full animate-spin mr-2" />
{t('common.loading')}
</div>
) : backups.length === 0 ? (
<div className="text-center py-12 text-gray-400">
<HardDrive className="w-10 h-10 mb-3 mx-auto opacity-40" />
<p className="text-sm">{t('backup.empty')}</p>
<button onClick={handleCreate} className="mt-4 text-slate-700 text-sm hover:underline">
{t('backup.createFirst')}
</button>
</div>
) : (
<div className="divide-y divide-gray-100">
{backups.map(backup => (
<div key={backup.filename} className="flex items-center gap-4 py-3">
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center flex-shrink-0">
{isAuto(backup.filename)
? <RefreshCw className="w-4 h-4 text-blue-500" />
: <HardDrive className="w-4 h-4 text-gray-500" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-sm text-gray-900 truncate">{backup.filename}</p>
{isAuto(backup.filename) && (
<span className="text-xs bg-blue-50 text-blue-600 border border-blue-100 rounded-full px-2 py-0.5 whitespace-nowrap">Auto</span>
)}
</div>
<div className="flex items-center gap-3 mt-0.5">
<span className="text-xs text-gray-400">{formatDate(backup.created_at)}</span>
<span className="text-xs text-gray-400">{formatSize(backup.size)}</span>
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<button
onClick={() => backupApi.download(backup.filename).catch(() => toast.error(t('backup.toast.downloadError')))}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-slate-700 border border-slate-200 rounded-lg hover:bg-slate-50"
>
<Download className="w-3.5 h-3.5" />
{t('backup.download')}
</button>
<button
onClick={() => handleRestore(backup.filename)}
disabled={restoringFile === backup.filename}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-50 disabled:opacity-60"
>
{restoringFile === backup.filename
? <div className="w-3.5 h-3.5 border-2 border-amber-400 border-t-transparent rounded-full animate-spin" />
: <RotateCcw className="w-3.5 h-3.5" />
}
{t('backup.restore')}
</button>
<button
onClick={() => handleDelete(backup.filename)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Auto-Backup Settings */}
<div className="bg-white rounded-2xl border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-6">
<Clock className="w-5 h-5 text-gray-400" />
<div>
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
</div>
</div>
<div className="flex flex-col gap-5">
{/* Enable toggle */}
<label className="flex items-center justify-between cursor-pointer">
<div>
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
</div>
<button
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
</label>
{autoSettings.enabled && (
<>
{/* Interval */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.interval')}</label>
<div className="flex flex-wrap gap-2">
{INTERVAL_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('interval', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.interval === opt.value
? 'bg-slate-700 text-white border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
{/* Keep duration */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
<div className="flex flex-wrap gap-2">
{KEEP_OPTIONS.map(opt => (
<button
key={opt.value}
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
autoSettings.keep_days === opt.value
? 'bg-slate-700 text-white border-slate-700'
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
}`}
>
{t(opt.labelKey)}
</button>
))}
</div>
</div>
</>
)}
{/* Save button */}
<div className="flex justify-end pt-2 border-t border-gray-100">
<button
onClick={handleSaveAutoSettings}
disabled={autoSettingsSaving || !autoSettingsDirty}
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
>
{autoSettingsSaving
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Check className="w-4 h-4" />
}
{autoSettingsSaving ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
</div>
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,331 @@
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
function isImage(mimeType) {
if (!mimeType) return false
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
}
function getFileIcon(mimeType) {
if (!mimeType) return File
if (mimeType === 'application/pdf') return FileText
if (isImage(mimeType)) return FileImage
return File
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
}
function formatDateWithLocale(dateStr, locale) {
if (!dateStr) return ''
try {
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
} catch { return '' }
}
// Image lightbox
function ImageLightbox({ file, onClose }) {
const { t } = useTranslation()
return (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={onClose}
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
<img
src={file.url}
alt={file.original_name}
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
/>
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
<div style={{ display: 'flex', gap: 8 }}>
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
<ExternalLink size={16} />
</a>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
<X size={18} />
</button>
</div>
</div>
</div>
</div>
)
}
// Source badge — unified style for both place and reservation
function SourceBadge({ icon: Icon, label }) {
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
fontSize: 10.5, color: '#4b5563',
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
borderRadius: 6, padding: '2px 7px',
fontWeight: 500, whiteSpace: 'nowrap',
}}>
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
{label}
</span>
)
}
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
const [uploading, setUploading] = useState(false)
const [filterType, setFilterType] = useState('all')
const [lightboxFile, setLightboxFile] = useState(null)
const toast = useToast()
const { t, locale } = useTranslation()
const onDrop = useCallback(async (acceptedFiles) => {
if (acceptedFiles.length === 0) return
setUploading(true)
try {
for (const file of acceptedFiles) {
const formData = new FormData()
formData.append('file', file)
await onUpload(formData)
}
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
} catch {
toast.error(t('files.uploadError'))
} finally {
setUploading(false)
}
}, [onUpload, toast, t])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
maxSize: 50 * 1024 * 1024,
noClick: false,
})
const filteredFiles = files.filter(f => {
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
if (filterType === 'image') return isImage(f.mime_type)
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
return true
})
const handleDelete = async (id) => {
if (!confirm(t('files.confirm.delete'))) return
try {
await onDelete(id)
toast.success(t('files.toast.deleted'))
} catch {
toast.error(t('files.toast.deleteError'))
}
}
const [previewFile, setPreviewFile] = useState(null)
const openFile = (file) => {
if (isImage(file.mime_type)) {
setLightboxFile(file)
} else {
setPreviewFile(file)
}
}
return (
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
{/* Lightbox */}
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
{/* Datei-Vorschau Modal */}
{previewFile && (
<div
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
onClick={() => setPreviewFile(null)}
>
<div
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
onClick={e => e.stopPropagation()}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
<ExternalLink size={13} /> {t('files.openTab')}
</a>
<button onClick={() => setPreviewFile(null)}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<X size={18} />
</button>
</div>
</div>
<iframe
src={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
style={{ flex: 1, width: '100%', border: 'none' }}
title={previewFile.original_name}
/>
</div>
</div>
)}
{/* Header */}
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
<div>
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
</p>
</div>
</div>
{/* Upload zone */}
<div
{...getRootProps()}
style={{
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
}}
>
<input {...getInputProps()} />
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
{uploading ? (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
{t('files.uploading')}
</div>
) : (
<>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
</>
)}
</div>
{/* Filter tabs */}
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
{[
{ id: 'all', label: t('files.filterAll') },
{ id: 'pdf', label: t('files.filterPdf') },
{ id: 'image', label: t('files.filterImages') },
{ id: 'doc', label: t('files.filterDocs') },
].map(tab => (
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
fontFamily: 'inherit', transition: 'all 0.12s',
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
fontWeight: filterType === tab.id ? 600 : 400,
}}>{tab.label}</button>
))}
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
</span>
</div>
{/* File list */}
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
{filteredFiles.length === 0 ? (
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{filteredFiles.map(file => {
const FileIcon = getFileIcon(file.mime_type)
const linkedPlace = places?.find(p => p.id === file.place_id)
const linkedReservation = file.reservation_id
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
: null
const fileUrl = file.url || `/uploads/files/${file.filename}`
return (
<div key={file.id} style={{
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
transition: 'border-color 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
className="group"
>
{/* Icon or thumbnail */}
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', overflow: 'hidden',
}}
>
{isImage(file.mime_type)
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
}
</div>
{/* Info */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
onClick={() => openFile({ ...file, url: fileUrl })}
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
>
{file.original_name}
</div>
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
{linkedPlace && (
<SourceBadge
icon={MapPin}
label={`${t('files.sourcePlan')} · ${linkedPlace.name}`}
/>
)}
{linkedReservation && (
<SourceBadge
icon={Ticket}
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
/>
)}
</div>
{file.description && !linkedReservation && (
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
<ExternalLink size={14} />
</button>
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
<Trash2 size={14} />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
<style>{`
div:hover > .file-actions { opacity: 1 !important; }
`}</style>
</div>
)
}

View File

@@ -0,0 +1,157 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../../store/authStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
const { user, logout } = useAuthStore()
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
const navigate = useNavigate()
const [userMenuOpen, setUserMenuOpen] = useState(false)
const dark = settings.dark_mode
const handleLogout = () => {
logout()
navigate('/login')
}
const toggleDark = () => {
updateSetting('dark_mode', !dark).catch(() => {})
}
return (
<nav style={{
background: dark ? 'rgba(9,9,11,0.95)' : 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
{/* Left side */}
<div className="flex items-center gap-3 min-w-0">
{showBack && (
<button onClick={onBack}
className="p-1.5 rounded-lg transition-colors flex items-center gap-1.5 text-sm flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<ArrowLeft className="w-4 h-4" />
<span className="hidden sm:inline">{t('common.back')}</span>
</button>
)}
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
style={{ color: 'var(--text-primary)' }}>
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<span className="font-bold text-sm hidden sm:inline">{t('nav.trip')}</span>
</Link>
{tripTitle && (
<>
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
<span className="text-sm font-medium truncate max-w-48" style={{ color: 'var(--text-muted)' }}>
{tripTitle}
</span>
</>
)}
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Share button */}
{onShare && (
<button onClick={onShare}
className="flex items-center gap-1.5 py-1.5 px-3 rounded-lg border transition-colors text-sm font-medium flex-shrink-0"
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-secondary)', background: 'var(--bg-card)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}>
<Users className="w-4 h-4" />
<span className="hidden sm:inline">{t('nav.share')}</span>
</button>
)}
{/* Dark mode toggle */}
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
className="p-2 rounded-lg transition-colors flex-shrink-0"
style={{ color: 'var(--text-muted)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{dark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button>
{/* User menu */}
{user && (
<div className="relative">
<button onClick={() => setUserMenuOpen(!userMenuOpen)}
className="flex items-center gap-2 py-1.5 px-3 rounded-lg transition-colors"
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
) : (
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold"
style={{ background: dark ? '#e2e8f0' : '#111827', color: dark ? '#0f172a' : '#ffffff' }}>
{user.username?.charAt(0).toUpperCase()}
</div>
)}
<span className="text-sm hidden sm:inline max-w-24 truncate" style={{ color: 'var(--text-secondary)' }}>
{user.username}
</span>
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
</button>
{userMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
{user.role === 'admin' && (
<span className="inline-flex items-center gap-1 text-xs font-medium mt-1" style={{ color: 'var(--text-secondary)' }}>
<Shield className="w-3 h-3" /> {t('nav.administrator')}
</span>
)}
</div>
<div className="py-1">
<Link to="/settings" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Settings className="w-4 h-4" />
{t('nav.settings')}
</Link>
{user.role === 'admin' && (
<Link to="/admin" onClick={() => setUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm transition-colors"
style={{ color: 'var(--text-secondary)' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
<Shield className="w-4 h-4" />
{t('nav.admin')}
</Link>
)}
</div>
<div className="py-1 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
<button onClick={handleLogout}
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-red-500 hover:bg-red-500/10 transition-colors">
<LogOut className="w-4 h-4" />
{t('nav.logout')}
</button>
</div>
</div>
</>
)}
</div>
)}
</nav>
)
}

View File

@@ -0,0 +1,258 @@
import React, { useEffect, useRef, useState } from 'react'
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
import L from 'leaflet'
import { mapsApi } from '../../api/client'
import { getCategoryIcon } from '../shared/categoryIcons'
// Fix default marker icons for vite
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
})
/**
* Create a round photo-circle marker.
* Shows image_url if available, otherwise category icon in colored circle.
*/
function createPlaceIcon(place, orderNumber, isSelected) {
const size = isSelected ? 44 : 36
const borderColor = isSelected ? '#111827' : 'white'
const borderWidth = isSelected ? 3 : 2.5
const shadow = isSelected
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
: '0 2px 8px rgba(0,0,0,0.22)'
const bgColor = place.category_color || '#6b7280'
const icon = place.category_icon || '📍'
// White semi-transparent number badge (bottom-right), only when orderNumber is set
const badgeHtml = orderNumber != null ? `
<span style="
position:absolute;bottom:-3px;right:-3px;
min-width:18px;height:18px;border-radius:9px;
padding:0 3px;
background:rgba(255,255,255,0.92);
border:1.5px solid rgba(0,0,0,0.18);
box-shadow:0 1px 4px rgba(0,0,0,0.18);
display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:800;color:#111827;
font-family:-apple-system,system-ui,sans-serif;line-height:1;
box-sizing:border-box;
">${orderNumber}</span>` : ''
if (place.image_url) {
return L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
overflow:visible;background:${bgColor};
cursor:pointer;flex-shrink:0;position:relative;
">
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
</div>
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
}
return L.divIcon({
className: '',
html: `<div style="
width:${size}px;height:${size}px;border-radius:50%;
border:${borderWidth}px solid ${borderColor};
box-shadow:${shadow};
background:${bgColor};
display:flex;align-items:center;justify-content:center;
cursor:pointer;position:relative;
">
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
${badgeHtml}
</div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
tooltipAnchor: [size / 2 + 6, 0],
})
}
// Pan/zoom to selected place
function SelectionController({ places, selectedPlaceId }) {
const map = useMap()
const prev = useRef(null)
useEffect(() => {
if (selectedPlaceId && selectedPlaceId !== prev.current) {
const place = places.find(p => p.id === selectedPlaceId)
if (place?.lat && place?.lng) {
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
}
}
prev.current = selectedPlaceId
}, [selectedPlaceId, places, map])
return null
}
// Recenter map when default center changes
function MapController({ center, zoom }) {
const map = useMap()
const prevCenter = useRef(center)
useEffect(() => {
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
map.setView(center, zoom)
prevCenter.current = center
}
}, [center, zoom, map])
return null
}
// Fit bounds when places change (fitKey triggers re-fit)
function BoundsController({ places, fitKey }) {
const map = useMap()
const prevFitKey = useRef(-1)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
if (places.length === 0) return
try {
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
} catch {}
}, [fitKey, places, map])
return null
}
function MapClickHandler({ onClick }) {
const map = useMap()
useEffect(() => {
if (!onClick) return
map.on('click', onClick)
return () => map.off('click', onClick)
}, [map, onClick])
return null
}
// Module-level photo cache shared with PlaceAvatar
const mapPhotoCache = new Map()
export function MapView({
places = [],
route = null,
selectedPlaceId = null,
onMarkerClick,
onMapClick,
center = [48.8566, 2.3522],
zoom = 10,
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
fitKey = 0,
dayOrderMap = {},
}) {
const [photoUrls, setPhotoUrls] = useState({})
// Fetch Google photos for places that have google_place_id but no image_url
useEffect(() => {
places.forEach(place => {
if (place.image_url || !place.google_place_id) return
if (mapPhotoCache.has(place.google_place_id)) {
const cached = mapPhotoCache.get(place.google_place_id)
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
return
}
mapsApi.placePhoto(place.google_place_id)
.then(data => {
if (data.photoUrl) {
mapPhotoCache.set(place.google_place_id, data.photoUrl)
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
}
})
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
})
}, [places])
return (
<MapContainer
center={center}
zoom={zoom}
zoomControl={false}
className="w-full h-full"
style={{ background: '#e5e7eb' }}
>
<TileLayer
url={tileUrl}
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
maxZoom={19}
/>
<MapController center={center} zoom={zoom} />
<BoundsController places={places} fitKey={fitKey} />
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
<MapClickHandler onClick={onMapClick} />
{places.map((place) => {
const isSelected = place.id === selectedPlaceId
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
const orderNumber = dayOrderMap[place.id] ?? null
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
return (
<Marker
key={place.id}
position={[place.lat, place.lng]}
icon={icon}
eventHandlers={{
click: () => onMarkerClick && onMarkerClick(place.id),
}}
zIndexOffset={isSelected ? 1000 : 0}
>
<Tooltip
direction="right"
offset={[0, 0]}
opacity={1}
className="map-tooltip"
>
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
{place.name}
</div>
{place.category_name && (() => {
const CatIcon = getCategoryIcon(place.category_icon)
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
</div>
)
})()}
{place.address && (
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{place.address}
</div>
)}
</div>
</Tooltip>
</Marker>
)
})}
{route && route.length > 1 && (
<Polyline
positions={route}
color="#111827"
weight={3}
opacity={0.9}
dashArray="6, 5"
/>
)}
</MapContainer>
)
}

View File

@@ -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.`
}

View File

@@ -0,0 +1,395 @@
// Trip PDF via browser print window
import { createElement } from 'react'
import { getCategoryIcon } from '../shared/categoryIcons'
import { mapsApi } from '../../api/client'
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
const svgClock = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#374151" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgClock2= `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#d97706" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 3"/></svg>`
const svgCheck = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5L19 7"/></svg>`
const svgEuro = `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round"><path d="M14 5c-3.87 0-7 3.13-7 7s3.13 7 7 7c2.17 0 4.1-.99 5.4-2.55"/><path d="M5 11h8M5 13h8"/></svg>`
function escHtml(str) {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function absUrl(url) {
if (!url) return null
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function safeImg(url) {
if (!url) return null
if (url.startsWith('https://') || url.startsWith('http://')) return url
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
}
// Generate SVG string from Lucide icon name (for category thumbnails)
let _renderToStaticMarkup = null
async function ensureRenderer() {
if (!_renderToStaticMarkup) {
const mod = await import('react-dom/server')
_renderToStaticMarkup = mod.renderToStaticMarkup
}
}
function categoryIconSvg(iconName, color = '#6366f1', size = 24) {
if (!_renderToStaticMarkup) return ''
const Icon = getCategoryIcon(iconName)
return _renderToStaticMarkup(
createElement(Icon, { size, strokeWidth: 1.8, color: 'rgba(255,255,255,0.92)' })
)
}
function shortDate(d, locale) {
if (!d) return ''
return new Date(d + 'T00:00:00').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
}
function longDateRange(days, locale) {
const dd = [...days].filter(d => d.date).sort((a, b) => a.day_number - b.day_number)
if (!dd.length) return null
const f = new Date(dd[0].date + 'T00:00:00')
const l = new Date(dd[dd.length - 1].date + 'T00:00:00')
return `${f.toLocaleDateString(locale, { day: 'numeric', month: 'long' })} ${l.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })}`
}
function dayCost(assignments, dayId, locale) {
const total = (assignments[String(dayId)] || []).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
return total > 0 ? `${total.toLocaleString(locale)} EUR` : null
}
// Pre-fetch Google Place photos for all assigned places
async function fetchPlacePhotos(assignments) {
const photoMap = {} // placeId → photoUrl
const allPlaces = Object.values(assignments).flatMap(a => a.map(x => x.place)).filter(Boolean)
const unique = [...new Map(allPlaces.map(p => [p.id, p])).values()]
const toFetch = unique.filter(p => !p.image_url && p.google_place_id)
await Promise.allSettled(
toFetch.map(async (place) => {
try {
const data = await mapsApi.placePhoto(place.google_place_id)
if (data.photoUrl) photoMap[place.id] = data.photoUrl
} catch {}
})
)
return photoMap
}
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
await ensureRenderer()
const loc = _locale || 'de-DE'
const tr = _t || (k => k)
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
const range = longDateRange(sorted, loc)
const coverImg = safeImg(trip?.cover_image)
// Pre-fetch place photos from Google
const photoMap = await fetchPlacePhotos(assignments)
const totalAssigned = new Set(
Object.values(assignments || {}).flatMap(a => a.map(x => x.place?.id)).filter(Boolean)
).size
const totalCost = Object.values(assignments || {})
.flatMap(a => a).reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
// Build day HTML
const daysHtml = sorted.map((day, di) => {
const assigned = assignments[String(day.id)] || []
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
const cost = dayCost(assignments, day.id, loc)
const merged = []
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
merged.sort((a, b) => a.k - b.k)
let pi = 0
const itemsHtml = merged.length === 0
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
: merged.map(item => {
if (item.type === 'note') {
const note = item.data
return `
<div class="note-card">
<div class="note-line"></div>
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
<rect x="4" y="3" width="16" height="18" rx="2"/>
<line x1="8" y1="8" x2="16" y2="8"/>
<line x1="8" y1="12" x2="16" y2="12"/>
<line x1="8" y1="16" x2="13" y2="16"/>
</svg>
<div class="note-body">
<div class="note-text">${escHtml(note.text)}</div>
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
</div>
</div>`
}
pi++
const place = item.data.place
if (!place) return ''
const cat = categories.find(c => c.id === place.category_id)
const color = cat?.color || '#6366f1'
// Image: direct > google photo > fallback icon
const directImg = safeImg(place.image_url)
const googleImg = photoMap[place.id] || null
const img = directImg || googleImg
const confirmed = place.reservation_status === 'confirmed'
const pending = place.reservation_status === 'pending'
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
const thumbHtml = img
? `<img class="place-thumb" src="${escHtml(img)}" />`
: `<div class="place-thumb-fallback" style="background:${color}">
${iconSvg}
</div>`
const chips = [
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString('de-DE')} EUR</span>` : '',
confirmed ? `<span class="chip chip-green">${svgCheck}${escHtml(tr('reservations.confirmed'))}</span>` : '',
pending ? `<span class="chip chip-amber">${svgClock2}${escHtml(tr('reservations.pending'))}</span>` : '',
].filter(Boolean).join('')
return `
<div class="place-card">
<div class="place-bar" style="background:${color}"></div>
${thumbHtml}
<div class="place-info">
<div class="place-name-row">
<span class="place-num">${pi}</span>
<span class="place-name">${escHtml(place.name)}</span>
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
</div>
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
${chips ? `<div class="chips">${chips}</div>` : ''}
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
</div>
</div>`
}).join('')
return `
<div class="day-section${di > 0 ? ' page-break' : ''}">
<div class="day-header">
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
${cost ? `<span class="day-cost">${cost}</span>` : ''}
</div>
<div class="day-body">${itemsHtml}</div>
</div>`
}).join('')
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<base href="${window.location.origin}/">
<title>${escHtml(trip?.name || tr('pdf.travelPlan'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
/* ── Cover ─────────────────────────────────────── */
.cover {
width: 100%; min-height: 100vh;
background: #0f172a;
display: flex; flex-direction: column; justify-content: flex-end;
padding: 52px; position: relative; overflow: hidden;
}
.cover-bg {
position: absolute; inset: 0;
background-size: cover; background-position: center;
opacity: 0.28;
}
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
.cover-brand {
position: absolute; top: 36px; right: 52px;
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
color: rgba(255,255,255,0.3); text-transform: uppercase;
}
.cover-body { position: relative; z-index: 1; }
.cover-circle {
width: 100px; height: 100px; border-radius: 50%;
overflow: hidden; border: 2.5px solid rgba(255,255,255,0.25);
margin-bottom: 26px; flex-shrink: 0;
}
.cover-circle img { width: 100%; height: 100%; object-fit: cover; }
.cover-circle-ph {
width: 100px; height: 100px; border-radius: 50%;
background: rgba(255,255,255,0.07);
margin-bottom: 26px;
}
.cover-label { font-size: 9px; font-weight: 600; letter-spacing: 2.5px; color: rgba(255,255,255,0.4); text-transform: uppercase; margin-bottom: 8px; }
.cover-title { font-size: 42px; font-weight: 700; color: #fff; line-height: 1.1; margin-bottom: 8px; }
.cover-desc { font-size: 13px; color: rgba(255,255,255,0.55); line-height: 1.6; margin-bottom: 18px; max-width: 420px; }
.cover-dates { font-size: 12px; color: rgba(255,255,255,0.45); margin-bottom: 30px; }
.cover-line { height: 1px; background: rgba(255,255,255,0.1); margin-bottom: 24px; }
.cover-stats { display: flex; gap: 36px; }
.cover-stat-num { font-size: 28px; font-weight: 700; color: #fff; line-height: 1; }
.cover-stat-lbl { font-size: 9px; font-weight: 500; color: rgba(255,255,255,0.4); letter-spacing: 1px; margin-top: 4px; text-transform: uppercase; }
/* ── Day ───────────────────────────────────────── */
.page-break { page-break-before: always; }
.day-header {
background: #0f172a; padding: 11px 28px;
display: flex; align-items: center; gap: 8px;
}
.day-tag { font-size: 8px; font-weight: 700; color: #fff; letter-spacing: 0.8px; background: rgba(255,255,255,0.12); border-radius: 4px; padding: 3px 8px; flex-shrink: 0; }
.day-title { font-size: 13px; font-weight: 600; color: #fff; flex: 1; }
.day-date { font-size: 9px; color: rgba(255,255,255,0.45); }
.day-cost { font-size: 9px; font-weight: 600; color: rgba(255,255,255,0.65); }
.day-body { padding: 12px 28px 6px; }
/* ── Place card ────────────────────────────────── */
.place-card {
display: flex; align-items: stretch;
border: 1px solid #e2e8f0; border-radius: 8px;
margin-bottom: 8px; overflow: hidden;
background: #fff; page-break-inside: avoid;
}
.place-bar { width: 4px; flex-shrink: 0; }
.place-thumb {
width: 52px; height: 52px; object-fit: cover;
margin: 8px; border-radius: 6px; flex-shrink: 0;
}
.place-thumb-fallback {
width: 52px; height: 52px; margin: 8px; border-radius: 8px;
flex-shrink: 0; display: flex; align-items: center; justify-content: center;
}
.place-thumb-fallback svg { width: 24px; height: 24px; }
.place-info { flex: 1; padding: 9px 10px 8px 0; min-width: 0; }
.place-name-row { display: flex; align-items: center; gap: 5px; margin-bottom: 4px; }
.place-num {
width: 16px; height: 16px; border-radius: 50%;
background: #1e293b; color: #fff; font-size: 8px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.place-name { font-size: 11.5px; font-weight: 600; color: #1e293b; flex: 1; }
.cat-badge { font-size: 7.5px; font-weight: 600; color: #fff; border-radius: 99px; padding: 2px 7px; flex-shrink: 0; white-space: nowrap; }
.info-row { display: flex; align-items: flex-start; gap: 4px; margin-bottom: 2px; padding-left: 21px; }
.info-row svg { flex-shrink: 0; margin-top: 1px; }
.info-spacer { width: 13px; flex-shrink: 0; }
.info-text { font-size: 9px; color: #64748b; line-height: 1.5; }
.info-text.muted { color: #94a3b8; }
.info-text.italic { font-style: italic; }
.chips { display: flex; flex-wrap: wrap; gap: 4px; padding-left: 21px; margin-top: 4px; }
.chip { display: inline-flex; align-items: center; gap: 3px; font-size: 8px; font-weight: 600; background: #f1f5f9; color: #374151; border-radius: 99px; padding: 2px 7px; white-space: nowrap; }
.chip svg { flex-shrink: 0; }
.chip-green { background: #ecfdf5; color: #059669; }
.chip-amber { background: #fffbeb; color: #d97706; }
/* ── Note card ─────────────────────────────────── */
.note-card {
display: flex; align-items: center; gap: 8px;
background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 6px;
padding: 8px 10px; margin-bottom: 7px; page-break-inside: avoid;
}
.note-line { width: 3px; border-radius: 99px; background: #94a3b8; align-self: stretch; flex-shrink: 0; }
.note-icon { flex-shrink: 0; }
.note-body { flex: 1; min-width: 0; }
.note-text { font-size: 9.5px; color: #334155; line-height: 1.55; }
.note-time { font-size: 8px; color: #94a3b8; margin-top: 2px; }
.empty-day { font-size: 9.5px; color: #cbd5e1; font-style: italic; text-align: center; padding: 14px 0; }
/* ── Print ─────────────────────────────────────── */
@media print {
body { margin: 0; }
.cover { min-height: 100vh; page-break-after: always; }
@page { margin: 0; }
}
</style>
</head>
<body>
<!-- Cover -->
<div class="cover">
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-brand">NOMAD</div>
<div class="cover-body">
${coverImg
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
: `<div class="cover-circle-ph"></div>`}
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</div>
<div class="cover-title">${escHtml(trip?.name || 'Meine Reise')}</div>
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
${range ? `<div class="cover-dates">${range}</div>` : ''}
<div class="cover-line"></div>
<div class="cover-stats">
<div>
<div class="cover-stat-num">${sorted.length}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.days'))}</div>
</div>
<div>
<div class="cover-stat-num">${places?.length || 0}</div>
<div class="cover-stat-lbl">${escHtml(tr('dashboard.places'))}</div>
</div>
<div>
<div class="cover-stat-num">${totalAssigned}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
</div>
${totalCost > 0 ? `<div>
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
</div>` : ''}
</div>
</div>
</div>
<!-- Days -->
${daysHtml}
</body></html>`
// Open print window
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
// Modal in die App einfügen
const overlay = document.createElement('div')
overlay.id = 'pdf-preview-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); URL.revokeObjectURL(url) } }
const card = document.createElement('div')
card.style.cssText = 'width:100%;max-width:1000px;height:95vh;background:var(--bg-card);border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.3);'
const header = document.createElement('div')
header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 16px;border-bottom:1px solid var(--border-primary);flex-shrink:0;'
header.innerHTML = `
<span style="font-size:13px;font-weight:600;color:var(--text-primary)">${escHtml(trip?.name || tr('pdf.travelPlan'))}</span>
<div style="display:flex;align-items:center;gap:8px">
<button id="pdf-print-btn" style="display:flex;align-items:center;gap:5px;font-size:12px;font-weight:500;color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px 8px;border-radius:6px;font-family:inherit">${tr('pdf.saveAsPdf')}</button>
<button id="pdf-close-btn" style="background:none;border:none;cursor:pointer;color:var(--text-faint);display:flex;padding:4px;border-radius:6px">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
iframe.src = url
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector('#pdf-close-btn').onclick = () => { overlay.remove(); URL.revokeObjectURL(url) }
header.querySelector('#pdf-print-btn').onclick = () => { iframe.contentWindow?.print() }
}

View File

@@ -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>
)
}

View File

@@ -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' })
}

View File

@@ -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`
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,177 @@
import React, { useState } from 'react'
import { useDroppable } from '@dnd-kit/core'
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
import AssignedPlaceItem from './AssignedPlaceItem'
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
export default function DayColumn({
day,
assignments,
tripId,
onRemoveAssignment,
onEditPlace,
onQuickAdd,
}) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [showNotes, setShowNotes] = useState(false)
const [notes, setNotes] = useState(day.notes || '')
const [notesEditing, setNotesEditing] = useState(false)
const { isOver, setNodeRef } = useDroppable({
id: `day-${day.id}`,
data: {
type: 'day',
dayId: day.id,
},
})
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
const totalCost = (assignments || []).reduce((sum, a) => {
return sum + (a.place?.price ? Number(a.place.price) : 0)
}, 0)
const formatDate = (dateStr) => {
if (!dateStr) return null
const d = new Date(dateStr + 'T00:00:00')
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
}
return (
<div
className={`
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
${isOver
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
: 'border-transparent bg-white shadow-sm'
}
`}
>
{/* Header */}
<div
className={`
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
{assignments?.length || 0}
</span>
</div>
{day.date && (
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
)}
</div>
<div className="flex items-center gap-1">
{totalCost > 0 && (
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
<DollarSign className="w-3 h-3" />
{totalCost.toLocaleString()}
</span>
)}
<button
onClick={() => setShowNotes(!showNotes)}
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
title="Notes"
>
<FileText className="w-4 h-4" />
</button>
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
>
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
</button>
</div>
</div>
{/* Notes area */}
{showNotes && (
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
onBlur={() => setNotesEditing(false)}
onFocus={() => setNotesEditing(true)}
placeholder="Add notes for this day..."
rows={2}
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
/>
{notesEditing && (
<div className="flex gap-2 mt-1">
<button
onMouseDown={(e) => {
e.preventDefault()
// Parent will handle save via onUpdateNotes if passed
}}
className="text-xs text-slate-600 hover:text-slate-900"
>
Save
</button>
</div>
)}
</div>
)}
{/* Assignments list */}
{!isCollapsed && (
<div
ref={setNodeRef}
className={`
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
${isOver ? 'bg-slate-50' : 'bg-transparent'}
`}
>
{assignments && assignments.length > 0 ? (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{assignments.map(assignment => (
<AssignedPlaceItem
key={assignment.id}
assignment={assignment}
dayId={day.id}
onRemove={(id) => onRemoveAssignment(day.id, id)}
onEdit={onEditPlace}
/>
))}
</SortableContext>
) : (
<div className={`
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
text-xs text-center transition-colors
${isOver
? 'border-slate-400 bg-slate-100 text-slate-500'
: 'border-slate-200 text-slate-400'
}
`}>
<Package className="w-8 h-8 mb-2 opacity-50" />
<p className="font-medium">Drop places here</p>
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
</div>
)}
{/* Quick add button */}
<button
onClick={() => onQuickAdd(day)}
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
>
<Plus className="w-3.5 h-3.5" />
Add place
</button>
</div>
)}
{isCollapsed && (
<div
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
onClick={() => setIsCollapsed(false)}
>
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} click to expand
</div>
)}
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,136 @@
import React from 'react'
import { CalendarDays, MapPin, Plus } from 'lucide-react'
import WeatherWidget from '../Weather/WeatherWidget'
function formatDate(dateStr) {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
function dayTotal(dayId, assignments) {
const dayAssignments = assignments[String(dayId)] || []
return dayAssignments.reduce((sum, a) => {
const cost = parseFloat(a.place?.cost) || 0
return sum + cost
}, 0)
}
export function DaysList({ days, selectedDayId, onSelectDay, assignments, trip }) {
const totalCost = days.reduce((sum, d) => sum + dayTotal(d.id, assignments), 0)
const currency = trip?.currency || 'EUR'
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-100 flex-shrink-0">
<h2 className="text-sm font-semibold text-gray-700">Tagesplan</h2>
<p className="text-xs text-gray-400 mt-0.5">{days.length} Tage</p>
</div>
{/* All places overview option */}
<button
onClick={() => onSelectDay(null)}
className={`w-full text-left px-4 py-3 border-b border-gray-100 transition-colors flex items-center gap-2 flex-shrink-0 ${
selectedDayId === null
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<MapPin className={`w-4 h-4 flex-shrink-0 ${selectedDayId === null ? 'text-slate-900' : 'text-gray-400'}`} />
<div>
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
Alle Orte
</p>
<p className="text-xs text-gray-400">Gesamtübersicht</p>
</div>
</button>
{/* Day list */}
<div className="flex-1 overflow-y-auto">
{days.length === 0 ? (
<div className="px-4 py-6 text-center">
<CalendarDays className="w-8 h-8 text-gray-300 mx-auto mb-2" />
<p className="text-xs text-gray-400">Noch keine Tage</p>
<p className="text-xs text-gray-300 mt-1">Reise bearbeiten um Tage hinzuzufügen</p>
</div>
) : (
days.map((day, index) => {
const isSelected = selectedDayId === day.id
const dayAssignments = assignments[String(day.id)] || []
const cost = dayTotal(day.id, assignments)
const placeCount = dayAssignments.length
return (
<button
key={day.id}
onClick={() => onSelectDay(day.id)}
className={`w-full text-left px-4 py-3 border-b border-gray-50 transition-colors ${
isSelected
? 'bg-slate-50 border-l-2 border-l-slate-900'
: 'hover:bg-gray-50'
}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className={`text-xs font-bold px-1.5 py-0.5 rounded ${
isSelected ? 'bg-slate-900 text-white' : 'bg-gray-200 text-gray-600'
}`}>
{index + 1}
</span>
<span className={`text-sm font-medium truncate ${isSelected ? 'text-slate-900' : 'text-gray-700'}`}>
{day.title || `Tag ${index + 1}`}
</span>
</div>
{day.date && (
<p className="text-xs text-gray-400 mt-1 ml-0.5">
{formatDate(day.date)}
</p>
)}
<div className="flex items-center gap-3 mt-1.5">
{placeCount > 0 && (
<span className="text-xs text-gray-400">
{placeCount} {placeCount === 1 ? 'Ort' : 'Orte'}
</span>
)}
{cost > 0 && (
<span className="text-xs text-emerald-600 font-medium">
{cost.toFixed(0)} {currency}
</span>
)}
</div>
</div>
</div>
{/* Weather for this day */}
{day.date && isSelected && (
<div className="mt-2">
<WeatherWidget date={day.date} compact />
</div>
)}
</button>
)
})
)}
</div>
{/* Budget summary footer */}
{totalCost > 0 && (
<div className="flex-shrink-0 border-t border-gray-100 px-4 py-3 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">Gesamtkosten</span>
<span className="text-sm font-semibold text-gray-800">
{totalCost.toFixed(2)} {currency}
</span>
</div>
</div>
)}
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -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
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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' })
}

View File

@@ -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>
)
}

View File

@@ -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
}
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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'

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,96 @@
import React, { useEffect, useCallback, useRef } from 'react'
import { X } from 'lucide-react'
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
}
export default function Modal({
isOpen,
onClose,
title,
children,
size = 'md',
footer,
hideCloseButton = false,
}) {
const handleEsc = useCallback((e) => {
if (e.key === 'Escape') onClose()
}, [onClose])
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleEsc)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleEsc)
document.body.style.overflow = ''
}
}, [isOpen, handleEsc])
const mouseDownTarget = useRef(null)
if (!isOpen) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center px-4 modal-backdrop"
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)', paddingTop: 70, paddingBottom: 20 }}
onMouseDown={e => { mouseDownTarget.current = e.target }}
onClick={e => {
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) onClose()
mouseDownTarget.current = null
}}
>
<div
className={`
rounded-2xl shadow-2xl w-full ${sizeClasses[size] || sizeClasses.md}
flex flex-col max-h-[90vh]
animate-in fade-in zoom-in-95 duration-200
`}
style={{
animation: 'modalIn 0.2s ease-out forwards',
background: 'var(--bg-card)',
}}
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between p-6" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
<h2 className="text-lg font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
{!hideCloseButton && (
<button
onClick={onClose}
className="p-2 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 transition-colors"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6">
{children}
</div>
{/* Footer */}
{footer && (
<div className="p-6" style={{ borderTop: '1px solid var(--border-secondary)' }}>
{footer}
</div>
)}
</div>
<style>{`
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
`}</style>
</div>
)
}

View File

@@ -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>
)
}

View File

@@ -0,0 +1,95 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
let toastIdCounter = 0
export function ToastContainer() {
const [toasts, setToasts] = useState([])
const addToast = useCallback((message, type = 'info', duration = 3000) => {
const id = ++toastIdCounter
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
if (duration > 0) {
setTimeout(() => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, duration)
}
return id
}, [])
const removeToast = useCallback((id) => {
setToasts(prev => prev.map(t => t.id === id ? { ...t, removing: true } : t))
setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
}, 300)
}, [])
// Make addToast globally accessible
useEffect(() => {
window.__addToast = addToast
return () => { delete window.__addToast }
}, [addToast])
const icons = {
success: <CheckCircle className="w-5 h-5 text-emerald-500 flex-shrink-0" />,
error: <XCircle className="w-5 h-5 text-red-500 flex-shrink-0" />,
warning: <AlertCircle className="w-5 h-5 text-amber-500 flex-shrink-0" />,
info: <Info className="w-5 h-5 text-blue-500 flex-shrink-0" />,
}
const bgColors = {
success: 'bg-white border-l-4 border-emerald-500',
error: 'bg-white border-l-4 border-red-500',
warning: 'bg-white border-l-4 border-amber-500',
info: 'bg-white border-l-4 border-blue-500',
}
return (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map(toast => (
<div
key={toast.id}
className={`
${bgColors[toast.type] || bgColors.info}
${toast.removing ? 'toast-exit' : 'toast-enter'}
flex items-start gap-3 p-4 rounded-lg shadow-lg pointer-events-auto
min-w-0
`}
>
{icons[toast.type] || icons.info}
<p className="text-sm text-slate-700 flex-1 leading-relaxed">{toast.message}</p>
<button
onClick={() => removeToast(toast.id)}
className="text-slate-400 hover:text-slate-600 transition-colors flex-shrink-0"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)
}
export const useToast = () => {
const show = useCallback((message, type, duration) => {
if (window.__addToast) {
window.__addToast(message, type, duration)
}
}, [])
return {
success: (message, duration) => show(message, 'success', duration),
error: (message, duration) => show(message, 'error', duration),
warning: (message, duration) => show(message, 'warning', duration),
info: (message, duration) => show(message, 'info', duration),
}
}
export default useToast

View File

@@ -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
}

View File

@@ -0,0 +1,36 @@
import React, { createContext, useContext, useMemo } from 'react'
import { useSettingsStore } from '../store/settingsStore'
import de from './translations/de'
import en from './translations/en'
const translations = { de, en }
const TranslationContext = createContext({ t: (k) => k, language: 'de', locale: 'de-DE' })
export function TranslationProvider({ children }) {
const language = useSettingsStore(s => s.settings.language) || 'de'
const value = useMemo(() => {
const strings = translations[language] || translations.de
const fallback = translations.de
function t(key, params) {
let val = strings[key] ?? fallback[key] ?? key
// Arrays/Objects direkt zurückgeben (z.B. Vorschläge-Liste)
if (typeof val !== 'string') return val
if (params) {
Object.entries(params).forEach(([k, v]) => {
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), v)
})
}
return val
}
return { t, language, locale: language === 'en' ? 'en-US' : 'de-DE' }
}, [language])
return <TranslationContext.Provider value={value}>{children}</TranslationContext.Provider>
}
export function useTranslation() {
return useContext(TranslationContext)
}

1
client/src/i18n/index.js Normal file
View File

@@ -0,0 +1 @@
export { TranslationProvider, useTranslation } from './TranslationContext'

View File

@@ -0,0 +1,567 @@
const de = {
// Allgemein
'common.save': 'Speichern',
'common.cancel': 'Abbrechen',
'common.delete': 'Löschen',
'common.edit': 'Bearbeiten',
'common.add': 'Hinzufügen',
'common.loading': 'Laden...',
'common.error': 'Fehler',
'common.back': 'Zurück',
'common.all': 'Alle',
'common.close': 'Schließen',
'common.open': 'Öffnen',
'common.upload': 'Hochladen',
'common.search': 'Suchen',
'common.confirm': 'Bestätigen',
'common.ok': 'OK',
'common.yes': 'Ja',
'common.no': 'Nein',
'common.or': 'oder',
'common.none': 'Keine',
'common.rename': 'Umbenennen',
'common.name': 'Name',
'common.email': 'E-Mail',
'common.password': 'Passwort',
'common.saving': 'Speichern...',
'common.update': 'Aktualisieren',
'common.change': 'Ändern',
'common.uploading': 'Hochladen…',
// Navbar
'nav.trip': 'Reise',
'nav.share': 'Teilen',
'nav.settings': 'Einstellungen',
'nav.admin': 'Admin',
'nav.logout': 'Abmelden',
'nav.lightMode': 'Heller Modus',
'nav.darkMode': 'Dunkler Modus',
'nav.administrator': 'Administrator',
// Dashboard
'dashboard.title': 'Meine Reisen',
'dashboard.subtitle.loading': 'Reisen werden geladen...',
'dashboard.subtitle.trips': '{count} Reisen ({archived} archiviert)',
'dashboard.subtitle.empty': 'Starte deine erste Reise',
'dashboard.subtitle.activeOne': '{count} aktive Reise',
'dashboard.subtitle.activeMany': '{count} aktive Reisen',
'dashboard.subtitle.archivedSuffix': ' · {count} archiviert',
'dashboard.newTrip': 'Neue Reise',
'dashboard.emptyTitle': 'Noch keine Reisen',
'dashboard.emptyText': 'Erstelle deine erste Reise und beginne mit der Planung von Orten, Tagesabläufen und Packlisten.',
'dashboard.emptyButton': 'Erste Reise erstellen',
'dashboard.nextTrip': 'Nächste Reise',
'dashboard.shared': 'Geteilt',
'dashboard.sharedBy': 'Geteilt von {name}',
'dashboard.days': 'Tage',
'dashboard.places': 'Orte',
'dashboard.archive': 'Archivieren',
'dashboard.restore': 'Wiederherstellen',
'dashboard.archived': 'Archiviert',
'dashboard.status.ongoing': 'Laufend',
'dashboard.status.today': 'Heute',
'dashboard.status.tomorrow': 'Morgen',
'dashboard.status.past': 'Vergangen',
'dashboard.status.daysLeft': 'Noch {count} Tage',
'dashboard.toast.loadError': 'Fehler beim Laden der Reisen',
'dashboard.toast.created': 'Reise erfolgreich erstellt!',
'dashboard.toast.createError': 'Fehler beim Erstellen',
'dashboard.toast.updated': 'Reise aktualisiert!',
'dashboard.toast.updateError': 'Fehler beim Aktualisieren',
'dashboard.toast.deleted': 'Reise gelöscht',
'dashboard.toast.deleteError': 'Fehler beim Löschen',
'dashboard.toast.archived': 'Reise archiviert',
'dashboard.toast.archiveError': 'Fehler beim Archivieren',
'dashboard.toast.restored': 'Reise wiederhergestellt',
'dashboard.toast.restoreError': 'Fehler beim Wiederherstellen',
'dashboard.confirm.delete': 'Reise "{title}" löschen? Alle Orte und Pläne werden unwiderruflich gelöscht.',
'dashboard.editTrip': 'Reise bearbeiten',
'dashboard.createTrip': 'Neue Reise erstellen',
'dashboard.tripTitle': 'Titel',
'dashboard.tripTitlePlaceholder': 'z.B. Sommer in Japan',
'dashboard.tripDescription': 'Beschreibung',
'dashboard.tripDescriptionPlaceholder': 'Worum geht es bei dieser Reise?',
'dashboard.startDate': 'Startdatum',
'dashboard.endDate': 'Enddatum',
'dashboard.noDateHint': 'Kein Datum gesetzt — es werden 7 Standardtage erstellt. Du kannst das jederzeit ändern.',
'dashboard.coverImage': 'Titelbild',
'dashboard.addCoverImage': 'Titelbild hinzufügen',
'dashboard.coverSaved': 'Titelbild gespeichert',
'dashboard.coverUploadError': 'Fehler beim Hochladen',
'dashboard.coverRemoveError': 'Fehler beim Entfernen',
'dashboard.titleRequired': 'Titel ist erforderlich',
'dashboard.endDateError': 'Enddatum muss nach dem Startdatum liegen',
// Settings
'settings.title': 'Einstellungen',
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
'settings.map': 'Karte',
'settings.mapTemplate': 'Karten-Vorlage',
'settings.mapTemplatePlaceholder.select': 'Vorlage auswählen...',
'settings.mapDefaultHint': 'Leer lassen für OpenStreetMap (Standard)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL-Template für die Kartenkacheln',
'settings.latitude': 'Breitengrad',
'settings.longitude': 'Längengrad',
'settings.saveMap': 'Karte speichern',
'settings.apiKeys': 'API-Schlüssel',
'settings.mapsKey': 'Google Maps API-Schlüssel',
'settings.mapsKeyHint': 'Für Ortsuche. Benötigt Places API (New). Erhalten unter console.cloud.google.com',
'settings.weatherKey': 'OpenWeatherMap API-Schlüssel',
'settings.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org/api',
'settings.keyPlaceholder': 'Schlüssel eingeben...',
'settings.configured': 'Konfiguriert',
'settings.saveKeys': 'Schlüssel speichern',
'settings.display': 'Darstellung',
'settings.colorMode': 'Farbmodus',
'settings.light': 'Hell',
'settings.dark': 'Dunkel',
'settings.language': 'Sprache',
'settings.temperature': 'Temperatureinheit',
'settings.timeFormat': 'Zeitformat',
'settings.account': 'Konto',
'settings.username': 'Benutzername',
'settings.email': 'E-Mail',
'settings.role': 'Rolle',
'settings.roleAdmin': 'Administrator',
'settings.roleUser': 'Benutzer',
'settings.saveProfile': 'Profil speichern',
'settings.toast.mapSaved': 'Karteneinstellungen gespeichert',
'settings.toast.keysSaved': 'API-Schlüssel gespeichert',
'settings.toast.displaySaved': 'Anzeigeeinstellungen gespeichert',
'settings.toast.profileSaved': 'Profil aktualisiert',
'settings.uploadAvatar': 'Profilbild hochladen',
'settings.removeAvatar': 'Profilbild entfernen',
'settings.avatarUploaded': 'Profilbild aktualisiert',
'settings.avatarRemoved': 'Profilbild entfernt',
'settings.avatarError': 'Fehler beim Hochladen',
// Login
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
'login.tagline': 'Deine Reisen.\nDein Plan.',
'login.description': 'Plane Reisen mit interaktiven Karten, Tagesabläufen und smarten Checklisten.',
'login.features.places': 'Orte',
'login.features.schedule': 'Tagesplan',
'login.features.packing': 'Packliste',
'login.title': 'Anmelden',
'login.subtitle': 'Willkommen zurück',
'login.signingIn': 'Anmelden…',
'login.signIn': 'Anmelden',
'login.createAdmin': 'Admin-Konto erstellen',
'login.createAdminHint': 'Erstelle das erste Admin-Konto für NOMAD.',
'login.createAccount': 'Konto erstellen',
'login.createAccountHint': 'Neues Konto registrieren.',
'login.creating': 'Erstelle…',
'login.noAccount': 'Noch kein Konto?',
'login.hasAccount': 'Bereits ein Konto?',
'login.register': 'Registrieren',
'login.username': 'Benutzername',
// Admin
'admin.title': 'Administration',
'admin.subtitle': 'Benutzerverwaltung und Systemeinstellungen',
'admin.tabs.users': 'Benutzer',
'admin.tabs.categories': 'Kategorien',
'admin.tabs.backup': 'Backup',
'admin.stats.users': 'Benutzer',
'admin.stats.trips': 'Reisen',
'admin.stats.places': 'Orte',
'admin.stats.photos': 'Fotos',
'admin.stats.files': 'Dateien',
'admin.table.user': 'Benutzer',
'admin.table.email': 'E-Mail',
'admin.table.role': 'Rolle',
'admin.table.created': 'Erstellt',
'admin.table.actions': 'Aktionen',
'admin.you': '(Du)',
'admin.editUser': 'Benutzer bearbeiten',
'admin.newPassword': 'Neues Passwort',
'admin.newPasswordHint': 'Leer lassen, um das Passwort nicht zu ändern',
'admin.deleteUser': 'Benutzer "{name}" löschen? Alle Reisen werden unwiderruflich gelöscht.',
'admin.deleteUserTitle': 'Benutzer löschen',
'admin.newPasswordPlaceholder': 'Neues Passwort eingeben…',
'admin.toast.loadError': 'Fehler beim Laden der Admin-Daten',
'admin.toast.userUpdated': 'Benutzer aktualisiert',
'admin.toast.updateError': 'Fehler beim Aktualisieren',
'admin.toast.userDeleted': 'Benutzer gelöscht',
'admin.toast.deleteError': 'Fehler beim Löschen',
'admin.toast.cannotDeleteSelf': 'Eigenes Konto kann nicht gelöscht werden',
'admin.tabs.settings': 'Einstellungen',
'admin.allowRegistration': 'Registrierung erlauben',
'admin.allowRegistrationHint': 'Neue Benutzer können sich selbst registrieren',
'admin.apiKeys': 'API-Schlüssel',
'admin.mapsKey': 'Google Maps API Key',
'admin.mapsKeyHint': 'Für Ortsuche benötigt. Erstellen unter console.cloud.google.com',
'admin.weatherKey': 'OpenWeatherMap API Key',
'admin.weatherKeyHint': 'Für Wetterdaten. Kostenlos unter openweathermap.org',
'admin.validateKey': 'Test',
'admin.keyValid': 'Verbunden',
'admin.keyInvalid': 'Ungültig',
'admin.keySaved': 'API-Schlüssel gespeichert',
// Trip Planner
'trip.tabs.plan': 'Planung',
'trip.tabs.reservations': 'Reservierungen',
'trip.tabs.packing': 'Packliste',
'trip.tabs.packingShort': 'Packen',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Dateien',
'trip.loading': 'Reise wird geladen...',
'trip.mobilePlan': 'Planung',
'trip.mobilePlaces': 'Orte',
'trip.toast.placeUpdated': 'Ort aktualisiert',
'trip.toast.placeAdded': 'Ort hinzugefügt',
'trip.toast.placeDeleted': 'Ort gelöscht',
'trip.toast.selectDay': 'Bitte wähle zuerst einen Tag aus',
'trip.toast.assignedToDay': 'Ort wurde dem Tag zugewiesen',
'trip.toast.reorderError': 'Fehler beim Sortieren',
'trip.toast.reservationUpdated': 'Reservierung aktualisiert',
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
'trip.toast.deleted': 'Gelöscht',
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
// Day Plan Sidebar
'dayplan.transport.car': 'Auto',
'dayplan.transport.walk': 'Zu Fuß',
'dayplan.transport.bike': 'Fahrrad',
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
'dayplan.addNote': 'Notiz hinzufügen',
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
'dayplan.noteEdit': 'Notiz bearbeiten',
'dayplan.noteTitle': 'Notiz',
'dayplan.noteSubtitle': 'Tagesnotiz',
'dayplan.totalCost': 'Gesamtkosten',
'dayplan.days': 'Tage',
'dayplan.dayN': 'Tag {n}',
'dayplan.calculating': 'Berechne...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimieren',
'dayplan.optimized': 'Route optimiert',
'dayplan.routeError': 'Fehler bei der Routenberechnung',
'dayplan.confirmed': 'Bestätigt',
'dayplan.pendingRes': 'Ausstehend',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Tagesplan als PDF exportieren',
'dayplan.pdfError': 'Fehler beim PDF-Export',
// Places Sidebar
'places.addPlace': 'Ort hinzufügen',
'places.all': 'Alle',
'places.unplanned': 'Ungeplant',
'places.search': 'Orte suchen...',
'places.allCategories': 'Alle Kategorien',
'places.count': '{count} Orte',
'places.countSingular': '1 Ort',
'places.allPlanned': 'Alle Orte sind eingeplant',
'places.noneFound': 'Keine Orte gefunden',
'places.editPlace': 'Ort bearbeiten',
'places.formName': 'Name',
'places.formNamePlaceholder': 'z.B. Eiffelturm',
'places.formDescription': 'Beschreibung',
'places.formDescriptionPlaceholder': 'Kurze Beschreibung...',
'places.formAddress': 'Adresse',
'places.formAddressPlaceholder': 'Straße, Stadt, Land',
'places.formLat': 'Breitengrad (z.B. 48.8566)',
'places.formLng': 'Längengrad (z.B. 2.3522)',
'places.formCategory': 'Kategorie',
'places.noCategory': 'Keine Kategorie',
'places.categoryNamePlaceholder': 'Kategoriename',
'places.formTime': 'Uhrzeit',
'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Persönliche Notizen...',
'places.formReservation': 'Reservierung',
'places.reservationNotesPlaceholder': 'Reservierungsnotizen, Bestätigungsnummer...',
'places.mapsSearchPlaceholder': 'Google Maps suchen...',
'places.mapsSearchError': 'Google Maps Suche fehlgeschlagen. Bitte API-Schlüssel in den Einstellungen hinterlegen.',
'places.categoryCreateError': 'Fehler beim Erstellen der Kategorie',
'places.nameRequired': 'Bitte einen Namen eingeben',
'places.saveError': 'Fehler beim Speichern',
'places.transport.walking': '🚶 Zu Fuß',
'places.transport.driving': '🚗 Auto',
'places.transport.cycling': '🚲 Fahrrad',
'places.transport.transit': '🚌 ÖPNV',
// Place Inspector
'inspector.opened': 'Geöffnet',
'inspector.closed': 'Geschlossen',
'inspector.openingHours': 'Öffnungszeiten',
'inspector.showHours': 'Öffnungszeiten anzeigen',
'inspector.files': 'Dateien',
'inspector.filesCount': '{count} Dateien',
'inspector.removeFromDay': 'Vom Tag entfernen',
'inspector.addToDay': 'Zum Tag hinzufügen',
'inspector.confirmedRes': 'Bestätigte Reservierung',
'inspector.pendingRes': 'Ausstehende Reservierung',
'inspector.google': 'In Google Maps öffnen',
'inspector.website': 'Webseite öffnen',
// Reservations
'reservations.title': 'Reservierungen',
'reservations.empty': 'Keine Reservierungen vorhanden',
'reservations.emptyHint': 'Füge Reservierungen für Flüge, Hotels und mehr hinzu',
'reservations.add': 'Reservierung hinzufügen',
'reservations.addManual': 'Manuelle Buchung',
'reservations.placeHint': 'Tipp: Buchungen werden am besten direkt über einen angelegten Ort erstellt, um sie mit dem Tagesplan zu verknüpfen.',
'reservations.confirmed': 'Bestätigt',
'reservations.pending': 'Ausstehend',
'reservations.summary': '{confirmed} bestätigt, {pending} ausstehend',
'reservations.fromPlan': 'Aus Planung',
'reservations.showFiles': 'Dateien anzeigen',
'reservations.editTitle': 'Reservierung bearbeiten',
'reservations.status': 'Status',
'reservations.datetime': 'Datum & Uhrzeit',
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
'reservations.notes': 'Notizen',
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
'reservations.type.flight': 'Flug',
'reservations.type.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Zug',
'reservations.type.car': 'Mietwagen',
'reservations.type.cruise': 'Kreuzfahrt',
'reservations.type.event': 'Veranstaltung',
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Sonstiges',
'reservations.confirm.delete': 'Möchtest du die Reservierung "{name}" wirklich löschen?',
'reservations.toast.updated': 'Reservierung aktualisiert',
'reservations.toast.removed': 'Reservierung gelöscht',
'reservations.toast.saveError': 'Fehler beim Speichern',
'reservations.toast.updateError': 'Fehler beim Aktualisieren',
'reservations.toast.deleteError': 'Fehler beim Löschen',
'reservations.confirm.remove': 'Reservierung für "{name}" entfernen?',
'reservations.toast.fileUploaded': 'Datei hochgeladen',
'reservations.toast.uploadError': 'Fehler beim Hochladen',
'reservations.newTitle': 'Neue Buchung',
'reservations.bookingType': 'Art der Buchung',
'reservations.titleLabel': 'Titel',
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Ort / Adresse',
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode',
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
'reservations.day': 'Tag',
'reservations.noDay': 'Kein Tag',
'reservations.place': 'Ort',
'reservations.noPlace': 'Kein Ort',
'reservations.pendingSave': 'wird gespeichert…',
'reservations.uploading': 'Wird hochgeladen...',
'reservations.attachFile': 'Datei anhängen',
// Budget
'budget.title': 'Budget',
'budget.emptyTitle': 'Noch kein Budget erstellt',
'budget.emptyText': 'Erstelle Kategorien und Einträge, um dein Reisebudget zu planen',
'budget.emptyPlaceholder': 'Kategoriename eingeben...',
'budget.createCategory': 'Kategorie erstellen',
'budget.category': 'Kategorie',
'budget.categoryName': 'Kategoriename',
'budget.table.name': 'Name',
'budget.table.total': 'Gesamt',
'budget.table.persons': 'Personen',
'budget.table.days': 'Tage',
'budget.table.perPerson': 'Pro Person',
'budget.table.perDay': 'Pro Tag',
'budget.table.perPersonDay': 'Pro Person/Tag',
'budget.table.note': 'Notiz',
'budget.newEntry': 'Neuer Eintrag',
'budget.defaultEntry': 'Neuer Eintrag',
'budget.defaultCategory': 'Neue Kategorie',
'budget.total': 'Gesamt',
'budget.totalBudget': 'Gesamtbudget',
'budget.byCategory': 'Nach Kategorie',
'budget.editTooltip': 'Klicken zum Bearbeiten',
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
'budget.deleteCategory': 'Kategorie löschen',
// Files
'files.title': 'Dateien',
'files.count': '{count} Dateien',
'files.countSingular': '1 Datei',
'files.uploaded': '{count} hochgeladen',
'files.uploadError': 'Fehler beim Hochladen',
'files.dropzone': 'Dateien hier ablegen',
'files.dropzoneHint': 'oder klicken zum Auswählen',
'files.uploading': 'Wird hochgeladen...',
'files.filterAll': 'Alle',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Bilder',
'files.filterDocs': 'Dokumente',
'files.empty': 'Keine Dateien vorhanden',
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
'files.openTab': 'In neuem Tab öffnen',
'files.confirm.delete': 'Möchtest du diese Datei wirklich löschen?',
'files.toast.deleted': 'Datei gelöscht',
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung',
// Packing
'packing.title': 'Packliste',
'packing.empty': 'Packliste ist leer',
'packing.progress': '{packed} von {total} gepackt ({percent}%)',
'packing.clearChecked': '{count} abgehakte entfernen',
'packing.suggestions': 'Vorschläge',
'packing.suggestionsTitle': 'Vorschläge hinzufügen',
'packing.allSuggested': 'Alle Vorschläge hinzugefügt',
'packing.allPacked': 'Alles gepackt!',
'packing.addPlaceholder': 'Neuen Gegenstand hinzufügen...',
'packing.categoryPlaceholder': 'Kategorie...',
'packing.filterAll': 'Alle',
'packing.filterOpen': 'Offen',
'packing.filterDone': 'Erledigt',
'packing.emptyTitle': 'Packliste ist leer',
'packing.emptyHint': 'Füge Gegenstände hinzu oder nutze die Vorschläge',
'packing.emptyFiltered': 'Keine Gegenstände in diesem Filter',
'packing.menuRename': 'Umbenennen',
'packing.menuCheckAll': 'Alle abhaken',
'packing.menuUncheckAll': 'Alle Haken entfernen',
'packing.menuDeleteCat': 'Kategorie löschen',
'packing.changeCategory': 'Kategorie ändern',
'packing.confirm.clearChecked': 'Möchtest du {count} abgehakte Gegenstände wirklich entfernen?',
'packing.confirm.deleteCat': 'Möchtest du die Kategorie "{name}" mit {count} Gegenständen wirklich löschen?',
'packing.defaultCategory': 'Sonstiges',
'packing.toast.saveError': 'Fehler beim Speichern',
'packing.toast.deleteError': 'Fehler beim Löschen',
'packing.toast.renameError': 'Fehler beim Umbenennen',
'packing.toast.addError': 'Fehler beim Hinzufügen',
// Packing suggestions
'packing.suggestions.items': [
{ name: 'Reisepass', category: 'Dokumente' },
{ name: 'Personalausweis', category: 'Dokumente' },
{ name: 'Reiseversicherung', category: 'Dokumente' },
{ name: 'Flugtickets', category: 'Dokumente' },
{ name: 'Kreditkarte', category: 'Finanzen' },
{ name: 'Bargeld', category: 'Finanzen' },
{ name: 'Visum', category: 'Dokumente' },
{ name: 'T-Shirts', category: 'Kleidung' },
{ name: 'Hosen', category: 'Kleidung' },
{ name: 'Unterwäsche', category: 'Kleidung' },
{ name: 'Socken', category: 'Kleidung' },
{ name: 'Jacke', category: 'Kleidung' },
{ name: 'Schlafkleidung', category: 'Kleidung' },
{ name: 'Badekleidung', category: 'Kleidung' },
{ name: 'Regenjacke', category: 'Kleidung' },
{ name: 'Bequeme Schuhe', category: 'Kleidung' },
{ name: 'Zahnbürste', category: 'Hygiene' },
{ name: 'Zahnpasta', category: 'Hygiene' },
{ name: 'Shampoo', category: 'Hygiene' },
{ name: 'Deo', category: 'Hygiene' },
{ name: 'Sonnencreme', category: 'Hygiene' },
{ name: 'Rasierer', category: 'Hygiene' },
{ name: 'Ladegerät', category: 'Elektronik' },
{ name: 'Powerbank', category: 'Elektronik' },
{ name: 'Kopfhörer', category: 'Elektronik' },
{ name: 'Reiseadapter', category: 'Elektronik' },
{ name: 'Kamera', category: 'Elektronik' },
{ name: 'Schmerzmittel', category: 'Gesundheit' },
{ name: 'Pflaster', category: 'Gesundheit' },
{ name: 'Desinfektionsmittel', category: 'Gesundheit' },
],
// Members / Sharing
'members.shareTrip': 'Reise teilen',
'members.inviteUser': 'Benutzer einladen',
'members.selectUser': 'Benutzer auswählen…',
'members.invite': 'Einladen',
'members.allHaveAccess': 'Alle Benutzer haben bereits Zugriff.',
'members.access': 'Zugriff',
'members.person': 'Person',
'members.persons': 'Personen',
'members.you': 'du',
'members.owner': 'Eigentümer',
'members.leaveTrip': 'Reise verlassen',
'members.removeAccess': 'Zugriff entfernen',
'members.confirmLeave': 'Reise verlassen? Du verlierst den Zugriff.',
'members.confirmRemove': 'Zugriff für diesen Benutzer entfernen?',
'members.loadError': 'Fehler beim Laden der Mitglieder',
'members.added': 'hinzugefügt',
'members.addError': 'Fehler beim Hinzufügen',
'members.removed': 'Mitglied entfernt',
'members.removeError': 'Fehler beim Entfernen',
// Categories (Admin)
'categories.title': 'Kategorien',
'categories.subtitle': 'Kategorien für Orte verwalten',
'categories.new': 'Neue Kategorie',
'categories.empty': 'Keine Kategorien vorhanden',
'categories.namePlaceholder': 'Kategoriename',
'categories.icon': 'Symbol',
'categories.color': 'Farbe',
'categories.customColor': 'Eigene Farbe wählen',
'categories.preview': 'Vorschau',
'categories.defaultName': 'Kategorie',
'categories.update': 'Aktualisieren',
'categories.create': 'Erstellen',
'categories.confirm.delete': 'Kategorie löschen? Orte dieser Kategorie werden nicht gelöscht.',
'categories.toast.loadError': 'Fehler beim Laden der Kategorien',
'categories.toast.nameRequired': 'Bitte einen Namen eingeben',
'categories.toast.updated': 'Kategorie aktualisiert',
'categories.toast.created': 'Kategorie erstellt',
'categories.toast.saveError': 'Fehler beim Speichern',
'categories.toast.deleted': 'Kategorie gelöscht',
'categories.toast.deleteError': 'Fehler beim Löschen',
// Backup (Admin)
'backup.title': 'Datensicherung',
'backup.subtitle': 'Datenbank und alle hochgeladenen Dateien',
'backup.refresh': 'Aktualisieren',
'backup.upload': 'Backup hochladen',
'backup.uploading': 'Wird hochgeladen…',
'backup.create': 'Backup erstellen',
'backup.creating': 'Erstelle…',
'backup.empty': 'Noch keine Backups vorhanden',
'backup.createFirst': 'Erstes Backup erstellen',
'backup.download': 'Herunterladen',
'backup.restore': 'Wiederherstellen',
'backup.confirm.restore': 'Backup "{name}" wiederherstellen?\n\nAlle aktuellen Daten werden durch den Backup-Stand ersetzt.',
'backup.confirm.uploadRestore': 'Backup-Datei "{name}" hochladen und wiederherstellen?\n\nAlle aktuellen Daten werden überschrieben.',
'backup.confirm.delete': 'Backup "{name}" löschen?',
'backup.toast.loadError': 'Fehler beim Laden der Backups',
'backup.toast.created': 'Backup erfolgreich erstellt',
'backup.toast.createError': 'Fehler beim Erstellen des Backups',
'backup.toast.restored': 'Backup wiederhergestellt. Seite wird neu geladen…',
'backup.toast.restoreError': 'Fehler beim Wiederherstellen',
'backup.toast.uploadError': 'Fehler beim Hochladen',
'backup.toast.deleted': 'Backup gelöscht',
'backup.toast.deleteError': 'Fehler beim Löschen',
'backup.toast.downloadError': 'Download fehlgeschlagen',
'backup.toast.settingsSaved': 'Auto-Backup Einstellungen gespeichert',
'backup.toast.settingsError': 'Fehler beim Speichern der Einstellungen',
'backup.auto.title': 'Auto-Backup',
'backup.auto.subtitle': 'Automatische Sicherung nach Zeitplan',
'backup.auto.enable': 'Auto-Backup aktivieren',
'backup.auto.enableHint': 'Backups werden automatisch nach dem gewählten Zeitplan erstellt',
'backup.auto.interval': 'Intervall',
'backup.auto.keepLabel': 'Alte Backups löschen nach',
'backup.interval.hourly': 'Stündlich',
'backup.interval.daily': 'Täglich',
'backup.interval.weekly': 'Wöchentlich',
'backup.interval.monthly': 'Monatlich',
'backup.keep.1day': '1 Tag',
'backup.keep.3days': '3 Tage',
'backup.keep.7days': '7 Tage',
'backup.keep.14days': '14 Tage',
'backup.keep.30days': '30 Tage',
'backup.keep.forever': 'Immer behalten',
// PDF
'pdf.travelPlan': 'Reiseplan',
'pdf.planned': 'Eingeplant',
'pdf.costLabel': 'Kosten EUR',
'pdf.preview': 'PDF Vorschau',
'pdf.saveAsPdf': 'Als PDF speichern',
// Dashboard Stats
'stats.countries': 'Länder',
'stats.cities': 'Städte',
'stats.trips': 'Reisen',
'stats.places': 'Orte',
'stats.worldProgress': 'Weltfortschritt',
'stats.visited': 'besucht',
'stats.remaining': 'verbleibend',
'stats.visitedCountries': 'Besuchte Länder',
}
export default de

View File

@@ -0,0 +1,567 @@
const en = {
// Common
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.add': 'Add',
'common.loading': 'Loading...',
'common.error': 'Error',
'common.back': 'Back',
'common.all': 'All',
'common.close': 'Close',
'common.open': 'Open',
'common.upload': 'Upload',
'common.search': 'Search',
'common.confirm': 'Confirm',
'common.ok': 'OK',
'common.yes': 'Yes',
'common.no': 'No',
'common.or': 'or',
'common.none': 'None',
'common.rename': 'Rename',
'common.name': 'Name',
'common.email': 'Email',
'common.password': 'Password',
'common.saving': 'Saving...',
'common.update': 'Update',
'common.change': 'Change',
'common.uploading': 'Uploading…',
// Navbar
'nav.trip': 'Trip',
'nav.share': 'Share',
'nav.settings': 'Settings',
'nav.admin': 'Admin',
'nav.logout': 'Log out',
'nav.lightMode': 'Light Mode',
'nav.darkMode': 'Dark Mode',
'nav.administrator': 'Administrator',
// Dashboard
'dashboard.title': 'My Trips',
'dashboard.subtitle.loading': 'Loading trips...',
'dashboard.subtitle.trips': '{count} trips ({archived} archived)',
'dashboard.subtitle.empty': 'Start your first trip',
'dashboard.subtitle.activeOne': '{count} active trip',
'dashboard.subtitle.activeMany': '{count} active trips',
'dashboard.subtitle.archivedSuffix': ' · {count} archived',
'dashboard.newTrip': 'New Trip',
'dashboard.emptyTitle': 'No trips yet',
'dashboard.emptyText': 'Create your first trip and start planning!',
'dashboard.emptyButton': 'Create First Trip',
'dashboard.nextTrip': 'Next Trip',
'dashboard.shared': 'Shared',
'dashboard.sharedBy': 'Shared by {name}',
'dashboard.days': 'Days',
'dashboard.places': 'Places',
'dashboard.archive': 'Archive',
'dashboard.restore': 'Restore',
'dashboard.archived': 'Archived',
'dashboard.status.ongoing': 'Ongoing',
'dashboard.status.today': 'Today',
'dashboard.status.tomorrow': 'Tomorrow',
'dashboard.status.past': 'Past',
'dashboard.status.daysLeft': '{count} days left',
'dashboard.toast.loadError': 'Failed to load trips',
'dashboard.toast.created': 'Trip created successfully!',
'dashboard.toast.createError': 'Failed to create trip',
'dashboard.toast.updated': 'Trip updated!',
'dashboard.toast.updateError': 'Failed to update trip',
'dashboard.toast.deleted': 'Trip deleted',
'dashboard.toast.deleteError': 'Failed to delete trip',
'dashboard.toast.archived': 'Trip archived',
'dashboard.toast.archiveError': 'Failed to archive trip',
'dashboard.toast.restored': 'Trip restored',
'dashboard.toast.restoreError': 'Failed to restore trip',
'dashboard.confirm.delete': 'Delete trip "{title}"? All places and plans will be permanently deleted.',
'dashboard.editTrip': 'Edit Trip',
'dashboard.createTrip': 'Create New Trip',
'dashboard.tripTitle': 'Title',
'dashboard.tripTitlePlaceholder': 'e.g. Summer in Japan',
'dashboard.tripDescription': 'Description',
'dashboard.tripDescriptionPlaceholder': 'What is this trip about?',
'dashboard.startDate': 'Start Date',
'dashboard.endDate': 'End Date',
'dashboard.noDateHint': 'No date set — 7 default days will be created. You can change this anytime.',
'dashboard.coverImage': 'Cover Image',
'dashboard.addCoverImage': 'Add cover image',
'dashboard.coverSaved': 'Cover image saved',
'dashboard.coverUploadError': 'Failed to upload',
'dashboard.coverRemoveError': 'Failed to remove',
'dashboard.titleRequired': 'Title is required',
'dashboard.endDateError': 'End date must be after start date',
// Settings
'settings.title': 'Settings',
'settings.subtitle': 'Configure your personal settings',
'settings.map': 'Map',
'settings.mapTemplate': 'Map Template',
'settings.mapTemplatePlaceholder.select': 'Select template...',
'settings.mapDefaultHint': 'Leave empty for OpenStreetMap (default)',
'settings.mapTemplatePlaceholder': 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
'settings.mapHint': 'URL template for map tiles',
'settings.latitude': 'Latitude',
'settings.longitude': 'Longitude',
'settings.saveMap': 'Save Map',
'settings.apiKeys': 'API Keys',
'settings.mapsKey': 'Google Maps API Key',
'settings.mapsKeyHint': 'For place search. Requires Places API (New). Get at console.cloud.google.com',
'settings.weatherKey': 'OpenWeatherMap API Key',
'settings.weatherKeyHint': 'For weather data. Free at openweathermap.org/api',
'settings.keyPlaceholder': 'Enter key...',
'settings.configured': 'Configured',
'settings.saveKeys': 'Save Keys',
'settings.display': 'Display',
'settings.colorMode': 'Color Mode',
'settings.light': 'Light',
'settings.dark': 'Dark',
'settings.language': 'Language',
'settings.temperature': 'Temperature Unit',
'settings.timeFormat': 'Time Format',
'settings.account': 'Account',
'settings.username': 'Username',
'settings.email': 'Email',
'settings.role': 'Role',
'settings.roleAdmin': 'Administrator',
'settings.roleUser': 'User',
'settings.saveProfile': 'Save Profile',
'settings.toast.mapSaved': 'Map settings saved',
'settings.toast.keysSaved': 'API keys saved',
'settings.toast.displaySaved': 'Display settings saved',
'settings.toast.profileSaved': 'Profile saved',
'settings.uploadAvatar': 'Upload Profile Picture',
'settings.removeAvatar': 'Remove Profile Picture',
'settings.avatarUploaded': 'Profile picture updated',
'settings.avatarRemoved': 'Profile picture removed',
'settings.avatarError': 'Upload failed',
// Login
'login.error': 'Login failed. Please check your credentials.',
'login.tagline': 'Your Trips.\nYour Plan.',
'login.description': 'Plan trips with interactive maps, daily schedules and smart checklists.',
'login.features.places': 'Places',
'login.features.schedule': 'Schedule',
'login.features.packing': 'Packing List',
'login.title': 'Sign In',
'login.subtitle': 'Welcome back',
'login.signingIn': 'Signing in…',
'login.signIn': 'Sign In',
'login.createAdmin': 'Create Admin Account',
'login.createAdminHint': 'Set up the first admin account for NOMAD.',
'login.createAccount': 'Create Account',
'login.createAccountHint': 'Register a new account.',
'login.creating': 'Creating…',
'login.noAccount': "Don't have an account?",
'login.hasAccount': 'Already have an account?',
'login.register': 'Register',
'login.username': 'Username',
// Admin
'admin.title': 'Administration',
'admin.subtitle': 'User management and system settings',
'admin.tabs.users': 'Users',
'admin.tabs.categories': 'Categories',
'admin.tabs.backup': 'Backup',
'admin.stats.users': 'Users',
'admin.stats.trips': 'Trips',
'admin.stats.places': 'Places',
'admin.stats.photos': 'Photos',
'admin.stats.files': 'Files',
'admin.table.user': 'User',
'admin.table.email': 'Email',
'admin.table.role': 'Role',
'admin.table.created': 'Created',
'admin.table.actions': 'Actions',
'admin.you': '(You)',
'admin.editUser': 'Edit User',
'admin.newPassword': 'New Password',
'admin.newPasswordHint': 'Leave empty to keep current password',
'admin.deleteUser': 'Delete user "{name}"? All trips will be permanently deleted.',
'admin.deleteUserTitle': 'Delete user',
'admin.newPasswordPlaceholder': 'Enter new password…',
'admin.toast.loadError': 'Failed to load admin data',
'admin.toast.userUpdated': 'User updated',
'admin.toast.updateError': 'Failed to update',
'admin.toast.userDeleted': 'User deleted',
'admin.toast.deleteError': 'Failed to delete',
'admin.toast.cannotDeleteSelf': 'Cannot delete your own account',
'admin.tabs.settings': 'Settings',
'admin.allowRegistration': 'Allow Registration',
'admin.allowRegistrationHint': 'New users can register themselves',
'admin.apiKeys': 'API Keys',
'admin.mapsKey': 'Google Maps API Key',
'admin.mapsKeyHint': 'Required for place search. Get at console.cloud.google.com',
'admin.weatherKey': 'OpenWeatherMap API Key',
'admin.weatherKeyHint': 'For weather data. Free at openweathermap.org',
'admin.validateKey': 'Test',
'admin.keyValid': 'Connected',
'admin.keyInvalid': 'Invalid',
'admin.keySaved': 'API keys saved',
// Trip Planner
'trip.tabs.plan': 'Plan',
'trip.tabs.reservations': 'Bookings',
'trip.tabs.packing': 'Packing List',
'trip.tabs.packingShort': 'Packing',
'trip.tabs.budget': 'Budget',
'trip.tabs.files': 'Files',
'trip.loading': 'Loading trip...',
'trip.mobilePlan': 'Plan',
'trip.mobilePlaces': 'Places',
'trip.toast.placeUpdated': 'Place updated',
'trip.toast.placeAdded': 'Place added',
'trip.toast.placeDeleted': 'Place deleted',
'trip.toast.selectDay': 'Please select a day first',
'trip.toast.assignedToDay': 'Place assigned to day',
'trip.toast.reorderError': 'Failed to reorder',
'trip.toast.reservationUpdated': 'Reservation updated',
'trip.toast.reservationAdded': 'Reservation added',
'trip.toast.deleted': 'Deleted',
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
// Day Plan Sidebar
'dayplan.transport.car': 'Car',
'dayplan.transport.walk': 'Walk',
'dayplan.transport.bike': 'Bike',
'dayplan.emptyDay': 'No places planned for this day',
'dayplan.addNote': 'Add Note',
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
'dayplan.noteEdit': 'Edit Note',
'dayplan.noteTitle': 'Note',
'dayplan.noteSubtitle': 'Daily Note',
'dayplan.totalCost': 'Total Cost',
'dayplan.days': 'Days',
'dayplan.dayN': 'Day {n}',
'dayplan.calculating': 'Calculating...',
'dayplan.route': 'Route',
'dayplan.optimize': 'Optimize',
'dayplan.optimized': 'Route optimized',
'dayplan.routeError': 'Failed to calculate route',
'dayplan.confirmed': 'Confirmed',
'dayplan.pendingRes': 'Pending',
'dayplan.pdf': 'PDF',
'dayplan.pdfTooltip': 'Export day plan as PDF',
'dayplan.pdfError': 'Failed to export PDF',
// Places Sidebar
'places.addPlace': 'Add Place',
'places.all': 'All',
'places.unplanned': 'Unplanned',
'places.search': 'Search places...',
'places.allCategories': 'All Categories',
'places.count': '{count} places',
'places.countSingular': '1 place',
'places.allPlanned': 'All places are planned',
'places.noneFound': 'No places found',
'places.editPlace': 'Edit Place',
'places.formName': 'Name',
'places.formNamePlaceholder': 'e.g. Eiffel Tower',
'places.formDescription': 'Description',
'places.formDescriptionPlaceholder': 'Short description...',
'places.formAddress': 'Address',
'places.formAddressPlaceholder': 'Street, City, Country',
'places.formLat': 'Latitude (e.g. 48.8566)',
'places.formLng': 'Longitude (e.g. 2.3522)',
'places.formCategory': 'Category',
'places.noCategory': 'No Category',
'places.categoryNamePlaceholder': 'Category name',
'places.formTime': 'Time',
'places.formWebsite': 'Website',
'places.formNotesPlaceholder': 'Personal notes...',
'places.formReservation': 'Reservation',
'places.reservationNotesPlaceholder': 'Reservation notes, confirmation number...',
'places.mapsSearchPlaceholder': 'Search Google Maps...',
'places.mapsSearchError': 'Google Maps search failed. Please add an API key in settings.',
'places.categoryCreateError': 'Failed to create category',
'places.nameRequired': 'Please enter a name',
'places.saveError': 'Failed to save',
'places.transport.walking': '🚶 Walking',
'places.transport.driving': '🚗 Driving',
'places.transport.cycling': '🚲 Cycling',
'places.transport.transit': '🚌 Transit',
// Place Inspector
'inspector.opened': 'Open',
'inspector.closed': 'Closed',
'inspector.openingHours': 'Opening Hours',
'inspector.showHours': 'Show opening hours',
'inspector.files': 'Files',
'inspector.filesCount': '{count} files',
'inspector.removeFromDay': 'Remove from Day',
'inspector.addToDay': 'Add to Day',
'inspector.confirmedRes': 'Confirmed Reservation',
'inspector.pendingRes': 'Pending Reservation',
'inspector.google': 'Open in Google Maps',
'inspector.website': 'Open Website',
// Reservations
'reservations.title': 'Bookings',
'reservations.empty': 'No reservations yet',
'reservations.emptyHint': 'Add reservations for flights, hotels and more',
'reservations.add': 'Add Reservation',
'reservations.addManual': 'Manual Booking',
'reservations.placeHint': 'Tip: Reservations are best created directly from a place to link them with your day plan.',
'reservations.confirmed': 'Confirmed',
'reservations.pending': 'Pending',
'reservations.summary': '{confirmed} confirmed, {pending} pending',
'reservations.fromPlan': 'From Plan',
'reservations.showFiles': 'Show Files',
'reservations.editTitle': 'Edit Reservation',
'reservations.status': 'Status',
'reservations.datetime': 'Date & Time',
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
'reservations.notes': 'Notes',
'reservations.notesPlaceholder': 'Additional notes...',
'reservations.type.flight': 'Flight',
'reservations.type.hotel': 'Hotel',
'reservations.type.restaurant': 'Restaurant',
'reservations.type.train': 'Train',
'reservations.type.car': 'Rental Car',
'reservations.type.cruise': 'Cruise',
'reservations.type.event': 'Event',
'reservations.type.tour': 'Tour',
'reservations.type.other': 'Other',
'reservations.confirm.delete': 'Are you sure you want to delete the reservation "{name}"?',
'reservations.toast.updated': 'Reservation updated',
'reservations.toast.removed': 'Reservation deleted',
'reservations.toast.fileUploaded': 'File uploaded',
'reservations.toast.uploadError': 'Failed to upload',
'reservations.newTitle': 'New Reservation',
'reservations.bookingType': 'Booking Type',
'reservations.titleLabel': 'Title',
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
'reservations.locationAddress': 'Location / Address',
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
'reservations.confirmationCode': 'Confirmation Number / Booking Code',
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
'reservations.day': 'Day',
'reservations.noDay': 'No Day',
'reservations.place': 'Place',
'reservations.noPlace': 'No Place',
'reservations.pendingSave': 'will be saved…',
'reservations.uploading': 'Uploading...',
'reservations.attachFile': 'Attach file',
'reservations.toast.saveError': 'Failed to save',
'reservations.toast.updateError': 'Failed to update',
'reservations.toast.deleteError': 'Failed to delete',
'reservations.confirm.remove': 'Remove reservation for "{name}"?',
// Budget
'budget.title': 'Budget',
'budget.emptyTitle': 'No budget created yet',
'budget.emptyText': 'Create categories and entries to plan your travel budget',
'budget.emptyPlaceholder': 'Enter category name...',
'budget.createCategory': 'Create Category',
'budget.category': 'Category',
'budget.categoryName': 'Category Name',
'budget.table.name': 'Name',
'budget.table.total': 'Total',
'budget.table.persons': 'Persons',
'budget.table.days': 'Days',
'budget.table.perPerson': 'Per Person',
'budget.table.perDay': 'Per Day',
'budget.table.perPersonDay': 'Per Person/Day',
'budget.table.note': 'Note',
'budget.newEntry': 'New Entry',
'budget.defaultEntry': 'New Entry',
'budget.defaultCategory': 'New Category',
'budget.total': 'Total',
'budget.totalBudget': 'Total Budget',
'budget.byCategory': 'By Category',
'budget.editTooltip': 'Click to edit',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.deleteCategory': 'Delete Category',
// Files
'files.title': 'Files',
'files.count': '{count} files',
'files.countSingular': '1 file',
'files.uploaded': '{count} uploaded',
'files.uploadError': 'Upload failed',
'files.dropzone': 'Drop files here',
'files.dropzoneHint': 'or click to browse',
'files.uploading': 'Uploading...',
'files.filterAll': 'All',
'files.filterPdf': 'PDFs',
'files.filterImages': 'Images',
'files.filterDocs': 'Documents',
'files.empty': 'No files yet',
'files.emptyHint': 'Upload files to attach them to your trip',
'files.openTab': 'Open in new tab',
'files.confirm.delete': 'Are you sure you want to delete this file?',
'files.toast.deleted': 'File deleted',
'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking',
// Packing
'packing.title': 'Packing List',
'packing.empty': 'Packing list is empty',
'packing.progress': '{packed} of {total} packed ({percent}%)',
'packing.clearChecked': 'Remove {count} checked',
'packing.suggestions': 'Suggestions',
'packing.suggestionsTitle': 'Add Suggestions',
'packing.allSuggested': 'All suggestions added',
'packing.allPacked': 'All packed!',
'packing.addPlaceholder': 'Add new item...',
'packing.categoryPlaceholder': 'Category...',
'packing.filterAll': 'All',
'packing.filterOpen': 'Open',
'packing.filterDone': 'Done',
'packing.emptyTitle': 'Packing list is empty',
'packing.emptyHint': 'Add items or use the suggestions',
'packing.emptyFiltered': 'No items match this filter',
'packing.menuRename': 'Rename',
'packing.menuCheckAll': 'Check All',
'packing.menuUncheckAll': 'Uncheck All',
'packing.menuDeleteCat': 'Delete Category',
'packing.changeCategory': 'Change Category',
'packing.confirm.clearChecked': 'Are you sure you want to remove {count} checked items?',
'packing.confirm.deleteCat': 'Are you sure you want to delete the category "{name}" with {count} items?',
'packing.defaultCategory': 'Other',
'packing.toast.saveError': 'Failed to save',
'packing.toast.deleteError': 'Failed to delete',
'packing.toast.renameError': 'Failed to rename',
'packing.toast.addError': 'Failed to add',
// Packing suggestions
'packing.suggestions.items': [
{ name: 'Passport', category: 'Documents' },
{ name: 'ID Card', category: 'Documents' },
{ name: 'Travel Insurance', category: 'Documents' },
{ name: 'Flight Tickets', category: 'Documents' },
{ name: 'Credit Card', category: 'Finances' },
{ name: 'Cash', category: 'Finances' },
{ name: 'Visa', category: 'Documents' },
{ name: 'T-Shirts', category: 'Clothing' },
{ name: 'Pants', category: 'Clothing' },
{ name: 'Underwear', category: 'Clothing' },
{ name: 'Socks', category: 'Clothing' },
{ name: 'Jacket', category: 'Clothing' },
{ name: 'Sleepwear', category: 'Clothing' },
{ name: 'Swimwear', category: 'Clothing' },
{ name: 'Rain Jacket', category: 'Clothing' },
{ name: 'Comfortable Shoes', category: 'Clothing' },
{ name: 'Toothbrush', category: 'Toiletries' },
{ name: 'Toothpaste', category: 'Toiletries' },
{ name: 'Shampoo', category: 'Toiletries' },
{ name: 'Deodorant', category: 'Toiletries' },
{ name: 'Sunscreen', category: 'Toiletries' },
{ name: 'Razor', category: 'Toiletries' },
{ name: 'Charger', category: 'Electronics' },
{ name: 'Power Bank', category: 'Electronics' },
{ name: 'Headphones', category: 'Electronics' },
{ name: 'Travel Adapter', category: 'Electronics' },
{ name: 'Camera', category: 'Electronics' },
{ name: 'Pain Medication', category: 'Health' },
{ name: 'Band-Aids', category: 'Health' },
{ name: 'Disinfectant', category: 'Health' },
],
// Members / Sharing
'members.shareTrip': 'Share Trip',
'members.inviteUser': 'Invite User',
'members.selectUser': 'Select user…',
'members.invite': 'Invite',
'members.allHaveAccess': 'All users already have access.',
'members.access': 'Access',
'members.person': 'person',
'members.persons': 'persons',
'members.you': 'you',
'members.owner': 'Owner',
'members.leaveTrip': 'Leave trip',
'members.removeAccess': 'Remove access',
'members.confirmLeave': 'Leave trip? You will lose access.',
'members.confirmRemove': 'Remove access for this user?',
'members.loadError': 'Failed to load members',
'members.added': 'added',
'members.addError': 'Failed to add',
'members.removed': 'Member removed',
'members.removeError': 'Failed to remove',
// Categories (Admin)
'categories.title': 'Categories',
'categories.subtitle': 'Manage categories for places',
'categories.new': 'New Category',
'categories.empty': 'No categories yet',
'categories.namePlaceholder': 'Category name',
'categories.icon': 'Icon',
'categories.color': 'Color',
'categories.customColor': 'Choose custom color',
'categories.preview': 'Preview',
'categories.defaultName': 'Category',
'categories.update': 'Update',
'categories.create': 'Create',
'categories.confirm.delete': 'Delete category? Places in this category will not be deleted.',
'categories.toast.loadError': 'Failed to load categories',
'categories.toast.nameRequired': 'Please enter a name',
'categories.toast.updated': 'Category updated',
'categories.toast.created': 'Category created',
'categories.toast.saveError': 'Failed to save',
'categories.toast.deleted': 'Category deleted',
'categories.toast.deleteError': 'Failed to delete',
// Backup (Admin)
'backup.title': 'Data Backup',
'backup.subtitle': 'Database and all uploaded files',
'backup.refresh': 'Refresh',
'backup.upload': 'Upload Backup',
'backup.uploading': 'Uploading…',
'backup.create': 'Create Backup',
'backup.creating': 'Creating…',
'backup.empty': 'No backups yet',
'backup.createFirst': 'Create first backup',
'backup.download': 'Download',
'backup.restore': 'Restore',
'backup.confirm.restore': 'Restore backup "{name}"?\n\nAll current data will be replaced with the backup.',
'backup.confirm.uploadRestore': 'Upload and restore backup file "{name}"?\n\nAll current data will be overwritten.',
'backup.confirm.delete': 'Delete backup "{name}"?',
'backup.toast.loadError': 'Failed to load backups',
'backup.toast.created': 'Backup created successfully',
'backup.toast.createError': 'Failed to create backup',
'backup.toast.restored': 'Backup restored. Page will reload…',
'backup.toast.restoreError': 'Failed to restore',
'backup.toast.uploadError': 'Failed to upload',
'backup.toast.deleted': 'Backup deleted',
'backup.toast.deleteError': 'Failed to delete',
'backup.toast.downloadError': 'Download failed',
'backup.toast.settingsSaved': 'Auto-backup settings saved',
'backup.toast.settingsError': 'Failed to save settings',
'backup.auto.title': 'Auto-Backup',
'backup.auto.subtitle': 'Automatic backup on a schedule',
'backup.auto.enable': 'Enable auto-backup',
'backup.auto.enableHint': 'Backups will be created automatically on the chosen schedule',
'backup.auto.interval': 'Interval',
'backup.auto.keepLabel': 'Delete old backups after',
'backup.interval.hourly': 'Hourly',
'backup.interval.daily': 'Daily',
'backup.interval.weekly': 'Weekly',
'backup.interval.monthly': 'Monthly',
'backup.keep.1day': '1 day',
'backup.keep.3days': '3 days',
'backup.keep.7days': '7 days',
'backup.keep.14days': '14 days',
'backup.keep.30days': '30 days',
'backup.keep.forever': 'Keep forever',
// PDF
'pdf.travelPlan': 'Travel Plan',
'pdf.planned': 'Planned',
'pdf.costLabel': 'Cost EUR',
'pdf.preview': 'PDF Preview',
'pdf.saveAsPdf': 'Save as PDF',
// Dashboard Stats
'stats.countries': 'Countries',
'stats.cities': 'Cities',
'stats.trips': 'Trips',
'stats.places': 'Places',
'stats.worldProgress': 'World Progress',
'stats.visited': 'visited',
'stats.remaining': 'remaining',
'stats.visitedCountries': 'Visited Countries',
}
export default en

273
client/src/index.css Normal file
View File

@@ -0,0 +1,273 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Ort-Zeile Hover: Sortier-Buttons anzeigen */
.place-row:hover .reorder-btns {
opacity: 1 !important;
}
/* Ladeanimation */
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── Design tokens ─────────────────────────────── */
:root {
--font-system: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-6: 24px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.1);
/* Theme colors */
--bg-primary: #ffffff;
--bg-secondary: #f8fafc;
--bg-tertiary: #f1f5f9;
--bg-elevated: rgba(250,250,250,0.82);
--bg-card: #ffffff;
--bg-input: #ffffff;
--bg-hover: rgba(0,0,0,0.03);
--text-primary: #111827;
--text-secondary: #374151;
--text-muted: #6b7280;
--text-faint: #9ca3af;
--border-primary: #e5e7eb;
--border-secondary: #f3f4f6;
--border-faint: rgba(0,0,0,0.06);
--accent: #111827;
--accent-text: #ffffff;
--sidebar-bg: rgba(250,250,250,0.82);
--sidebar-shadow: 0 4px 32px rgba(0,0,0,0.10), 0 0 0 1px rgba(0,0,0,0.06);
--tooltip-bg: rgba(255,255,255,0.96);
--scrollbar-track: #f1f5f9;
--scrollbar-thumb: #d1d5db;
--scrollbar-hover: #9ca3af;
}
.dark {
--bg-primary: #121215;
--bg-secondary: #1a1a1e;
--bg-tertiary: #1c1c21;
--bg-elevated: rgba(19,19,22,0.82);
--bg-card: #131316;
--bg-input: #1c1c21;
--bg-hover: rgba(255,255,255,0.06);
--text-primary: #f4f4f5;
--text-secondary: #d4d4d8;
--text-muted: #a1a1aa;
--text-faint: #71717a;
--border-primary: #27272a;
--border-secondary: #1c1c21;
--border-faint: rgba(255,255,255,0.07);
--accent: #e4e4e7;
--accent-text: #09090b;
--sidebar-bg: rgba(19,19,22,0.82);
--sidebar-shadow: 0 4px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.05);
--tooltip-bg: rgba(19,19,22,0.96);
--scrollbar-track: #131316;
--scrollbar-thumb: #3f3f46;
--scrollbar-hover: #52525b;
}
body {
font-family: var(--font-system);
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.2s, color 0.2s;
}
/* ── Leaflet z-index ───────────────────────────── */
.leaflet-container {
z-index: 0;
}
.leaflet-pane {
z-index: 0 !important;
}
.leaflet-top, .leaflet-bottom {
z-index: 1 !important;
}
/* ── iOS-style map tooltip ─────────────────────── */
.leaflet-tooltip.map-tooltip {
background: var(--tooltip-bg);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: none;
border-radius: var(--radius-md);
box-shadow: var(--shadow-elevated);
padding: 6px 10px;
font-family: var(--font-system);
pointer-events: none;
color: var(--text-primary);
}
.leaflet-tooltip.map-tooltip::before {
border-right-color: var(--tooltip-bg);
}
.leaflet-tooltip-left.map-tooltip::before {
border-left-color: var(--tooltip-bg);
}
/* Scrollbalken */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--scrollbar-track);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar-thumb);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}
/* Einheitliche Formular-Inputs */
.form-input {
width: 100%;
border: 1px solid var(--border-primary);
border-radius: 10px;
padding: 8px 14px;
font-size: 13px;
font-family: inherit;
outline: none;
box-sizing: border-box;
color: var(--text-primary);
background: var(--bg-input);
transition: border-color 0.15s;
}
.form-input:focus {
border-color: var(--text-faint);
}
.form-input::placeholder {
color: var(--text-faint);
}
/* Weiche Übergänge */
.transition-smooth {
transition: all 0.2s ease;
}
/* Tagesliste */
.day-list-item {
transition: background-color 0.15s ease;
}
.day-list-item:hover {
background-color: var(--bg-secondary);
}
.day-list-item.active {
background-color: #4f46e5;
color: white;
}
.scroll-container {
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
}
/* ── Dark-Mode: Tailwind-Klassen ─────────────── */
.dark .bg-slate-50 { background-color: #131316 !important; }
.dark .bg-slate-100 { background-color: #1c1c21 !important; }
.dark .bg-white { background-color: #09090b !important; }
.dark .bg-gray-50 { background-color: #131316 !important; }
.dark .bg-gray-100 { background-color: #1c1c21 !important; }
.dark .text-slate-900, .dark .text-gray-900, .dark .text-slate-800 { color: #f4f4f5 !important; }
.dark .text-slate-700, .dark .text-gray-700 { color: #d4d4d8 !important; }
.dark .text-slate-600, .dark .text-gray-600 { color: #a1a1aa !important; }
.dark .text-slate-500, .dark .text-gray-500 { color: #a1a1aa !important; }
.dark .text-slate-400, .dark .text-gray-400 { color: #71717a !important; }
.dark .border-slate-200, .dark .border-gray-200, .dark .border-slate-300 { border-color: #27272a !important; }
.dark .border-gray-100, .dark .border-slate-100 { border-color: #1c1c21 !important; }
.dark .hover\:bg-slate-50:hover, .dark .hover\:bg-gray-50:hover { background-color: #1c1c21 !important; }
.dark .bg-slate-50\/60 { background-color: transparent !important; }
.dark .divide-slate-100 > :not([hidden]) ~ :not([hidden]) { border-color: #27272a !important; }
.dark .bg-slate-100 { background-color: #27272a !important; }
.dark .bg-slate-900 { background-color: #e4e4e7 !important; color: #09090b !important; }
.dark .hover\:bg-slate-700:hover { background-color: #d4d4d8 !important; color: #09090b !important; }
.dark input, .dark textarea, .dark select {
background-color: #1c1c21;
color: #f4f4f5;
border-color: #27272a;
}
.dark input::placeholder, .dark textarea::placeholder { color: #71717a; }
.dark .bg-emerald-50 { background-color: rgba(16,185,129,0.15) !important; }
.dark .bg-red-50 { background-color: rgba(239,68,68,0.15) !important; }
/* Ladebildschirm */
.dark .min-h-screen { background-color: var(--bg-primary) !important; }
/* Modal-Hintergrund */
.dark .modal-backdrop { background: rgba(0,0,0,0.6); }
/* ── Dark: Fallback für Komponenten die noch nicht auf CSS-Variablen umgestellt sind ── */
.dark {
color-scheme: dark;
}
/* Scroll-Container */
.scroll-container {
scrollbar-width: thin;
scrollbar-color: #d1d5db #f1f5f9;
}
/* Toast-Animationen */
@keyframes slide-in-right {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-out-right {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.toast-enter {
animation: slide-in-right 0.3s ease-out forwards;
}
.toast-exit {
animation: slide-out-right 0.3s ease-in forwards;
}
/* Modal-Hintergrund */
.modal-backdrop {
backdrop-filter: blur(4px);
}
/* Fortschrittsbalken */
@keyframes progress-fill {
from { width: 0; }
to { width: var(--progress); }
}
/* Anmeldeseiten-Gradient */
.auth-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
/* Bildanzeige-Overlay */
.lightbox-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.95);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}

13
client/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,536 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { adminApi, authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import Modal from '../components/shared/Modal'
import { useToast } from '../components/shared/Toast'
import CategoryManager from '../components/Admin/CategoryManager'
import BackupPanel from '../components/Admin/BackupPanel'
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2 } from 'lucide-react'
export default function AdminPage() {
const { t } = useTranslation()
const TABS = [
{ id: 'users', label: t('admin.tabs.users') },
{ id: 'categories', label: t('admin.tabs.categories') },
{ id: 'settings', label: t('admin.tabs.settings') },
{ id: 'backup', label: t('admin.tabs.backup') },
]
const [activeTab, setActiveTab] = useState('users')
const [users, setUsers] = useState([])
const [stats, setStats] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [editingUser, setEditingUser] = useState(null)
const [editForm, setEditForm] = useState({ username: '', email: '', role: 'user', password: '' })
// Registration toggle
const [allowRegistration, setAllowRegistration] = useState(true)
// API Keys
const [mapsKey, setMapsKey] = useState('')
const [weatherKey, setWeatherKey] = useState('')
const [showKeys, setShowKeys] = useState({})
const [savingKeys, setSavingKeys] = useState(false)
const [validating, setValidating] = useState({})
const [validation, setValidation] = useState({})
const { user: currentUser, updateApiKeys } = useAuthStore()
const navigate = useNavigate()
const toast = useToast()
useEffect(() => {
loadData()
loadAppConfig()
loadApiKeys()
}, [])
const loadData = async () => {
setIsLoading(true)
try {
const [usersData, statsData] = await Promise.all([
adminApi.users(),
adminApi.stats(),
])
setUsers(usersData.users)
setStats(statsData)
} catch (err) {
toast.error(t('admin.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const loadAppConfig = async () => {
try {
const config = await authApi.getAppConfig()
setAllowRegistration(config.allow_registration)
} catch (err) {
// ignore
}
}
const loadApiKeys = async () => {
try {
const data = await authApi.me()
setMapsKey(data.user?.maps_api_key || '')
setWeatherKey(data.user?.openweather_api_key || '')
} catch (err) {
// ignore
}
}
const handleToggleRegistration = async (value) => {
setAllowRegistration(value)
try {
await authApi.updateAppSettings({ allow_registration: value })
} catch (err) {
setAllowRegistration(!value)
toast.error(err.response?.data?.error || t('common.error'))
}
}
const toggleKey = (key) => {
setShowKeys(prev => ({ ...prev, [key]: !prev[key] }))
}
const handleSaveApiKeys = async () => {
setSavingKeys(true)
try {
await updateApiKeys({
maps_api_key: mapsKey,
openweather_api_key: weatherKey,
})
toast.success(t('admin.keySaved'))
} catch (err) {
toast.error(err.message)
} finally {
setSavingKeys(false)
}
}
const handleValidateKeys = async () => {
setValidating({ maps: true, weather: true })
try {
const result = await authApi.validateKeys()
setValidation(result)
} catch (err) {
toast.error(t('common.error'))
} finally {
setValidating({})
}
}
const handleValidateKey = async (keyType) => {
setValidating(prev => ({ ...prev, [keyType]: true }))
try {
const result = await authApi.validateKeys()
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
} catch (err) {
toast.error(t('common.error'))
} finally {
setValidating(prev => ({ ...prev, [keyType]: false }))
}
}
const handleEditUser = (user) => {
setEditingUser(user)
setEditForm({ username: user.username, email: user.email, role: user.role, password: '' })
}
const handleSaveUser = async () => {
try {
const payload = {
username: editForm.username.trim() || undefined,
email: editForm.email.trim() || undefined,
role: editForm.role,
}
if (editForm.password.trim()) payload.password = editForm.password.trim()
const data = await adminApi.updateUser(editingUser.id, payload)
setUsers(prev => prev.map(u => u.id === editingUser.id ? data.user : u))
setEditingUser(null)
toast.success(t('admin.toast.userUpdated'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.updateError'))
}
}
const handleDeleteUser = async (user) => {
if (user.id === currentUser?.id) {
toast.error(t('admin.toast.cannotDeleteSelf'))
return
}
if (!confirm(t('admin.deleteUser', { name: user.username }))) return
try {
await adminApi.deleteUser(user.id)
setUsers(prev => prev.filter(u => u.id !== user.id))
toast.success(t('admin.toast.userDeleted'))
} catch (err) {
toast.error(err.response?.data?.error || t('admin.toast.deleteError'))
}
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar />
<div className="pt-14">
<div className="max-w-6xl mx-auto px-4 py-8">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 bg-slate-100 rounded-xl flex items-center justify-center">
<Shield className="w-5 h-5 text-slate-700" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Administration</h1>
<p className="text-slate-500 text-sm">{t('admin.subtitle')}</p>
</div>
</div>
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4 mb-6">
{[
{ label: t('admin.stats.users'), value: stats.totalUsers, icon: Users },
{ label: t('admin.stats.trips'), value: stats.totalTrips, icon: Briefcase },
{ label: t('admin.stats.places'), value: stats.totalPlaces, icon: Map },
{ label: t('admin.stats.photos'), value: stats.totalPhotos || 0, icon: Camera },
{ label: t('admin.stats.files'), value: stats.totalFiles || 0, icon: FileText },
].map(({ label, value, icon: Icon }) => (
<div key={label} className="rounded-xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="flex items-center gap-4">
<Icon className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
<div>
<p className="text-xl font-bold" style={{ color: 'var(--text-primary)' }}>{value}</p>
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{label}</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div className="flex gap-1 mb-6 bg-white border border-slate-200 rounded-xl p-1 w-fit">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'users' && (
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-5 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.tabs.users')} ({users.length})</h2>
</div>
{isLoading ? (
<div className="p-8 text-center">
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto"></div>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="text-left text-xs font-medium text-slate-500 uppercase tracking-wider border-b border-slate-100 bg-slate-50">
<th className="px-5 py-3">{t('admin.table.user')}</th>
<th className="px-5 py-3">{t('admin.table.email')}</th>
<th className="px-5 py-3">{t('admin.table.role')}</th>
<th className="px-5 py-3">{t('admin.table.created')}</th>
<th className="px-5 py-3 text-right">{t('admin.table.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map(u => (
<tr key={u.id} className={`hover:bg-slate-50 transition-colors ${u.id === currentUser?.id ? 'bg-slate-50/60' : ''}`}>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center text-sm font-medium text-slate-700">
{u.username.charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-slate-900">{u.username}</p>
{u.id === currentUser?.id && (
<span className="text-xs text-slate-500">{t('admin.you')}</span>
)}
</div>
</div>
</td>
<td className="px-5 py-3 text-sm text-slate-600">{u.email}</td>
<td className="px-5 py-3">
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-0.5 rounded-full ${
u.role === 'admin'
? 'bg-slate-900 text-white'
: 'bg-slate-100 text-slate-600'
}`}>
{u.role === 'admin' && <Shield className="w-3 h-3" />}
{u.role === 'admin' ? t('settings.roleAdmin') : t('settings.roleUser')}
</span>
</td>
<td className="px-5 py-3 text-sm text-slate-500">
{new Date(u.created_at).toLocaleDateString('de-DE')}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2 justify-end">
<button
onClick={() => handleEditUser(u)}
className="p-1.5 text-slate-400 hover:text-slate-900 hover:bg-slate-100 rounded-lg transition-colors"
title={t('admin.editUser')}
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDeleteUser(u)}
disabled={u.id === currentUser?.id}
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
title={t('admin.deleteUserTitle')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{activeTab === 'categories' && <CategoryManager />}
{activeTab === 'settings' && (
<div className="space-y-6">
{/* Registration Toggle */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.allowRegistration')}</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-700">{t('admin.allowRegistration')}</p>
<p className="text-xs text-slate-400 mt-0.5">{t('admin.allowRegistrationHint')}</p>
</div>
<button
onClick={() => handleToggleRegistration(!allowRegistration)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
allowRegistration ? 'bg-slate-900' : 'bg-slate-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
allowRegistration ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
{/* API Keys */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-100">
<h2 className="font-semibold text-slate-900">{t('admin.apiKeys')}</h2>
</div>
<div className="p-6 space-y-4">
{/* Google Maps Key */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.mapsKey')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKeys.maps ? 'text' : 'password'}
value={mapsKey}
onChange={e => setMapsKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
type="button"
onClick={() => toggleKey('maps')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.maps ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
onClick={() => handleValidateKey('maps')}
disabled={!mapsKey || validating.maps}
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{validating.maps ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : validation.maps === true ? (
<CheckCircle className="w-4 h-4 text-emerald-500" />
) : validation.maps === false ? (
<XCircle className="w-4 h-4 text-red-500" />
) : null}
{t('admin.validateKey')}
</button>
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.mapsKeyHint')}</p>
{validation.maps === true && (
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
{t('admin.keyValid')}
</p>
)}
{validation.maps === false && (
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
{t('admin.keyInvalid')}
</p>
)}
</div>
{/* OpenWeatherMap Key */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.weatherKey')}</label>
<div className="flex gap-2">
<div className="relative flex-1">
<input
type={showKeys.weather ? 'text' : 'password'}
value={weatherKey}
onChange={e => setWeatherKey(e.target.value)}
placeholder={t('settings.keyPlaceholder')}
className="w-full pr-10 px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<button
type="button"
onClick={() => toggleKey('weather')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showKeys.weather ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<button
onClick={() => handleValidateKey('weather')}
disabled={!weatherKey || validating.weather}
className="px-3 py-2 text-sm border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-1.5"
>
{validating.weather ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : validation.weather === true ? (
<CheckCircle className="w-4 h-4 text-emerald-500" />
) : validation.weather === false ? (
<XCircle className="w-4 h-4 text-red-500" />
) : null}
{t('admin.validateKey')}
</button>
</div>
<p className="text-xs text-slate-400 mt-1">{t('admin.weatherKeyHint')}</p>
{validation.weather === true && (
<p className="text-xs text-emerald-600 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-emerald-500 rounded-full inline-block"></span>
{t('admin.keyValid')}
</p>
)}
{validation.weather === false && (
<p className="text-xs text-red-500 mt-1 flex items-center gap-1">
<span className="w-2 h-2 bg-red-500 rounded-full inline-block"></span>
{t('admin.keyInvalid')}
</p>
)}
</div>
<button
onClick={handleSaveApiKeys}
disabled={savingKeys}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{savingKeys ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('common.save')}
</button>
</div>
</div>
</div>
)}
{activeTab === 'backup' && <BackupPanel />}
</div>
</div>
{/* Edit user modal */}
<Modal
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
title={t('admin.editUser')}
size="sm"
footer={
<div className="flex gap-3 justify-end">
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
>
{t('common.cancel')}
</button>
<button
onClick={handleSaveUser}
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 text-white rounded-lg"
>
{t('common.save')}
</button>
</div>
}
>
{editingUser && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<input
type="text"
value={editForm.username}
onChange={e => setEditForm(f => ({ ...f, username: 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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</label>
<input
type="email"
value={editForm.email}
onChange={e => setEditForm(f => ({ ...f, email: 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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
<input
type="password"
value={editForm.password}
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
placeholder={t('admin.newPasswordPlaceholder')}
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 text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
<select
value={editForm.role}
onChange={e => setEditForm(f => ({ ...f, role: 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 text-sm"
>
<option value="user">{t('settings.roleUser')}</option>
<option value="admin">{t('settings.roleAdmin')}</option>
</select>
</div>
</div>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,593 @@
import React, { useEffect, useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { tripsApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import TravelStats from '../components/Dashboard/TravelStats'
import TripFormModal from '../components/Trips/TripFormModal'
import { useToast } from '../components/shared/Toast'
import {
Plus, Calendar, Trash2, Edit2, Map, ChevronDown, ChevronUp,
Archive, ArchiveRestore, Clock, MapPin,
} from 'lucide-react'
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
function daysUntil(dateStr) {
if (!dateStr) return null
const today = new Date(); today.setHours(0, 0, 0, 0)
const d = new Date(dateStr + 'T00:00:00'); d.setHours(0, 0, 0, 0)
return Math.round((d - today) / 86400000)
}
function getTripStatus(trip) {
const today = new Date().toISOString().split('T')[0]
if (trip.start_date && trip.end_date && trip.start_date <= today && trip.end_date >= today) return 'ongoing'
const until = daysUntil(trip.start_date)
if (until === null) return null
if (until === 0) return 'today'
if (until === 1) return 'tomorrow'
if (until > 1) return 'future'
return 'past'
}
function formatDate(dateStr, locale = 'de-DE') {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' })
}
function formatDateShort(dateStr, locale = 'de-DE') {
if (!dateStr) return null
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
}
function sortTrips(trips) {
const today = new Date().toISOString().split('T')[0]
function rank(t) {
if (t.start_date && t.end_date && t.start_date <= today && t.end_date >= today) return 0 // ongoing
if (t.start_date && t.start_date >= today) return 1 // upcoming
return 2 // past
}
return [...trips].sort((a, b) => {
const ra = rank(a), rb = rank(b)
if (ra !== rb) return ra - rb
const ad = a.start_date || '', bd = b.start_date || ''
if (ra <= 1) return ad.localeCompare(bd)
return bd.localeCompare(ad)
})
}
// Gradient backgrounds when no cover image
const GRADIENTS = [
'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
]
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
const status = getTripStatus(trip)
const coverBg = trip.cover_image
? `url(${trip.cover_image}) center/cover no-repeat`
: tripGradient(trip.id)
return (
<div style={{ marginBottom: 32, borderRadius: 20, overflow: 'hidden', boxShadow: '0 8px 40px rgba(0,0,0,0.13)', position: 'relative', cursor: 'pointer' }}
onClick={() => onClick(trip)}>
{/* Cover / Background */}
<div style={{ height: 300, background: coverBg, position: 'relative' }}>
<div style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(to top, rgba(0,0,0,0.78) 0%, rgba(0,0,0,0.25) 50%, rgba(0,0,0,0.1) 100%)',
}} />
{/* Badges top-left */}
<div style={{ position: 'absolute', top: 16, left: 16, display: 'flex', gap: 8 }}>
{status && (
<span style={{
background: 'rgba(255,255,255,0.15)', backdropFilter: 'blur(8px)',
color: 'white', fontSize: 12, fontWeight: 700,
padding: '5px 12px', borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
display: 'flex', alignItems: 'center', gap: 6,
}}>
{status === 'ongoing' && (
<span style={{ width: 7, height: 7, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
)}
{status === 'ongoing' ? t('dashboard.status.ongoing')
: status === 'today' ? t('dashboard.status.today')
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
: t('dashboard.status.past')}
</span>
)}
</div>
{/* Top-right actions */}
<div style={{ position: 'absolute', top: 16, right: 16, display: 'flex', gap: 6 }}
onClick={e => e.stopPropagation()}>
<IconBtn onClick={() => onEdit(trip)} title={t('common.edit')}><Edit2 size={14} /></IconBtn>
<IconBtn onClick={() => onArchive(trip.id)} title={t('dashboard.archive')}><Archive size={14} /></IconBtn>
<IconBtn onClick={() => onDelete(trip)} title={t('common.delete')} danger><Trash2 size={14} /></IconBtn>
</div>
{/* Bottom content */}
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, padding: '20px 24px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'rgba(255,255,255,0.65)', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: 6 }}>
{trip.is_owner ? t('dashboard.nextTrip') : t('dashboard.sharedBy', { name: trip.owner_username })}
</div>
<h2 style={{ margin: 0, fontSize: 26, fontWeight: 800, color: 'white', lineHeight: 1.2, textShadow: '0 1px 4px rgba(0,0,0,0.3)' }}>
{trip.title}
</h2>
{trip.description && (
<p style={{ margin: '6px 0 0', fontSize: 13.5, color: 'rgba(255,255,255,0.75)', lineHeight: 1.4, overflow: 'hidden', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical' }}>
{trip.description}
</p>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginTop: 12 }}>
{trip.start_date && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Calendar size={13} />
{formatDateShort(trip.start_date, locale)}
{trip.end_date && <> {formatDateShort(trip.end_date, locale)}</>}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<Clock size={13} /> {trip.day_count || 0} {t('dashboard.days')}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'rgba(255,255,255,0.8)', fontSize: 13 }}>
<MapPin size={13} /> {trip.place_count || 0} {t('dashboard.places')}
</div>
</div>
</div>
</div>
</div>
)
}
// ── Regular Trip Card ────────────────────────────────────────────────────────
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
const status = getTripStatus(trip)
const [hovered, setHovered] = useState(false)
const coverBg = trip.cover_image
? `url(${trip.cover_image}) center/cover no-repeat`
: tripGradient(trip.id)
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={() => onClick(trip)}
style={{
background: 'var(--bg-card)', borderRadius: 16, overflow: 'hidden', cursor: 'pointer',
border: '1px solid var(--border-primary)', transition: 'all 0.18s',
boxShadow: hovered ? '0 8px 28px rgba(0,0,0,0.10)' : '0 1px 4px rgba(0,0,0,0.04)',
transform: hovered ? 'translateY(-2px)' : 'none',
}}
>
{/* Image area */}
<div style={{ height: 120, background: coverBg, position: 'relative', overflow: 'hidden' }}>
{trip.cover_image && <div style={{ position: 'absolute', inset: 0, background: 'linear-gradient(to top, rgba(0,0,0,0.35) 0%, transparent 60%)' }} />}
{/* Status badge */}
{status && (
<div style={{ position: 'absolute', top: 8, left: 8 }}>
<span style={{
fontSize: 10.5, fontWeight: 700, padding: '2px 8px', borderRadius: 99,
background: 'rgba(0,0,0,0.4)', color: 'white', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', gap: 5,
}}>
{status === 'ongoing' && (
<span style={{ width: 6, height: 6, borderRadius: '50%', background: '#ef4444', animation: 'blink 1s ease-in-out infinite', display: 'inline-block', flexShrink: 0 }} />
)}
{status === 'ongoing' ? t('dashboard.status.ongoing')
: status === 'today' ? t('dashboard.status.today')
: status === 'tomorrow' ? t('dashboard.status.tomorrow')
: status === 'future' ? t('dashboard.status.daysLeft', { count: daysUntil(trip.start_date) })
: t('dashboard.status.past')}
</span>
</div>
)}
</div>
{/* Content */}
<div style={{ padding: '12px 14px 14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden', marginBottom: 3 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.title}
</span>
{!trip.is_owner && (
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', padding: '1px 6px', borderRadius: 99, whiteSpace: 'nowrap', flexShrink: 0 }}>
{t('dashboard.shared')}
</span>
)}
</div>
{trip.description && (
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 8px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{trip.description}
</p>
)}
{(trip.start_date || trip.end_date) && (
<div style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', marginBottom: 10 }}>
<Calendar size={11} style={{ flexShrink: 0 }} />
{trip.start_date && trip.end_date
? `${formatDateShort(trip.start_date, locale)}${formatDateShort(trip.end_date, locale)}`
: formatDate(trip.start_date || trip.end_date, locale)}
</div>
)}
<div style={{ display: 'flex', gap: 8, marginBottom: 10 }}>
<Stat label={t('dashboard.days')} value={trip.day_count || 0} />
<Stat label={t('dashboard.places')} value={trip.place_count || 0} />
</div>
<div style={{ display: 'flex', gap: 6, borderTop: '1px solid #f3f4f6', paddingTop: 10 }}
onClick={e => e.stopPropagation()}>
<CardAction onClick={() => onEdit(trip)} icon={<Edit2 size={12} />} label={t('common.edit')} />
<CardAction onClick={() => onArchive(trip.id)} icon={<Archive size={12} />} label={t('dashboard.archive')} />
<CardAction onClick={() => onDelete(trip)} icon={<Trash2 size={12} />} label={t('common.delete')} danger />
</div>
</div>
</div>
)
}
// ── Archived Trip Row ────────────────────────────────────────────────────────
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
return (
<div onClick={() => onClick(trip)} style={{
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
borderRadius: 12, border: '1px solid #f3f4f6', background: 'white', cursor: 'pointer',
transition: 'border-color 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.borderColor = '#e5e7eb'}
onMouseLeave={e => e.currentTarget.style.borderColor = '#f3f4f6'}>
{/* Mini cover */}
<div style={{
width: 40, height: 40, borderRadius: 10, flexShrink: 0,
background: trip.cover_image ? `url(${trip.cover_image}) center/cover no-repeat` : tripGradient(trip.id),
opacity: 0.7,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{trip.title}</span>
{!trip.is_owner && <span style={{ fontSize: 10, color: '#9ca3af', background: '#f3f4f6', padding: '1px 6px', borderRadius: 99, flexShrink: 0 }}>{t('dashboard.shared')}</span>}
</div>
{trip.start_date && (
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 1 }}>
{formatDateShort(trip.start_date, locale)}{trip.end_date ? `${formatDateShort(trip.end_date, locale)}` : ''}
</div>
)}
</div>
<div style={{ display: 'flex', gap: 4, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
<button onClick={() => onUnarchive(trip.id)} title={t('dashboard.restore')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#6b7280' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#6b7280' }}>
<ArchiveRestore size={12} /> {t('dashboard.restore')}
</button>
<button onClick={() => onDelete(trip)} title={t('common.delete')} style={{ padding: '4px 8px', borderRadius: 8, border: '1px solid #e5e7eb', background: 'white', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: '#9ca3af' }}
onMouseEnter={e => { e.currentTarget.style.borderColor = '#fecaca'; e.currentTarget.style.color = '#ef4444' }}
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
<Trash2 size={12} />
</button>
</div>
</div>
)
}
// ── Helpers ──────────────────────────────────────────────────────────────────
function Stat({ value, label }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
<span style={{ fontSize: 11, color: '#9ca3af' }}>{label}</span>
</div>
)
}
function CardAction({ onClick, icon, label, danger }) {
return (
<button onClick={onClick} style={{
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
border: 'none', background: 'none', cursor: 'pointer', fontSize: 11,
color: danger ? '#9ca3af' : '#9ca3af', fontFamily: 'inherit',
}}
onMouseEnter={e => { e.currentTarget.style.background = danger ? '#fef2f2' : '#f3f4f6'; e.currentTarget.style.color = danger ? '#ef4444' : '#374151' }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = '#9ca3af' }}>
{icon}{label}
</button>
)
}
function IconBtn({ onClick, title, danger, loading, children }) {
return (
<button onClick={onClick} title={title} disabled={loading} style={{
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(8px)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
cursor: 'pointer', color: danger ? '#fca5a5' : 'white', transition: 'background 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.background = danger ? 'rgba(239,68,68,0.5)' : 'rgba(255,255,255,0.25)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.3)'}>
{children}
</button>
)
}
// ── Skeleton ─────────────────────────────────────────────────────────────────
function SkeletonCard() {
return (
<div style={{ background: 'white', borderRadius: 16, overflow: 'hidden', border: '1px solid #f3f4f6' }}>
<div style={{ height: 120, background: '#f3f4f6', animation: 'pulse 1.5s ease-in-out infinite' }} />
<div style={{ padding: '12px 14px 14px' }}>
<div style={{ height: 14, background: '#f3f4f6', borderRadius: 6, marginBottom: 8, width: '70%' }} />
<div style={{ height: 11, background: '#f3f4f6', borderRadius: 6, width: '50%' }} />
</div>
</div>
)
}
// ── Main Page ────────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [trips, setTrips] = useState([])
const [archivedTrips, setArchivedTrips] = useState([])
const [isLoading, setIsLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editingTrip, setEditingTrip] = useState(null)
const [showArchived, setShowArchived] = useState(false)
const navigate = useNavigate()
const toast = useToast()
const { t, locale } = useTranslation()
useEffect(() => { loadTrips() }, [])
const loadTrips = async () => {
setIsLoading(true)
try {
const [active, archived] = await Promise.all([
tripsApi.list(),
tripsApi.list({ archived: 1 }),
])
setTrips(sortTrips(active.trips))
setArchivedTrips(sortTrips(archived.trips))
} catch {
toast.error(t('dashboard.toast.loadError'))
} finally {
setIsLoading(false)
}
}
const handleCreate = async (tripData) => {
try {
const data = await tripsApi.create(tripData)
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.created'))
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.createError'))
}
}
const handleUpdate = async (tripData) => {
try {
const data = await tripsApi.update(editingTrip.id, tripData)
setTrips(prev => sortTrips(prev.map(t => t.id === editingTrip.id ? data.trip : t)))
toast.success(t('dashboard.toast.updated'))
} catch (err) {
throw new Error(err.response?.data?.error || t('dashboard.toast.updateError'))
}
}
const handleDelete = async (trip) => {
if (!confirm(t('dashboard.confirm.delete', { title: trip.title }))) return
try {
await tripsApi.delete(trip.id)
setTrips(prev => prev.filter(t => t.id !== trip.id))
setArchivedTrips(prev => prev.filter(t => t.id !== trip.id))
toast.success(t('dashboard.toast.deleted'))
} catch {
toast.error(t('dashboard.toast.deleteError'))
}
}
const handleArchive = async (id) => {
try {
const data = await tripsApi.archive(id)
setTrips(prev => prev.filter(t => t.id !== id))
setArchivedTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.archived'))
} catch {
toast.error(t('dashboard.toast.archiveError'))
}
}
const handleUnarchive = async (id) => {
try {
const data = await tripsApi.unarchive(id)
setArchivedTrips(prev => prev.filter(t => t.id !== id))
setTrips(prev => sortTrips([data.trip, ...prev]))
toast.success(t('dashboard.toast.restored'))
} catch {
toast.error(t('dashboard.toast.restoreError'))
}
}
const handleCoverUpdate = (tripId, coverImage) => {
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
setTrips(prev => prev.map(update))
setArchivedTrips(prev => prev.map(update))
}
const today = new Date().toISOString().split('T')[0]
const spotlight = trips.find(t => t.start_date && t.end_date && t.start_date <= today && t.end_date >= today)
|| trips.find(t => t.start_date && t.start_date >= today)
|| trips[0]
|| null
const rest = spotlight ? trips.filter(t => t.id !== spotlight.id) : trips
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-secondary)', ...font }}>
<Navbar />
<div style={{ paddingTop: 56 }}>
<div style={{ maxWidth: 1300, margin: '0 auto', padding: '32px 20px 60px' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 28 }}>
<div>
<h1 style={{ margin: 0, fontSize: 24, fontWeight: 800, color: 'var(--text-primary)' }}>{t('dashboard.title')}</h1>
<p style={{ margin: '3px 0 0', fontSize: 13, color: '#9ca3af' }}>
{isLoading ? t('common.loading')
: trips.length > 0 ? `${t(trips.length !== 1 ? 'dashboard.subtitle.activeMany' : 'dashboard.subtitle.activeOne', { count: trips.length })}${archivedTrips.length > 0 ? t('dashboard.subtitle.archivedSuffix', { count: archivedTrips.length }) : ''}`
: t('dashboard.subtitle.empty')}
</p>
</div>
<button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{
display: 'flex', alignItems: 'center', gap: 7, padding: '9px 18px',
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12,
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
}}
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
>
<Plus size={15} /> {t('dashboard.newTrip')}
</button>
</div>
<div style={{ display: 'flex', gap: 24, alignItems: 'flex-start' }}>
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Loading skeletons */}
{isLoading && (
<>
<div style={{ height: 260, background: '#e5e7eb', borderRadius: 20, marginBottom: 32, animation: 'pulse 1.5s ease-in-out infinite' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 16 }}>
{[1, 2, 3].map(i => <SkeletonCard key={i} />)}
</div>
</>
)}
{/* Empty state */}
{!isLoading && trips.length === 0 && (
<div style={{ textAlign: 'center', padding: '80px 20px' }}>
<div style={{ width: 80, height: 80, background: '#f3f4f6', borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 20px' }}>
<Map size={36} style={{ color: '#d1d5db' }} />
</div>
<h3 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('dashboard.emptyTitle')}</h3>
<p style={{ margin: '0 0 24px', fontSize: 14, color: '#9ca3af', maxWidth: 340, marginLeft: 'auto', marginRight: 'auto' }}>
{t('dashboard.emptyText')}
</p>
<button
onClick={() => { setEditingTrip(null); setShowForm(true) }}
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '10px 22px', background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}
>
<Plus size={16} /> {t('dashboard.emptyButton')}
</button>
</div>
)}
{/* Spotlight */}
{!isLoading && spotlight && (
<SpotlightCard
trip={spotlight}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
)}
{/* Rest grid */}
{!isLoading && rest.length > 0 && (
<div className="trip-grid" style={{ display: 'grid', gap: 16, marginBottom: 40 }}>
{rest.map(trip => (
<TripCard
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onDelete={handleDelete}
onArchive={handleArchive}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
</div>
)}
{/* Archived section */}
{!isLoading && archivedTrips.length > 0 && (
<div style={{ borderTop: '1px solid #f3f4f6', paddingTop: 24 }}>
<button
onClick={() => setShowArchived(v => !v)}
style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: showArchived ? 16 : 0, fontFamily: 'inherit' }}
>
<Archive size={15} style={{ color: '#9ca3af' }} />
<span style={{ fontSize: 13, fontWeight: 600, color: '#6b7280' }}>
{t('dashboard.archived')} ({archivedTrips.length})
</span>
{showArchived ? <ChevronUp size={14} style={{ color: '#9ca3af' }} /> : <ChevronDown size={14} style={{ color: '#9ca3af' }} />}
</button>
{showArchived && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{archivedTrips.map(trip => (
<ArchivedRow
key={trip.id}
trip={trip}
t={t} locale={locale}
onEdit={tr => { setEditingTrip(tr); setShowForm(true) }}
onUnarchive={handleUnarchive}
onDelete={handleDelete}
onClick={tr => navigate(`/trips/${tr.id}`)}
/>
))}
</div>
)}
</div>
)}
</div>
{/* Stats sidebar */}
<div className="hidden lg:block" style={{ position: 'sticky', top: 80, flexShrink: 0 }}>
<TravelStats />
</div>
</div>
</div>
</div>
<TripFormModal
isOpen={showForm}
onClose={() => { setShowForm(false); setEditingTrip(null) }}
onSave={editingTrip ? handleUpdate : handleCreate}
trip={editingTrip}
onCoverUpdate={handleCoverUpdate}
/>
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1 }
50% { opacity: 0.5 }
}
@keyframes blink {
0%, 100% { opacity: 1 }
50% { opacity: 0 }
}
.trip-grid { grid-template-columns: repeat(3, 1fr); }
@media(max-width: 1024px) { .trip-grid { grid-template-columns: repeat(2, 1fr); } }
@media(max-width: 640px) { .trip-grid { grid-template-columns: 1fr; } }
`}</style>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { tripsApi, placesApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import FileManager from '../components/Files/FileManager'
import { ArrowLeft } from 'lucide-react'
export default function FilesPage() {
const { id: tripId } = useParams()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState(null)
const [places, setPlaces] = useState([])
const [files, setFiles] = useState([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async () => {
setIsLoading(true)
try {
const [tripData, placesData] = await Promise.all([
tripsApi.get(tripId),
placesApi.list(tripId),
])
setTrip(tripData.trip)
setPlaces(placesData.places)
await tripStore.loadFiles(tripId)
} catch (err) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
setFiles(tripStore.files)
}, [tripStore.files])
const handleUpload = async (formData) => {
await tripStore.addFile(tripId, formData)
}
const handleDelete = async (fileId) => {
await tripStore.deleteFile(tripId, fileId)
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div className="pt-14">
<div className="max-w-5xl mx-auto px-4 py-6">
<div className="flex items-center gap-3 mb-6">
<Link
to={`/trips/${tripId}`}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="w-4 h-4" />
Zurück zur Planung
</Link>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dateien & Dokumente</h1>
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.title}</p>
</div>
</div>
<FileManager
files={files}
onUpload={handleUpload}
onDelete={handleDelete}
places={places}
tripId={tripId}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,231 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import { authApi } from '../api/client'
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe } from 'lucide-react'
export default function LoginPage() {
const { t, language } = useTranslation()
const [mode, setMode] = useState('login') // 'login' | 'register'
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [appConfig, setAppConfig] = useState(null)
const { login, register } = useAuthStore()
const { setLanguageLocal } = useSettingsStore()
const navigate = useNavigate()
useEffect(() => {
authApi.getAppConfig?.().catch(() => null).then(config => {
if (config) {
setAppConfig(config)
if (!config.has_users) setMode('register')
}
})
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
if (mode === 'register') {
if (!username.trim()) { setError('Username is required'); setIsLoading(false); return }
if (password.length < 6) { setError('Password must be at least 6 characters'); setIsLoading(false); return }
await register(username, email, password)
} else {
await login(email, password)
}
navigate('/dashboard')
} catch (err) {
setError(err.message || t('login.error'))
} finally {
setIsLoading(false)
}
}
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
const inputBase = {
width: '100%', padding: '11px 12px 11px 40px', border: '1px solid #e5e7eb',
borderRadius: 12, fontSize: 14, fontFamily: 'inherit', outline: 'none',
color: '#111827', background: 'white', boxSizing: 'border-box', transition: 'border-color 0.15s',
}
return (
<div style={{ minHeight: '100vh', display: 'flex', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif", position: 'relative' }}>
{/* Sprach-Toggle oben rechts */}
<button
onClick={() => setLanguageLocal(language === 'en' ? 'de' : 'en')}
style={{
position: 'absolute', top: 16, right: 16, zIndex: 10,
display: 'flex', alignItems: 'center', gap: 6,
padding: '6px 12px', borderRadius: 99,
background: 'rgba(0,0,0,0.06)', border: 'none',
fontSize: 13, fontWeight: 500, color: '#374151',
cursor: 'pointer', fontFamily: 'inherit',
transition: 'background 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
onMouseLeave={e => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
>
<Globe size={14} />
{language === 'en' ? 'DE' : 'EN'}
</button>
{/* Left — branding */}
<div style={{ display: 'none', width: '45%', background: '#111827', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '60px 48px' }}
className="lg-panel">
<style>{`@media(min-width:1024px){.lg-panel{display:flex!important}}`}</style>
{/* Logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 48 }}>
<div style={{ width: 44, height: 44, background: 'white', borderRadius: 12, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Plane size={22} style={{ color: '#111827' }} />
</div>
<span style={{ fontSize: 26, fontWeight: 800, color: 'white', letterSpacing: '-0.02em' }}>NOMAD</span>
</div>
<div style={{ maxWidth: 320, textAlign: 'center' }}>
<h2 style={{ margin: '0 0 16px', fontSize: 32, fontWeight: 800, color: 'white', lineHeight: 1.2 }}>
{t('login.tagline')}
</h2>
<p style={{ margin: 0, fontSize: 15, color: 'rgba(255,255,255,0.55)', lineHeight: 1.65 }}>
{t('login.description')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, marginTop: 40 }}>
{[
{ Icon: MapPin, label: t('login.features.places') },
{ Icon: Calendar, label: t('login.features.schedule') },
{ Icon: Package, label: t('login.features.packing') },
].map(({ Icon, label }) => (
<div key={label} style={{ background: 'rgba(255,255,255,0.07)', borderRadius: 14, padding: '18px 12px', border: '1px solid rgba(255,255,255,0.1)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
<Icon size={20} style={{ color: 'rgba(255,255,255,0.6)' }} />
<div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)', fontWeight: 500 }}>{label}</div>
</div>
))}
</div>
</div>
</div>
{/* Right — form */}
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '32px 24px', background: '#f9fafb' }}>
<div style={{ width: '100%', maxWidth: 400 }}>
{/* Mobile logo */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 36, justifyContent: 'center' }}
className="mobile-logo">
<style>{`@media(min-width:1024px){.mobile-logo{display:none!important}}`}</style>
<div style={{ width: 36, height: 36, background: '#111827', borderRadius: 10, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Plane size={18} style={{ color: 'white' }} />
</div>
<span style={{ fontSize: 22, fontWeight: 800, color: '#111827', letterSpacing: '-0.02em' }}>NOMAD</span>
</div>
<div style={{ background: 'white', borderRadius: 20, border: '1px solid #e5e7eb', padding: '36px 32px', boxShadow: '0 2px 16px rgba(0,0,0,0.06)' }}>
<h2 style={{ margin: '0 0 4px', fontSize: 22, fontWeight: 800, color: '#111827' }}>
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdmin') : t('login.createAccount')) : t('login.title')}
</h2>
<p style={{ margin: '0 0 28px', fontSize: 13.5, color: '#9ca3af' }}>
{mode === 'register' ? (!appConfig?.has_users ? t('login.createAdminHint') : t('login.createAccountHint')) : t('login.subtitle')}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{error && (
<div style={{ padding: '10px 14px', background: '#fef2f2', border: '1px solid #fecaca', borderRadius: 10, fontSize: 13, color: '#dc2626' }}>
{error}
</div>
)}
{/* Username (register only) */}
{mode === 'register' && (
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('login.username')}</label>
<div style={{ position: 'relative' }}>
<User size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="text" value={username} onChange={e => setUsername(e.target.value)} required
placeholder="admin" style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
)}
{/* Email */}
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.email')}</label>
<div style={{ position: 'relative' }}>
<Mail size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type="email" value={email} onChange={e => setEmail(e.target.value)} required
placeholder="deine@email.de" style={inputBase}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
</div>
</div>
{/* Password */}
<div>
<label style={{ display: 'block', fontSize: 12.5, fontWeight: 600, color: '#374151', marginBottom: 6 }}>{t('common.password')}</label>
<div style={{ position: 'relative' }}>
<Lock size={15} style={{ position: 'absolute', left: 13, top: '50%', transform: 'translateY(-50%)', color: '#9ca3af', pointerEvents: 'none' }} />
<input
type={showPassword ? 'text' : 'password'} value={password} onChange={e => setPassword(e.target.value)} required
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
onFocus={e => e.target.style.borderColor = '#111827'}
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
/>
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex', color: '#9ca3af',
}}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<button type="submit" disabled={isLoading} style={{
marginTop: 4, width: '100%', padding: '12px', background: '#111827', color: 'white',
border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 700, cursor: isLoading ? 'default' : 'pointer',
fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
opacity: isLoading ? 0.7 : 1, transition: 'opacity 0.15s',
}}
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
onMouseLeave={e => e.currentTarget.style.background = '#111827'}
>
{isLoading
? <><div style={{ width: 15, height: 15, border: '2px solid rgba(255,255,255,0.3)', borderTopColor: 'white', borderRadius: '50%', animation: 'spin 0.7s linear infinite' }} />{mode === 'register' ? t('login.creating') : t('login.signingIn')}</>
: mode === 'register' ? t('login.createAccount') : t('login.signIn')
}
</button>
</form>
{/* Toggle login/register */}
{showRegisterOption && appConfig?.has_users && (
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: '#9ca3af' }}>
{mode === 'login' ? t('login.noAccount') + ' ' : t('login.hasAccount') + ' '}
<button onClick={() => { setMode(m => m === 'login' ? 'register' : 'login'); setError('') }}
style={{ background: 'none', border: 'none', color: '#111827', fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', fontSize: 13 }}>
{mode === 'login' ? t('login.register') : t('login.signIn')}
</button>
</p>
)}
</div>
</div>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { tripsApi, daysApi, placesApi } from '../api/client'
import Navbar from '../components/Layout/Navbar'
import PhotoGallery from '../components/Photos/PhotoGallery'
import { ArrowLeft } from 'lucide-react'
export default function PhotosPage() {
const { id: tripId } = useParams()
const navigate = useNavigate()
const tripStore = useTripStore()
const [trip, setTrip] = useState(null)
const [days, setDays] = useState([])
const [places, setPlaces] = useState([])
const [photos, setPhotos] = useState([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
loadData()
}, [tripId])
const loadData = async () => {
setIsLoading(true)
try {
const [tripData, daysData, placesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
])
setTrip(tripData.trip)
setDays(daysData.days)
setPlaces(placesData.places)
// Load photos
await tripStore.loadPhotos(tripId)
} catch (err) {
navigate('/dashboard')
} finally {
setIsLoading(false)
}
}
// Sync photos from store
useEffect(() => {
setPhotos(tripStore.photos)
}, [tripStore.photos])
const handleUpload = async (formData) => {
await tripStore.addPhoto(tripId, formData)
}
const handleDelete = async (photoId) => {
await tripStore.deletePhoto(tripId, photoId)
}
const handleUpdate = async (photoId, data) => {
await tripStore.updatePhoto(tripId, photoId, data)
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-slate-50">
<div className="w-10 h-10 border-4 border-slate-200 border-t-slate-700 rounded-full animate-spin"></div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50">
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
<div className="pt-14">
<div className="max-w-7xl mx-auto px-4 py-6">
{/* Header */}
<div className="flex items-center gap-3 mb-6">
<Link
to={`/trips/${tripId}`}
className="flex items-center gap-1 text-sm text-gray-500 hover:text-gray-700"
>
<ArrowLeft className="w-4 h-4" />
Zurück zur Planung
</Link>
</div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Fotos</h1>
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.title}</p>
</div>
</div>
<PhotoGallery
photos={photos}
onUpload={handleUpload}
onDelete={handleDelete}
onUpdate={handleUpdate}
places={places}
days={days}
tripId={tripId}
/>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,185 @@
import React, { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
export default function RegisterPage() {
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const { register } = useAuthStore()
const navigate = useNavigate()
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (password !== confirmPassword) {
setError('Passwörter stimmen nicht überein')
return
}
if (password.length < 6) {
setError('Passwort muss mindestens 6 Zeichen lang sein')
return
}
setIsLoading(true)
try {
await register(username, email, password)
navigate('/dashboard')
} catch (err) {
setError(err.message || 'Registrierung fehlgeschlagen')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen flex">
{/* Left panel */}
<div className="hidden lg:flex lg:w-1/2 bg-slate-900 flex-col justify-center items-center p-12 text-white">
<div className="max-w-sm text-center">
<div className="w-20 h-20 bg-white/10 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Map className="w-10 h-10 text-white" />
</div>
<h1 className="text-4xl font-bold mb-4">Jetzt starten</h1>
<p className="text-slate-300 text-lg leading-relaxed">
Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.
</p>
<div className="mt-10 space-y-3 text-left">
{[
'✓ Unbegrenzte Reisepläne',
'✓ Interaktive Kartenansicht',
'✓ Orte und Kategorien verwalten',
'✓ Reservierungen tracken',
'✓ Packlisten erstellen',
'✓ Fotos und Dateien speichern',
].map(item => (
<p key={item} className="text-slate-200 text-sm">{item}</p>
))}
</div>
</div>
</div>
{/* Right panel */}
<div className="flex-1 flex items-center justify-center p-8 bg-slate-50">
<div className="w-full max-w-md">
<div className="lg:hidden flex items-center gap-2 mb-8 justify-center">
<Map className="w-8 h-8 text-slate-900" />
<span className="text-2xl font-bold text-slate-900">NOMAD</span>
</div>
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<h2 className="text-2xl font-bold text-slate-900 mb-1">Konto erstellen</h2>
<p className="text-slate-500 mb-8">Beginnen Sie Ihre Reiseplanung</p>
<form onSubmit={handleSubmit} 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>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Benutzername</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
required
placeholder="maxmustermann"
minLength={3}
className="w-full pl-10 pr-4 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 transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
placeholder="ihre@email.de"
className="w-full pl-10 pr-4 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 transition-all"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
required
placeholder="Mind. 6 Zeichen"
className="w-full pl-10 pr-12 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 transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
required
placeholder="Passwort wiederholen"
className="w-full pl-10 pr-4 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 transition-all"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-2.5 px-4 bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
Registrieren...
</>
) : 'Registrieren'}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-slate-500">
Bereits ein Konto?{' '}
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
Anmelden
</Link>
</p>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,421 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useAuthStore } from '../store/authStore'
import { useSettingsStore } from '../store/settingsStore'
import { useTranslation } from '../i18n'
import Navbar from '../components/Layout/Navbar'
import CustomSelect from '../components/shared/CustomSelect'
import { useToast } from '../components/shared/Toast'
import { Save, Map, Palette, User, Moon, Sun, Shield, Camera, Trash2 } from 'lucide-react'
const MAP_PRESETS = [
{ name: 'OpenStreetMap', url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' },
{ name: 'OpenStreetMap DE', url: 'https://tile.openstreetmap.de/{z}/{x}/{y}.png' },
{ name: 'CartoDB Light', url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' },
{ name: 'CartoDB Dark', url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png' },
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
]
function Section({ title, icon: Icon, children }) {
return (
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
<div className="px-6 py-4 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-secondary)' }}>
<Icon className="w-5 h-5" style={{ color: 'var(--text-secondary)' }} />
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{title}</h2>
</div>
<div className="p-6 space-y-4">
{children}
</div>
</div>
)
}
export default function SettingsPage() {
const { user, updateProfile, uploadAvatar, deleteAvatar } = useAuthStore()
const avatarInputRef = React.useRef(null)
const { settings, updateSetting, updateSettings } = useSettingsStore()
const { t, locale } = useTranslation()
const toast = useToast()
const navigate = useNavigate()
const [saving, setSaving] = useState({})
// Map settings
const [mapTileUrl, setMapTileUrl] = useState(settings.map_tile_url || '')
const [defaultLat, setDefaultLat] = useState(settings.default_lat || 48.8566)
const [defaultLng, setDefaultLng] = useState(settings.default_lng || 2.3522)
const [defaultZoom, setDefaultZoom] = useState(settings.default_zoom || 10)
// Display
const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius')
// Account
const [username, setUsername] = useState(user?.username || '')
const [email, setEmail] = useState(user?.email || '')
useEffect(() => {
setMapTileUrl(settings.map_tile_url || '')
setDefaultLat(settings.default_lat || 48.8566)
setDefaultLng(settings.default_lng || 2.3522)
setDefaultZoom(settings.default_zoom || 10)
setTempUnit(settings.temperature_unit || 'celsius')
}, [settings])
useEffect(() => {
setUsername(user?.username || '')
setEmail(user?.email || '')
}, [user])
const saveMapSettings = async () => {
setSaving(s => ({ ...s, map: true }))
try {
await updateSettings({
map_tile_url: mapTileUrl,
default_lat: parseFloat(defaultLat),
default_lng: parseFloat(defaultLng),
default_zoom: parseInt(defaultZoom),
})
toast.success(t('settings.toast.mapSaved'))
} catch (err) {
toast.error(err.message)
} finally {
setSaving(s => ({ ...s, map: false }))
}
}
const saveDisplay = async () => {
setSaving(s => ({ ...s, display: true }))
try {
await updateSetting('temperature_unit', tempUnit)
toast.success(t('settings.toast.displaySaved'))
} catch (err) {
toast.error(err.message)
} finally {
setSaving(s => ({ ...s, display: false }))
}
}
const handleAvatarUpload = async (e) => {
const file = e.target.files?.[0]
if (!file) return
try {
await uploadAvatar(file)
toast.success(t('settings.avatarUploaded'))
} catch {
toast.error(t('settings.avatarError'))
}
if (avatarInputRef.current) avatarInputRef.current.value = ''
}
const handleAvatarRemove = async () => {
try {
await deleteAvatar()
toast.success(t('settings.avatarRemoved'))
} catch {
toast.error(t('settings.avatarError'))
}
}
const saveProfile = async () => {
setSaving(s => ({ ...s, profile: true }))
try {
await updateProfile({ username, email })
toast.success(t('settings.toast.profileSaved'))
} catch (err) {
toast.error(err.message)
} finally {
setSaving(s => ({ ...s, profile: false }))
}
}
return (
<div className="min-h-screen" style={{ background: 'var(--bg-secondary)' }}>
<Navbar />
<div className="pt-14">
<div className="max-w-2xl mx-auto px-4 py-8 space-y-6">
<div>
<h1 className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>{t('settings.title')}</h1>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-muted)' }}>{t('settings.subtitle')}</p>
</div>
{/* Map settings */}
<Section title={t('settings.map')} icon={Map}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.mapTemplate')}</label>
<CustomSelect
value=""
onChange={value => { if (value) setMapTileUrl(value) }}
placeholder={t('settings.mapTemplatePlaceholder.select')}
options={MAP_PRESETS.map(p => ({
value: p.url,
label: p.name,
}))}
size="sm"
style={{ marginBottom: 8 }}
/>
<input
type="text"
value={mapTileUrl}
onChange={e => setMapTileUrl(e.target.value)}
placeholder="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
<p className="text-xs text-slate-400 mt-1">{t('settings.mapDefaultHint')}</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.latitude')}</label>
<input
type="number"
step="any"
value={defaultLat}
onChange={e => setDefaultLat(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm 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">{t('settings.longitude')}</label>
<input
type="number"
step="any"
value={defaultLng}
onChange={e => setDefaultLng(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
</div>
<button
onClick={saveMapSettings}
disabled={saving.map}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{saving.map ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveMap')}
</button>
</Section>
{/* Display */}
<Section title={t('settings.display')} icon={Palette}>
{/* Dark Mode Toggle */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.colorMode')}</label>
<div className="flex gap-3">
{[
{ value: false, label: t('settings.light'), icon: Sun },
{ value: true, label: t('settings.dark'), icon: Moon },
].map(opt => (
<button
key={String(opt.value)}
onClick={async () => {
try {
await updateSetting('dark_mode', opt.value)
} catch (e) { toast.error(e.message) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: settings.dark_mode === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.dark_mode === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
<opt.icon size={16} />
{opt.label}
</button>
))}
</div>
</div>
{/* Sprache */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.language')}</label>
<div className="flex gap-3">
{[
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
].map(opt => (
<button
key={opt.value}
onClick={async () => {
try { await updateSetting('language', opt.value) }
catch (e) { toast.error(e.message) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: settings.language === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.language === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Temperature */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.temperature')}</label>
<div className="flex gap-3">
{[
{ value: 'celsius', label: '°C Celsius' },
{ value: 'fahrenheit', label: '°F Fahrenheit' },
].map(opt => (
<button
key={opt.value}
onClick={async () => {
setTempUnit(opt.value)
try { await updateSetting('temperature_unit', opt.value) }
catch (e) { toast.error(e.message) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: tempUnit === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: tempUnit === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Zeitformat */}
<div>
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.timeFormat')}</label>
<div className="flex gap-3">
{[
{ value: '24h', label: '24h (14:30)' },
{ value: '12h', label: '12h (2:30 PM)' },
].map(opt => (
<button
key={opt.value}
onClick={async () => {
try { await updateSetting('time_format', opt.value) }
catch (e) { toast.error(e.message) }
}}
style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
border: settings.time_format === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
background: settings.time_format === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
color: 'var(--text-primary)',
transition: 'all 0.15s',
}}
>
{opt.label}
</button>
))}
</div>
</div>
</Section>
{/* Account */}
<Section title={t('settings.account')} icon={User}>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm 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">{t('settings.email')}</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
/>
</div>
<div className="flex items-center gap-4">
{user?.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 64, height: 64, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
) : (
<div style={{
width: 64, height: 64, borderRadius: '50%', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 24, fontWeight: 700,
background: 'var(--bg-hover)', color: 'var(--text-secondary)',
}}>
{user?.username?.charAt(0).toUpperCase()}
</div>
)}
<div className="flex flex-col gap-2">
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>
<span className="font-medium" style={{ display: 'inline-flex', alignItems: 'center', gap: 4, color: 'var(--text-secondary)' }}>
{user?.role === 'admin' ? <><Shield size={13} /> {t('settings.roleAdmin')}</> : t('settings.roleUser')}
</span>
</div>
<div className="flex items-center gap-2">
<input
ref={avatarInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
style={{ display: 'none' }}
/>
<button
onClick={() => avatarInputRef.current?.click()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
color: 'var(--text-secondary)',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Camera size={14} />
{t('settings.uploadAvatar')}
</button>
{user?.avatar_url && (
<button
onClick={handleAvatarRemove}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
style={{
border: '1px solid var(--border-primary)',
background: 'var(--bg-card)',
color: '#ef4444',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-card)'}
>
<Trash2 size={14} />
{t('settings.removeAvatar')}
</button>
)}
</div>
</div>
</div>
<button
onClick={saveProfile}
disabled={saving.profile}
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
>
{saving.profile ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{t('settings.saveProfile')}
</button>
</Section>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useTripStore } from '../store/tripStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapView } from '../components/Map/MapView'
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
import PlacesSidebar from '../components/Planner/PlacesSidebar'
import PlaceInspector from '../components/Planner/PlaceInspector'
import PlaceFormModal from '../components/Planner/PlaceFormModal'
import TripFormModal from '../components/Trips/TripFormModal'
import TripMembersModal from '../components/Trips/TripMembersModal'
import { ReservationModal } from '../components/Planner/ReservationModal'
import ReservationsPanel from '../components/Planner/ReservationsPanel'
import PackingListPanel from '../components/Packing/PackingListPanel'
import FileManager from '../components/Files/FileManager'
import BudgetPanel from '../components/Budget/BudgetPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from 'lucide-react'
import { useTranslation } from '../i18n'
const MIN_SIDEBAR = 200
const MAX_SIDEBAR = 520
export default function TripPlannerPage() {
const { id: tripId } = useParams()
const navigate = useNavigate()
const toast = useToast()
const { t } = useTranslation()
const { settings } = useSettingsStore()
const tripStore = useTripStore()
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
const TRIP_TABS = [
{ id: 'plan', label: t('trip.tabs.plan') },
{ id: 'buchungen', label: t('trip.tabs.reservations') },
{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') },
{ id: 'finanzplan', label: t('trip.tabs.budget') },
{ id: 'dateien', label: t('trip.tabs.files') },
]
// Layout state
const [activeTab, setActiveTab] = useState('plan')
const handleTabChange = (tabId) => {
setActiveTab(tabId)
if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId)
if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId)
}
const [leftWidth, setLeftWidth] = useState(() => parseInt(localStorage.getItem('sidebarLeftWidth')) || 340)
const [rightWidth, setRightWidth] = useState(() => parseInt(localStorage.getItem('sidebarRightWidth')) || 300)
const [leftCollapsed, setLeftCollapsed] = useState(false)
const [rightCollapsed, setRightCollapsed] = useState(false)
const isResizingLeft = useRef(false)
const isResizingRight = useRef(false)
// Content state
const [selectedPlaceId, setSelectedPlaceId] = useState(null)
const [showPlaceForm, setShowPlaceForm] = useState(false)
const [editingPlace, setEditingPlace] = useState(null)
const [showTripForm, setShowTripForm] = useState(false)
const [showMembersModal, setShowMembersModal] = useState(false)
const [showReservationModal, setShowReservationModal] = useState(false)
const [editingReservation, setEditingReservation] = useState(null)
const [route, setRoute] = useState(null)
const [routeInfo, setRouteInfo] = useState(null)
const [fitKey, setFitKey] = useState(0)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(null) // 'left' | 'right' | null
// Load trip + files (needed for place inspector file section)
useEffect(() => {
if (tripId) {
tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
tripStore.loadFiles(tripId)
}
}, [tripId])
useEffect(() => {
if (tripId) tripStore.loadReservations(tripId)
}, [tripId])
// Resize handlers
useEffect(() => {
const onMove = (e) => {
if (isResizingLeft.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
setLeftWidth(w)
localStorage.setItem('sidebarLeftWidth', w)
}
if (isResizingRight.current) {
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
setRightWidth(w)
localStorage.setItem('sidebarRightWidth', w)
}
}
const onUp = () => {
isResizingLeft.current = false
isResizingRight.current = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
return () => {
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
}, [])
// Map places — always show all places with coordinates
const mapPlaces = useCallback(() => {
return places.filter(p => p.lat && p.lng)
}, [places])
const handleSelectDay = useCallback((dayId) => {
tripStore.setSelectedDay(dayId)
setRouteInfo(null)
setFitKey(k => k + 1)
setMobileSidebarOpen(null)
// Auto-show Luftlinien for the selected day
const da = (tripStore.assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng)
if (waypoints.length >= 2) {
setRoute(waypoints.map(p => [p.lat, p.lng]))
} else {
setRoute(null)
}
}, [tripStore])
const handlePlaceClick = useCallback((placeId) => {
setSelectedPlaceId(placeId)
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
}, [])
const handleMarkerClick = useCallback((placeId) => {
const opening = placeId !== undefined
setSelectedPlaceId(prev => prev === placeId ? null : placeId)
if (opening) { setLeftCollapsed(false); setRightCollapsed(false) }
}, [])
const handleMapClick = useCallback(() => {
setSelectedPlaceId(null)
}, [])
const handleSavePlace = useCallback(async (data) => {
if (editingPlace) {
await tripStore.updatePlace(tripId, editingPlace.id, data)
toast.success(t('trip.toast.placeUpdated'))
} else {
await tripStore.addPlace(tripId, data)
toast.success(t('trip.toast.placeAdded'))
}
}, [editingPlace, tripId, tripStore, toast])
const handleDeletePlace = useCallback(async (placeId) => {
if (!confirm(t('trip.confirm.deletePlace'))) return
try {
await tripStore.deletePlace(tripId, placeId)
if (selectedPlaceId === placeId) setSelectedPlaceId(null)
toast.success(t('trip.toast.placeDeleted'))
} catch (err) { toast.error(err.message) }
}, [tripId, tripStore, toast, selectedPlaceId])
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
const target = dayId || selectedDayId
if (!target) { toast.error(t('trip.toast.selectDay')); return }
try {
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
toast.success(t('trip.toast.assignedToDay'))
} catch (err) { toast.error(err.message) }
}, [selectedDayId, tripId, tripStore, toast])
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
try { await tripStore.removeAssignment(tripId, dayId, assignmentId) }
catch (err) { toast.error(err.message) }
}, [tripId, tripStore, toast])
const handleReorder = useCallback(async (dayId, orderedIds) => {
try { await tripStore.reorderAssignments(tripId, dayId, orderedIds) }
catch { toast.error(t('trip.toast.reorderError')) }
}, [tripId, tripStore, toast])
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
try { await tripStore.updateDayTitle(tripId, dayId, title) }
catch (err) { toast.error(err.message) }
}, [tripId, tripStore, toast])
const handleSaveReservation = async (data) => {
try {
if (editingReservation) {
const r = await tripStore.updateReservation(tripId, editingReservation.id, data)
toast.success(t('trip.toast.reservationUpdated'))
setShowReservationModal(false)
return r
} else {
const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null })
toast.success(t('trip.toast.reservationAdded'))
setShowReservationModal(false)
return r
}
} catch (err) { toast.error(err.message) }
}
const handleDeleteReservation = async (id) => {
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
catch (err) { toast.error(err.message) }
}
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
// Build placeId → order-number map from the selected day's assignments
const dayOrderMap = useMemo(() => {
if (!selectedDayId) return {}
const da = assignments[String(selectedDayId)] || []
const sorted = [...da].sort((a, b) => a.order_index - b.order_index)
const map = {}
sorted.forEach((a, i) => { if (a.place?.id) map[a.place.id] = i + 1 })
return map
}, [selectedDayId, assignments])
const mapTileUrl = settings.map_tile_url || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522]
const defaultZoom = settings.default_zoom || 10
const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }
if (isLoading) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f9fafb', ...fontStyle }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
<div style={{ width: 32, height: 32, border: '3px solid rgba(0,0,0,0.1)', borderTopColor: '#111827', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
<span style={{ fontSize: 13, color: '#9ca3af' }}>{t('trip.loading')}</span>
</div>
</div>
)
}
if (!trip) return null
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column', overflow: 'hidden', ...fontStyle }}>
<Navbar tripTitle={trip.title} tripId={tripId} showBack onBack={() => navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
{/* Tab bar */}
<div style={{
position: 'fixed', top: 56, left: 0, right: 0, zIndex: 40,
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: '0 12px',
background: 'var(--bg-elevated)',
backdropFilter: 'blur(16px)',
WebkitBackdropFilter: 'blur(16px)',
borderBottom: '1px solid var(--border-faint)',
height: 44,
overflowX: 'auto', scrollbarWidth: 'none', msOverflowStyle: 'none',
gap: 2,
}}>
{TRIP_TABS.map(tab => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
onClick={() => handleTabChange(tab.id)}
style={{
flexShrink: 0,
padding: '5px 14px', borderRadius: 20, border: 'none', cursor: 'pointer',
fontSize: 13, fontWeight: isActive ? 600 : 400,
background: isActive ? 'var(--accent)' : 'transparent',
color: isActive ? 'var(--accent-text)' : 'var(--text-muted)',
fontFamily: 'inherit', transition: 'all 0.15s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-primary)' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = isActive ? 'var(--accent-text)' : 'var(--text-muted)' }}
>{tab.shortLabel
? <><span className="sm:hidden">{tab.shortLabel}</span><span className="hidden sm:inline">{tab.label}</span></>
: tab.label
}</button>
)
})}
</div>
{/* Content — offset by navbar (56px) + tab bar (44px) */}
<div style={{ flex: 1, overflow: 'hidden', marginTop: 100, position: 'relative' }}>
{/* PLAN MODE */}
{activeTab === 'plan' && (
<div style={{ position: 'absolute', inset: 0 }}>
{/* Map fills entire space */}
<MapView
places={mapPlaces()}
route={route}
selectedPlaceId={selectedPlaceId}
onMarkerClick={handleMarkerClick}
onMapClick={handleMapClick}
center={defaultCenter}
zoom={defaultZoom}
tileUrl={mapTileUrl}
fitKey={fitKey}
dayOrderMap={dayOrderMap}
/>
{/* Route info overlay */}
{routeInfo && (
<div style={{
position: 'absolute', bottom: selectedPlace ? 180 : 20, left: '50%', transform: 'translateX(-50%)',
background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(20px)',
borderRadius: 99, padding: '6px 20px', zIndex: 30,
boxShadow: '0 2px 16px rgba(0,0,0,0.1)',
display: 'flex', gap: 12, fontSize: 13, color: '#374151',
}}>
<span>{routeInfo.distance}</span>
<span style={{ color: '#d1d5db' }}>·</span>
<span>{routeInfo.duration}</span>
</div>
)}
{/* LEFT SIDEBAR — glass, absolute, floating rounded */}
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
{/* Collapse toggle — am rechten Rand der Sidebar, halb herausstehend */}
<button onClick={() => setLeftCollapsed(c => !c)}
style={{
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(56px + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
width: 36, height: 36, borderRadius: leftCollapsed ? 10 : '0 10px 10px 0',
background: leftCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
boxShadow: leftCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: leftCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
}}
onMouseEnter={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!leftCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
{leftCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
<div style={{
width: leftCollapsed ? 0 : leftWidth, height: '100%',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
boxShadow: leftCollapsed ? 'none' : 'var(--sidebar-shadow)',
borderRadius: 16,
overflow: 'hidden', display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease',
opacity: leftCollapsed ? 0 : 1,
}}>
<DayPlanSidebar
tripId={tripId}
trip={trip}
days={days}
places={places}
categories={categories}
assignments={assignments}
selectedDayId={selectedDayId}
selectedPlaceId={selectedPlaceId}
onSelectDay={handleSelectDay}
onPlaceClick={handlePlaceClick}
onReorder={handleReorder}
onUpdateDayTitle={handleUpdateDayTitle}
onAssignToDay={handleAssignToDay}
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } else { setRoute(null); setRouteInfo(null) } }}
reservations={reservations}
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
/>
{/* Resize handle — right edge */}
{!leftCollapsed && (
<div
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
style={{ position: 'absolute', right: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
/>
)}
</div>
</div>
{/* RIGHT SIDEBAR — glass, absolute, floating rounded */}
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
{/* Collapse toggle — am linken Rand der Sidebar, halb herausstehend */}
<button onClick={() => setRightCollapsed(c => !c)}
style={{
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(56px + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
width: 36, height: 36, borderRadius: rightCollapsed ? 10 : '10px 0 0 10px',
background: rightCollapsed ? '#000' : 'var(--sidebar-bg)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
boxShadow: rightCollapsed ? '0 2px 12px rgba(0,0,0,0.2)' : 'none', border: 'none',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
color: rightCollapsed ? '#fff' : 'var(--text-faint)', transition: 'color 0.15s',
}}
onMouseEnter={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-primary)' }}
onMouseLeave={e => { if (!rightCollapsed) e.currentTarget.style.color = 'var(--text-faint)' }}>
{rightCollapsed ? <PanelRightOpen size={16} /> : <PanelRightClose size={16} />}
</button>
<div style={{
width: rightCollapsed ? 0 : rightWidth, height: '100%',
background: 'var(--sidebar-bg)',
backdropFilter: 'blur(24px) saturate(180%)',
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
boxShadow: rightCollapsed ? 'none' : 'var(--sidebar-shadow)',
borderRadius: 16,
overflow: 'hidden', display: 'flex', flexDirection: 'column',
transition: 'width 0.25s ease',
opacity: rightCollapsed ? 0 : 1,
}}>
{/* Resize handle — left edge */}
{!rightCollapsed && (
<div
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 4, cursor: 'col-resize', background: 'transparent' }}
onMouseEnter={e => e.currentTarget.style.background = 'rgba(0,0,0,0.08)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
/>
)}
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', paddingLeft: 4 }}>
<PlacesSidebar
places={places}
categories={categories}
assignments={assignments}
selectedDayId={selectedDayId}
selectedPlaceId={selectedPlaceId}
onPlaceClick={handlePlaceClick}
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
onAssignToDay={handleAssignToDay}
/>
</div>
</div>
</div>
{/* Mobile controls */}
<div className="flex md:hidden" style={{ position: 'absolute', top: 12, left: 12, right: 12, justifyContent: 'space-between', zIndex: 30 }}>
<button onClick={() => setMobileSidebarOpen('left')}
style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(12px)', border: 'none', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
{t('trip.mobilePlan')}
</button>
<button onClick={() => setMobileSidebarOpen('right')}
style={{ background: 'rgba(255,255,255,0.95)', backdropFilter: 'blur(12px)', border: 'none', borderRadius: 24, padding: '11px 24px', fontSize: 15, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 12px rgba(0,0,0,0.15)', minHeight: 44, fontFamily: 'inherit' }}>
{t('trip.mobilePlaces')}
</button>
</div>
{/* Bottom inspector */}
{selectedPlace && (
<PlaceInspector
place={selectedPlace}
categories={categories}
days={days}
selectedDayId={selectedDayId}
assignments={assignments}
onClose={() => setSelectedPlaceId(null)}
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
onDelete={() => handleDeletePlace(selectedPlace.id)}
onAssignToDay={handleAssignToDay}
onRemoveAssignment={handleRemoveAssignment}
files={files}
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
/>
)}
{/* Mobile bottom sheet */}
{mobileSidebarOpen && (
<div style={{ position: 'absolute', inset: 0, background: 'rgba(0,0,0,0.3)', zIndex: 50 }} onClick={() => setMobileSidebarOpen(null)}>
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'white', borderRadius: '20px 20px 0 0', maxHeight: '80vh', display: 'flex', flexDirection: 'column', overflow: 'hidden' }} onClick={e => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<span style={{ fontWeight: 600, fontSize: 14, color: '#111827' }}>{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}</span>
<button onClick={() => setMobileSidebarOpen(null)} style={{ background: 'rgba(0,0,0,0.07)', border: 'none', borderRadius: '50%', width: 28, height: 28, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<X size={14} />
</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} />
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} />
}
</div>
</div>
</div>
)}
</div>
)}
{/* BUCHUNGEN */}
{activeTab === 'buchungen' && (
<div style={{ height: '100%', maxWidth: 1200, margin: '0 auto', width: '100%', display: 'flex', flexDirection: 'column' }}>
<ReservationsPanel
tripId={tripId}
reservations={reservations}
days={days}
assignments={assignments}
files={files}
onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }}
onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }}
onDelete={handleDeleteReservation}
onNavigateToFiles={() => handleTabChange('dateien')}
/>
</div>
)}
{/* PACKLISTE */}
{activeTab === 'packliste' && (
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1200, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<PackingListPanel tripId={tripId} items={packingItems} />
</div>
)}
{/* FINANZPLAN */}
{activeTab === 'finanzplan' && (
<div style={{ height: '100%', overflowY: 'auto', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
<BudgetPanel tripId={tripId} />
</div>
)}
{/* DATEIEN */}
{activeTab === 'dateien' && (
<div style={{ height: '100%', overflow: 'hidden' }}>
<FileManager
files={files || []}
onUpload={(fd) => tripStore.addFile(tripId, fd)}
onDelete={(id) => tripStore.deleteFile(tripId, id)}
onUpdate={null}
places={places}
reservations={reservations}
tripId={tripId}
/>
</div>
)}
</div>
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
</div>
)
}

View File

@@ -0,0 +1,127 @@
import { create } from 'zustand'
import { authApi } from '../api/client'
export const useAuthStore = create((set, get) => ({
user: null,
token: localStorage.getItem('auth_token') || null,
isAuthenticated: !!localStorage.getItem('auth_token'),
isLoading: false,
error: null,
login: async (email, password) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.login({ email, password })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
return data
} catch (err) {
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
set({ isLoading: false, error })
throw new Error(error)
}
},
register: async (username, email, password) => {
set({ isLoading: true, error: null })
try {
const data = await authApi.register({ username, email, password })
localStorage.setItem('auth_token', data.token)
set({
user: data.user,
token: data.token,
isAuthenticated: true,
isLoading: false,
error: null,
})
return data
} catch (err) {
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen'
set({ isLoading: false, error })
throw new Error(error)
}
},
logout: () => {
localStorage.removeItem('auth_token')
set({
user: null,
token: null,
isAuthenticated: false,
error: null,
})
},
loadUser: async () => {
const token = get().token
if (!token) {
set({ isLoading: false })
return
}
set({ isLoading: true })
try {
const data = await authApi.me()
set({
user: data.user,
isAuthenticated: true,
isLoading: false,
})
} catch (err) {
localStorage.removeItem('auth_token')
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
})
}
},
updateMapsKey: async (key) => {
try {
await authApi.updateMapsKey(key)
set(state => ({
user: { ...state.user, maps_api_key: key || null }
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Speichern des API-Schlüssels')
}
},
updateApiKeys: async (keys) => {
try {
const data = await authApi.updateApiKeys(keys)
set({ user: data.user })
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der API-Schlüssel')
}
},
updateProfile: async (profileData) => {
try {
const data = await authApi.updateSettings(profileData)
set({ user: data.user })
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Profils')
}
},
uploadAvatar: async (file) => {
const formData = new FormData()
formData.append('avatar', file)
const data = await authApi.uploadAvatar(formData)
set(state => ({ user: { ...state.user, avatar_url: data.avatar_url } }))
return data
},
deleteAvatar: async () => {
await authApi.deleteAvatar()
set(state => ({ user: { ...state.user, avatar_url: null } }))
},
}))

View File

@@ -0,0 +1,61 @@
import { create } from 'zustand'
import { settingsApi } from '../api/client'
export const useSettingsStore = create((set, get) => ({
settings: {
map_tile_url: '',
default_lat: 48.8566,
default_lng: 2.3522,
default_zoom: 10,
dark_mode: false,
default_currency: 'USD',
language: localStorage.getItem('app_language') || 'en',
temperature_unit: 'fahrenheit',
time_format: '12h',
show_place_description: false,
},
isLoaded: false,
loadSettings: async () => {
try {
const data = await settingsApi.get()
set(state => ({
settings: { ...state.settings, ...data.settings },
isLoaded: true,
}))
} catch (err) {
set({ isLoaded: true })
console.error('Failed to load settings:', err)
}
},
updateSetting: async (key, value) => {
set(state => ({
settings: { ...state.settings, [key]: value }
}))
if (key === 'language') localStorage.setItem('app_language', value)
try {
await settingsApi.set(key, value)
} catch (err) {
console.error('Failed to save setting:', err)
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellung')
}
},
setLanguageLocal: (lang) => {
localStorage.setItem('app_language', lang)
set(state => ({ settings: { ...state.settings, language: lang } }))
},
updateSettings: async (settingsObj) => {
set(state => ({
settings: { ...state.settings, ...settingsObj }
}))
try {
await settingsApi.setBulk(settingsObj)
} catch (err) {
console.error('Failed to save settings:', err)
throw new Error(err.response?.data?.error || 'Fehler beim Speichern der Einstellungen')
}
},
}))

View File

@@ -0,0 +1,577 @@
import { create } from 'zustand'
import { tripsApi, daysApi, placesApi, assignmentsApi, packingApi, tagsApi, categoriesApi, budgetApi, filesApi, reservationsApi, dayNotesApi } from '../api/client'
export const useTripStore = create((set, get) => ({
trip: null,
days: [],
places: [],
assignments: {}, // { [dayId]: [assignment objects] }
dayNotes: {}, // { [dayId]: [note objects] }
packingItems: [],
tags: [],
categories: [],
budgetItems: [],
files: [],
reservations: [],
selectedDayId: null,
isLoading: false,
error: null,
setSelectedDay: (dayId) => set({ selectedDayId: dayId }),
// Load everything for a trip
loadTrip: async (tripId) => {
set({ isLoading: true, error: null })
try {
const [tripData, daysData, placesData, packingData, tagsData, categoriesData] = await Promise.all([
tripsApi.get(tripId),
daysApi.list(tripId),
placesApi.list(tripId),
packingApi.list(tripId),
tagsApi.list(),
categoriesApi.list(),
])
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({
trip: tripData.trip,
days: daysData.days,
places: placesData.places,
assignments: assignmentsMap,
dayNotes: dayNotesMap,
packingItems: packingData.items,
tags: tagsData.tags,
categories: categoriesData.categories,
isLoading: false,
})
} catch (err) {
set({ isLoading: false, error: err.message })
throw err
}
},
// Refresh just the places
refreshPlaces: async (tripId) => {
try {
const data = await placesApi.list(tripId)
set({ places: data.places })
} catch (err) {
console.error('Failed to refresh places:', err)
}
},
// Places
addPlace: async (tripId, placeData) => {
try {
const data = await placesApi.create(tripId, placeData)
set(state => ({ places: [data.place, ...state.places] }))
return data.place
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Ortes')
}
},
updatePlace: async (tripId, placeId, placeData) => {
try {
const data = await placesApi.update(tripId, placeId, placeData)
set(state => ({
places: state.places.map(p => p.id === placeId ? data.place : p),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.map(a => a.place?.id === placeId ? { ...a, place: data.place } : a)
])
),
}))
return data.place
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Ortes')
}
},
deletePlace: async (tripId, placeId) => {
try {
await placesApi.delete(tripId, placeId)
set(state => ({
places: state.places.filter(p => p.id !== placeId),
assignments: Object.fromEntries(
Object.entries(state.assignments).map(([dayId, items]) => [
dayId,
items.filter(a => a.place?.id !== placeId)
])
),
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Ortes')
}
},
// Assignments
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
const state = get()
const place = state.places.find(p => p.id === parseInt(placeId))
if (!place) return
// Check if already assigned
const existing = (state.assignments[String(dayId)] || []).find(a => a.place?.id === parseInt(placeId))
if (existing) return
const tempId = Date.now() * -1
const current = [...(state.assignments[String(dayId)] || [])]
const insertIdx = position != null ? position : current.length
const tempAssignment = {
id: tempId,
day_id: parseInt(dayId),
order_index: insertIdx,
notes: null,
place,
}
current.splice(insertIdx, 0, tempAssignment)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: current,
}
}))
try {
const data = await assignmentsApi.create(tripId, dayId, { place_id: placeId })
const newAssignment = position != null
? { ...data.assignment, order_index: insertIdx }
: data.assignment
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].map(
a => a.id === tempId ? newAssignment : a
),
}
}))
// Reihenfolge am Server aktualisieren
if (position != null) {
const updated = get().assignments[String(dayId)] || []
const orderedIds = updated.map(a => a.id)
try { await assignmentsApi.reorder(tripId, dayId, orderedIds) } catch {}
}
return data.assignment
} catch (err) {
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
}
}))
throw new Error(err.response?.data?.error || 'Fehler beim Zuweisen des Ortes')
}
},
removeAssignment: async (tripId, dayId, assignmentId) => {
const prevAssignments = get().assignments
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== assignmentId),
}
}))
try {
await assignmentsApi.delete(tripId, dayId, assignmentId)
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Entfernen der Zuweisung')
}
},
reorderAssignments: async (tripId, dayId, orderedIds) => {
const prevAssignments = get().assignments
const dayItems = get().assignments[String(dayId)] || []
const reordered = orderedIds.map((id, idx) => {
const item = dayItems.find(a => a.id === id)
return item ? { ...item, order_index: idx } : null
}).filter(Boolean)
set(state => ({
assignments: {
...state.assignments,
[String(dayId)]: reordered,
}
}))
try {
await assignmentsApi.reorder(tripId, dayId, orderedIds)
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Neuanordnen')
}
},
moveAssignment: async (tripId, assignmentId, fromDayId, toDayId, toOrderIndex = null) => {
const state = get()
const prevAssignments = state.assignments
const assignment = (state.assignments[String(fromDayId)] || []).find(a => a.id === assignmentId)
if (!assignment) return
const toItems = (state.assignments[String(toDayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
const insertAt = toOrderIndex !== null ? toOrderIndex : toItems.length
// Build new order for target day with item inserted at correct position
const newToItems = [...toItems]
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(toDayId) })
newToItems.forEach((a, i) => { a.order_index = i })
set(s => ({
assignments: {
...s.assignments,
[String(fromDayId)]: s.assignments[String(fromDayId)].filter(a => a.id !== assignmentId),
[String(toDayId)]: newToItems,
}
}))
try {
await assignmentsApi.move(tripId, assignmentId, toDayId, insertAt)
if (newToItems.length > 1) {
await assignmentsApi.reorder(tripId, toDayId, newToItems.map(a => a.id))
}
} catch (err) {
set({ assignments: prevAssignments })
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Zuweisung')
}
},
moveDayNote: async (tripId, fromDayId, toDayId, noteId, sort_order = 9999) => {
const state = get()
const note = (state.dayNotes[String(fromDayId)] || []).find(n => n.id === noteId)
if (!note) return
// Optimistic: remove from old day
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: (s.dayNotes[String(fromDayId)] || []).filter(n => n.id !== noteId),
}
}))
try {
await dayNotesApi.delete(tripId, fromDayId, noteId)
const result = await dayNotesApi.create(tripId, toDayId, {
text: note.text, time: note.time, icon: note.icon, sort_order,
})
set(s => ({
dayNotes: {
...s.dayNotes,
[String(toDayId)]: [...(s.dayNotes[String(toDayId)] || []), result.note],
}
}))
} catch (err) {
// Rollback
set(s => ({
dayNotes: {
...s.dayNotes,
[String(fromDayId)]: [...(s.dayNotes[String(fromDayId)] || []), note],
}
}))
throw new Error(err.response?.data?.error || 'Fehler beim Verschieben der Notiz')
}
},
setAssignments: (assignments) => {
set({ assignments })
},
// Packing
addPackingItem: async (tripId, data) => {
try {
const result = await packingApi.create(tripId, data)
set(state => ({ packingItems: [...state.packingItems, result.item] }))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Artikels')
}
},
updatePackingItem: async (tripId, id, data) => {
try {
const result = await packingApi.update(tripId, id, data)
set(state => ({
packingItems: state.packingItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Artikels')
}
},
deletePackingItem: async (tripId, id) => {
const prev = get().packingItems
set(state => ({ packingItems: state.packingItems.filter(item => item.id !== id) }))
try {
await packingApi.delete(tripId, id)
} catch (err) {
set({ packingItems: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Artikels')
}
},
togglePackingItem: async (tripId, id, checked) => {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 1 : 0 } : item
)
}))
try {
await packingApi.update(tripId, id, { checked })
} catch (err) {
set(state => ({
packingItems: state.packingItems.map(item =>
item.id === id ? { ...item, checked: checked ? 0 : 1 } : item
)
}))
}
},
// Days
updateDayNotes: async (tripId, dayId, notes) => {
try {
await daysApi.update(tripId, dayId, { notes })
set(state => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, notes } : d)
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notizen')
}
},
updateDayTitle: async (tripId, dayId, title) => {
try {
await daysApi.update(tripId, dayId, { title })
set(state => ({
days: state.days.map(d => d.id === parseInt(dayId) ? { ...d, title } : d)
}))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Tagesnamens')
}
},
// Tags and categories
addTag: async (data) => {
try {
const result = await tagsApi.create(data)
set(state => ({ tags: [...state.tags, result.tag] }))
return result.tag
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen des Tags')
}
},
addCategory: async (data) => {
try {
const result = await categoriesApi.create(data)
set(state => ({ categories: [...state.categories, result.category] }))
return result.category
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Kategorie')
}
},
// Update trip
updateTrip: async (tripId, data) => {
try {
const result = await tripsApi.update(tripId, data)
set({ trip: result.trip })
const daysData = await daysApi.list(tripId)
const assignmentsMap = {}
const dayNotesMap = {}
for (const day of daysData.days) {
assignmentsMap[String(day.id)] = day.assignments || []
dayNotesMap[String(day.id)] = day.notes_items || []
}
set({ days: daysData.days, assignments: assignmentsMap, dayNotes: dayNotesMap })
return result.trip
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reise')
}
},
// Budget
loadBudgetItems: async (tripId) => {
try {
const data = await budgetApi.list(tripId)
set({ budgetItems: data.items })
} catch (err) {
console.error('Failed to load budget items:', err)
}
},
addBudgetItem: async (tripId, data) => {
try {
const result = await budgetApi.create(tripId, data)
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen des Budget-Eintrags')
}
},
updateBudgetItem: async (tripId, id, data) => {
try {
const result = await budgetApi.update(tripId, id, data)
set(state => ({
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
}))
return result.item
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren des Budget-Eintrags')
}
},
deleteBudgetItem: async (tripId, id) => {
const prev = get().budgetItems
set(state => ({ budgetItems: state.budgetItems.filter(item => item.id !== id) }))
try {
await budgetApi.delete(tripId, id)
} catch (err) {
set({ budgetItems: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen des Budget-Eintrags')
}
},
// Files
loadFiles: async (tripId) => {
try {
const data = await filesApi.list(tripId)
set({ files: data.files })
} catch (err) {
console.error('Failed to load files:', err)
}
},
addFile: async (tripId, formData) => {
try {
const data = await filesApi.upload(tripId, formData)
set(state => ({ files: [data.file, ...state.files] }))
return data.file
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hochladen der Datei')
}
},
deleteFile: async (tripId, id) => {
try {
await filesApi.delete(tripId, id)
set(state => ({ files: state.files.filter(f => f.id !== id) }))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Datei')
}
},
// Reservations
loadReservations: async (tripId) => {
try {
const data = await reservationsApi.list(tripId)
set({ reservations: data.reservations })
} catch (err) {
console.error('Failed to load reservations:', err)
}
},
addReservation: async (tripId, data) => {
try {
const result = await reservationsApi.create(tripId, data)
set(state => ({ reservations: [result.reservation, ...state.reservations] }))
return result.reservation
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Erstellen der Reservierung')
}
},
updateReservation: async (tripId, id, data) => {
try {
const result = await reservationsApi.update(tripId, id, data)
set(state => ({
reservations: state.reservations.map(r => r.id === id ? result.reservation : r)
}))
return result.reservation
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Reservierung')
}
},
toggleReservationStatus: async (tripId, id) => {
const prev = get().reservations
const current = prev.find(r => r.id === id)
if (!current) return
const newStatus = current.status === 'confirmed' ? 'pending' : 'confirmed'
set(state => ({
reservations: state.reservations.map(r => r.id === id ? { ...r, status: newStatus } : r)
}))
try {
await reservationsApi.update(tripId, id, { status: newStatus })
} catch {
set({ reservations: prev })
}
},
deleteReservation: async (tripId, id) => {
try {
await reservationsApi.delete(tripId, id)
set(state => ({ reservations: state.reservations.filter(r => r.id !== id) }))
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Reservierung')
}
},
// Day Notes
addDayNote: async (tripId, dayId, data) => {
try {
const result = await dayNotesApi.create(tripId, dayId, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: [...(state.dayNotes[String(dayId)] || []), result.note],
}
}))
return result.note
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Hinzufügen der Notiz')
}
},
updateDayNote: async (tripId, dayId, id, data) => {
try {
const result = await dayNotesApi.update(tripId, dayId, id, data)
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).map(n => n.id === id ? result.note : n),
}
}))
return result.note
} catch (err) {
throw new Error(err.response?.data?.error || 'Fehler beim Aktualisieren der Notiz')
}
},
deleteDayNote: async (tripId, dayId, id) => {
const prev = get().dayNotes
set(state => ({
dayNotes: {
...state.dayNotes,
[String(dayId)]: (state.dayNotes[String(dayId)] || []).filter(n => n.id !== id),
}
}))
try {
await dayNotesApi.delete(tripId, dayId, id)
} catch (err) {
set({ dayNotes: prev })
throw new Error(err.response?.data?.error || 'Fehler beim Löschen der Notiz')
}
},
}))

43
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,43 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eef2ff',
100: '#e0e7ff',
200: '#c7d2fe',
300: '#a5b4fc',
400: '#818cf8',
500: '#6366f1',
600: '#4f46e5',
700: '#4338ca',
800: '#3730a3',
900: '#312e81',
950: '#1e1b4b',
},
planner: {
day: '#f8fafc',
dayBorder: '#e2e8f0',
dayHeader: '#1e293b',
sidebar: '#ffffff',
sidebarBorder: '#f1f5f9',
overlay: 'rgba(15, 23, 42, 0.4)',
dragActive: '#eef2ff',
dragOver: '#c7d2fe',
},
},
boxShadow: {
'day-column': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
'place-card': '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
'drag-overlay': '0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
},
},
},
plugins: [],
}

19
client/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
}
}
}
})