mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a992b7b4e | |||
| 8396a75223 | |||
| 510475a46f | |||
| cb080954c9 | |||
| 35275e209d | |||
| feb2a8a5f2 | |||
| fae8473319 | |||
| 93d7e965cc | |||
| 6c470f5de3 | |||
| 502fbb2f3f | |||
| b11f85eda0 | |||
| 068b90ed72 | |||
| 17288f9a0e | |||
| 3bf49d4180 | |||
| 66e2799870 | |||
| 732accce3d | |||
| 785e8264cd | |||
| e3cb5745dd | |||
| 785f0a7684 | |||
| e1cd9655fb | |||
| 2e0481c045 | |||
| 3d13ed75d7 | |||
| 7094e54432 | |||
| 858bea1952 | |||
| 3fd2410ba6 | |||
| c1e568cb1e | |||
| 21a71697be | |||
| e660cca284 | |||
| 763c878dab | |||
| d0d39d1e35 | |||
| e70cd5729e | |||
| 114ec7d131 | |||
| 0497032ed7 | |||
| e4607e426c | |||
| faa8c84655 | |||
| 88dca41ef7 | |||
| 33162123af |
@@ -0,0 +1,2 @@
|
||||
ko_fi: mauriceboe
|
||||
buy_me_a_coffee: mauriceboe
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: "[BUG]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: "[FEATURE]"
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
+4
-3
@@ -26,8 +26,9 @@ COPY --from=client-builder /app/client/dist ./public
|
||||
# Fonts für PDF-Export kopieren
|
||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||
|
||||
# Verzeichnisse erstellen
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers
|
||||
# Verzeichnisse erstellen + Symlink für Abwärtskompatibilität (alte docker-compose mounten nach /app/server/uploads)
|
||||
RUN mkdir -p /app/data /app/uploads/files /app/uploads/covers /app/uploads/avatars /app/uploads/photos && \
|
||||
mkdir -p /app/server && ln -s /app/uploads /app/server/uploads && ln -s /app/data /app/server/data
|
||||
|
||||
# Umgebung setzen
|
||||
ENV NODE_ENV=production
|
||||
@@ -35,4 +36,4 @@ ENV PORT=3000
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "src/index.js"]
|
||||
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
||||
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching
|
||||
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||
|
||||
### Travel Management
|
||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
||||
@@ -62,16 +62,18 @@
|
||||
- **Real-Time Sync** — Plan together via WebSocket — changes appear instantly across all connected users
|
||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
||||
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
|
||||
### Addons (modular, admin-toggleable)
|
||||
- **Vacay** — Personal vacation day planner with calendar view, public holidays (100+ countries), company holidays, user fusion with live sync, and carry-over tracking
|
||||
- **Atlas** — Interactive world map with visited countries, travel stats, continent breakdown, streak tracking, and liquid glass UI effects
|
||||
- **Collab** — Chat with your group, share notes, create polls, and track who's signed up for each day's activities
|
||||
- **Dashboard Widgets** — Currency converter and timezone clock, toggleable per user
|
||||
|
||||
### Customization & Admin
|
||||
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||
- **Multilingual** — English and German (i18n)
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, and backups
|
||||
- **Admin Panel** — User management, global categories, addon management, API keys, backups, and GitHub release history
|
||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
||||
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||
|
||||
@@ -84,7 +86,7 @@
|
||||
- **State**: Zustand
|
||||
- **Auth**: JWT + OIDC
|
||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||
- **Weather**: OpenWeatherMap API (optional)
|
||||
- **Weather**: Open-Meteo API (free, no key required)
|
||||
- **Icons**: lucide-react
|
||||
|
||||
## Quick Start
|
||||
@@ -131,15 +133,23 @@ docker compose up -d
|
||||
|
||||
### Updating
|
||||
|
||||
**Docker Compose** (recommended):
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||
|
||||
```bash
|
||||
docker pull mauriceboe/nomad
|
||||
docker rm -f nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v /your/data:/app/data -v /your/uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
docker run -d --name nomad -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/nomad
|
||||
```
|
||||
|
||||
Or with Docker Compose: `docker compose pull && docker compose up -d`
|
||||
> **Tip:** Not sure which paths you used? Run `docker inspect nomad --format '{{json .Mounts}}'` before removing the container.
|
||||
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes.
|
||||
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||
|
||||
### Reverse Proxy (recommended)
|
||||
|
||||
@@ -212,12 +222,6 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
|
||||
3. Create an API key under Credentials
|
||||
4. In NOMAD: Admin Panel → Settings → Google Maps
|
||||
|
||||
### OpenWeatherMap (Weather Forecasts)
|
||||
|
||||
1. Sign up at [OpenWeatherMap](https://openweathermap.org/api)
|
||||
2. Get a free API key
|
||||
3. In NOMAD: Admin Panel → Settings → OpenWeatherMap
|
||||
|
||||
## Building from Source
|
||||
|
||||
```bash
|
||||
|
||||
+1
-1
@@ -25,6 +25,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+28
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.2",
|
||||
"version": "2.6.0",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
@@ -26,11 +26,13 @@
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0"
|
||||
}
|
||||
@@ -3266,6 +3268,16 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-window": {
|
||||
"version": "1.8.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/resolve": {
|
||||
"version": "1.20.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
|
||||
@@ -7534,6 +7546,20 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz",
|
||||
"integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/unbox-primitive": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
|
||||
|
||||
+3
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "nomad-client",
|
||||
"version": "2.5.5",
|
||||
"version": "2.6.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -28,11 +28,13 @@
|
||||
"@types/leaflet": "^1.9.8",
|
||||
"@types/react": "^18.2.61",
|
||||
"@types/react-dom": "^18.2.19",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"postcss": "^8.4.35",
|
||||
"sharp": "^0.33.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^5.1.4",
|
||||
"vite-plugin-pwa": "^0.21.0"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import React, { useEffect, ReactNode } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { useSettingsStore } from './store/settingsStore'
|
||||
@@ -6,26 +6,31 @@ 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 VacayPage from './pages/VacayPage'
|
||||
import AtlasPage from './pages/AtlasPage'
|
||||
import { ToastContainer } from './components/shared/Toast'
|
||||
import { TranslationProvider } from './i18n'
|
||||
import { TranslationProvider, useTranslation } from './i18n'
|
||||
import DemoBanner from './components/Layout/DemoBanner'
|
||||
import { authApi } from './api/client'
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }) {
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
adminRequired?: boolean
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children, adminRequired = false }: ProtectedRouteProps) {
|
||||
const { isAuthenticated, user, isLoading } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
|
||||
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>
|
||||
<p className="text-slate-500 text-sm">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -39,7 +44,7 @@ function ProtectedRoute({ children, adminRequired = false }) {
|
||||
return <Navigate to="/dashboard" replace />
|
||||
}
|
||||
|
||||
return children
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function RootRedirect() {
|
||||
@@ -64,7 +69,7 @@ export default function App() {
|
||||
if (token) {
|
||||
loadUser()
|
||||
}
|
||||
authApi.getAppConfig().then(config => {
|
||||
authApi.getAppConfig().then((config: { demo_mode?: boolean; has_maps_key?: boolean }) => {
|
||||
if (config?.demo_mode) setDemoMode(true)
|
||||
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||
}).catch(() => {})
|
||||
@@ -78,17 +83,22 @@ export default function App() {
|
||||
}
|
||||
}, [isAuthenticated])
|
||||
|
||||
// Apply dark mode class to <html> + update PWA theme-color
|
||||
useEffect(() => {
|
||||
if (settings.dark_mode) {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
const mode = settings.dark_mode
|
||||
const applyDark = (isDark: boolean) => {
|
||||
document.documentElement.classList.toggle('dark', isDark)
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) meta.setAttribute('content', isDark ? '#09090b' : '#ffffff')
|
||||
}
|
||||
const meta = document.querySelector('meta[name="theme-color"]')
|
||||
if (meta) {
|
||||
meta.setAttribute('content', settings.dark_mode ? '#09090b' : '#ffffff')
|
||||
|
||||
if (mode === 'auto') {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
applyDark(mq.matches)
|
||||
const handler = (e: MediaQueryListEvent) => applyDark(e.matches)
|
||||
mq.addEventListener('change', handler)
|
||||
return () => mq.removeEventListener('change', handler)
|
||||
}
|
||||
applyDark(mode === true || mode === 'dark')
|
||||
}, [settings.dark_mode])
|
||||
|
||||
return (
|
||||
@@ -1,214 +0,0 @@
|
||||
import axios from 'axios'
|
||||
import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token and socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
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),
|
||||
changePassword: (data) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').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),
|
||||
createUser: (data) => apiClient.post('/admin/users', data).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),
|
||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||
updateOidc: (data) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id, data) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').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
|
||||
@@ -0,0 +1,248 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { getSocketId } from './websocket'
|
||||
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: '/api',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor - add auth token and socket ID
|
||||
apiClient.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('auth_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const sid = getSocketId()
|
||||
if (sid) {
|
||||
config.headers['X-Socket-Id'] = sid
|
||||
}
|
||||
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: { username: string; email: string; password: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||
me: () => apiClient.get('/auth/me').then(r => r.data),
|
||||
updateMapsKey: (key: string | null) => apiClient.put('/auth/me/maps-key', { maps_api_key: key }).then(r => r.data),
|
||||
updateApiKeys: (data: Record<string, string | null>) => apiClient.put('/auth/me/api-keys', data).then(r => r.data),
|
||||
updateSettings: (data: Record<string, unknown>) => 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: 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: Record<string, unknown>) => 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),
|
||||
changePassword: (data: { current_password: string; new_password: string }) => apiClient.put('/auth/me/password', data).then(r => r.data),
|
||||
deleteOwnAccount: () => apiClient.delete('/auth/me').then(r => r.data),
|
||||
demoLogin: () => apiClient.post('/auth/demo-login').then(r => r.data),
|
||||
}
|
||||
|
||||
export const tripsApi = {
|
||||
list: (params?: Record<string, unknown>) => apiClient.get('/trips', { params }).then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/trips', data).then(r => r.data),
|
||||
get: (id: number | string) => apiClient.get(`/trips/${id}`).then(r => r.data),
|
||||
update: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${id}`, data).then(r => r.data),
|
||||
delete: (id: number | string) => apiClient.delete(`/trips/${id}`).then(r => r.data),
|
||||
uploadCover: (id: number | string, formData: FormData) => apiClient.post(`/trips/${id}/cover`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
archive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: true }).then(r => r.data),
|
||||
unarchive: (id: number | string) => apiClient.put(`/trips/${id}`, { is_archived: false }).then(r => r.data),
|
||||
getMembers: (id: number | string) => apiClient.get(`/trips/${id}/members`).then(r => r.data),
|
||||
addMember: (id: number | string, identifier: string) => apiClient.post(`/trips/${id}/members`, { identifier }).then(r => r.data),
|
||||
removeMember: (id: number | string, userId: number) => apiClient.delete(`/trips/${id}/members/${userId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const daysApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/days`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
list: (tripId: number | string, params?: Record<string, unknown>) => apiClient.get(`/trips/${tripId}/places`, { params }).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/places`, data).then(r => r.data),
|
||||
get: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/assignments`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: { place_id: number | string }) => apiClient.post(`/trips/${tripId}/days/${dayId}/assignments`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/assignments/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, dayId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/reorder`, { orderedIds }).then(r => r.data),
|
||||
move: (tripId: number | string, assignmentId: number, newDayId: number | string, orderIndex: number | null) => apiClient.put(`/trips/${tripId}/assignments/${assignmentId}/move`, { new_day_id: newDayId, order_index: orderIndex }).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/assignments/${id}`, data).then(r => r.data),
|
||||
getParticipants: (tripId: number | string, id: number) => apiClient.get(`/trips/${tripId}/assignments/${id}/participants`).then(r => r.data),
|
||||
setParticipants: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/assignments/${id}/participants`, { user_ids: userIds }).then(r => r.data),
|
||||
updateTime: (tripId: number | string, id: number, times: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/assignments/${id}/time`, times).then(r => r.data),
|
||||
}
|
||||
|
||||
export const packingApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/packing`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/packing/${id}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const tagsApi = {
|
||||
list: () => apiClient.get('/tags').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/tags', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/tags/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/tags/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const categoriesApi = {
|
||||
list: () => apiClient.get('/categories').then(r => r.data),
|
||||
create: (data: Record<string, unknown>) => apiClient.post('/categories', data).then(r => r.data),
|
||||
update: (id: number, data: Record<string, unknown>) => apiClient.put(`/categories/${id}`, data).then(r => r.data),
|
||||
delete: (id: number) => apiClient.delete(`/categories/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
users: () => apiClient.get('/admin/users').then(r => r.data),
|
||||
createUser: (data: Record<string, unknown>) => apiClient.post('/admin/users', data).then(r => r.data),
|
||||
updateUser: (id: number, data: Record<string, unknown>) => apiClient.put(`/admin/users/${id}`, data).then(r => r.data),
|
||||
deleteUser: (id: number) => apiClient.delete(`/admin/users/${id}`).then(r => r.data),
|
||||
stats: () => apiClient.get('/admin/stats').then(r => r.data),
|
||||
saveDemoBaseline: () => apiClient.post('/admin/save-demo-baseline').then(r => r.data),
|
||||
getOidc: () => apiClient.get('/admin/oidc').then(r => r.data),
|
||||
updateOidc: (data: Record<string, unknown>) => apiClient.put('/admin/oidc', data).then(r => r.data),
|
||||
addons: () => apiClient.get('/admin/addons').then(r => r.data),
|
||||
updateAddon: (id: number | string, data: Record<string, unknown>) => apiClient.put(`/admin/addons/${id}`, data).then(r => r.data),
|
||||
checkVersion: () => apiClient.get('/admin/version-check').then(r => r.data),
|
||||
installUpdate: () => apiClient.post('/admin/update', {}, { timeout: 300000 }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const addonsApi = {
|
||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||
}
|
||||
|
||||
export const mapsApi = {
|
||||
search: (query: string, lang?: string) => apiClient.post(`/maps/search?lang=${lang || 'en'}`, { query }).then(r => r.data),
|
||||
details: (placeId: string, lang?: string) => apiClient.get(`/maps/details/${placeId}`, { params: { lang } }).then(r => r.data),
|
||||
placePhoto: (placeId: string) => apiClient.get(`/maps/place-photo/${placeId}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const budgetApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data),
|
||||
setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data),
|
||||
togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data),
|
||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/files`).then(r => r.data),
|
||||
upload: (tripId: number | string, formData: FormData) => apiClient.post(`/trips/${tripId}/files`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/files/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const reservationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/reservations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/reservations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/reservations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/reservations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const weatherApi = {
|
||||
get: (lat: number, lng: number, date: string) => apiClient.get('/weather', { params: { lat, lng, date } }).then(r => r.data),
|
||||
getDetailed: (lat: number, lng: number, date: string, lang?: string) => apiClient.get('/weather/detailed', { params: { lat, lng, date, lang } }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const settingsApi = {
|
||||
get: () => apiClient.get('/settings').then(r => r.data),
|
||||
set: (key: string, value: unknown) => apiClient.put('/settings', { key, value }).then(r => r.data),
|
||||
setBulk: (settings: Record<string, unknown>) => apiClient.post('/settings/bulk', { settings }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const accommodationsApi = {
|
||||
list: (tripId: number | string) => apiClient.get(`/trips/${tripId}/accommodations`).then(r => r.data),
|
||||
create: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/accommodations`, data).then(r => r.data),
|
||||
update: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/accommodations/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/accommodations/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const dayNotesApi = {
|
||||
list: (tripId: number | string, dayId: number | string) => apiClient.get(`/trips/${tripId}/days/${dayId}/notes`).then(r => r.data),
|
||||
create: (tripId: number | string, dayId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/days/${dayId}/notes`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/days/${dayId}/notes/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/days/${dayId}/notes/${id}`).then(r => r.data),
|
||||
}
|
||||
|
||||
export const collabApi = {
|
||||
getNotes: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/notes`).then(r => r.data),
|
||||
createNote: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/notes`, data).then(r => r.data),
|
||||
updateNote: (tripId: number | string, id: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/collab/notes/${id}`, data).then(r => r.data),
|
||||
deleteNote: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${id}`).then(r => r.data),
|
||||
uploadNoteFile: (tripId: number | string, noteId: number, formData: FormData) => apiClient.post(`/trips/${tripId}/collab/notes/${noteId}/files`, formData, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data),
|
||||
deleteNoteFile: (tripId: number | string, noteId: number, fileId: number) => apiClient.delete(`/trips/${tripId}/collab/notes/${noteId}/files/${fileId}`).then(r => r.data),
|
||||
getPolls: (tripId: number | string) => apiClient.get(`/trips/${tripId}/collab/polls`).then(r => r.data),
|
||||
createPoll: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/polls`, data).then(r => r.data),
|
||||
votePoll: (tripId: number | string, id: number, optionIndex: number) => apiClient.post(`/trips/${tripId}/collab/polls/${id}/vote`, { option_index: optionIndex }).then(r => r.data),
|
||||
closePoll: (tripId: number | string, id: number) => apiClient.put(`/trips/${tripId}/collab/polls/${id}/close`).then(r => r.data),
|
||||
deletePoll: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/polls/${id}`).then(r => r.data),
|
||||
getMessages: (tripId: number | string, before?: string) => apiClient.get(`/trips/${tripId}/collab/messages${before ? `?before=${before}` : ''}`).then(r => r.data),
|
||||
sendMessage: (tripId: number | string, data: Record<string, unknown>) => apiClient.post(`/trips/${tripId}/collab/messages`, data).then(r => r.data),
|
||||
deleteMessage: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/collab/messages/${id}`).then(r => r.data),
|
||||
reactMessage: (tripId: number | string, id: number, emoji: string) => apiClient.post(`/trips/${tripId}/collab/messages/${id}/react`, { emoji }).then(r => r.data),
|
||||
linkPreview: (tripId: number | string, url: string) => apiClient.get(`/trips/${tripId}/collab/link-preview?url=${encodeURIComponent(url)}`).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: string): Promise<void> => {
|
||||
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 failed')
|
||||
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: string) => apiClient.delete(`/backup/${filename}`).then(r => r.data),
|
||||
restore: (filename: string) => apiClient.post(`/backup/restore/${filename}`).then(r => r.data),
|
||||
uploadRestore: (file: 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: Record<string, unknown>) => apiClient.put('/backup/auto-settings', settings).then(r => r.data),
|
||||
}
|
||||
|
||||
export default apiClient
|
||||
@@ -1,45 +1,47 @@
|
||||
// Singleton WebSocket manager for real-time collaboration
|
||||
|
||||
let socket = null
|
||||
let reconnectTimer = null
|
||||
type WebSocketListener = (event: Record<string, unknown>) => void
|
||||
type RefetchCallback = (tripId: string) => void
|
||||
|
||||
let socket: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectDelay = 1000
|
||||
const MAX_RECONNECT_DELAY = 30000
|
||||
const listeners = new Set()
|
||||
const activeTrips = new Set()
|
||||
let currentToken = null
|
||||
let refetchCallback = null
|
||||
let mySocketId = null
|
||||
const listeners = new Set<WebSocketListener>()
|
||||
const activeTrips = new Set<string>()
|
||||
let currentToken: string | null = null
|
||||
let refetchCallback: RefetchCallback | null = null
|
||||
let mySocketId: string | null = null
|
||||
|
||||
export function getSocketId() {
|
||||
export function getSocketId(): string | null {
|
||||
return mySocketId
|
||||
}
|
||||
|
||||
export function setRefetchCallback(fn) {
|
||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||
refetchCallback = fn
|
||||
}
|
||||
|
||||
function getWsUrl(token) {
|
||||
function getWsUrl(token: string): string {
|
||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${protocol}://${location.host}/ws?token=${token}`
|
||||
}
|
||||
|
||||
function handleMessage(event) {
|
||||
function handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data)
|
||||
// Store our socket ID from welcome message
|
||||
if (parsed.type === 'welcome') {
|
||||
mySocketId = parsed.socketId
|
||||
return
|
||||
}
|
||||
listeners.forEach(fn => {
|
||||
try { fn(parsed) } catch (err) { console.error('WebSocket listener error:', err) }
|
||||
try { fn(parsed) } catch (err: unknown) { console.error('WebSocket listener error:', err) }
|
||||
})
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('WebSocket message parse error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
function scheduleReconnect(): void {
|
||||
if (reconnectTimer) return
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null
|
||||
@@ -50,7 +52,7 @@ function scheduleReconnect() {
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
||||
}
|
||||
|
||||
function connectInternal(token, isReconnect = false) {
|
||||
function connectInternal(token: string, _isReconnect = false): void {
|
||||
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||
return
|
||||
}
|
||||
@@ -59,20 +61,16 @@ function connectInternal(token, isReconnect = false) {
|
||||
socket = new WebSocket(url)
|
||||
|
||||
socket.onopen = () => {
|
||||
// connection established
|
||||
reconnectDelay = 1000
|
||||
// Join active trips on any connect (initial or reconnect)
|
||||
if (activeTrips.size > 0) {
|
||||
activeTrips.forEach(tripId => {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId }))
|
||||
// joined trip room
|
||||
}
|
||||
})
|
||||
// Refetch trip data for active trips
|
||||
if (refetchCallback) {
|
||||
activeTrips.forEach(tripId => {
|
||||
try { refetchCallback(tripId) } catch (err) {
|
||||
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||
console.error('Failed to refetch trip data on reconnect:', err)
|
||||
}
|
||||
})
|
||||
@@ -94,7 +92,7 @@ function connectInternal(token, isReconnect = false) {
|
||||
}
|
||||
}
|
||||
|
||||
export function connect(token) {
|
||||
export function connect(token: string): void {
|
||||
currentToken = token
|
||||
reconnectDelay = 1000
|
||||
if (reconnectTimer) {
|
||||
@@ -104,7 +102,7 @@ export function connect(token) {
|
||||
connectInternal(token, false)
|
||||
}
|
||||
|
||||
export function disconnect() {
|
||||
export function disconnect(): void {
|
||||
currentToken = null
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
@@ -112,30 +110,30 @@ export function disconnect() {
|
||||
}
|
||||
activeTrips.clear()
|
||||
if (socket) {
|
||||
socket.onclose = null // prevent reconnect
|
||||
socket.onclose = null
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
}
|
||||
|
||||
export function joinTrip(tripId) {
|
||||
export function joinTrip(tripId: number | string): void {
|
||||
activeTrips.add(String(tripId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function leaveTrip(tripId) {
|
||||
export function leaveTrip(tripId: number | string): void {
|
||||
activeTrips.delete(String(tripId))
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
|
||||
}
|
||||
}
|
||||
|
||||
export function addListener(fn) {
|
||||
export function addListener(fn: WebSocketListener): void {
|
||||
listeners.add(fn)
|
||||
}
|
||||
|
||||
export function removeListener(fn) {
|
||||
export function removeListener(fn: WebSocketListener): void {
|
||||
listeners.delete(fn)
|
||||
}
|
||||
+40
-13
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { adminApi } from '../../api/client'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
@@ -9,14 +9,28 @@ const ICON_MAP = {
|
||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase,
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }) {
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
interface AddonIconProps {
|
||||
name: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function AddonIcon({ name, size = 20 }: AddonIconProps) {
|
||||
const Icon = ICON_MAP[name] || Puzzle
|
||||
return <Icon size={size} />
|
||||
}
|
||||
|
||||
export default function AddonManager() {
|
||||
const { t } = useTranslation()
|
||||
const dark = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dm = useSettingsStore(s => s.settings.dark_mode)
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const toast = useToast()
|
||||
const [addons, setAddons] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -30,7 +44,7 @@ export default function AddonManager() {
|
||||
try {
|
||||
const data = await adminApi.addons()
|
||||
setAddons(data.addons)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
@@ -45,7 +59,7 @@ export default function AddonManager() {
|
||||
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||
window.dispatchEvent(new Event('addons-changed'))
|
||||
toast.success(t('admin.addons.toast.updated'))
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
// Rollback
|
||||
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||
toast.error(t('admin.addons.toast.error'))
|
||||
@@ -116,9 +130,16 @@ export default function AddonManager() {
|
||||
)
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }) {
|
||||
interface AddonRowProps {
|
||||
addon: Addon
|
||||
onToggle: (addonId: string) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||
const isComingSoon = false
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div className="flex items-center gap-4 px-6 py-4 border-b transition-colors hover:opacity-95" style={{ borderColor: 'var(--border-secondary)', opacity: isComingSoon ? 0.5 : 1, pointerEvents: isComingSoon ? 'none' : 'auto' }}>
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)' }}>
|
||||
<AddonIcon name={addon.icon} size={20} />
|
||||
@@ -128,6 +149,11 @@ function AddonRow({ addon, onToggle, t }) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{addon.name}</span>
|
||||
{isComingSoon && (
|
||||
<span className="text-[9px] font-semibold px-2 py-0.5 rounded-full" style={{ background: 'var(--bg-tertiary)', color: 'var(--text-faint)' }}>
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full" style={{
|
||||
background: addon.type === 'global' ? 'var(--bg-secondary)' : 'var(--bg-secondary)',
|
||||
color: 'var(--text-muted)',
|
||||
@@ -140,19 +166,20 @@ function AddonRow({ addon, onToggle, t }) {
|
||||
|
||||
{/* Toggle */}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs font-medium" style={{ color: addon.enabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
<span className="text-xs font-medium" style={{ color: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||
{isComingSoon ? t('admin.addons.disabled') : addon.enabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onToggle(addon)}
|
||||
onClick={() => !isComingSoon && onToggle(addon)}
|
||||
disabled={isComingSoon}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||
style={{ background: addon.enabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
|
||||
style={{ background: (addon.enabled && !isComingSoon) ? 'var(--text-primary)' : 'var(--border-primary)', cursor: isComingSoon ? 'not-allowed' : 'pointer' }}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-4 w-4 transform rounded-full transition-transform"
|
||||
style={{
|
||||
background: addon.enabled ? 'var(--bg-card)' : 'var(--bg-card)',
|
||||
transform: addon.enabled ? 'translateX(22px)' : 'translateX(4px)',
|
||||
background: 'var(--bg-card)',
|
||||
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
+12
-15
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { 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, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const INTERVAL_OPTIONS = [
|
||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||
@@ -73,7 +74,7 @@ export default function BackupPanel() {
|
||||
}
|
||||
|
||||
const handleUploadRestore = (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
e.target.value = ''
|
||||
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||
@@ -90,8 +91,8 @@ export default function BackupPanel() {
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
|
||||
setRestoringFile(null)
|
||||
}
|
||||
} else {
|
||||
@@ -100,8 +101,8 @@ export default function BackupPanel() {
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
@@ -387,7 +388,7 @@ export default function BackupPanel() {
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||
{language === 'de' ? 'Backup wiederherstellen?' : 'Restore Backup?'}
|
||||
{t('backup.restoreConfirmTitle')}
|
||||
</h3>
|
||||
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||
{restoreConfirm.filename}
|
||||
@@ -398,17 +399,13 @@ export default function BackupPanel() {
|
||||
{/* Body */}
|
||||
<div style={{ padding: '20px 24px' }}>
|
||||
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||
{language === 'de'
|
||||
? 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.'
|
||||
: 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.'}
|
||||
{t('backup.restoreWarning')}
|
||||
</p>
|
||||
|
||||
<div style={{ marginTop: 14, padding: '10px 12px', borderRadius: 10, fontSize: 12, lineHeight: 1.5 }}
|
||||
className="bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300 border border-red-200 dark:border-red-800"
|
||||
>
|
||||
{language === 'de'
|
||||
? 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.'
|
||||
: 'Tip: Create a backup of the current state before restoring.'}
|
||||
{t('backup.restoreTip')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,7 +416,7 @@ export default function BackupPanel() {
|
||||
className="text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit' }}
|
||||
>
|
||||
{language === 'de' ? 'Abbrechen' : 'Cancel'}
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={executeRestore}
|
||||
@@ -427,7 +424,7 @@ export default function BackupPanel() {
|
||||
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||
>
|
||||
{language === 'de' ? 'Ja, wiederherstellen' : 'Yes, restore'}
|
||||
{t('backup.restoreConfirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
+7
-6
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { 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'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
|
||||
@@ -31,7 +32,7 @@ export default function CategoryManager() {
|
||||
try {
|
||||
const data = await categoriesApi.list()
|
||||
setCategories(data.categories || [])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('categories.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -71,8 +72,8 @@ export default function CategoryManager() {
|
||||
toast.success(t('categories.toast.created'))
|
||||
}
|
||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -84,8 +85,8 @@ export default function CategoryManager() {
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const REPO = 'mauriceboe/NOMAD'
|
||||
const PER_PAGE = 10
|
||||
|
||||
export default function GitHubPanel() {
|
||||
const { t, language } = useTranslation()
|
||||
const [releases, setReleases] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [expanded, setExpanded] = useState({})
|
||||
const [page, setPage] = useState(1)
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
|
||||
const fetchReleases = async (pageNum = 1, append = false) => {
|
||||
try {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=${PER_PAGE}&page=${pageNum}`)
|
||||
if (!res.ok) throw new Error(`GitHub API: ${res.status}`)
|
||||
const data = await res.json()
|
||||
setReleases(prev => append ? [...prev, ...data] : data)
|
||||
setHasMore(data.length === PER_PAGE)
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetchReleases(1).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = async () => {
|
||||
const next = page + 1
|
||||
setLoadingMore(true)
|
||||
await fetchReleases(next, true)
|
||||
setPage(next)
|
||||
setLoadingMore(false)
|
||||
}
|
||||
|
||||
const toggleExpand = (id) => {
|
||||
setExpanded(prev => ({ ...prev, [id]: !prev[id] }))
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
// Simple markdown-to-html for release notes (handles headers, bold, lists, links)
|
||||
const renderBody = (body) => {
|
||||
if (!body) return null
|
||||
const lines = body.split('\n')
|
||||
const elements = []
|
||||
let listItems = []
|
||||
|
||||
const flushList = () => {
|
||||
if (listItems.length > 0) {
|
||||
elements.push(
|
||||
<ul key={`ul-${elements.length}`} className="space-y-1 my-2">
|
||||
{listItems.map((item, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
<span className="mt-1.5 w-1 h-1 rounded-full flex-shrink-0" style={{ background: 'var(--text-faint)' }} />
|
||||
<span dangerouslySetInnerHTML={{ __html: inlineFormat(item) }} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
listItems = []
|
||||
}
|
||||
}
|
||||
|
||||
const inlineFormat = (text) => {
|
||||
return text
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code style="font-size:11px;padding:1px 4px;border-radius:4px;background:var(--bg-secondary)">$1</code>')
|
||||
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:#3b82f6;text-decoration:underline">$1</a>')
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) { flushList(); continue }
|
||||
|
||||
if (trimmed.startsWith('### ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h4 key={elements.length} className="text-xs font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{trimmed.slice(4)}
|
||||
</h4>
|
||||
)
|
||||
} else if (trimmed.startsWith('## ')) {
|
||||
flushList()
|
||||
elements.push(
|
||||
<h3 key={elements.length} className="text-sm font-semibold mt-3 mb-1" style={{ color: 'var(--text-primary)' }}>
|
||||
{trimmed.slice(3)}
|
||||
</h3>
|
||||
)
|
||||
} else if (/^[-*] /.test(trimmed)) {
|
||||
listItems.push(trimmed.slice(2))
|
||||
} else {
|
||||
flushList()
|
||||
elements.push(
|
||||
<p key={elements.length} className="text-xs my-1" style={{ color: 'var(--text-muted)' }}
|
||||
dangerouslySetInnerHTML={{ __html: inlineFormat(trimmed) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
flushList()
|
||||
return elements
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="p-8 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin" style={{ color: 'var(--text-muted)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="p-6 text-center">
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.github.error')}</p>
|
||||
<p className="text-xs mt-1" style={{ color: 'var(--text-faint)' }}>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header card */}
|
||||
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||
<div>
|
||||
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.github.title')}</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.github.subtitle').replace('{repo}', REPO)}</p>
|
||||
</div>
|
||||
<a
|
||||
href={`https://github.com/${REPO}/releases`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Timeline */}
|
||||
<div className="px-5 py-4">
|
||||
<div className="relative">
|
||||
{/* Timeline line */}
|
||||
<div className="absolute left-[11px] top-3 bottom-3 w-px" style={{ background: 'var(--border-primary)' }} />
|
||||
|
||||
<div className="space-y-0">
|
||||
{releases.map((release, idx) => {
|
||||
const isLatest = idx === 0
|
||||
const isExpanded = expanded[release.id]
|
||||
|
||||
return (
|
||||
<div key={release.id} className="relative pl-8 pb-5">
|
||||
{/* Timeline dot */}
|
||||
<div
|
||||
className="absolute left-0 top-1 w-[23px] h-[23px] rounded-full flex items-center justify-center border-2"
|
||||
style={{
|
||||
background: isLatest ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: isLatest ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
}}
|
||||
>
|
||||
<Tag size={10} style={{ color: isLatest ? 'var(--bg-card)' : 'var(--text-faint)' }} />
|
||||
</div>
|
||||
|
||||
{/* Release content */}
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{release.tag_name}
|
||||
</span>
|
||||
{isLatest && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(34,197,94,0.12)', color: '#16a34a' }}>
|
||||
{t('admin.github.latest')}
|
||||
</span>
|
||||
)}
|
||||
{release.prerelease && (
|
||||
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full"
|
||||
style={{ background: 'rgba(245,158,11,0.12)', color: '#d97706' }}>
|
||||
{t('admin.github.prerelease')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{release.name && release.name !== release.tag_name && (
|
||||
<p className="text-xs font-medium mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
{release.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className="flex items-center gap-1 text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
<Calendar size={10} />
|
||||
{formatDate(release.published_at || release.created_at)}
|
||||
</span>
|
||||
{release.author && (
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('admin.github.by')} {release.author.login}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expandable body */}
|
||||
{release.body && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => toggleExpand(release.id)}
|
||||
className="flex items-center gap-1 text-[11px] font-medium transition-colors"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={12} /> : <ChevronDown size={12} />}
|
||||
{isExpanded ? t('admin.github.hideDetails') : t('admin.github.showDetails')}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 p-3 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
{renderBody(release.body)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load more */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-2">
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-medium transition-colors"
|
||||
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||
>
|
||||
{loadingMore ? <Loader2 size={12} className="animate-spin" /> : <ChevronDown size={12} />}
|
||||
{loadingMore ? t('admin.github.loading') : t('admin.github.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+292
-15
@@ -1,8 +1,31 @@
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
|
||||
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface PieSegment {
|
||||
label: string
|
||||
value: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface PerPersonSummaryEntry {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
total_assigned: number
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
||||
@@ -58,7 +81,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
||||
}
|
||||
|
||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
||||
function AddItemRow({ onAdd, t }) {
|
||||
interface AddItemRowProps {
|
||||
onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null }) => void
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function AddItemRow({ onAdd, t }: AddItemRowProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [price, setPrice] = useState('')
|
||||
const [persons, setPersons] = useState('')
|
||||
@@ -110,8 +138,200 @@ function AddItemRow({ onAdd, t }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||
interface ChipWithTooltipProps {
|
||||
label: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
|
||||
const onEnter = () => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)',
|
||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{avatarUrl
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||
interface BudgetMemberChipsProps {
|
||||
members?: BudgetMember[]
|
||||
tripMembers?: TripMember[]
|
||||
onSetMembers: (memberIds: number[]) => void
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) {
|
||||
const chipSize = compact ? 20 : 30
|
||||
const btnSize = compact ? 18 : 28
|
||||
const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
|
||||
const btnRef = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
|
||||
const openDropdown = useCallback(() => {
|
||||
if (btnRef.current) {
|
||||
const rect = btnRef.current.getBoundingClientRect()
|
||||
setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setShowDropdown(v => !v)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showDropdown) return
|
||||
const close = (e) => {
|
||||
if (dropRef.current && dropRef.current.contains(e.target)) return
|
||||
if (btnRef.current && btnRef.current.contains(e.target)) return
|
||||
setShowDropdown(false)
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [showDropdown])
|
||||
|
||||
const memberIds = members.map(m => m.user_id)
|
||||
|
||||
const toggleMember = (userId) => {
|
||||
const newIds = memberIds.includes(userId)
|
||||
? memberIds.filter(id => id !== userId)
|
||||
: [...memberIds, userId]
|
||||
onSetMembers(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-Person Inline (inside total card) ────────────────────────────────────
|
||||
interface PerPersonInlineProps {
|
||||
tripId: number
|
||||
budgetItems: BudgetItem[]
|
||||
currency: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
function PerPersonInline({ tripId, budgetItems, currency, locale }: PerPersonInlineProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const fmt = (v) => fmtNum(v, locale, currency)
|
||||
|
||||
useEffect(() => {
|
||||
budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
|
||||
}, [tripId, budgetItems])
|
||||
|
||||
if (!data || data.length === 0) return null
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map(person => (
|
||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{person.avatar_url
|
||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: person.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
|
||||
function PieChart({ segments, size = 200, totalLabel }) {
|
||||
interface PieChartProps {
|
||||
segments: PieSegment[]
|
||||
size?: number
|
||||
totalLabel: string
|
||||
}
|
||||
|
||||
function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
|
||||
if (!segments.length) return null
|
||||
|
||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||
@@ -148,13 +368,20 @@ function PieChart({ segments, size = 200, totalLabel }) {
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
export default function BudgetPanel({ tripId }) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
|
||||
interface BudgetPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||
const hasMultipleMembers = tripMembers.length > 1
|
||||
|
||||
const setCurrency = (cur) => {
|
||||
if (tripId) updateTrip(tripId, { currency: cur })
|
||||
@@ -163,7 +390,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||
|
||||
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
||||
const cat = item.category || 'Sonstiges'
|
||||
const cat = item.category || 'Other'
|
||||
if (!acc[cat]) acc[cat] = []
|
||||
acc[cat].push(item)
|
||||
return acc
|
||||
@@ -185,7 +412,12 @@ export default function BudgetPanel({ tripId }) {
|
||||
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)
|
||||
for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
|
||||
}
|
||||
const handleRenameCategory = async (oldName, newName) => {
|
||||
if (!newName.trim() || newName.trim() === oldName) return
|
||||
const items = grouped[oldName] || []
|
||||
for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
|
||||
}
|
||||
const handleAddCategory = () => {
|
||||
if (!newCategoryName.trim()) return
|
||||
@@ -239,9 +471,27 @@ export default function BudgetPanel({ tripId }) {
|
||||
return (
|
||||
<div key={cat} style={{ marginBottom: 16 }}>
|
||||
<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={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1, minWidth: 0 }}>
|
||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: color, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
{editingCat?.name === cat ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={editingCat.value}
|
||||
onChange={e => setEditingCat({ ...editingCat, value: e.target.value })}
|
||||
onBlur={() => { handleRenameCategory(cat, editingCat.value); setEditingCat(null) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { handleRenameCategory(cat, editingCat.value); setEditingCat(null) } if (e.key === 'Escape') setEditingCat(null) }}
|
||||
style={{ fontWeight: 600, fontSize: 13, background: 'rgba(255,255,255,0.15)', border: 'none', borderRadius: 4, color: '#fff', padding: '1px 6px', outline: 'none', fontFamily: 'inherit', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span style={{ fontWeight: 600, fontSize: 13 }}>{cat}</span>
|
||||
<button onClick={() => setEditingCat({ name: cat, value: cat })}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.4)', display: 'flex', padding: 1 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#fff'} onMouseLeave={e => e.currentTarget.style.color = 'rgba(255,255,255,0.4)'}>
|
||||
<Pencil size={10} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||
@@ -258,8 +508,8 @@ export default function BudgetPanel({ tripId }) {
|
||||
<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 style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{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>
|
||||
@@ -273,16 +523,38 @@ export default function BudgetPanel({ tripId }) {
|
||||
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)
|
||||
const hasMembers = item.members?.length > 0
|
||||
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}>
|
||||
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
{/* Mobile: larger chips under name since Persons column is hidden */}
|
||||
{hasMultipleMembers && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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 className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
/>
|
||||
) : (
|
||||
<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')} />
|
||||
@@ -351,6 +623,9 @@ export default function BudgetPanel({ tripId }) {
|
||||
{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>
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pieSegments.length > 0 && (
|
||||
@@ -358,6 +633,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
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)',
|
||||
marginBottom: 16,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||
|
||||
@@ -386,6 +662,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,830 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ArrowUp, Trash2, Reply, ChevronUp, MessageCircle, Smile, X } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface ChatReaction {
|
||||
emoji: string
|
||||
count: number
|
||||
users: { id: number; username: string }[]
|
||||
}
|
||||
|
||||
interface ChatMessage {
|
||||
id: number
|
||||
trip_id: number
|
||||
user_id: number
|
||||
text: string
|
||||
reply_to_id: number | null
|
||||
reactions: ChatReaction[]
|
||||
created_at: string
|
||||
user?: { username: string; avatar_url: string | null }
|
||||
reply_to?: ChatMessage | null
|
||||
}
|
||||
|
||||
// ── Twemoji helper (Apple-style emojis via CDN) ──
|
||||
function emojiToCodepoint(emoji) {
|
||||
const codepoints = []
|
||||
for (const c of emoji) {
|
||||
const cp = c.codePointAt(0)
|
||||
if (cp !== 0xfe0f) codepoints.push(cp.toString(16)) // skip variation selector
|
||||
}
|
||||
return codepoints.join('-')
|
||||
}
|
||||
|
||||
function TwemojiImg({ emoji, size = 20, style = {} }) {
|
||||
const cp = emojiToCodepoint(emoji)
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
if (failed) {
|
||||
return <span style={{ fontSize: size, lineHeight: 1, display: 'inline-block', verticalAlign: 'middle', ...style }}>{emoji}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/${cp}.png`}
|
||||
alt={emoji}
|
||||
draggable={false}
|
||||
style={{ width: size, height: size, display: 'inline-block', verticalAlign: 'middle', ...style }}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const EMOJI_CATEGORIES = {
|
||||
'Smileys': ['😀','😂','🥹','😍','🤩','😎','🥳','😭','🤔','👀','🙈','🫠','😴','🤯','🥺','😤','💀','👻','🫡','🤝'],
|
||||
'Reactions': ['❤️','🔥','👍','👎','👏','🎉','💯','✨','⭐','💪','🙏','😱','😂','💖','💕','🤞','✅','❌','⚡','🏆'],
|
||||
'Travel': ['✈️','🏖️','🗺️','🧳','🏔️','🌅','🌴','🚗','🚂','🛳️','🏨','🍽️','🍕','🍹','📸','🎒','⛱️','🌍','🗼','🎌'],
|
||||
}
|
||||
|
||||
// SQLite stores UTC without 'Z' suffix — append it so JS parses as UTC
|
||||
function parseUTC(s) { return new Date(s && !s.endsWith('Z') ? s + 'Z' : s) }
|
||||
|
||||
function formatTime(isoString, is12h) {
|
||||
const d = parseUTC(isoString)
|
||||
const h = d.getHours()
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${mm} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${mm}`
|
||||
}
|
||||
|
||||
function formatDateSeparator(isoString, t) {
|
||||
const d = parseUTC(isoString)
|
||||
const now = new Date()
|
||||
const yesterday = new Date(); yesterday.setDate(now.getDate() - 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.chat.today') || 'Today'
|
||||
if (d.toDateString() === yesterday.toDateString()) return t('collab.chat.yesterday') || 'Yesterday'
|
||||
|
||||
return d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' })
|
||||
}
|
||||
|
||||
function shouldShowDateSeparator(msg, prevMsg) {
|
||||
if (!prevMsg) return true
|
||||
const d1 = parseUTC(msg.created_at).toDateString()
|
||||
const d2 = parseUTC(prevMsg.created_at).toDateString()
|
||||
return d1 !== d2
|
||||
}
|
||||
|
||||
/* ── Emoji Picker ── */
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void
|
||||
onClose: () => void
|
||||
anchorRef: React.RefObject<HTMLElement | null>
|
||||
containerRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
|
||||
function EmojiPicker({ onSelect, onClose, anchorRef, containerRef }: EmojiPickerProps) {
|
||||
const [cat, setCat] = useState(Object.keys(EMOJI_CATEGORIES)[0])
|
||||
const ref = useRef(null)
|
||||
|
||||
const getPos = () => {
|
||||
const container = containerRef?.current
|
||||
const anchor = anchorRef?.current
|
||||
if (container && anchor) {
|
||||
const cRect = container.getBoundingClientRect()
|
||||
const aRect = anchor.getBoundingClientRect()
|
||||
return { bottom: window.innerHeight - aRect.top + 16, left: cRect.left + cRect.width / 2 - 140 }
|
||||
}
|
||||
return { bottom: 80, left: 0 }
|
||||
}
|
||||
const pos = getPos()
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => {
|
||||
if (ref.current && ref.current.contains(e.target)) return
|
||||
if (anchorRef?.current && anchorRef.current.contains(e.target)) return
|
||||
onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose, anchorRef])
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', bottom: pos.bottom, left: pos.left, zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', width: 280, overflow: 'hidden',
|
||||
}}>
|
||||
{/* Category tabs */}
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-faint)', padding: '6px 8px', gap: 2 }}>
|
||||
{Object.keys(EMOJI_CATEGORIES).map(c => (
|
||||
<button key={c} onClick={() => setCat(c)} style={{
|
||||
flex: 1, padding: '4px 0', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||
background: cat === c ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-primary)', fontSize: 10, fontWeight: 600, fontFamily: 'inherit',
|
||||
}}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Emoji grid */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(10, 1fr)', gap: 2, padding: 8 }}>
|
||||
{EMOJI_CATEGORIES[cat].map((emoji, i) => (
|
||||
<button key={i} onClick={() => onSelect(emoji)} style={{
|
||||
width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: 6,
|
||||
padding: 2, transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Quick Menu (right-click) ── */
|
||||
const QUICK_REACTIONS = ['❤️', '😂', '👍', '😮', '😢', '🔥', '👏', '🎉']
|
||||
|
||||
interface ReactionMenuProps {
|
||||
x: number
|
||||
y: number
|
||||
onReact: (emoji: string) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ReactionMenu({ x, y, onReact, onClose }: ReactionMenuProps) {
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const close = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose() }
|
||||
document.addEventListener('mousedown', close)
|
||||
return () => document.removeEventListener('mousedown', close)
|
||||
}, [onClose])
|
||||
|
||||
// Clamp to viewport
|
||||
const menuWidth = 156
|
||||
const clampedLeft = Math.max(menuWidth / 2 + 8, Math.min(x, window.innerWidth - menuWidth / 2 - 8))
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', top: y - 80, left: clampedLeft, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)', borderRadius: 16,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.18)', padding: '6px 8px',
|
||||
display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 2, width: menuWidth,
|
||||
}}>
|
||||
{QUICK_REACTIONS.map(emoji => (
|
||||
<button key={emoji} onClick={() => onReact(emoji)} style={{
|
||||
width: 30, height: 30, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: 'none', border: 'none', cursor: 'pointer', borderRadius: '50%',
|
||||
padding: 3, transition: 'transform 0.1s, background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<TwemojiImg emoji={emoji} size={18} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Message Text with clickable URLs ── */
|
||||
interface MessageTextProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
function MessageText({ text }: MessageTextProps) {
|
||||
const parts = text.split(URL_REGEX)
|
||||
const urls = text.match(URL_REGEX) || []
|
||||
const result = []
|
||||
parts.forEach((part, i) => {
|
||||
if (part) result.push(part)
|
||||
if (urls[i]) result.push(
|
||||
<a key={i} href={urls[i]} target="_blank" rel="noopener noreferrer" style={{ color: 'inherit', textDecoration: 'underline', textUnderlineOffset: 2, opacity: 0.85 }}>
|
||||
{urls[i]}
|
||||
</a>
|
||||
)
|
||||
})
|
||||
return <>{result}</>
|
||||
}
|
||||
|
||||
/* ── Link Preview ── */
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/g
|
||||
const previewCache = {}
|
||||
|
||||
interface LinkPreviewProps {
|
||||
url: string
|
||||
tripId: number
|
||||
own: boolean
|
||||
onLoad: (() => void) | undefined
|
||||
}
|
||||
|
||||
function LinkPreview({ url, tripId, own, onLoad }: LinkPreviewProps) {
|
||||
const [data, setData] = useState(previewCache[url] || null)
|
||||
const [loading, setLoading] = useState(!previewCache[url])
|
||||
|
||||
useEffect(() => {
|
||||
if (previewCache[url]) return
|
||||
collabApi.linkPreview(tripId, url).then(d => {
|
||||
previewCache[url] = d
|
||||
setData(d)
|
||||
setLoading(false)
|
||||
if (d?.title || d?.description || d?.image) onLoad?.()
|
||||
}).catch(() => setLoading(false))
|
||||
}, [url, tripId])
|
||||
|
||||
if (loading || !data || (!data.title && !data.description && !data.image)) return null
|
||||
|
||||
const domain = (() => { try { return new URL(url).hostname.replace('www.', '') } catch { return '' } })()
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" style={{
|
||||
display: 'block', textDecoration: 'none', marginTop: 6, borderRadius: 12, overflow: 'hidden',
|
||||
border: own ? '1px solid rgba(255,255,255,0.15)' : '1px solid var(--border-faint)',
|
||||
background: own ? 'rgba(255,255,255,0.1)' : 'var(--bg-secondary)',
|
||||
maxWidth: 280, transition: 'opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.85'}
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
{data.image && (
|
||||
<img src={data.image} alt="" style={{ width: '100%', height: 140, objectFit: 'cover', display: 'block' }}
|
||||
onError={e => e.target.style.display = 'none'} />
|
||||
)}
|
||||
<div style={{ padding: '8px 10px' }}>
|
||||
{domain && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: own ? 'rgba(255,255,255,0.5)' : 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, marginBottom: 2 }}>
|
||||
{data.site_name || domain}
|
||||
</div>
|
||||
)}
|
||||
{data.title && (
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: own ? '#fff' : 'var(--text-primary)', lineHeight: 1.3, marginBottom: 2, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.title}
|
||||
</div>
|
||||
)}
|
||||
{data.description && (
|
||||
<div style={{ fontSize: 11, color: own ? 'rgba(255,255,255,0.7)' : 'var(--text-muted)', lineHeight: 1.3, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Reaction Badge with NOMAD tooltip ── */
|
||||
interface ReactionBadgeProps {
|
||||
reaction: ChatReaction
|
||||
currentUserId: number
|
||||
onReact: () => void
|
||||
}
|
||||
|
||||
function ReactionBadge({ reaction, currentUserId, onReact }: ReactionBadgeProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
const ref = useRef(null)
|
||||
const names = reaction.users.map(u => u.username).join(', ')
|
||||
|
||||
return (
|
||||
<>
|
||||
<button ref={ref} onClick={onReact}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '1px 3px',
|
||||
borderRadius: 99, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: 'transparent', transition: 'transform 0.1s',
|
||||
}}
|
||||
>
|
||||
<TwemojiImg emoji={reaction.emoji} size={16} />
|
||||
{reaction.count > 1 && <span style={{ fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', minWidth: 8 }}>{reaction.count}</span>}
|
||||
</button>
|
||||
{hover && names && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{names}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main Component ── */
|
||||
interface CollabChatProps {
|
||||
tripId: number
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
export default function CollabChat({ tripId, currentUser }: CollabChatProps) {
|
||||
const { t } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const [messages, setMessages] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [text, setText] = useState('')
|
||||
const [replyTo, setReplyTo] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [showEmoji, setShowEmoji] = useState(false)
|
||||
const [reactMenu, setReactMenu] = useState(null) // { msgId, x, y }
|
||||
const [deletingIds, setDeletingIds] = useState(new Set())
|
||||
|
||||
const containerRef = useRef(null)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
const scrollRef = useRef(null)
|
||||
const textareaRef = useRef(null)
|
||||
const emojiBtnRef = useRef(null)
|
||||
const isAtBottom = useRef(true)
|
||||
|
||||
const scrollToBottom = useCallback((behavior = 'auto') => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
requestAnimationFrame(() => el.scrollTo({ top: el.scrollHeight, behavior }))
|
||||
}, [])
|
||||
|
||||
const checkAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
isAtBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight < 48
|
||||
}, [])
|
||||
|
||||
/* ── load messages ── */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
collabApi.getMessages(tripId).then(data => {
|
||||
if (cancelled) return
|
||||
const msgs = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
setMessages(msgs)
|
||||
setHasMore(msgs.length >= 100)
|
||||
setLoading(false)
|
||||
setTimeout(() => scrollToBottom(), 30)
|
||||
}).catch(() => { if (!cancelled) setLoading(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── load more ── */
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (loadingMore || messages.length === 0) return
|
||||
setLoadingMore(true)
|
||||
const el = scrollRef.current
|
||||
const prevHeight = el ? el.scrollHeight : 0
|
||||
try {
|
||||
const data = await collabApi.getMessages(tripId, messages[0]?.id)
|
||||
const older = (Array.isArray(data) ? data : data.messages || []).map(m => m.deleted ? { ...m, _deleted: true } : m)
|
||||
if (older.length === 0) { setHasMore(false) }
|
||||
else {
|
||||
setMessages(prev => [...older, ...prev])
|
||||
setHasMore(older.length >= 100)
|
||||
requestAnimationFrame(() => { if (el) el.scrollTop = el.scrollHeight - prevHeight })
|
||||
}
|
||||
} catch {} finally { setLoadingMore(false) }
|
||||
}, [tripId, loadingMore, messages])
|
||||
|
||||
/* ── websocket ── */
|
||||
useEffect(() => {
|
||||
const handler = (event) => {
|
||||
if (event.type === 'collab:message:created' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.some(m => m.id === event.message.id) ? prev : [...prev, event.message])
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 30)
|
||||
}
|
||||
if (event.type === 'collab:message:deleted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, _deleted: true } : m))
|
||||
if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
}
|
||||
if (event.type === 'collab:message:reacted' && String(event.tripId) === String(tripId)) {
|
||||
setMessages(prev => prev.map(m => m.id === event.messageId ? { ...m, reactions: event.reactions } : m))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [tripId, scrollToBottom])
|
||||
|
||||
/* ── auto-resize textarea ── */
|
||||
const handleTextChange = useCallback((e) => {
|
||||
setText(e.target.value)
|
||||
const ta = textareaRef.current
|
||||
if (ta) {
|
||||
ta.style.height = 'auto'
|
||||
const h = Math.min(ta.scrollHeight, 100)
|
||||
ta.style.height = h + 'px'
|
||||
ta.style.overflowY = ta.scrollHeight > 100 ? 'auto' : 'hidden'
|
||||
}
|
||||
}, [])
|
||||
|
||||
/* ── send ── */
|
||||
const handleSend = useCallback(async () => {
|
||||
const body = text.trim()
|
||||
if (!body || sending) return
|
||||
setSending(true)
|
||||
try {
|
||||
const payload = { text: body }
|
||||
if (replyTo) payload.reply_to = replyTo.id
|
||||
const data = await collabApi.sendMessage(tripId, payload)
|
||||
if (data?.message) {
|
||||
setMessages(prev => prev.some(m => m.id === data.message.id) ? prev : [...prev, data.message])
|
||||
}
|
||||
setText(''); setReplyTo(null); setShowEmoji(false)
|
||||
if (textareaRef.current) textareaRef.current.style.height = 'auto'
|
||||
isAtBottom.current = true
|
||||
setTimeout(() => scrollToBottom('smooth'), 50)
|
||||
} catch {} finally { setSending(false) }
|
||||
}, [text, sending, replyTo, tripId, scrollToBottom])
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
|
||||
}, [handleSend])
|
||||
|
||||
const handleDelete = useCallback(async (msgId) => {
|
||||
const msg = messages.find(m => m.id === msgId)
|
||||
requestAnimationFrame(() => {
|
||||
setDeletingIds(prev => new Set(prev).add(msgId))
|
||||
})
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await collabApi.deleteMessage(tripId, msgId)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, _deleted: true } : m))
|
||||
} catch {}
|
||||
setDeletingIds(prev => { const s = new Set(prev); s.delete(msgId); return s })
|
||||
}, 400)
|
||||
}, [tripId])
|
||||
|
||||
const handleReact = useCallback(async (msgId, emoji) => {
|
||||
setReactMenu(null)
|
||||
try {
|
||||
const data = await collabApi.reactMessage(tripId, msgId, emoji)
|
||||
setMessages(prev => prev.map(m => m.id === msgId ? { ...m, reactions: data.reactions } : m))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleEmojiSelect = useCallback((emoji) => {
|
||||
setText(prev => prev + emoji)
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const isOwn = (msg) => String(msg.user_id) === String(currentUser.id)
|
||||
|
||||
// Check if message is only emoji (1-3 emojis, no other text)
|
||||
const isEmojiOnly = (text) => {
|
||||
const emojiRegex = /^(?:\p{Emoji_Presentation}|\p{Extended_Pictographic}[\uFE0F]?(?:\u200D\p{Extended_Pictographic}[\uFE0F]?)*){1,3}$/u
|
||||
return emojiRegex.test(text.trim())
|
||||
}
|
||||
|
||||
/* ── Loading ── */
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div style={{ width: 24, height: 24, border: '2px solid var(--border-faint)', borderTopColor: 'var(--accent)', borderRadius: '50%', animation: 'spin .7s linear infinite' }} />
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ── Main ── */
|
||||
return (
|
||||
<div ref={containerRef} style={{ display: 'flex', flexDirection: 'column', flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0, height: '100%' }}>
|
||||
{/* Messages */}
|
||||
{messages.length === 0 ? (
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 8, color: 'var(--text-faint)', padding: 32 }}>
|
||||
<MessageCircle size={40} strokeWidth={1.2} style={{ opacity: 0.4 }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('collab.chat.empty')}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.6 }}>{t('collab.chat.emptyDesc') || ''}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={scrollRef} onScroll={checkAtBottom} className="chat-scroll" style={{
|
||||
flex: 1, overflowY: 'auto', overflowX: 'hidden', padding: '8px 14px 4px', WebkitOverflowScrolling: 'touch',
|
||||
display: 'flex', flexDirection: 'column', gap: 1,
|
||||
}}>
|
||||
{hasMore && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0 10px' }}>
|
||||
<button onClick={handleLoadMore} disabled={loadingMore} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600,
|
||||
color: 'var(--text-muted)', background: 'var(--bg-secondary)', border: '1px solid var(--border-faint)',
|
||||
borderRadius: 99, padding: '5px 14px', cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<ChevronUp size={13} />
|
||||
{loadingMore ? '...' : t('collab.chat.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map((msg, idx) => {
|
||||
const own = isOwn(msg)
|
||||
const prevMsg = messages[idx - 1]
|
||||
const nextMsg = messages[idx + 1]
|
||||
const isNewGroup = idx === 0 || String(prevMsg?.user_id) !== String(msg.user_id)
|
||||
const isLastInGroup = !nextMsg || String(nextMsg?.user_id) !== String(msg.user_id)
|
||||
const showDate = shouldShowDateSeparator(msg, prevMsg)
|
||||
const showAvatar = !own && isLastInGroup
|
||||
const bigEmoji = isEmojiOnly(msg.text)
|
||||
const hasReply = msg.reply_text || msg.reply_to
|
||||
// Deleted message placeholder
|
||||
if (msg._deleted) {
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontStyle: 'italic' }}>
|
||||
{msg.username} {t('collab.chat.deletedMessage') || 'deleted a message'} · {formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
// Bubble border radius — iMessage style tails
|
||||
const br = own
|
||||
? `18px 18px ${isLastInGroup ? '4px' : '18px'} 18px`
|
||||
: `18px 18px 18px ${isLastInGroup ? '4px' : '18px'}`
|
||||
|
||||
return (
|
||||
<React.Fragment key={msg.id}>
|
||||
{/* Date separator */}
|
||||
{showDate && (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '14px 0 6px' }}>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 600, color: 'var(--text-faint)',
|
||||
background: 'var(--bg-secondary)', padding: '3px 12px', borderRadius: 99,
|
||||
letterSpacing: 0.3, textTransform: 'uppercase',
|
||||
}}>
|
||||
{formatDateSeparator(msg.created_at, t)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', alignItems: own ? 'flex-end' : 'flex-start',
|
||||
flexDirection: own ? 'row-reverse' : 'row',
|
||||
gap: 6, marginTop: isNewGroup ? 10 : 1,
|
||||
paddingLeft: own ? 40 : 0, paddingRight: own ? 0 : 40,
|
||||
transition: 'transform 0.3s ease, opacity 0.3s ease, max-height 0.3s ease',
|
||||
...(deletingIds.has(msg.id) ? { transform: 'scale(0.3)', opacity: 0, maxHeight: 0, marginTop: 0, overflow: 'hidden' } : {}),
|
||||
}}>
|
||||
{/* Avatar slot for others */}
|
||||
{!own && (
|
||||
<div style={{ width: 28, flexShrink: 0, alignSelf: 'flex-end' }}>
|
||||
{showAvatar && (
|
||||
msg.user_avatar ? (
|
||||
<img src={msg.user_avatar} alt="" style={{ width: 28, height: 28, borderRadius: '50%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 11, fontWeight: 700, color: 'var(--text-muted)',
|
||||
}}>
|
||||
{(msg.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: own ? 'flex-end' : 'flex-start', maxWidth: '78%', minWidth: 0 }}>
|
||||
{/* Username for others at group start */}
|
||||
{!own && isNewGroup && (
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 2, paddingLeft: 4 }}>
|
||||
{msg.username}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Bubble */}
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setHoveredId(msg.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onContextMenu={e => { e.preventDefault(); setReactMenu({ msgId: msg.id, x: e.clientX, y: e.clientY }) }}
|
||||
onTouchEnd={e => {
|
||||
const now = Date.now()
|
||||
const lastTap = e.currentTarget.dataset.lastTap || 0
|
||||
if (now - lastTap < 300) {
|
||||
e.preventDefault()
|
||||
const touch = e.changedTouches?.[0]
|
||||
if (touch) setReactMenu({ msgId: msg.id, x: touch.clientX, y: touch.clientY })
|
||||
}
|
||||
e.currentTarget.dataset.lastTap = now
|
||||
}}
|
||||
>
|
||||
{bigEmoji ? (
|
||||
<div style={{ fontSize: 40, lineHeight: 1.2, padding: '2px 0' }}>
|
||||
{msg.text}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{
|
||||
background: own ? '#007AFF' : 'var(--bg-secondary)',
|
||||
color: own ? '#fff' : 'var(--text-primary)',
|
||||
borderRadius: br, padding: hasReply ? '4px 4px 8px 4px' : '8px 14px',
|
||||
fontSize: 14, lineHeight: 1.4, wordBreak: 'break-word', whiteSpace: 'pre-wrap',
|
||||
}}>
|
||||
{/* Inline reply quote */}
|
||||
{hasReply && (
|
||||
<div style={{
|
||||
padding: '5px 10px', marginBottom: 4, borderRadius: 12,
|
||||
background: own ? 'rgba(255,255,255,0.15)' : 'var(--bg-tertiary)',
|
||||
fontSize: 12, lineHeight: 1.3,
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 11, opacity: 0.7, marginBottom: 1 }}>
|
||||
{msg.reply_username || ''}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(msg.reply_text || '').slice(0, 80)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasReply ? (
|
||||
<div style={{ padding: '0 10px 4px' }}><MessageText text={msg.text} /></div>
|
||||
) : <MessageText text={msg.text} />}
|
||||
{(msg.text.match(URL_REGEX) || []).slice(0, 1).map(url => (
|
||||
<LinkPreview key={url} url={url} tripId={tripId} own={own} onLoad={() => { if (isAtBottom.current) setTimeout(() => scrollToBottom('smooth'), 50) }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover actions */}
|
||||
<div style={{
|
||||
position: 'absolute', top: -14,
|
||||
display: 'flex', gap: 2,
|
||||
opacity: hoveredId === msg.id ? 1 : 0,
|
||||
pointerEvents: hoveredId === msg.id ? 'auto' : 'none',
|
||||
transition: 'opacity .1s',
|
||||
...(own ? { left: -6 } : { right: -6 }),
|
||||
}}>
|
||||
<button onClick={() => setReplyTo(msg)} title="Reply" style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)' }}
|
||||
>
|
||||
<Reply size={11} />
|
||||
</button>
|
||||
{own && (
|
||||
<button onClick={() => handleDelete(msg.id)} title="Delete" style={{
|
||||
width: 24, height: 24, borderRadius: '50%', border: 'none',
|
||||
background: 'var(--accent)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: 'var(--accent-text)', padding: 0,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', transition: 'transform 0.12s, background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.2)'; e.currentTarget.style.background = '#ef4444'; e.currentTarget.style.color = '#fff' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reactions — iMessage style floating badge */}
|
||||
{msg.reactions?.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: 3, marginTop: -6, marginBottom: 4,
|
||||
justifyContent: own ? 'flex-end' : 'flex-start',
|
||||
paddingLeft: own ? 0 : 8, paddingRight: own ? 8 : 0,
|
||||
position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 2, padding: '3px 6px',
|
||||
borderRadius: 99, background: 'var(--bg-card)',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.12)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{msg.reactions.map(r => {
|
||||
const myReaction = r.users.some(u => String(u.user_id) === String(currentUser.id))
|
||||
return (
|
||||
<ReactionBadge key={r.emoji} reaction={r} currentUserId={currentUser.id} onReact={() => handleReact(msg.id, r.emoji)} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamp — only on last message of group */}
|
||||
{isLastInGroup && (
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', marginTop: 2, padding: '0 4px' }}>
|
||||
{formatTime(msg.created_at, is12h)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Composer */}
|
||||
<div style={{ flexShrink: 0, padding: '8px 12px calc(12px + env(safe-area-inset-bottom, 0px))', borderTop: '1px solid var(--border-faint)', background: 'var(--bg-card)' }}>
|
||||
{/* Reply preview */}
|
||||
{replyTo && (
|
||||
<div style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8,
|
||||
padding: '6px 10px', borderRadius: 10, background: 'var(--bg-secondary)',
|
||||
borderLeft: '3px solid #007AFF', fontSize: 12, color: 'var(--text-muted)',
|
||||
}}>
|
||||
<Reply size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
|
||||
<strong>{replyTo.username}</strong>: {(replyTo.text || '').slice(0, 60)}
|
||||
</span>
|
||||
<button onClick={() => setReplyTo(null)} style={{
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2, color: 'var(--text-faint)',
|
||||
display: 'flex', flexShrink: 0,
|
||||
}}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 6 }}>
|
||||
{/* Emoji button */}
|
||||
<button ref={emojiBtnRef} onClick={() => setShowEmoji(!showEmoji)} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: showEmoji ? 'var(--bg-hover)' : 'transparent',
|
||||
color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, flexShrink: 0, transition: 'background 0.15s',
|
||||
}}>
|
||||
<Smile size={20} />
|
||||
</button>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1, resize: 'none', border: '1px solid var(--border-primary)', borderRadius: 20,
|
||||
padding: '8px 14px', fontSize: 14, lineHeight: 1.4, fontFamily: 'inherit',
|
||||
background: 'var(--bg-input)', color: 'var(--text-primary)', outline: 'none',
|
||||
maxHeight: 100, overflowY: 'hidden',
|
||||
}}
|
||||
placeholder={t('collab.chat.placeholder')}
|
||||
value={text}
|
||||
onChange={handleTextChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
|
||||
{/* Send */}
|
||||
<button onClick={handleSend} disabled={!text.trim() || sending} style={{
|
||||
width: 34, height: 34, borderRadius: '50%', border: 'none',
|
||||
background: text.trim() ? '#007AFF' : 'var(--border-primary)',
|
||||
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: text.trim() ? 'pointer' : 'default', flexShrink: 0,
|
||||
transition: 'background 0.15s',
|
||||
}}>
|
||||
<ArrowUp size={18} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emoji picker */}
|
||||
{showEmoji && <EmojiPicker onSelect={handleEmojiSelect} onClose={() => setShowEmoji(false)} anchorRef={emojiBtnRef} containerRef={containerRef} />}
|
||||
|
||||
{/* Reaction quick menu (right-click) */}
|
||||
{reactMenu && ReactDOM.createPortal(
|
||||
<ReactionMenu x={reactMenu.x} y={reactMenu.y} onReact={(emoji) => handleReact(reactMenu.msgId, emoji)} onClose={() => setReactMenu(null)} />,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MessageCircle, StickyNote, BarChart3, Sparkles } from 'lucide-react'
|
||||
import CollabChat from './CollabChat'
|
||||
import CollabNotes from './CollabNotes'
|
||||
import CollabPolls from './CollabPolls'
|
||||
import WhatsNextWidget from './WhatsNextWidget'
|
||||
|
||||
function useIsDesktop(breakpoint = 1024) {
|
||||
const [isDesktop, setIsDesktop] = useState(window.innerWidth >= breakpoint)
|
||||
useEffect(() => {
|
||||
const check = () => setIsDesktop(window.innerWidth >= breakpoint)
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [breakpoint])
|
||||
return isDesktop
|
||||
}
|
||||
|
||||
const card = {
|
||||
display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card)', borderRadius: 16, border: '1px solid var(--border-faint)',
|
||||
overflow: 'hidden', minHeight: 0,
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface CollabPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function CollabPanel({ tripId, tripMembers = [] }: CollabPanelProps) {
|
||||
const { user } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
const [mobileTab, setMobileTab] = useState('chat')
|
||||
const isDesktop = useIsDesktop()
|
||||
|
||||
const tabs = [
|
||||
{ id: 'chat', label: t('collab.tabs.chat') || 'Chat', icon: MessageCircle },
|
||||
{ id: 'notes', label: t('collab.tabs.notes') || 'Notes', icon: StickyNote },
|
||||
{ id: 'polls', label: t('collab.tabs.polls') || 'Polls', icon: BarChart3 },
|
||||
{ id: 'next', label: t('collab.whatsNext.title') || "What's Next", icon: Sparkles },
|
||||
]
|
||||
|
||||
if (isDesktop) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', gap: 12, padding: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Chat — left, fixed width */}
|
||||
<div style={{ ...card, flex: '0 0 380px' }}>
|
||||
<CollabChat tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Right column: Notes top, Polls + What's Next bottom */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
{/* Notes — top */}
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabNotes tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
|
||||
{/* Polls + What's Next — bottom row */}
|
||||
<div style={{ flex: 1, display: 'flex', gap: 12, overflow: 'hidden', minHeight: 0 }}>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<CollabPolls tripId={tripId} currentUser={user} />
|
||||
</div>
|
||||
<div style={{ ...card, flex: 1 }}>
|
||||
<WhatsNextWidget tripMembers={tripMembers} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Mobile: tab bar + single panel
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden', position: 'absolute', inset: 0 }}>
|
||||
<div style={{
|
||||
display: 'flex', gap: 2, padding: '8px 12px', borderBottom: '1px solid var(--border-faint)',
|
||||
background: 'var(--bg-card)', flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(tab => {
|
||||
const Icon = tab.icon
|
||||
const active = mobileTab === tab.id
|
||||
return (
|
||||
<button key={tab.id} onClick={() => setMobileTab(tab.id)} style={{
|
||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||
padding: '8px 0', borderRadius: 10, border: 'none', cursor: 'pointer',
|
||||
background: active ? 'var(--accent)' : 'transparent',
|
||||
color: active ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||
fontSize: 11, fontWeight: 600, fontFamily: 'inherit',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
{mobileTab === 'chat' && <CollabChat tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'notes' && <CollabNotes tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'polls' && <CollabPolls tripId={tripId} currentUser={user} />}
|
||||
{mobileTab === 'next' && <WhatsNextWidget tripMembers={tripMembers} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { Plus, Trash2, X, Check, BarChart3, Lock, Clock } from 'lucide-react'
|
||||
import { collabApi } from '../../api/client'
|
||||
import { addListener, removeListener } from '../../api/websocket'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import ReactDOM from 'react-dom'
|
||||
import type { User } from '../../types'
|
||||
|
||||
interface PollVoter {
|
||||
user_id: number
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}
|
||||
|
||||
interface PollOption {
|
||||
id: number
|
||||
text: string
|
||||
voters: PollVoter[]
|
||||
}
|
||||
|
||||
interface Poll {
|
||||
id: number
|
||||
question: string
|
||||
options: PollOption[]
|
||||
multi_choice: boolean
|
||||
is_closed: boolean
|
||||
deadline: string | null
|
||||
created_by: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const FONT = "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif"
|
||||
|
||||
function timeRemaining(deadline) {
|
||||
if (!deadline) return null
|
||||
const diff = new Date(deadline).getTime() - Date.now()
|
||||
if (diff <= 0) return null
|
||||
const mins = Math.floor(diff / 60000)
|
||||
const hrs = Math.floor(mins / 60)
|
||||
const days = Math.floor(hrs / 24)
|
||||
if (days > 0) return `${days}d ${hrs % 24}h`
|
||||
if (hrs > 0) return `${hrs}h ${mins % 60}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
function isExpired(deadline) {
|
||||
if (!deadline) return false
|
||||
return new Date(deadline).getTime() <= Date.now()
|
||||
}
|
||||
|
||||
function totalVotes(poll) {
|
||||
return (poll.options || []).reduce((s, o) => s + (o.voters?.length || 0), 0)
|
||||
}
|
||||
|
||||
// ── Create Poll Modal ────────────────────────────────────────────────────────
|
||||
interface CreatePollModalProps {
|
||||
onClose: () => void
|
||||
onCreate: (data: { question: string; options: string[]; multi_choice: boolean }) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function CreatePollModal({ onClose, onCreate, t }: CreatePollModalProps) {
|
||||
const [question, setQuestion] = useState('')
|
||||
const [options, setOptions] = useState(['', ''])
|
||||
const [multiChoice, setMultiChoice] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const addOption = () => setOptions(prev => [...prev, ''])
|
||||
const removeOption = (i) => setOptions(prev => prev.filter((_, j) => j !== i))
|
||||
const updateOption = (i, v) => setOptions(prev => prev.map((o, j) => j === i ? v : o))
|
||||
|
||||
const canSubmit = question.trim() && options.filter(o => o.trim()).length >= 2 && !submitting
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onCreate({ question: question.trim(), options: options.filter(o => o.trim()), multiple_choice: multiChoice })
|
||||
onClose()
|
||||
} catch {} finally { setSubmitting(false) }
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'var(--overlay-bg, rgba(0,0,0,0.35))', backdropFilter: 'blur(6px)', WebkitBackdropFilter: 'blur(6px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999, padding: 16, fontFamily: FONT }} onClick={onClose}>
|
||||
<form style={{ background: 'var(--bg-card)', borderRadius: 16, width: '100%', maxWidth: 400, maxHeight: '90vh', overflow: 'auto', border: '1px solid var(--border-faint)' }} onClick={e => e.stopPropagation()} onSubmit={handleSubmit}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px 12px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<h3 style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', margin: 0 }}>{t('collab.polls.new')}</h3>
|
||||
<button type="button" onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 2, display: 'flex' }}><X size={16} /></button>
|
||||
</div>
|
||||
<div style={{ padding: '14px 16px 16px', display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{/* Question */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.question')}</div>
|
||||
<input autoFocus value={question} onChange={e => setQuestion(e.target.value)} placeholder={t('collab.polls.questionPlaceholder') || 'Ask a question...'} style={{ width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none', boxSizing: 'border-box' }} />
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 4 }}>{t('collab.polls.options')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<input value={opt} onChange={e => updateOption(i, e.target.value)} placeholder={`${t('collab.polls.option')} ${i + 1}`}
|
||||
style={{ flex: 1, border: '1px solid var(--border-primary)', borderRadius: 10, padding: '8px 12px', fontSize: 13, background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }} />
|
||||
{options.length > 2 && (
|
||||
<button type="button" onClick={() => removeOption(i)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 2 }}><X size={14} /></button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={addOption} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '6px 12px', borderRadius: 10, border: '1px dashed var(--border-faint)', background: 'transparent', cursor: 'pointer', color: 'var(--text-faint)', fontSize: 12, fontFamily: FONT }}>
|
||||
<Plus size={12} /> {t('collab.polls.addOption')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi choice toggle */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<div onClick={() => setMultiChoice(!multiChoice)} style={{
|
||||
width: 36, height: 20, borderRadius: 10, padding: 2, cursor: 'pointer',
|
||||
background: multiChoice ? '#007AFF' : 'var(--border-primary)', transition: 'background 0.2s',
|
||||
display: 'flex', alignItems: 'center',
|
||||
}}>
|
||||
<div style={{ width: 16, height: 16, borderRadius: '50%', background: '#fff', transition: 'transform 0.2s', transform: multiChoice ? 'translateX(16px)' : 'translateX(0)' }} />
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontFamily: FONT }}>{t('collab.polls.multiChoice')}</span>
|
||||
</label>
|
||||
|
||||
{/* Submit */}
|
||||
<button type="submit" disabled={!canSubmit} style={{
|
||||
width: '100%', borderRadius: 99, padding: '9px 14px', background: canSubmit ? 'var(--accent)' : 'var(--border-primary)',
|
||||
color: canSubmit ? 'var(--accent-text)' : 'var(--text-faint)', fontSize: 13, fontWeight: 600, border: 'none', cursor: canSubmit ? 'pointer' : 'default', fontFamily: FONT,
|
||||
}}>
|
||||
{submitting ? '...' : t('collab.polls.create')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// ── Voter Chip with custom tooltip ────────────────────────────────────────────
|
||||
interface VoterChipProps {
|
||||
voter: PollVoter
|
||||
offset: boolean
|
||||
}
|
||||
|
||||
function VoterChip({ voter, offset }: VoterChipProps) {
|
||||
const [hover, setHover] = useState(false)
|
||||
const ref = React.useRef(null)
|
||||
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={ref}
|
||||
onMouseEnter={() => {
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
|
||||
}
|
||||
setHover(true)
|
||||
}}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden',
|
||||
border: '1.5px solid var(--bg-card)', marginLeft: offset ? -5 : 0, flexShrink: 0,
|
||||
}}>
|
||||
{voter.avatar_url ? <img src={voter.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : (voter.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card)', color: 'var(--text-primary)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
{voter.username}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Poll Card ────────────────────────────────────────────────────────────────
|
||||
interface PollCardProps {
|
||||
poll: Poll
|
||||
currentUser: User
|
||||
onVote: (pollId: number, optionId: number) => Promise<void>
|
||||
onClose: (pollId: number) => Promise<void>
|
||||
onDelete: (pollId: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function PollCard({ poll, currentUser, onVote, onClose, onDelete, t }: PollCardProps) {
|
||||
const total = totalVotes(poll)
|
||||
const isClosed = poll.is_closed || isExpired(poll.deadline)
|
||||
const remaining = timeRemaining(poll.deadline)
|
||||
const hasVoted = (poll.options || []).some(o => (o.voters || []).some(v => String(v.user_id) === String(currentUser.id)))
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: 14, border: '1px solid var(--border-faint)', overflow: 'hidden',
|
||||
background: 'var(--bg-card)', fontFamily: FONT,
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 8,
|
||||
background: isClosed ? 'var(--bg-secondary)' : 'transparent',
|
||||
}}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.35, wordBreak: 'break-word' }}>
|
||||
{poll.question}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Lock size={8} /> {t('collab.polls.closed')}
|
||||
</span>
|
||||
)}
|
||||
{remaining && !isClosed && (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 9, fontWeight: 600, color: '#f59e0b', background: '#f59e0b18', padding: '2px 7px', borderRadius: 99 }}>
|
||||
<Clock size={8} /> {remaining}
|
||||
</span>
|
||||
)}
|
||||
{poll.multiple_choice && (
|
||||
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', background: 'var(--bg-tertiary)', padding: '2px 7px', borderRadius: 99 }}>
|
||||
{t('collab.polls.multiChoice')}
|
||||
</span>
|
||||
)}
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)' }}>
|
||||
{total} {total === 1 ? 'vote' : 'votes'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||
{!isClosed && (
|
||||
<button onClick={() => onClose(poll.id)} title={t('collab.polls.close')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Lock size={12} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => onDelete(poll.id)} title={t('collab.polls.delete')}
|
||||
style={{ padding: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', borderRadius: 6 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div style={{ padding: '4px 12px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{(poll.options || []).map((opt, idx) => {
|
||||
const count = opt.voters?.length || 0
|
||||
const pct = total > 0 ? Math.round((count / total) * 100) : 0
|
||||
const myVote = (opt.voters || []).some(v => String(v.user_id) === String(currentUser.id))
|
||||
const isWinner = isClosed && count === Math.max(...(poll.options || []).map(o => o.voters?.length || 0)) && count > 0
|
||||
|
||||
return (
|
||||
<button key={idx} onClick={() => !isClosed && onVote(poll.id, idx)}
|
||||
disabled={isClosed}
|
||||
style={{
|
||||
position: 'relative', display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 12px', borderRadius: 10, border: 'none', cursor: isClosed ? 'default' : 'pointer',
|
||||
background: 'var(--bg-secondary)', fontFamily: FONT, textAlign: 'left', width: '100%',
|
||||
overflow: 'hidden', transition: 'transform 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isClosed) e.currentTarget.style.transform = 'scale(1.01)' }}
|
||||
onMouseLeave={e => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
{/* Progress bar background */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 0, top: 0, bottom: 0,
|
||||
width: `${pct}%`, borderRadius: 10,
|
||||
background: myVote ? '#007AFF20' : isWinner ? '#10b98118' : 'var(--bg-tertiary)',
|
||||
transition: 'width 0.4s ease',
|
||||
}} />
|
||||
|
||||
{/* Check circle */}
|
||||
<div style={{
|
||||
width: 20, height: 20, borderRadius: '50%', flexShrink: 0, position: 'relative',
|
||||
border: myVote ? '2px solid #007AFF' : '2px solid var(--border-primary)',
|
||||
background: myVote ? '#007AFF' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
{myVote && <Check size={11} color="#fff" strokeWidth={3} />}
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span style={{
|
||||
flex: 1, fontSize: 13, fontWeight: myVote || isWinner ? 600 : 400,
|
||||
color: 'var(--text-primary)', position: 'relative', zIndex: 1,
|
||||
}}>
|
||||
{typeof opt === 'string' ? opt : opt.label || opt}
|
||||
</span>
|
||||
|
||||
{/* Voter avatars */}
|
||||
{(opt.voters || []).length > 0 && (hasVoted || isClosed) && (
|
||||
<div style={{ display: 'flex', position: 'relative', zIndex: 1 }}>
|
||||
{(opt.voters || []).slice(0, 3).map((v, vi) => (
|
||||
<VoterChip key={v.user_id || vi} voter={v} offset={vi > 0} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Percentage */}
|
||||
{(hasVoted || isClosed) && (
|
||||
<span style={{
|
||||
fontSize: 12, fontWeight: 700, color: myVote ? '#007AFF' : 'var(--text-muted)',
|
||||
position: 'relative', zIndex: 1, minWidth: 32, textAlign: 'right',
|
||||
}}>
|
||||
{pct}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main Component ───────────────────────────────────────────────────────────
|
||||
interface CollabPollsProps {
|
||||
tripId: number
|
||||
currentUser: User
|
||||
}
|
||||
|
||||
export default function CollabPolls({ tripId, currentUser }: CollabPollsProps) {
|
||||
const { t } = useTranslation()
|
||||
const [polls, setPolls] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
collabApi.getPolls(tripId).then(data => {
|
||||
setPolls(Array.isArray(data) ? data : data.polls || [])
|
||||
}).catch(() => {}).finally(() => setLoading(false))
|
||||
}, [tripId])
|
||||
|
||||
// WebSocket
|
||||
useEffect(() => {
|
||||
const handler = (msg) => {
|
||||
if (!msg?.type) return
|
||||
if (msg.type === 'collab:poll:created' && msg.poll) {
|
||||
setPolls(prev => prev.some(p => p.id === msg.poll.id) ? prev : [msg.poll, ...prev])
|
||||
}
|
||||
if (msg.type === 'collab:poll:voted' && msg.poll) {
|
||||
setPolls(prev => prev.map(p => p.id === msg.poll.id ? msg.poll : p))
|
||||
}
|
||||
if (msg.type === 'collab:poll:closed' && msg.poll) {
|
||||
setPolls(prev => prev.map(p => p.id === msg.poll.id ? { ...p, ...msg.poll, is_closed: true } : p))
|
||||
}
|
||||
if (msg.type === 'collab:poll:deleted') {
|
||||
const id = msg.pollId || msg.poll?.id
|
||||
if (id) setPolls(prev => prev.filter(p => p.id !== id))
|
||||
}
|
||||
}
|
||||
addListener(handler)
|
||||
return () => removeListener(handler)
|
||||
}, [])
|
||||
|
||||
const handleCreate = useCallback(async (data) => {
|
||||
const result = await collabApi.createPoll(tripId, data)
|
||||
const created = result.poll || result
|
||||
setPolls(prev => prev.some(p => p.id === created.id) ? prev : [created, ...prev])
|
||||
setShowForm(false)
|
||||
}, [tripId])
|
||||
|
||||
const handleVote = useCallback(async (pollId, optionIndex) => {
|
||||
try {
|
||||
const result = await collabApi.votePoll(tripId, pollId, optionIndex)
|
||||
const updated = result.poll || result
|
||||
setPolls(prev => prev.map(p => p.id === updated.id ? updated : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleClose = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.closePoll(tripId, pollId)
|
||||
setPolls(prev => prev.map(p => p.id === pollId ? { ...p, is_closed: true } : p))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const handleDelete = useCallback(async (pollId) => {
|
||||
try {
|
||||
await collabApi.deletePoll(tripId, pollId)
|
||||
setPolls(prev => prev.filter(p => p.id !== pollId))
|
||||
} catch {}
|
||||
}, [tripId])
|
||||
|
||||
const activePolls = polls.filter(p => !p.is_closed && !isExpired(p.deadline))
|
||||
const closedPolls = polls.filter(p => p.is_closed || isExpired(p.deadline))
|
||||
|
||||
// Deadline ticker
|
||||
const [, setTick] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!polls.some(p => p.deadline && !p.is_closed)) return
|
||||
const iv = setInterval(() => setTick(t => t + 1), 30000)
|
||||
return () => clearInterval(iv)
|
||||
}, [polls])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontFamily: FONT }}>
|
||||
<div style={{ width: 20, height: 20, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'collab-poll-spin 0.7s linear infinite' }} />
|
||||
<style>{`@keyframes collab-poll-spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: FONT }}>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', flexShrink: 0 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 7, letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
<BarChart3 size={14} color="var(--text-faint)" />
|
||||
{t('collab.polls.title')}
|
||||
</h3>
|
||||
<button onClick={() => setShowForm(true)} style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4, borderRadius: 99, padding: '6px 12px',
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 600,
|
||||
fontFamily: FONT, border: 'none', cursor: 'pointer',
|
||||
}}>
|
||||
<Plus size={12} /> {t('collab.polls.new')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '0 12px 12px' }}>
|
||||
{polls.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '48px 20px', textAlign: 'center', height: '100%' }}>
|
||||
<BarChart3 size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.polls.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.polls.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{activePolls.length > 0 && activePolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
{closedPolls.length > 0 && (
|
||||
<>
|
||||
{activePolls.length > 0 && (
|
||||
<div style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: 0.3, padding: '8px 0 2px' }}>
|
||||
{t('collab.polls.closedSection') || 'Closed'}
|
||||
</div>
|
||||
)}
|
||||
{closedPolls.map(poll => (
|
||||
<PollCard key={poll.id} poll={poll} currentUser={currentUser} onVote={handleVote} onClose={handleClose} onDelete={handleDelete} t={t} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showForm && <CreatePollModal onClose={() => setShowForm(false)} onCreate={handleCreate} t={t} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { MapPin, Clock, Calendar, Users, Sparkles } from 'lucide-react'
|
||||
|
||||
function formatTime(timeStr, is12h) {
|
||||
if (!timeStr) return ''
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
if (is12h) {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatDayLabel(date, t, locale) {
|
||||
const d = new Date(date + 'T00:00:00')
|
||||
const now = new Date()
|
||||
const tomorrow = new Date(); tomorrow.setDate(now.getDate() + 1)
|
||||
|
||||
if (d.toDateString() === now.toDateString()) return t('collab.whatsNext.today') || 'Today'
|
||||
if (d.toDateString() === tomorrow.toDateString()) return t('collab.whatsNext.tomorrow') || 'Tomorrow'
|
||||
|
||||
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface WhatsNextWidgetProps {
|
||||
tripMembers?: TripMember[]
|
||||
}
|
||||
|
||||
export default function WhatsNextWidget({ tripMembers = [] }: WhatsNextWidgetProps) {
|
||||
const { days, assignments } = useTripStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
|
||||
const upcoming = useMemo(() => {
|
||||
const now = new Date()
|
||||
const nowDate = now.toISOString().split('T')[0]
|
||||
const nowTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
|
||||
const items = []
|
||||
|
||||
for (const day of (days || [])) {
|
||||
if (!day.date) continue
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
for (const a of dayAssignments) {
|
||||
if (!a.place) continue
|
||||
// Include: today (future times) + all future days
|
||||
const isFutureDay = day.date > nowDate
|
||||
const isTodayFuture = day.date === nowDate && (!a.place.place_time || a.place.place_time >= nowTime)
|
||||
if (isFutureDay || isTodayFuture) {
|
||||
items.push({
|
||||
id: a.id,
|
||||
name: a.place.name,
|
||||
time: a.place.place_time,
|
||||
endTime: a.place.end_time,
|
||||
date: day.date,
|
||||
dayTitle: day.title,
|
||||
category: a.place.category,
|
||||
participants: (a.participants && a.participants.length > 0)
|
||||
? a.participants
|
||||
: tripMembers.map(m => ({ user_id: m.id, username: m.username, avatar: m.avatar })),
|
||||
address: a.place.address,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => {
|
||||
const da = a.date + (a.time || '99:99')
|
||||
const db = b.date + (b.time || '99:99')
|
||||
return da.localeCompare(db)
|
||||
})
|
||||
|
||||
return items.slice(0, 8)
|
||||
}, [days, assignments, tripMembers])
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '10px 14px', display: 'flex', alignItems: 'center', gap: 7, flexShrink: 0,
|
||||
}}>
|
||||
<Sparkles size={14} color="var(--text-faint)" />
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-muted)', letterSpacing: 0.3, textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.title') || "What's Next"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="chat-scroll" style={{ flex: 1, overflowY: 'auto', padding: '8px 10px' }}>
|
||||
{upcoming.length === 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: '48px 20px', textAlign: 'center' }}>
|
||||
<Calendar size={36} color="var(--text-faint)" strokeWidth={1.3} style={{ marginBottom: 12 }} />
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 4 }}>{t('collab.whatsNext.empty')}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('collab.whatsNext.emptyHint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{upcoming.map((item, idx) => {
|
||||
const prevItem = upcoming[idx - 1]
|
||||
const showDayHeader = !prevItem || prevItem.date !== item.date
|
||||
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
{showDayHeader && (
|
||||
<div style={{
|
||||
fontSize: 10, fontWeight: 500, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: 0.5,
|
||||
padding: idx === 0 ? '0 4px 4px' : '8px 4px 4px',
|
||||
}}>
|
||||
{formatDayLabel(item.date, t, locale)}
|
||||
{item.dayTitle ? ` — ${item.dayTitle}` : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex', gap: 10, padding: '8px 10px', borderRadius: 10,
|
||||
background: 'var(--bg-secondary)', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-secondary)'}
|
||||
>
|
||||
{/* Time column */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minWidth: 44, flexShrink: 0 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{item.time ? formatTime(item.time, is12h) : 'TBD'}
|
||||
</span>
|
||||
{item.endTime && (
|
||||
<>
|
||||
<span style={{ fontSize: 7, color: 'var(--text-faint)', fontWeight: 600, letterSpacing: 0.3, margin: '2px 0', textTransform: 'uppercase' }}>
|
||||
{t('collab.whatsNext.until') || 'bis'}
|
||||
</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--text-primary)', whiteSpace: 'nowrap', lineHeight: 1 }}>
|
||||
{formatTime(item.endTime, is12h)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-faint)', flexShrink: 0, margin: '2px 0' }} />
|
||||
|
||||
{/* Details */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.name}
|
||||
</div>
|
||||
{item.address && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 2 }}>
|
||||
<MapPin size={9} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{item.address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Participants */}
|
||||
{item.participants.length > 0 && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginTop: 5 }}>
|
||||
{item.participants.map(p => (
|
||||
<div key={p.user_id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 3px',
|
||||
borderRadius: 99, background: 'var(--bg-tertiary)', border: '1px solid var(--border-faint)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-secondary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{p.avatar
|
||||
? <img src={`/uploads/avatars/${p.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: p.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-muted)' }}>{p.username}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Clock, Plus, X } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
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","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>
|
||||
)
|
||||
}
|
||||
+67
-8
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useCallback } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Reservation, TripFile } from '../../types'
|
||||
|
||||
function isImage(mimeType) {
|
||||
if (!mimeType) return false
|
||||
@@ -32,7 +34,12 @@ function formatDateWithLocale(dateStr, locale) {
|
||||
}
|
||||
|
||||
// Image lightbox
|
||||
function ImageLightbox({ file, onClose }) {
|
||||
interface ImageLightboxProps {
|
||||
file: TripFile & { url: string }
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
@@ -62,7 +69,12 @@ function ImageLightbox({ file, onClose }) {
|
||||
}
|
||||
|
||||
// Source badge — unified style for both place and reservation
|
||||
function SourceBadge({ icon: Icon, label }) {
|
||||
interface SourceBadgeProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
}
|
||||
|
||||
function SourceBadge({ icon: Icon, label }: SourceBadgeProps) {
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
@@ -77,7 +89,18 @@ function SourceBadge({ icon: Icon, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
|
||||
interface FileManagerProps {
|
||||
files?: TripFile[]
|
||||
onUpload: (fd: FormData) => Promise<void>
|
||||
onDelete: (fileId: number) => Promise<void>
|
||||
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
||||
places: Place[]
|
||||
reservations?: Reservation[]
|
||||
tripId: number
|
||||
allowedFileTypes: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filterType, setFilterType] = useState('all')
|
||||
const [lightboxFile, setLightboxFile] = useState(null)
|
||||
@@ -107,10 +130,28 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
noClick: false,
|
||||
})
|
||||
|
||||
// Paste support
|
||||
const handlePaste = useCallback((e) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
const files = []
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
if (file) files.push(file)
|
||||
}
|
||||
}
|
||||
if (files.length > 0) {
|
||||
e.preventDefault()
|
||||
onDrop(files)
|
||||
}
|
||||
}, [onDrop])
|
||||
|
||||
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')
|
||||
if (filterType === 'collab') return !!f.note_id
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -135,7 +176,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} onPaste={handlePaste} tabIndex={-1}>
|
||||
{/* Lightbox */}
|
||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
||||
|
||||
@@ -212,6 +253,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
<>
|
||||
<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>
|
||||
<p style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, opacity: 0.7 }}>
|
||||
{(allowedFileTypes || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv').toUpperCase().split(',').join(', ')} · Max 50 MB
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -223,6 +267,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
{ id: 'pdf', label: t('files.filterPdf') },
|
||||
{ id: 'image', label: t('files.filterImages') },
|
||||
{ id: 'doc', label: t('files.filterDocs') },
|
||||
...(files.some(f => f.note_id) ? [{ id: 'collab', label: t('files.filterCollab') || 'Collab' }] : []),
|
||||
].map(tab => (
|
||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
||||
@@ -253,7 +298,7 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
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}`
|
||||
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||
|
||||
return (
|
||||
<div key={file.id} style={{
|
||||
@@ -276,7 +321,15 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
>
|
||||
{isImage(file.mime_type)
|
||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
: (() => {
|
||||
const ext = (file.original_name || '').split('.').pop()?.toUpperCase() || '?'
|
||||
const isPdf = file.mime_type === 'application/pdf'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', background: isPdf ? '#ef44441a' : 'var(--bg-tertiary)' }}>
|
||||
<span style={{ fontSize: 9, fontWeight: 700, color: isPdf ? '#ef4444' : 'var(--text-muted)', letterSpacing: 0.3 }}>{ext}</span>
|
||||
</div>
|
||||
)
|
||||
})()
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -305,6 +358,12 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
|
||||
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
|
||||
/>
|
||||
)}
|
||||
{file.note_id && (
|
||||
<SourceBadge
|
||||
icon={StickyNote}
|
||||
label={t('files.sourceCollab') || 'Collab Notes'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{file.description && !linkedReservation && (
|
||||
+24
-5
@@ -2,7 +2,26 @@ import React, { useState, useEffect } from 'react'
|
||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock, Puzzle, CalendarDays, Globe, ArrowRightLeft, Map, Briefcase, ListChecks, Wallet, FileText, Plane } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
const texts = {
|
||||
interface DemoTexts {
|
||||
titleBefore: string
|
||||
titleAfter: string
|
||||
title: string
|
||||
description: string
|
||||
resetIn: string
|
||||
minutes: string
|
||||
uploadNote: string
|
||||
fullVersionTitle: string
|
||||
features: string[]
|
||||
addonsTitle: string
|
||||
addons: [string, string][]
|
||||
whatIs: string
|
||||
whatIsDesc: string
|
||||
selfHost: string
|
||||
selfHostLink: string
|
||||
close: string
|
||||
}
|
||||
|
||||
const texts: Record<string, DemoTexts> = {
|
||||
de: {
|
||||
titleBefore: 'Willkommen bei ',
|
||||
titleAfter: '',
|
||||
@@ -72,9 +91,9 @@ const texts = {
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||
|
||||
export default function DemoBanner() {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes())
|
||||
export default function DemoBanner(): React.ReactElement | null {
|
||||
const [dismissed, setDismissed] = useState<boolean>(false)
|
||||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes())
|
||||
const { language } = useTranslation()
|
||||
const t = texts[language] || texts.en
|
||||
|
||||
@@ -98,7 +117,7 @@ export default function DemoBanner() {
|
||||
maxWidth: 480, width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: '90vh', overflow: 'auto',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
@@ -5,20 +5,37 @@ import { useAuthStore } from '../../store/authStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { addonsApi } from '../../api/client'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const ADDON_ICONS = { CalendarDays, Briefcase, Globe }
|
||||
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
|
||||
interface NavbarProps {
|
||||
tripTitle?: string
|
||||
tripId?: string
|
||||
onBack?: () => void
|
||||
showBack?: boolean
|
||||
onShare?: () => void
|
||||
}
|
||||
|
||||
interface Addon {
|
||||
id: string
|
||||
name: string
|
||||
icon: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }: NavbarProps): React.ReactElement {
|
||||
const { user, logout } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [appVersion, setAppVersion] = useState(null)
|
||||
const [globalAddons, setGlobalAddons] = useState([])
|
||||
const dark = settings.dark_mode
|
||||
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||
const [globalAddons, setGlobalAddons] = useState<Addon[]>([])
|
||||
const darkMode = settings.dark_mode
|
||||
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
|
||||
const loadAddons = () => {
|
||||
if (user) {
|
||||
@@ -46,8 +63,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
const toggleDark = () => {
|
||||
updateSetting('dark_mode', !dark).catch(() => {})
|
||||
const toggleDarkMode = () => {
|
||||
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -139,8 +156,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dark mode toggle */}
|
||||
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
|
||||
<button onClick={toggleDarkMode} 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)'}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
@@ -6,6 +7,7 @@ import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
// Fix default marker icons for vite
|
||||
delete L.Icon.Default.prototype._getIconUrl
|
||||
@@ -24,7 +26,7 @@ function escAttr(s) {
|
||||
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
const size = isSelected ? 44 : 36
|
||||
const borderColor = isSelected ? '#111827' : 'white'
|
||||
const borderWidth = isSelected ? 3 : 2.5
|
||||
@@ -34,20 +36,23 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
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);
|
||||
// Number badges (bottom-right), supports multiple numbers for duplicate places
|
||||
let badgeHtml = ''
|
||||
if (orderNumbers && orderNumbers.length > 0) {
|
||||
const label = orderNumbers.join(' · ')
|
||||
badgeHtml = `<span style="
|
||||
position:absolute;bottom:-4px;right:-4px;
|
||||
min-width:18px;height:${orderNumbers.length > 1 ? 16 : 18}px;border-radius:${orderNumbers.length > 1 ? 8 : 9}px;
|
||||
padding:0 ${orderNumbers.length > 1 ? 4 : 3}px;
|
||||
background:rgba(255,255,255,0.94);
|
||||
border:1.5px solid rgba(0,0,0,0.15);
|
||||
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-size:${orderNumbers.length > 1 ? 7.5 : 9}px;font-weight:800;color:#111827;
|
||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
||||
box-sizing:border-box;
|
||||
">${orderNumber}</span>` : ''
|
||||
box-sizing:border-box;white-space:nowrap;
|
||||
">${label}</span>`
|
||||
}
|
||||
|
||||
if (place.image_url) {
|
||||
return L.divIcon({
|
||||
@@ -89,7 +94,14 @@ function createPlaceIcon(place, orderNumber, isSelected) {
|
||||
})
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }) {
|
||||
interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
dayPlaces: Place[]
|
||||
paddingOpts: Record<string, number>
|
||||
}
|
||||
|
||||
function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }: SelectionControllerProps) {
|
||||
const map = useMap()
|
||||
const prev = useRef(null)
|
||||
|
||||
@@ -113,7 +125,12 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
||||
return null
|
||||
}
|
||||
|
||||
function MapController({ center, zoom }) {
|
||||
interface MapControllerProps {
|
||||
center: [number, number]
|
||||
zoom: number
|
||||
}
|
||||
|
||||
function MapController({ center, zoom }: MapControllerProps) {
|
||||
const map = useMap()
|
||||
const prevCenter = useRef(center)
|
||||
|
||||
@@ -128,7 +145,13 @@ function MapController({ center, zoom }) {
|
||||
}
|
||||
|
||||
// Fit bounds when places change (fitKey triggers re-fit)
|
||||
function BoundsController({ places, fitKey, paddingOpts }) {
|
||||
interface BoundsControllerProps {
|
||||
places: Place[]
|
||||
fitKey: number
|
||||
paddingOpts: Record<string, number>
|
||||
}
|
||||
|
||||
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
|
||||
const map = useMap()
|
||||
const prevFitKey = useRef(-1)
|
||||
|
||||
@@ -145,7 +168,11 @@ function BoundsController({ places, fitKey, paddingOpts }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }) {
|
||||
interface MapClickHandlerProps {
|
||||
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||
}
|
||||
|
||||
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onClick) return
|
||||
@@ -155,6 +182,56 @@ function MapClickHandler({ onClick }) {
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Route travel time label ──
|
||||
interface RouteLabelProps {
|
||||
midpoint: [number, number]
|
||||
walkingText: string
|
||||
drivingText: string
|
||||
}
|
||||
|
||||
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
|
||||
const map = useMap()
|
||||
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!map) return
|
||||
const check = () => setVisible(map.getZoom() >= 12)
|
||||
check()
|
||||
map.on('zoomend', check)
|
||||
return () => map.off('zoomend', check)
|
||||
}, [map])
|
||||
|
||||
if (!visible || !midpoint) return null
|
||||
|
||||
const icon = L.divIcon({
|
||||
className: 'route-info-pill',
|
||||
html: `<div style="
|
||||
display:flex;align-items:center;gap:5px;
|
||||
background:rgba(0,0,0,0.85);backdrop-filter:blur(8px);
|
||||
color:#fff;border-radius:99px;padding:3px 9px;
|
||||
font-size:9px;font-weight:600;white-space:nowrap;
|
||||
font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif;
|
||||
box-shadow:0 2px 12px rgba(0,0,0,0.3);
|
||||
pointer-events:none;
|
||||
position:relative;left:-50%;top:-50%;
|
||||
">
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="13" cy="4" r="2"/><path d="M7 21l3-7"/><path d="M10 14l5-5"/><path d="M15 9l-4 7"/><path d="M18 18l-3-7"/></svg>
|
||||
${walkingText}
|
||||
</span>
|
||||
<span style="opacity:0.3">|</span>
|
||||
<span style="display:flex;align-items:center;gap:2px">
|
||||
<svg width="9" height="9" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M19 17h2c.6 0 1-.4 1-1v-3c0-.9-.7-1.7-1.5-1.9L18 10l-2-4H7L5 10l-2.5 1.1C1.7 11.3 1 12.1 1 13v3c0 .6.4 1 1 1h2"/><circle cx="7" cy="17" r="2"/><circle cx="17" cy="17" r="2"/></svg>
|
||||
${drivingText}
|
||||
</span>
|
||||
</div>`,
|
||||
iconSize: [0, 0],
|
||||
iconAnchor: [0, 0],
|
||||
})
|
||||
|
||||
return <Marker position={midpoint} icon={icon} interactive={false} zIndexOffset={2000} />
|
||||
}
|
||||
|
||||
// Module-level photo cache shared with PlaceAvatar
|
||||
const mapPhotoCache = new Map()
|
||||
|
||||
@@ -162,6 +239,7 @@ export function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
route = null,
|
||||
routeSegments = [],
|
||||
selectedPlaceId = null,
|
||||
onMarkerClick,
|
||||
onMapClick,
|
||||
@@ -232,6 +310,7 @@ export function MapView({
|
||||
spiderfyOnMaxZoom
|
||||
showCoverageOnHover={false}
|
||||
zoomToBoundsOnClick
|
||||
singleMarkerMode
|
||||
iconCreateFunction={(cluster) => {
|
||||
const count = cluster.getChildCount()
|
||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||
@@ -248,8 +327,8 @@ export function MapView({
|
||||
{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)
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
@@ -293,13 +372,18 @@ export function MapView({
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
<>
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</MapContainer>
|
||||
)
|
||||
@@ -1,109 +0,0 @@
|
||||
// OSRM routing utility - free, no API key required
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
/**
|
||||
* Calculate a route between multiple waypoints using OSRM
|
||||
* @param {Array<{lat: number, lng: number}>} waypoints
|
||||
* @param {string} profile - 'driving' | 'walking' | 'cycling'
|
||||
* @returns {Promise<{coordinates: Array<[number,number]>, distance: number, duration: number, distanceText: string, durationText: string}>}
|
||||
*/
|
||||
export async function calculateRoute(waypoints, profile = 'driving') {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
throw new Error('Mindestens 2 Wegpunkte erforderlich')
|
||||
}
|
||||
|
||||
const coords = waypoints.map(p => `${p.lng},${p.lat}`).join(';')
|
||||
// OSRM public API only supports driving; we override duration for other modes
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error('Route konnte nicht berechnet werden')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
||||
throw new Error('Keine Route gefunden')
|
||||
}
|
||||
|
||||
const route = data.routes[0]
|
||||
const coordinates = route.geometry.coordinates.map(([lng, lat]) => [lat, lng])
|
||||
|
||||
const distance = route.distance // meters
|
||||
// Compute duration based on mode (walking: 5 km/h, cycling: 15 km/h)
|
||||
let duration
|
||||
if (profile === 'walking') {
|
||||
duration = distance / (5000 / 3600)
|
||||
} else if (profile === 'cycling') {
|
||||
duration = distance / (15000 / 3600)
|
||||
} else {
|
||||
duration = route.duration // driving: use OSRM value
|
||||
}
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
distance,
|
||||
duration,
|
||||
distanceText: formatDistance(distance),
|
||||
durationText: formatDuration(duration),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a Google Maps directions URL for the given places
|
||||
*/
|
||||
export function generateGoogleMapsUrl(places) {
|
||||
const valid = places.filter(p => p.lat && p.lng)
|
||||
if (valid.length === 0) return null
|
||||
if (valid.length === 1) {
|
||||
return `https://www.google.com/maps/search/?api=1&query=${valid[0].lat},${valid[0].lng}`
|
||||
}
|
||||
// Use /dir/stop1/stop2/.../stopN format — all stops as path segments
|
||||
const stops = valid.map(p => `${p.lat},${p.lng}`).join('/')
|
||||
return `https://www.google.com/maps/dir/${stops}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple nearest-neighbor route optimization
|
||||
*/
|
||||
export function optimizeRoute(places) {
|
||||
const valid = places.filter(p => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
const visited = new Set()
|
||||
const result = []
|
||||
let current = valid[0]
|
||||
visited.add(current.id)
|
||||
result.push(current)
|
||||
|
||||
while (result.length < valid.length) {
|
||||
let nearest = null
|
||||
let minDist = Infinity
|
||||
for (const place of valid) {
|
||||
if (visited.has(place.id)) continue
|
||||
const d = Math.sqrt(
|
||||
Math.pow(place.lat - current.lat, 2) + Math.pow(place.lng - current.lng, 2)
|
||||
)
|
||||
if (d < minDist) { minDist = d; nearest = place }
|
||||
}
|
||||
if (nearest) { visited.add(nearest.id); result.push(nearest); current = nearest }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) {
|
||||
return `${h} Std. ${m} Min.`
|
||||
}
|
||||
return `${m} Min.`
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { RouteResult, RouteSegment, Waypoint } from '../../types'
|
||||
|
||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||
|
||||
/** Fetches a full route via OSRM and returns coordinates, distance, and duration estimates for driving/walking. */
|
||||
export async function calculateRoute(
|
||||
waypoints: Waypoint[],
|
||||
profile: 'driving' | 'walking' | 'cycling' = 'driving',
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
): Promise<RouteResult> {
|
||||
if (!waypoints || waypoints.length < 2) {
|
||||
throw new Error('At least 2 waypoints required')
|
||||
}
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=full&geometries=geojson&steps=false`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) {
|
||||
throw new Error('Route could not be calculated')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.code !== 'Ok' || !data.routes || data.routes.length === 0) {
|
||||
throw new Error('No route found')
|
||||
}
|
||||
|
||||
const route = data.routes[0]
|
||||
const coordinates: [number, number][] = route.geometry.coordinates.map(([lng, lat]: [number, number]) => [lat, lng])
|
||||
|
||||
const distance: number = route.distance
|
||||
let duration: number
|
||||
if (profile === 'walking') {
|
||||
duration = distance / (5000 / 3600)
|
||||
} else if (profile === 'cycling') {
|
||||
duration = distance / (15000 / 3600)
|
||||
} else {
|
||||
duration = route.duration
|
||||
}
|
||||
|
||||
const walkingDuration = distance / (5000 / 3600)
|
||||
const drivingDuration: number = route.duration
|
||||
|
||||
return {
|
||||
coordinates,
|
||||
distance,
|
||||
duration,
|
||||
distanceText: formatDistance(distance),
|
||||
durationText: formatDuration(duration),
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(drivingDuration),
|
||||
}
|
||||
}
|
||||
|
||||
export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
||||
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}`
|
||||
}
|
||||
const stops = valid.map((p) => `${p.lat},${p.lng}`).join('/')
|
||||
return `https://www.google.com/maps/dir/${stops}`
|
||||
}
|
||||
|
||||
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
|
||||
export function optimizeRoute(places: Waypoint[]): Waypoint[] {
|
||||
const valid = places.filter((p) => p.lat && p.lng)
|
||||
if (valid.length <= 2) return places
|
||||
|
||||
const visited = new Set<number>()
|
||||
const result: Waypoint[] = []
|
||||
let current = valid[0]
|
||||
visited.add(0)
|
||||
result.push(current)
|
||||
|
||||
while (result.length < valid.length) {
|
||||
let nearestIdx = -1
|
||||
let minDist = Infinity
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
if (visited.has(i)) continue
|
||||
const d = Math.sqrt(
|
||||
Math.pow(valid[i].lat - current.lat, 2) + Math.pow(valid[i].lng - current.lng, 2)
|
||||
)
|
||||
if (d < minDist) { minDist = d; nearestIdx = i }
|
||||
}
|
||||
if (nearestIdx === -1) break
|
||||
visited.add(nearestIdx)
|
||||
current = valid[nearestIdx]
|
||||
result.push(current)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
|
||||
export async function calculateSegments(
|
||||
waypoints: Waypoint[],
|
||||
{ signal }: { signal?: AbortSignal } = {}
|
||||
): Promise<RouteSegment[]> {
|
||||
if (!waypoints || waypoints.length < 2) return []
|
||||
|
||||
const coords = waypoints.map((p) => `${p.lng},${p.lat}`).join(';')
|
||||
const url = `${OSRM_BASE}/driving/${coords}?overview=false&geometries=geojson&steps=false&annotations=distance,duration`
|
||||
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error('Route could not be calculated')
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 'Ok' || !data.routes?.[0]) throw new Error('No route found')
|
||||
|
||||
const legs = data.routes[0].legs
|
||||
return legs.map((leg: { distance: number; duration: number }, i: number): RouteSegment => {
|
||||
const from: [number, number] = [waypoints[i].lat, waypoints[i].lng]
|
||||
const to: [number, number] = [waypoints[i + 1].lat, waypoints[i + 1].lng]
|
||||
const mid: [number, number] = [(from[0] + to[0]) / 2, (from[1] + to[1]) / 2]
|
||||
const walkingDuration = leg.distance / (5000 / 3600)
|
||||
return {
|
||||
mid, from, to,
|
||||
walkingText: formatDuration(walkingDuration),
|
||||
drivingText: formatDuration(leg.duration),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function formatDistance(meters: number): string {
|
||||
if (meters < 1000) {
|
||||
return `${Math.round(meters)} m`
|
||||
}
|
||||
return `${(meters / 1000).toFixed(1)} km`
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (h > 0) {
|
||||
return `${h} h ${m} min`
|
||||
}
|
||||
return `${m} min`
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createElement } from 'react'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
|
||||
|
||||
const NOTE_ICON_MAP = { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark }
|
||||
function noteIconSvg(iconId) {
|
||||
@@ -88,7 +89,18 @@ async function fetchPlacePhotos(assignments) {
|
||||
return photoMap
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }) {
|
||||
interface downloadTripPDFProps {
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
categories: Category[]
|
||||
dayNotes: DayNotesMap
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||
await ensureRenderer()
|
||||
const loc = _locale || 'de-DE'
|
||||
const tr = _t || (k => k)
|
||||
@@ -144,9 +156,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
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)}" />`
|
||||
@@ -157,8 +166,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
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 `
|
||||
@@ -352,7 +359,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
? `<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?.title || 'Meine Reise')}</div>
|
||||
<div class="cover-title">${escHtml(trip?.title || 'My Trip')}</div>
|
||||
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
|
||||
${range ? `<div class="cover-dates">${range}</div>` : ''}
|
||||
<div class="cover-line"></div>
|
||||
+66
-37
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useRef } from 'react'
|
||||
import { useState, useMemo, useRef } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -6,38 +6,39 @@ import {
|
||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
||||
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
|
||||
} from 'lucide-react'
|
||||
import type { PackingItem } from '../../types'
|
||||
|
||||
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' },
|
||||
{ name: 'Passport', category: 'Documents' },
|
||||
{ name: 'Travel Insurance', category: 'Documents' },
|
||||
{ name: 'Visa Documents', category: 'Documents' },
|
||||
{ name: 'Flight Tickets', category: 'Documents' },
|
||||
{ name: 'Hotel Bookings', category: 'Documents' },
|
||||
{ name: 'Vaccination Card', category: 'Documents' },
|
||||
{ name: 'T-Shirts (5x)', category: 'Clothing' },
|
||||
{ name: 'Pants (2x)', category: 'Clothing' },
|
||||
{ name: 'Underwear (7x)', category: 'Clothing' },
|
||||
{ name: 'Socks (7x)', category: 'Clothing' },
|
||||
{ name: 'Jacket', category: 'Clothing' },
|
||||
{ name: 'Swimwear', category: 'Clothing' },
|
||||
{ name: 'Sport Shoes', category: 'Clothing' },
|
||||
{ name: 'Toothbrush', category: 'Toiletries' },
|
||||
{ name: 'Toothpaste', category: 'Toiletries' },
|
||||
{ name: 'Shampoo', category: 'Toiletries' },
|
||||
{ name: 'Sunscreen', category: 'Toiletries' },
|
||||
{ name: 'Deodorant', category: 'Toiletries' },
|
||||
{ name: 'Razor', category: 'Toiletries' },
|
||||
{ name: 'Phone Charger', category: 'Electronics' },
|
||||
{ name: 'Travel Adapter', category: 'Electronics' },
|
||||
{ name: 'Headphones', category: 'Electronics' },
|
||||
{ name: 'Camera', category: 'Electronics' },
|
||||
{ name: 'Power Bank', category: 'Electronics' },
|
||||
{ name: 'First Aid Kit', category: 'Health' },
|
||||
{ name: 'Prescription Medication', category: 'Health' },
|
||||
{ name: 'Pain Medication', category: 'Health' },
|
||||
{ name: 'Insect Repellent', category: 'Health' },
|
||||
{ name: 'Cash', category: 'Finances' },
|
||||
{ name: 'Credit Card', category: 'Finances' },
|
||||
]
|
||||
|
||||
// Cycling color palette — works in light & dark mode
|
||||
@@ -64,7 +65,14 @@ function katColor(kat, allCategories) {
|
||||
}
|
||||
|
||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
interface ArtikelZeileProps {
|
||||
item: PackingItem
|
||||
tripId: number
|
||||
categories: string[]
|
||||
onCategoryChange: () => void
|
||||
}
|
||||
|
||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }: ArtikelZeileProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [editName, setEditName] = useState(item.name)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
@@ -178,7 +186,16 @@ function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
||||
}
|
||||
|
||||
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
|
||||
interface KategorieGruppeProps {
|
||||
kategorie: string
|
||||
items: PackingItem[]
|
||||
tripId: number
|
||||
allCategories: string[]
|
||||
onRename: (oldName: string, newName: string) => Promise<void>
|
||||
onDeleteAll: (items: PackingItem[]) => Promise<void>
|
||||
}
|
||||
|
||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }: KategorieGruppeProps) {
|
||||
const [offen, setOffen] = useState(true)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [editKatName, setEditKatName] = useState(kategorie)
|
||||
@@ -198,12 +215,12 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
}
|
||||
|
||||
const handleCheckAll = async () => {
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
||||
}
|
||||
}
|
||||
const handleUncheckAll = async () => {
|
||||
for (const item of items) {
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
||||
}
|
||||
}
|
||||
@@ -272,7 +289,14 @@ function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, on
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }) {
|
||||
interface MenuItemProps {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick: () => void
|
||||
danger: boolean
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: MenuItemProps) {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
@@ -289,7 +313,12 @@ function MenuItem({ icon, label, onClick, danger }) {
|
||||
}
|
||||
|
||||
// ── Haupt-Panel ────────────────────────────────────────────────────────────
|
||||
export default function PackingListPanel({ tripId, items }) {
|
||||
interface PackingListPanelProps {
|
||||
tripId: number
|
||||
items: PackingItem[]
|
||||
}
|
||||
|
||||
export default function PackingListPanel({ tripId, items }: PackingListPanelProps) {
|
||||
const [neuerName, setNeuerName] = useState('')
|
||||
const [neueKategorie, setNeueKategorie] = useState('')
|
||||
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
|
||||
+30
-10
@@ -1,10 +1,23 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import { useState, useMemo } from 'react'
|
||||
import { PhotoLightbox } from './PhotoLightbox'
|
||||
import { PhotoUpload } from './PhotoUpload'
|
||||
import { Upload, Camera } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Photo, Place, Day } from '../../types'
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }) {
|
||||
interface PhotoGalleryProps {
|
||||
photos: Photo[]
|
||||
onUpload: (fd: FormData) => Promise<void>
|
||||
onDelete: (photoId: number) => Promise<void>
|
||||
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
|
||||
places: Place[]
|
||||
days: Day[]
|
||||
tripId: number
|
||||
}
|
||||
|
||||
export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, places, days, tripId }: PhotoGalleryProps) {
|
||||
const { t } = useTranslation()
|
||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||
const [showUpload, setShowUpload] = useState(false)
|
||||
const [filterDayId, setFilterDayId] = useState('')
|
||||
@@ -49,7 +62,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
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>
|
||||
<option value="">{t('photos.allDays')}</option>
|
||||
{(days || []).map(day => (
|
||||
<option key={day.id} value={day.id}>
|
||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
||||
@@ -62,7 +75,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
onClick={() => setFilterDayId('')}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||
>
|
||||
Zurücksetzen
|
||||
{t('common.reset')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -80,8 +93,8 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
{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>
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>{t('photos.noPhotos')}</p>
|
||||
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>{t('photos.uploadHint')}</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"
|
||||
@@ -109,7 +122,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
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>
|
||||
<span className="text-xs">{t('common.add')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -151,7 +164,14 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
||||
)
|
||||
}
|
||||
|
||||
function PhotoThumbnail({ photo, days, places, onClick }) {
|
||||
interface PhotoThumbnailProps {
|
||||
photo: Photo
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function PhotoThumbnail({ photo, days, places, onClick }: PhotoThumbnailProps) {
|
||||
const day = days?.find(d => d.id === photo.day_id)
|
||||
const place = places?.find(p => p.id === photo.place_id)
|
||||
|
||||
@@ -166,8 +186,8 @@ function PhotoThumbnail({ photo, days, places, onClick }) {
|
||||
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')
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
|
||||
+17
-3
@@ -1,7 +1,21 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { X, ChevronLeft, ChevronRight, Edit2, Trash2, Check } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Photo, Place, Day } from '../../types'
|
||||
|
||||
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }) {
|
||||
interface PhotoLightboxProps {
|
||||
photos: Photo[]
|
||||
initialIndex: number
|
||||
onClose: () => void
|
||||
onUpdate: (photoId: number, data: Partial<Photo>) => Promise<void>
|
||||
onDelete: (photoId: number) => Promise<void>
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
tripId: number
|
||||
}
|
||||
|
||||
export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelete, days, places, tripId }: PhotoLightboxProps) {
|
||||
const { t } = useTranslation()
|
||||
const [index, setIndex] = useState(initialIndex || 0)
|
||||
const [editCaption, setEditCaption] = useState(false)
|
||||
const [caption, setCaption] = useState('')
|
||||
@@ -81,7 +95,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
|
||||
title="Löschen"
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
+19
-8
@@ -1,8 +1,19 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, X, Image } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Place, Day } from '../../types'
|
||||
|
||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
interface PhotoUploadProps {
|
||||
tripId: number
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
onUpload: (fd: FormData) => Promise<void>
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function PhotoUpload({ tripId, days, places, onUpload, onClose }: PhotoUploadProps) {
|
||||
const { t } = useTranslation()
|
||||
const [files, setFiles] = useState([])
|
||||
const [dayId, setDayId] = useState('')
|
||||
const [placeId, setPlaceId] = useState('')
|
||||
@@ -46,7 +57,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
await onUpload(formData)
|
||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||
setFiles([])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed:', err)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
@@ -78,7 +89,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
) : (
|
||||
<>
|
||||
<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-sm mt-1">{t('photos.clickToSelect')}</p>
|
||||
<p className="text-gray-400 text-xs mt-2">JPG, PNG, WebP · max. 10 MB · bis zu 30 Fotos</p>
|
||||
</>
|
||||
)}
|
||||
@@ -128,13 +139,13 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">Ort verknüpfen</label>
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">{t('photos.linkPlace')}</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>
|
||||
<option value="">{t('photos.noPlace')}</option>
|
||||
{(places || []).map(place => (
|
||||
<option key={place.id} value={place.id}>{place.name}</option>
|
||||
))}
|
||||
@@ -175,7 +186,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
@@ -183,7 +194,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
||||
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`}
|
||||
{uploading ? t('common.uploading') : t('photos.uploadN', { n: files.length })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,512 +0,0 @@
|
||||
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 { useTranslation } from '../../i18n'
|
||||
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, hasMapsKey } = useAuthStore()
|
||||
const { t } = useTranslation()
|
||||
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 [searchSource, setSearchSource] = useState(null)
|
||||
|
||||
const handleMapSearch = async () => {
|
||||
if (!mapQuery.trim()) return
|
||||
setMapSearching(true)
|
||||
try {
|
||||
const data = await mapsApi.search(mapQuery)
|
||||
setMapResults(data.places || [])
|
||||
setSearchSource(data.source || 'google')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('places.mapsSearchError'))
|
||||
} 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>
|
||||
)}
|
||||
|
||||
{/* Place search — Google Maps or OpenStreetMap fallback */}
|
||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||
{!hasMapsKey && (
|
||||
<p className="mb-2 text-xs" style={{ color: 'var(--text-faint)' }}>
|
||||
{t('places.osmActive')}
|
||||
</p>
|
||||
)}
|
||||
<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={t('places.mapsSearchPlaceholder')}
|
||||
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" /> : t('common.search')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useDroppable } from '@dnd-kit/core'
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
||||
import AssignedPlaceItem from './AssignedPlaceItem'
|
||||
import { ChevronDown, ChevronUp, Plus, FileText, Package, DollarSign } from 'lucide-react'
|
||||
|
||||
export default function DayColumn({
|
||||
day,
|
||||
assignments,
|
||||
tripId,
|
||||
onRemoveAssignment,
|
||||
onEditPlace,
|
||||
onQuickAdd,
|
||||
}) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
const [notes, setNotes] = useState(day.notes || '')
|
||||
const [notesEditing, setNotesEditing] = useState(false)
|
||||
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `day-${day.id}`,
|
||||
data: {
|
||||
type: 'day',
|
||||
dayId: day.id,
|
||||
},
|
||||
})
|
||||
|
||||
const sortableIds = (assignments || []).map(a => `assignment-${a.id}`)
|
||||
|
||||
const totalCost = (assignments || []).reduce((sum, a) => {
|
||||
return sum + (a.place?.price ? Number(a.place.price) : 0)
|
||||
}, 0)
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return null
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex-shrink-0 w-72 flex flex-col rounded-xl border-2 transition-all duration-150
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-50 shadow-lg shadow-slate-100'
|
||||
: 'border-transparent bg-white shadow-sm'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`
|
||||
px-3 py-2.5 border-b flex items-center gap-2 rounded-t-xl
|
||||
${isOver ? 'border-slate-200 bg-slate-50' : 'border-slate-100 bg-slate-50'}
|
||||
`}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-bold text-slate-900">Day {day.day_number}</span>
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full font-medium">
|
||||
{assignments?.length || 0}
|
||||
</span>
|
||||
</div>
|
||||
{day.date && (
|
||||
<p className="text-xs text-slate-500 mt-0.5">{formatDate(day.date)}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{totalCost > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-xs text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{totalCost.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
className={`p-1 rounded transition-colors ${showNotes ? 'text-slate-700 bg-slate-100' : 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'}`}
|
||||
title="Notes"
|
||||
>
|
||||
<FileText className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
className="p-1 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded transition-colors"
|
||||
>
|
||||
{isCollapsed ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes area */}
|
||||
{showNotes && (
|
||||
<div className="px-3 py-2 border-b border-slate-100 bg-amber-50">
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
onBlur={() => setNotesEditing(false)}
|
||||
onFocus={() => setNotesEditing(true)}
|
||||
placeholder="Add notes for this day..."
|
||||
rows={2}
|
||||
className="w-full text-xs text-slate-600 bg-transparent resize-none focus:outline-none placeholder-amber-400"
|
||||
/>
|
||||
{notesEditing && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault()
|
||||
// Parent will handle save via onUpdateNotes if passed
|
||||
}}
|
||||
className="text-xs text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignments list */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`
|
||||
flex-1 p-2 flex flex-col gap-2 min-h-24 transition-colors duration-150
|
||||
${isOver ? 'bg-slate-50' : 'bg-transparent'}
|
||||
`}
|
||||
>
|
||||
{assignments && assignments.length > 0 ? (
|
||||
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
|
||||
{assignments.map(assignment => (
|
||||
<AssignedPlaceItem
|
||||
key={assignment.id}
|
||||
assignment={assignment}
|
||||
dayId={day.id}
|
||||
onRemove={(id) => onRemoveAssignment(day.id, id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
) : (
|
||||
<div className={`
|
||||
flex-1 flex flex-col items-center justify-center py-6 rounded-lg border-2 border-dashed
|
||||
text-xs text-center transition-colors
|
||||
${isOver
|
||||
? 'border-slate-400 bg-slate-100 text-slate-500'
|
||||
: 'border-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
<Package className="w-8 h-8 mb-2 opacity-50" />
|
||||
<p className="font-medium">Drop places here</p>
|
||||
<p className="text-[10px] mt-0.5 opacity-70">or drag from the left panel</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick add button */}
|
||||
<button
|
||||
onClick={() => onQuickAdd(day)}
|
||||
className="flex items-center justify-center gap-1 py-1.5 text-xs text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg border border-dashed border-slate-200 hover:border-slate-300 transition-all mt-1"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add place
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCollapsed && (
|
||||
<div
|
||||
className="px-3 py-2 text-xs text-slate-400 cursor-pointer hover:bg-slate-50"
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
>
|
||||
{assignments?.length || 0} place{(assignments?.length || 0) !== 1 ? 's' : ''} — click to expand
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { X, Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind, Droplets, Sunrise, Sunset, Hotel, Calendar, MapPin, LogIn, LogOut, Hash, Pencil, Plane, Utensils, Train, Car, Ship, Ticket, FileText, Users } from 'lucide-react'
|
||||
|
||||
const RES_TYPE_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
const RES_TYPE_COLORS = { flight: '#3b82f6', hotel: '#8b5cf6', restaurant: '#ef4444', train: '#06b6d4', car: '#6b7280', cruise: '#0ea5e9', event: '#f59e0b', tour: '#10b981', other: '#6b7280' }
|
||||
import { weatherApi, accommodationsApi } from '../../api/client'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
|
||||
Thunderstorm: CloudLightning, Snow: CloudSnow, Mist: Wind, Fog: Wind, Haze: Wind,
|
||||
}
|
||||
|
||||
interface WIconProps {
|
||||
main: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function WIcon({ main, size = 14 }: WIconProps) {
|
||||
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
|
||||
function cTemp(c, f) { return Math.round(f ? c * 9 / 5 + 32 : c) }
|
||||
|
||||
function formatTime12(val, is12h) {
|
||||
if (!val) return val
|
||||
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}`
|
||||
}
|
||||
|
||||
interface DayDetailPanelProps {
|
||||
day: Day
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories?: Category[]
|
||||
tripId: number
|
||||
assignments: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
onClose: () => void
|
||||
onAccommodationChange: () => void
|
||||
}
|
||||
|
||||
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange }: DayDetailPanelProps) {
|
||||
const { t, language } = useTranslation()
|
||||
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
const fmtTime = (v) => formatTime12(v, is12h)
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const [weather, setWeather] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [accommodation, setAccommodation] = useState(null)
|
||||
const [accommodations, setAccommodations] = useState([])
|
||||
const [showHotelPicker, setShowHotelPicker] = useState(false)
|
||||
const [hotelDayRange, setHotelDayRange] = useState({ start: day?.id, end: day?.id })
|
||||
const [hotelCategoryFilter, setHotelCategoryFilter] = useState('')
|
||||
const [hotelForm, setHotelForm] = useState({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
|
||||
useEffect(() => {
|
||||
if (!day?.date || !lat || !lng) { setWeather(null); return }
|
||||
setLoading(true)
|
||||
weatherApi.getDetailed(lat, lng, day.date, language)
|
||||
.then(data => setWeather(data.error ? null : data))
|
||||
.catch(() => setWeather(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [day?.date, lat, lng, language])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
accommodationsApi.list(tripId)
|
||||
.then(data => {
|
||||
setAccommodations(data.accommodations || [])
|
||||
const acc = (data.accommodations || []).find(a =>
|
||||
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||
)
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
.catch(() => {})
|
||||
}, [tripId, day?.id])
|
||||
|
||||
useEffect(() => { if (day) setHotelDayRange({ start: day.id, end: day.id }) }, [day?.id])
|
||||
|
||||
const handleSelectPlace = (placeId) => {
|
||||
setHotelForm(f => ({ ...f, place_id: placeId }))
|
||||
}
|
||||
|
||||
const handleSaveAccommodation = async () => {
|
||||
if (!hotelForm.place_id) return
|
||||
try {
|
||||
const data = await accommodationsApi.create(tripId, {
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setAccommodation(data.accommodation)
|
||||
setAccommodations(prev => [...prev, data.accommodation])
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const updateAccommodationField = async (field, value) => {
|
||||
if (!accommodation) return
|
||||
try {
|
||||
const data = await accommodationsApi.update(tripId, accommodation.id, { [field]: value || null })
|
||||
setAccommodation(data.accommodation)
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const handleRemoveAccommodation = async () => {
|
||||
if (!accommodation) return
|
||||
try {
|
||||
await accommodationsApi.delete(tripId, accommodation.id)
|
||||
setAccommodations(prev => prev.filter(a => a.id !== accommodation.id))
|
||||
setAccommodation(null)
|
||||
onAccommodationChange?.()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!day) return null
|
||||
|
||||
const formattedDate = day.date ? new Date(day.date + 'T00:00:00').toLocaleDateString(
|
||||
language === 'de' ? 'de-DE' : 'en-US',
|
||||
{ weekday: 'long', day: 'numeric', month: 'long' }
|
||||
) : null
|
||||
|
||||
const placesWithCoords = places.filter(p => p.lat && p.lng)
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', bottom: 20, left: '50%', transform: 'translateX(-50%)', width: 'min(800px, calc(100vw - 32px))', zIndex: 50, ...font }}>
|
||||
<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: 14, padding: '18px 16px 14px 20px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<Calendar size={20} style={{ color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||
{day.title || t('planner.dayN', { n: (days.indexOf(day) + 1) || '?' })}
|
||||
</div>
|
||||
{formattedDate && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{formattedDate}</div>}
|
||||
</div>
|
||||
<button onClick={onClose} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 10, width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0 }}>
|
||||
<X size={14} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div style={{ overflowY: 'auto', padding: '14px 20px 18px' }}>
|
||||
|
||||
{/* ── Weather ── */}
|
||||
{day.date && lat && lng && (
|
||||
loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 16, color: 'var(--text-faint)', fontSize: 12 }}>
|
||||
<div style={{ width: 18, height: 18, border: '2px solid var(--border-primary)', borderTopColor: 'var(--text-primary)', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto 6px' }} />
|
||||
</div>
|
||||
) : weather ? (
|
||||
<div>
|
||||
{/* Summary row */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 10 }}>
|
||||
<div style={{ width: 40, height: 40, borderRadius: 12, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
<WIcon main={weather.main} size={20} />
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'baseline', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span style={{ fontSize: 20, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>
|
||||
{weather.type === 'climate' ? 'Ø ' : ''}{cTemp(weather.temp, isFahrenheit)}{unit}
|
||||
</span>
|
||||
{weather.temp_max != null && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>
|
||||
{cTemp(weather.temp_min, isFahrenheit)}° / {cTemp(weather.temp_max, isFahrenheit)}°
|
||||
</span>
|
||||
)}
|
||||
{weather.description && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', textTransform: 'capitalize' }}>{weather.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chips row */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: weather.hourly ? 10 : 0 }}>
|
||||
{weather.precipitation_probability_max != null && (
|
||||
<Chip icon={Droplets} value={`${weather.precipitation_probability_max}%`} />
|
||||
)}
|
||||
{weather.precipitation_sum > 0 && (
|
||||
<Chip icon={CloudRain} value={`${weather.precipitation_sum.toFixed(1)} mm`} />
|
||||
)}
|
||||
{weather.wind_max != null && (
|
||||
<Chip icon={Wind} value={isFahrenheit ? `${Math.round(weather.wind_max * 0.621371)} mph` : `${Math.round(weather.wind_max)} km/h`} />
|
||||
)}
|
||||
{weather.sunrise && <Chip icon={Sunrise} value={weather.sunrise} />}
|
||||
{weather.sunset && <Chip icon={Sunset} value={weather.sunset} />}
|
||||
</div>
|
||||
|
||||
{/* Hourly scroll */}
|
||||
{weather.hourly?.length > 0 && (
|
||||
<div style={{ overflowX: 'auto', margin: '0 -6px', padding: '0 6px 4px' }}>
|
||||
<div style={{ display: 'inline-flex', gap: 2 }}>
|
||||
{weather.hourly.filter((_, i) => i % 2 === 0).map(h => (
|
||||
<div key={h.hour} style={{
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
|
||||
width: 44, padding: '5px 2px', borderRadius: 8,
|
||||
background: h.precipitation_probability > 50 ? 'rgba(59,130,246,0.07)' : 'transparent',
|
||||
}}>
|
||||
<span style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500 }}>{String(h.hour).padStart(2, '0')}</span>
|
||||
<WIcon main={h.main} size={12} />
|
||||
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-primary)' }}>{cTemp(h.temp, isFahrenheit)}°</span>
|
||||
{h.precipitation_probability > 0 && (
|
||||
<span style={{ fontSize: 8, color: '#3b82f6', fontWeight: 500 }}>{h.precipitation_probability}%</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{weather.type === 'climate' && (
|
||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 6, fontStyle: 'italic' }}>{t('day.climateHint')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-faint)', textAlign: 'center', padding: 8 }}>{t('day.noWeather')}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Reservations for this day's assignments ── */}
|
||||
{(() => {
|
||||
const dayAssignments = assignments[String(day.id)] || []
|
||||
const dayReservations = reservations.filter(r => dayAssignments.some(a => a.id === r.assignment_id))
|
||||
if (dayReservations.length === 0) return null
|
||||
return (
|
||||
<div style={{ marginBottom: 0 }}>
|
||||
{day.date && lat && lng && <div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />}
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>{t('day.reservations')}</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{dayReservations.map(r => {
|
||||
const linkedAssignment = dayAssignments.find(a => a.id === r.assignment_id)
|
||||
const confirmed = r.status === 'confirmed'
|
||||
return (
|
||||
<div key={r.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', borderRadius: 8, background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.15)' : 'rgba(217,119,6,0.15)'}` }}>
|
||||
{(() => { const TIcon = RES_TYPE_ICONS[r.type] || FileText; return <TIcon size={12} style={{ color: RES_TYPE_COLORS[r.type] || 'var(--text-faint)', flexShrink: 0 }} /> })()}
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', gap: 6, overflow: 'hidden' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{r.title}</span>
|
||||
{linkedAssignment?.place && <span style={{ fontSize: 9, color: 'var(--text-faint)', whiteSpace: 'nowrap' }}>· {linkedAssignment.place.name}</span>}
|
||||
</div>
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{new Date(r.reservation_time).toLocaleTimeString(language === 'de' ? 'de-DE' : 'en-US', { hour: '2-digit', minute: '2-digit', hour12: is12h })}
|
||||
{r.reservation_end_time && ` – ${fmtTime(r.reservation_end_time)}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Divider before accommodation */}
|
||||
<div style={{ height: 1, background: 'var(--border-faint)', margin: '12px 0' }} />
|
||||
|
||||
{/* ── Accommodation ── */}
|
||||
<div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 8 }}>{t('day.accommodation')}</div>
|
||||
|
||||
{accommodation ? (
|
||||
<div style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
|
||||
{/* Hotel header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px' }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{accommodation.place_image ? (
|
||||
<img src={accommodation.place_image} style={{ width: '100%', height: '100%', borderRadius: 10, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<Hotel size={16} style={{ color: 'var(--text-muted)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_name}</div>
|
||||
{accommodation.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{accommodation.place_address}</div>}
|
||||
</div>
|
||||
<button onClick={handleRemoveAccommodation} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Details row */}
|
||||
{/* Details grid */}
|
||||
<div style={{ display: 'flex', gap: 0, margin: '0 12px 10px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||
{accommodation.check_in && (
|
||||
<div style={{ flex: 1, padding: '8px 10px', borderRight: '1px solid var(--border-faint)', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_in)}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<LogIn size={8} /> {t('day.checkIn')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{accommodation.check_out && (
|
||||
<div style={{ flex: 1, padding: '8px 10px', borderRight: accommodation.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(accommodation.check_out)}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<LogOut size={8} /> {t('day.checkOut')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{accommodation.confirmation && (
|
||||
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{accommodation.confirmation}</div>
|
||||
<div style={{ fontSize: 9, color: 'var(--text-faint)', fontWeight: 500, marginTop: 2, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3 }}>
|
||||
<Hash size={8} /> {t('day.confirmation')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button onClick={() => { setHotelForm({ check_in: accommodation.check_in || '', check_out: accommodation.check_out || '', confirmation: accommodation.confirmation || '', place_id: accommodation.place_id }); setHotelDayRange({ start: accommodation.start_day_id, end: accommodation.end_day_id }); setShowHotelPicker('edit') }}
|
||||
style={{ padding: '0 8px', background: 'none', border: 'none', borderLeft: '1px solid var(--border-faint)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
||||
<Pencil size={10} style={{ color: 'var(--text-faint)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||
width: '100%', padding: 10, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
fontSize: 11, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Hotel size={12} /> {t('day.addAccommodation')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Hotel Picker Popup — portal to body to escape transform stacking context */}
|
||||
{showHotelPicker && ReactDOM.createPortal(
|
||||
<div style={{ position: 'fixed', inset: 0, zIndex: 99999, background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||
onClick={() => setShowHotelPicker(false)}>
|
||||
<div onClick={e => e.stopPropagation()} style={{
|
||||
width: '100%', maxWidth: 900, borderRadius: 16, overflow: 'hidden',
|
||||
background: 'var(--bg-card)', boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||
...font,
|
||||
}}>
|
||||
{/* Popup Header */}
|
||||
<div style={{ padding: '16px 18px 12px', borderBottom: '1px solid var(--border-faint)', display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<Hotel size={16} style={{ color: 'var(--text-primary)' }} />
|
||||
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text-primary)', flex: 1 }}>{showHotelPicker === 'edit' ? t('day.editAccommodation') : t('day.addAccommodation')}</span>
|
||||
<button onClick={() => setShowHotelPicker(false)} style={{ background: 'var(--bg-secondary)', border: 'none', borderRadius: 8, width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}>
|
||||
<X size={12} style={{ color: 'var(--text-muted)' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Day Range */}
|
||||
<div style={{ padding: '12px 18px', borderBottom: '1px solid var(--border-faint)', background: 'var(--bg-secondary)' }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.04em' }}>{t('day.hotelDayRange')}</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={hotelDayRange.start}
|
||||
onChange={v => setHotelDayRange(prev => ({ start: v, end: Math.max(v, prev.end) }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>→</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<CustomSelect
|
||||
value={hotelDayRange.end}
|
||||
onChange={v => setHotelDayRange(prev => ({ start: Math.min(prev.start, v), end: v }))}
|
||||
options={days.map((d, i) => ({
|
||||
value: d.id,
|
||||
label: `${d.title || t('planner.dayN', { n: i + 1 })}${d.date ? ` — ${new Date(d.date + 'T00:00:00').toLocaleDateString(language === 'de' ? 'de-DE' : 'en-US', { day: 'numeric', month: 'short' })}` : ''}`,
|
||||
}))}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<button onClick={() => setHotelDayRange({ start: days[0]?.id, end: days[days.length - 1]?.id })} style={{
|
||||
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 11, fontWeight: 600, cursor: 'pointer', flexShrink: 0,
|
||||
background: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
color: hotelDayRange.start === days[0]?.id && hotelDayRange.end === days[days.length - 1]?.id ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||
}}>
|
||||
{t('day.allDays')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Check-in / Check-out / Confirmation */}
|
||||
<div style={{ padding: '10px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkIn')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_in} onChange={v => setHotelForm(f => ({ ...f, check_in: v }))} placeholder="14:00" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 100 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.checkOut')}</label>
|
||||
<CustomTimePicker value={hotelForm.check_out} onChange={v => setHotelForm(f => ({ ...f, check_out: v }))} placeholder="11:00" />
|
||||
</div>
|
||||
<div style={{ flex: 2, minWidth: 120 }}>
|
||||
<label style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.04em', display: 'block', marginBottom: 3 }}>{t('day.confirmation')}</label>
|
||||
<input type="text" value={hotelForm.confirmation} onChange={e => setHotelForm(f => ({ ...f, confirmation: e.target.value }))}
|
||||
placeholder="ABC-12345" style={{ width: '100%', padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'var(--bg-card)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', boxSizing: 'border-box', height: 38 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
{categories.length > 0 && (
|
||||
<div style={{ padding: '8px 18px', borderBottom: '1px solid var(--border-faint)', display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button onClick={() => setHotelCategoryFilter('')} style={{
|
||||
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 10, fontWeight: 600, cursor: 'pointer',
|
||||
background: !hotelCategoryFilter ? 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||
color: !hotelCategoryFilter ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||
}}>{t('day.allDays')}</button>
|
||||
|
||||
{categories.map(c => (
|
||||
<button key={c.id} onClick={() => setHotelCategoryFilter(c.id)} style={{
|
||||
padding: '3px 10px', borderRadius: 6, border: 'none', fontSize: 10, fontWeight: 600, cursor: 'pointer',
|
||||
background: hotelCategoryFilter === c.id ? c.color || 'var(--text-primary)' : 'var(--bg-secondary)',
|
||||
color: hotelCategoryFilter === c.id ? '#fff' : 'var(--text-muted)',
|
||||
}}>{c.name}</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Place List */}
|
||||
<div style={{ maxHeight: 250, overflowY: 'auto' }}>
|
||||
{(() => {
|
||||
const filtered = hotelCategoryFilter ? places.filter(p => p.category_id === hotelCategoryFilter) : places
|
||||
return filtered.length === 0 ? (
|
||||
<div style={{ padding: 20, textAlign: 'center', fontSize: 12, color: 'var(--text-faint)' }}>{t('day.noPlacesForHotel')}</div>
|
||||
) : filtered.map(p => (
|
||||
<button key={p.id} onClick={() => handleSelectPlace(p.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '10px 18px',
|
||||
border: 'none', borderBottom: '1px solid var(--border-faint)',
|
||||
background: hotelForm.place_id === p.id ? 'var(--bg-hover)' : 'none',
|
||||
cursor: 'pointer', fontFamily: 'inherit', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
outline: hotelForm.place_id === p.id ? '2px solid var(--accent)' : 'none',
|
||||
outlineOffset: -2, borderRadius: hotelForm.place_id === p.id ? 8 : 0,
|
||||
}}
|
||||
onMouseEnter={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (hotelForm.place_id !== p.id) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--bg-secondary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||
{p.image_url ? (
|
||||
<img src={p.image_url} style={{ width: '100%', height: '100%', borderRadius: 8, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<MapPin size={13} style={{ color: 'var(--text-faint)' }} />
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</div>
|
||||
{p.address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.address}</div>}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Save / Cancel */}
|
||||
<div style={{ padding: '12px 18px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<button onClick={() => setShowHotelPicker(false)} style={{ padding: '7px 16px', borderRadius: 8, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button onClick={async () => {
|
||||
if (showHotelPicker === 'edit' && accommodation) {
|
||||
// Update existing
|
||||
await accommodationsApi.update(tripId, accommodation.id, {
|
||||
place_id: hotelForm.place_id,
|
||||
start_day_id: hotelDayRange.start,
|
||||
end_day_id: hotelDayRange.end,
|
||||
check_in: hotelForm.check_in || null,
|
||||
check_out: hotelForm.check_out || null,
|
||||
confirmation: hotelForm.confirmation || null,
|
||||
})
|
||||
setShowHotelPicker(false)
|
||||
setHotelForm({ check_in: '', check_out: '', confirmation: '', place_id: null })
|
||||
// Reload
|
||||
accommodationsApi.list(tripId).then(d => {
|
||||
setAccommodations(d.accommodations || [])
|
||||
const acc = (d.accommodations || []).find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
|
||||
setAccommodation(acc || null)
|
||||
})
|
||||
onAccommodationChange?.()
|
||||
} else {
|
||||
await handleSaveAccommodation()
|
||||
}
|
||||
}} disabled={!hotelForm.place_id} style={{
|
||||
padding: '7px 20px', borderRadius: 8, border: 'none', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
background: hotelForm.place_id ? 'var(--text-primary)' : 'var(--bg-tertiary)',
|
||||
color: hotelForm.place_id ? 'var(--bg-card)' : 'var(--text-faint)',
|
||||
}}>
|
||||
{t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ChipProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
value: string
|
||||
}
|
||||
|
||||
function Chip({ icon: Icon, value }: ChipProps) {
|
||||
return (
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Icon size={11} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
<span style={{ fontWeight: 500 }}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface InfoChipProps {
|
||||
icon: React.ComponentType<{ size?: number; style?: React.CSSProperties }>
|
||||
label: string
|
||||
value: string
|
||||
placeholder: string
|
||||
onEdit: (value: string) => void
|
||||
type: 'text' | 'time'
|
||||
}
|
||||
|
||||
function InfoChip({ icon: Icon, label, value, placeholder, onEdit, type }: InfoChipProps) {
|
||||
const [editing, setEditing] = React.useState(false)
|
||||
const [val, setVal] = React.useState(value || '')
|
||||
const inputRef = React.useRef(null)
|
||||
|
||||
React.useEffect(() => { setVal(value || '') }, [value])
|
||||
React.useEffect(() => { if (editing && inputRef.current) inputRef.current.focus() }, [editing])
|
||||
|
||||
const save = () => {
|
||||
setEditing(false)
|
||||
if (val !== (value || '')) onEdit(val)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => setEditing(true)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||
cursor: 'pointer', minWidth: 0, flex: type === 'text' ? 1 : undefined,
|
||||
}}
|
||||
>
|
||||
<Icon size={11} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 8, color: 'var(--text-faint)', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', lineHeight: 1 }}>{label}</div>
|
||||
{editing ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type={type}
|
||||
value={val}
|
||||
onChange={e => setVal(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setVal(value || ''); setEditing(false) } }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
border: 'none', outline: 'none', background: 'none', padding: 0, margin: 0,
|
||||
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', fontFamily: 'inherit',
|
||||
width: type === 'time' ? 50 : '100%', lineHeight: 1.3,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: value ? 'var(--text-primary)' : 'var(--text-faint)', lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{value || placeholder}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+166
-144
@@ -1,42 +1,25 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
declare global { interface Window { __dragData: DragDataPayload | null } }
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, 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, Lock } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users } from 'lucide-react'
|
||||
|
||||
const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, event: Ticket, tour: Users, other: FileText }
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
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
|
||||
}
|
||||
import { formatDate, formatTime, dayTotalCost } from '../../utils/formatters'
|
||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||
|
||||
const NOTE_ICONS = [
|
||||
{ id: 'FileText', Icon: FileText },
|
||||
@@ -68,32 +51,58 @@ const TYPE_ICONS = {
|
||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
||||
}
|
||||
|
||||
interface DayPlanSidebarProps {
|
||||
tripId: number
|
||||
trip: Trip
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
selectedPlaceId: number | null
|
||||
selectedAssignmentId: number | null
|
||||
onSelectDay: (dayId: number | null) => void
|
||||
onPlaceClick: (placeId: number) => void
|
||||
onDayDetail: (day: Day) => void
|
||||
accommodations?: Assignment[]
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
onRouteCalculated: (dayId: number, route: RouteResult | null) => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
reservations?: Reservation[]
|
||||
onAddReservation: () => void
|
||||
}
|
||||
|
||||
export default function DayPlanSidebar({
|
||||
tripId,
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId,
|
||||
onSelectDay, onPlaceClick,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||
onAssignToDay,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
onAddReservation,
|
||||
}) {
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const 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 { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote: _openAddNote, openEditNote: _openEditNote, cancelNote, saveNote, deleteNote: _deleteNote, moveNote: _moveNote } = useDayNotes(tripId)
|
||||
|
||||
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
||||
const [expandedDays, setExpandedDays] = useState(() => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(`day-expanded-${tripId}`)
|
||||
if (saved) return new Set(JSON.parse(saved))
|
||||
} catch {}
|
||||
return 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)
|
||||
@@ -102,9 +111,7 @@ export default function DayPlanSidebar({
|
||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
|
||||
const inputRef = useRef(null)
|
||||
const noteInputRef = useRef(null)
|
||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
||||
|
||||
const currency = trip?.currency || 'EUR'
|
||||
@@ -127,9 +134,20 @@ export default function DayPlanSidebar({
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
}
|
||||
|
||||
// Only auto-expand genuinely new days (not on initial load from storage)
|
||||
const prevDayCount = React.useRef(days.length)
|
||||
useEffect(() => {
|
||||
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
|
||||
}, [days.length])
|
||||
if (days.length > prevDayCount.current) {
|
||||
// New days added — expand only those
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
days.forEach(d => { if (!prev.has(d.id)) n.add(d.id) })
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
prevDayCount.current = days.length
|
||||
}, [days.length, tripId])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingDayId && inputRef.current) inputRef.current.focus()
|
||||
@@ -153,6 +171,7 @@ export default function DayPlanSidebar({
|
||||
setExpandedDays(prev => {
|
||||
const n = new Set(prev)
|
||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
||||
try { sessionStorage.setItem(`day-expanded-${tripId}`, JSON.stringify([...n])) } catch {}
|
||||
return n
|
||||
})
|
||||
}
|
||||
@@ -171,40 +190,19 @@ export default function DayPlanSidebar({
|
||||
|
||||
const openAddNote = (dayId, e) => {
|
||||
e?.stopPropagation()
|
||||
const merged = getMergedItems(dayId)
|
||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
|
||||
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
_openAddNote(dayId, getMergedItems, (id) => {
|
||||
if (!expandedDays.has(id)) setExpandedDays(prev => new Set([...prev, id]))
|
||||
})
|
||||
}
|
||||
|
||||
const openEditNote = (dayId, note, e) => {
|
||||
e?.stopPropagation()
|
||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const cancelNote = (dayId) => {
|
||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId) => {
|
||||
const ui = noteUi[dayId]
|
||||
if (!ui?.text?.trim()) return
|
||||
try {
|
||||
if (ui.mode === 'add') {
|
||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
|
||||
} else {
|
||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
|
||||
}
|
||||
cancelNote(dayId)
|
||||
} catch (err) { toast.error(err.message) }
|
||||
_openEditNote(dayId, note)
|
||||
}
|
||||
|
||||
const deleteNote = async (dayId, noteId, e) => {
|
||||
e?.stopPropagation()
|
||||
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
await _deleteNote(dayId, noteId)
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
@@ -244,26 +242,14 @@ export default function DayPlanSidebar({
|
||||
for (const n of noteChanges) {
|
||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
dragDataRef.current = null
|
||||
}
|
||||
|
||||
const moveNote = async (dayId, noteId, direction) => {
|
||||
const merged = getMergedItems(dayId)
|
||||
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
|
||||
if (idx === -1) return
|
||||
let newSortOrder
|
||||
if (direction === 'up') {
|
||||
if (idx === 0) return
|
||||
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
|
||||
} else {
|
||||
if (idx >= merged.length - 1) return
|
||||
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
|
||||
}
|
||||
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
await _moveNote(dayId, noteId, direction, getMergedItems)
|
||||
}
|
||||
|
||||
const startEditTitle = (day, e) => {
|
||||
@@ -284,7 +270,7 @@ export default function DayPlanSidebar({
|
||||
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
|
||||
setIsCalculating(true)
|
||||
try {
|
||||
const result = await calculateRoute(waypoints, transportMode)
|
||||
const result = await calculateRoute(waypoints, 'walking')
|
||||
// Luftlinien zwischen Wegpunkten anzeigen
|
||||
const lineCoords = waypoints.map(p => [p.lat, p.lng])
|
||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
||||
@@ -315,12 +301,13 @@ export default function DayPlanSidebar({
|
||||
else unlocked.push(a)
|
||||
})
|
||||
|
||||
// Optimize only unlocked places
|
||||
const unlockedWithCoords = unlocked.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
const optimized = unlockedWithCoords.length >= 2 ? optimizeRoute(unlockedWithCoords) : unlockedWithCoords
|
||||
const optimizedQueue = optimized.map(p => unlocked.find(a => a.place?.id === p.id)).filter(Boolean)
|
||||
// Add unlocked without coords at the end
|
||||
for (const a of unlocked) { if (!optimizedQueue.includes(a)) optimizedQueue.push(a) }
|
||||
// Optimize only unlocked assignments (work on assignments, not places)
|
||||
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
|
||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||
const optimizedAssignments = unlockedWithCoords.length >= 2
|
||||
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||
: unlockedWithCoords
|
||||
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
|
||||
|
||||
// Merge: locked stay at their index, fill gaps with optimized
|
||||
const result = new Array(da.length)
|
||||
@@ -349,9 +336,9 @@ export default function DayPlanSidebar({
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
} else if (noteId && fromDayId !== dayId) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
}
|
||||
setDraggingId(null)
|
||||
setDropTargetKey(null)
|
||||
@@ -447,7 +434,7 @@ export default function DayPlanSidebar({
|
||||
<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)}
|
||||
onClick={() => { onSelectDay(day.id); if (onDayDetail) onDayDetail(day) }}
|
||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
||||
onDrop={e => handleDropOnDay(e, day.id)}
|
||||
@@ -455,7 +442,7 @@ export default function DayPlanSidebar({
|
||||
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'),
|
||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-tertiary)' : 'transparent'),
|
||||
transition: 'background 0.12s',
|
||||
userSelect: 'none',
|
||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
||||
@@ -493,8 +480,8 @@ export default function DayPlanSidebar({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, minWidth: 0 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flexShrink: 1, minWidth: 0 }}>
|
||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
||||
</span>
|
||||
<button
|
||||
@@ -503,11 +490,21 @@ export default function DayPlanSidebar({
|
||||
>
|
||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
{(() => {
|
||||
const acc = accommodations.find(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
return acc ? (
|
||||
<span onClick={e => { e.stopPropagation(); onPlaceClick(acc.place_id) }} style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '2px 7px', borderRadius: 5, background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', flexShrink: 1, minWidth: 0, maxWidth: '40%', cursor: 'pointer' }}>
|
||||
<Hotel size={8} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 9, color: 'var(--text-muted)', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_name}</span>
|
||||
</span>
|
||||
) : null
|
||||
})()}
|
||||
</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 && <span style={{ width: 1, height: 10, background: 'var(--text-faint)', opacity: 0.3, flexShrink: 0 }} />}
|
||||
{day.date && anyGeoPlace && (() => {
|
||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
||||
@@ -543,11 +540,11 @@ export default function DayPlanSidebar({
|
||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -580,9 +577,7 @@ export default function DayPlanSidebar({
|
||||
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 isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
@@ -624,7 +619,7 @@ export default function DayPlanSidebar({
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
||||
@@ -632,14 +627,22 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id, isPlaceSelected ? null : assignment.id); if (!isPlaceSelected) onSelectDay(day.id, true) }}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place, assignment.id) },
|
||||
onRemoveAssignment && { label: t('planner.removeFromDay'), icon: Trash2, onClick: () => onRemoveAssignment(day.id, assignment.id) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
@@ -651,9 +654,7 @@ export default function DayPlanSidebar({
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: hasReservation
|
||||
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
||||
: '3px solid transparent',
|
||||
: '3px solid transparent',
|
||||
transition: 'background 0.15s, border-color 0.15s',
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
@@ -689,8 +690,8 @@ export default function DayPlanSidebar({
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{lockedIds.has(assignment.id)
|
||||
? (language === 'de' ? 'Klicken zum Entsperren' : 'Click to unlock')
|
||||
: (language === 'de' ? 'Position bei Routenoptimierung beibehalten' : 'Keep position during route optimization')}
|
||||
? t('planner.clickToUnlock')
|
||||
: t('planner.keepPosition')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -706,26 +707,52 @@ export default function DayPlanSidebar({
|
||||
{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)}
|
||||
{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && !hasReservation && (
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<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>
|
||||
{(() => {
|
||||
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
return (
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{assignment.participants?.length > 0 && (
|
||||
<div style={{ marginTop: 3, display: 'flex', alignItems: 'center', gap: -4 }}>
|
||||
{assignment.participants.slice(0, 5).map((p, pi) => (
|
||||
<div key={p.user_id} style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)', border: '1.5px solid var(--bg-card)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700, color: 'var(--text-muted)',
|
||||
marginLeft: pi > 0 ? -4 : 0, flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{p.avatar ? <img src={p.avatar} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : p.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
))}
|
||||
{assignment.participants.length > 5 && (
|
||||
<span style={{ fontSize: 8, color: 'var(--text-faint)', marginLeft: 2 }}>+{assignment.participants.length - 5}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -762,7 +789,7 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
||||
@@ -770,12 +797,17 @@ export default function DayPlanSidebar({
|
||||
const tm = getMergedItems(day.id)
|
||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null)
|
||||
} else if (fromAssignmentId) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
style={{
|
||||
@@ -796,12 +828,11 @@ export default function DayPlanSidebar({
|
||||
<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' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', wordBreak: 'break-word' }}>
|
||||
{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 style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}>{note.time}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
@@ -831,11 +862,11 @@ export default function DayPlanSidebar({
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (noteId && fromDayId !== day.id) {
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : 'Unknown error'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
const m = getMergedItems(day.id)
|
||||
@@ -855,18 +886,6 @@ export default function DayPlanSidebar({
|
||||
{/* 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>
|
||||
@@ -935,14 +954,16 @@ export default function DayPlanSidebar({
|
||||
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"
|
||||
<textarea
|
||||
value={ui.time}
|
||||
maxLength={150}
|
||||
rows={3}
|
||||
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)) }}
|
||||
onKeyDown={e => { 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)' }}
|
||||
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)', resize: 'none', lineHeight: 1.4 }}
|
||||
/>
|
||||
<div style={{ textAlign: 'right', fontSize: 9, color: (ui.time?.length || 0) >= 140 ? '#d97706' : 'var(--text-faint)', marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||
<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' }}>
|
||||
@@ -961,6 +982,7 @@ export default function DayPlanSidebar({
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
|
||||
</div>
|
||||
)}
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import React from 'react'
|
||||
import { useDraggable } from '@dnd-kit/core'
|
||||
import { MapPin, DollarSign, Check } from 'lucide-react'
|
||||
|
||||
export default function DraggablePlaceCard({ place, isAssigned, onEdit }) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: `place-${place.id}`,
|
||||
data: {
|
||||
type: 'place',
|
||||
place,
|
||||
},
|
||||
})
|
||||
|
||||
const style = transform ? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
zIndex: isDragging ? 999 : undefined,
|
||||
} : undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
group relative bg-white border rounded-lg p-3 cursor-grab active:cursor-grabbing
|
||||
transition-all select-none
|
||||
${isDragging
|
||||
? 'opacity-50 shadow-2xl border-slate-400 scale-105'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:shadow-md place-card-hover'
|
||||
}
|
||||
`}
|
||||
onClick={e => {
|
||||
if (!isDragging && onEdit) {
|
||||
e.stopPropagation()
|
||||
onEdit(place)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* Category left border accent */}
|
||||
{place.category && (
|
||||
<div
|
||||
className="absolute left-0 top-3 bottom-3 w-0.5 rounded-r"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="pl-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-1 mb-1">
|
||||
<p className="text-sm font-medium text-slate-800 leading-tight line-clamp-2 flex-1">
|
||||
{place.name}
|
||||
</p>
|
||||
{isAssigned && (
|
||||
<span className="flex-shrink-0 w-5 h-5 bg-emerald-100 rounded-full flex items-center justify-center" title="Already assigned to a day">
|
||||
<Check className="w-3 h-3 text-emerald-600" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
{place.address && (
|
||||
<p className="text-xs text-slate-400 truncate flex items-center gap-1 mb-1.5">
|
||||
<MapPin className="w-3 h-3 flex-shrink-0" />
|
||||
{place.address}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Category badge */}
|
||||
{place.category && (
|
||||
<span
|
||||
className="inline-block text-[10px] px-1.5 py-0.5 rounded text-white font-medium mr-1"
|
||||
style={{ backgroundColor: place.category.color || '#6366f1' }}
|
||||
>
|
||||
{place.category.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{place.price != null && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-emerald-700 bg-emerald-50 px-1.5 py-0.5 rounded">
|
||||
<DollarSign className="w-2.5 h-2.5" />
|
||||
{Number(place.price).toLocaleString()} {place.currency || ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{place.tags && place.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{place.tags.slice(0, 3).map(tag => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded-full text-white font-medium"
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))}
|
||||
{place.tags.length > 3 && (
|
||||
<span className="text-[10px] text-slate-400">+{place.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { X, ExternalLink, Phone, MapPin, Clock, Euro, Edit2, Trash2, Plus, Minus } from 'lucide-react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+174
-68
@@ -1,22 +1,29 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { Search } from 'lucide-react'
|
||||
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { Place, Category, Assignment } from '../../types'
|
||||
|
||||
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' },
|
||||
]
|
||||
interface PlaceFormData {
|
||||
name: string
|
||||
description: string
|
||||
address: string
|
||||
lat: string
|
||||
lng: string
|
||||
category_id: string
|
||||
place_time: string
|
||||
end_time: string
|
||||
notes: string
|
||||
transport_mode: string
|
||||
website: string
|
||||
}
|
||||
|
||||
const DEFAULT_FORM = {
|
||||
const DEFAULT_FORM: PlaceFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
address: '',
|
||||
@@ -24,18 +31,28 @@ const DEFAULT_FORM = {
|
||||
lng: '',
|
||||
category_id: '',
|
||||
place_time: '',
|
||||
end_time: '',
|
||||
notes: '',
|
||||
transport_mode: 'walking',
|
||||
reservation_status: 'none',
|
||||
reservation_notes: '',
|
||||
reservation_datetime: '',
|
||||
website: '',
|
||||
}
|
||||
|
||||
interface PlaceFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
|
||||
place: Place | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: Category) => void
|
||||
assignmentId: number | null
|
||||
dayAssignments?: Assignment[]
|
||||
}
|
||||
|
||||
export default function PlaceFormModal({
|
||||
isOpen, onClose, onSave, place, tripId, categories,
|
||||
onCategoryCreated,
|
||||
}) {
|
||||
onCategoryCreated, assignmentId, dayAssignments = [],
|
||||
}: PlaceFormModalProps) {
|
||||
const [form, setForm] = useState(DEFAULT_FORM)
|
||||
const [mapsSearch, setMapsSearch] = useState('')
|
||||
const [mapsResults, setMapsResults] = useState([])
|
||||
@@ -43,6 +60,8 @@ export default function PlaceFormModal({
|
||||
const [newCategoryName, setNewCategoryName] = useState('')
|
||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const fileRef = useRef(null)
|
||||
const toast = useToast()
|
||||
const { t, language } = useTranslation()
|
||||
const { hasMapsKey } = useAuthStore()
|
||||
@@ -57,16 +76,15 @@ export default function PlaceFormModal({
|
||||
lng: place.lng || '',
|
||||
category_id: place.category_id || '',
|
||||
place_time: place.place_time || '',
|
||||
end_time: place.end_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)
|
||||
}
|
||||
setPendingFiles([])
|
||||
}, [place, isOpen])
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
@@ -79,7 +97,7 @@ export default function PlaceFormModal({
|
||||
try {
|
||||
const result = await mapsApi.search(mapsSearch, language)
|
||||
setMapsResults(result.places || [])
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('places.mapsSearchError'))
|
||||
} finally {
|
||||
setIsSearchingMaps(false)
|
||||
@@ -106,11 +124,37 @@ export default function PlaceFormModal({
|
||||
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
|
||||
setNewCategoryName('')
|
||||
setShowNewCategory(false)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('places.categoryCreateError'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileAdd = (e) => {
|
||||
const files = Array.from((e.target as HTMLInputElement).files || [])
|
||||
setPendingFiles(prev => [...prev, ...files])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleRemoveFile = (idx) => {
|
||||
setPendingFiles(prev => prev.filter((_, i) => i !== idx))
|
||||
}
|
||||
|
||||
// Paste support for files/images
|
||||
const handlePaste = (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/') || item.type === 'application/pdf') {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) setPendingFiles(prev => [...prev, file])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hasTimeError = place && form.place_time && form.end_time && form.place_time.length >= 5 && form.end_time.length >= 5 && form.end_time <= form.place_time
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!form.name.trim()) {
|
||||
@@ -124,10 +168,11 @@ export default function PlaceFormModal({
|
||||
lat: form.lat ? parseFloat(form.lat) : null,
|
||||
lng: form.lng ? parseFloat(form.lng) : null,
|
||||
category_id: form.category_id || null,
|
||||
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||
})
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.error(err.message || t('places.saveError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -140,7 +185,7 @@ export default function PlaceFormModal({
|
||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||
size="lg"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||
{/* Place Search */}
|
||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
||||
{!hasMapsKey && (
|
||||
@@ -277,14 +322,17 @@ export default function PlaceFormModal({
|
||||
)}
|
||||
</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)}
|
||||
{/* Time — only shown when editing, not when creating */}
|
||||
{place && (
|
||||
<TimeSection
|
||||
form={form}
|
||||
handleChange={handleChange}
|
||||
assignmentId={assignmentId}
|
||||
dayAssignments={dayAssignments}
|
||||
hasTimeError={hasTimeError}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Website */}
|
||||
<div>
|
||||
@@ -298,45 +346,35 @@ export default function PlaceFormModal({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Reservation */}
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
||||
<label className="block text-sm font-medium text-gray-700 shrink-0">{t('places.formReservation')}</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{['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>
|
||||
))}
|
||||
{/* File Attachments */}
|
||||
{true && (
|
||||
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||
<button type="button" onClick={() => fileRef.current?.click()}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors">
|
||||
<Paperclip size={12} /> {t('files.attach')}
|
||||
</button>
|
||||
</div>
|
||||
<input ref={fileRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileAdd} />
|
||||
{pendingFiles.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{pendingFiles.map((file, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 px-2 py-1.5 rounded-lg bg-slate-50 text-xs">
|
||||
<Paperclip size={10} className="text-slate-400 shrink-0" />
|
||||
<span className="truncate flex-1 text-slate-600">{file.name}</span>
|
||||
<button type="button" onClick={() => handleRemoveFile(idx)} className="text-slate-400 hover:text-red-500 shrink-0">
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{pendingFiles.length === 0 && (
|
||||
<p className="text-xs text-slate-400">{t('files.pasteHint')}</p>
|
||||
)}
|
||||
</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">
|
||||
@@ -349,7 +387,7 @@ export default function PlaceFormModal({
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
disabled={isSaving || hasTimeError}
|
||||
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')}
|
||||
@@ -359,3 +397,71 @@ export default function PlaceFormModal({
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
interface TimeSectionProps {
|
||||
form: PlaceFormData
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void
|
||||
assignmentId: number | null
|
||||
dayAssignments: Assignment[]
|
||||
hasTimeError: boolean
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
}
|
||||
|
||||
function TimeSection({ form, handleChange, assignmentId, dayAssignments, hasTimeError, t }: TimeSectionProps) {
|
||||
|
||||
const collisions = useMemo(() => {
|
||||
if (!assignmentId || !form.place_time || form.place_time.length < 5) return []
|
||||
// Find the day_id for the current assignment
|
||||
const current = dayAssignments.find(a => a.id === assignmentId)
|
||||
if (!current) return []
|
||||
const myStart = form.place_time
|
||||
const myEnd = form.end_time && form.end_time.length >= 5 ? form.end_time : null
|
||||
return dayAssignments.filter(a => {
|
||||
if (a.id === assignmentId) return false
|
||||
if (a.day_id !== current.day_id) return false
|
||||
const aStart = a.place?.place_time
|
||||
const aEnd = a.place?.end_time
|
||||
if (!aStart) return false
|
||||
// Check overlap: two intervals overlap if start < otherEnd AND otherStart < end
|
||||
const s1 = myStart, e1 = myEnd || myStart
|
||||
const s2 = aStart, e2 = aEnd || aStart
|
||||
return s1 < (e2 || '23:59') && s2 < (e1 || '23:59') && s1 !== e2 && s2 !== e1
|
||||
})
|
||||
}, [assignmentId, dayAssignments, form.place_time, form.end_time])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.place_time}
|
||||
onChange={v => handleChange('place_time', v)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.endTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={form.end_time}
|
||||
onChange={v => handleChange('end_time', v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasTimeError && (
|
||||
<div className="flex items-center gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
|
||||
<AlertTriangle size={13} className="shrink-0" />
|
||||
{t('places.endTimeBeforeStart')}
|
||||
</div>
|
||||
)}
|
||||
{collisions.length > 0 && (
|
||||
<div className="flex items-start gap-1.5 mt-2 px-2.5 py-1.5 rounded-lg text-xs" style={{ background: 'var(--bg-warning, #fef3c7)', color: 'var(--text-warning, #92400e)' }}>
|
||||
<AlertTriangle size={13} className="shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t('places.timeCollision')}{' '}
|
||||
{collisions.map(a => a.place?.name).filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+302
-73
@@ -1,10 +1,11 @@
|
||||
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 { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users } 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'
|
||||
import type { Place, Category, Day, Assignment, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
const detailsCache = new Map()
|
||||
|
||||
@@ -75,7 +76,10 @@ function convertHoursLine(line, timeFormat) {
|
||||
function formatTime(timeStr, locale, timeFormat) {
|
||||
if (!timeStr) return ''
|
||||
try {
|
||||
const [h, m] = timeStr.split(':').map(Number)
|
||||
const parts = timeStr.split(':')
|
||||
const h = Number(parts[0]) || 0
|
||||
const m = Number(parts[1]) || 0
|
||||
if (isNaN(h)) return timeStr
|
||||
if (timeFormat === '12h') {
|
||||
const period = h >= 12 ? 'PM' : 'AM'
|
||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||
@@ -86,16 +90,6 @@ function formatTime(timeStr, locale, timeFormat) {
|
||||
} 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 ''
|
||||
@@ -104,19 +98,68 @@ function formatFileSize(bytes) {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
interface TripMember {
|
||||
id: number
|
||||
username: string
|
||||
avatar_url?: string | null
|
||||
}
|
||||
|
||||
interface PlaceInspectorProps {
|
||||
place: Place | null
|
||||
categories: Category[]
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
selectedAssignmentId: number | null
|
||||
assignments: AssignmentsMap
|
||||
reservations?: Reservation[]
|
||||
onClose: () => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onRemoveAssignment: (assignmentId: number, dayId: number) => void
|
||||
files: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
tripMembers?: TripMember[]
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
onUpdatePlace: (placeId: number, data: Partial<Place>) => void
|
||||
}
|
||||
|
||||
export default function PlaceInspector({
|
||||
place, categories, days, selectedDayId, assignments,
|
||||
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||
files, onFileUpload,
|
||||
}) {
|
||||
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||
}: PlaceInspectorProps) {
|
||||
const { t, locale, language } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [nameValue, setNameValue] = useState('')
|
||||
const nameInputRef = useRef(null)
|
||||
const fileInputRef = useRef(null)
|
||||
const googleDetails = useGoogleDetails(place?.google_place_id, language)
|
||||
|
||||
const startNameEdit = () => {
|
||||
if (!onUpdatePlace) return
|
||||
setNameValue(place.name || '')
|
||||
setEditingName(true)
|
||||
setTimeout(() => nameInputRef.current?.focus(), 0)
|
||||
}
|
||||
|
||||
const commitNameEdit = () => {
|
||||
if (!editingName) return
|
||||
const trimmed = nameValue.trim()
|
||||
setEditingName(false)
|
||||
if (!trimmed || trimmed === place.name) return
|
||||
onUpdatePlace(place.id, { name: trimmed })
|
||||
}
|
||||
|
||||
const handleNameKeyDown = (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); commitNameEdit() }
|
||||
if (e.key === 'Escape') setEditingName(false)
|
||||
}
|
||||
|
||||
if (!place) return null
|
||||
|
||||
const category = categories?.find(c => c.id === place.category_id)
|
||||
@@ -131,7 +174,7 @@ export default function PlaceInspector({
|
||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
||||
|
||||
const handleFileUpload = useCallback(async (e) => {
|
||||
const selectedFiles = Array.from(e.target.files || [])
|
||||
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||
if (!selectedFiles.length || !onFileUpload) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
@@ -142,7 +185,7 @@ export default function PlaceInspector({
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
setFilesExpanded(true)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed', err)
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
@@ -199,7 +242,21 @@ export default function PlaceInspector({
|
||||
</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>
|
||||
{editingName ? (
|
||||
<input
|
||||
ref={nameInputRef}
|
||||
value={nameValue}
|
||||
onChange={e => setNameValue(e.target.value)}
|
||||
onBlur={commitNameEdit}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)', borderRadius: 6, padding: '1px 6px', fontFamily: 'inherit', outline: 'none', width: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
onDoubleClick={startNameEdit}
|
||||
style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3', cursor: onUpdatePlace ? 'text' : 'default' }}
|
||||
>{place.name}</span>
|
||||
)}
|
||||
{category && (() => {
|
||||
const CatIcon = getCategoryIcon(category.icon)
|
||||
return (
|
||||
@@ -212,7 +269,7 @@ export default function PlaceInspector({
|
||||
padding: '2px 8px', borderRadius: 99,
|
||||
}}>
|
||||
<CatIcon size={10} />
|
||||
{category.name}
|
||||
<span className="hidden sm:inline">{category.name}</span>
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
@@ -220,17 +277,17 @@ export default function PlaceInspector({
|
||||
{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>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>{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>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{place.lat && place.lng && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
||||
<div className="hidden sm:block" style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
@@ -248,8 +305,8 @@ export default function PlaceInspector({
|
||||
{/* 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' }}>
|
||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||
{googleDetails?.rating && (() => {
|
||||
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
|
||||
return (
|
||||
@@ -279,46 +336,83 @@ export default function PlaceInspector({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description + Reservation in one box */}
|
||||
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
|
||||
{/* Description */}
|
||||
{(place.description || place.notes) && (
|
||||
<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>
|
||||
)}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||
{place.description || place.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Opening hours */}
|
||||
{/* Reservation + Participants — side by side */}
|
||||
{(() => {
|
||||
const res = selectedAssignmentId ? reservations.find(r => r.assignment_id === selectedAssignmentId) : null
|
||||
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
|
||||
const currentParticipants = assignment?.participants || []
|
||||
const participantIds = currentParticipants.map(p => p.user_id)
|
||||
const allJoined = currentParticipants.length === 0
|
||||
const showParticipants = selectedAssignmentId && tripMembers.length > 1
|
||||
if (!res && !showParticipants) return null
|
||||
return (
|
||||
<div className={`grid ${res && showParticipants ? 'grid-cols-1 sm:grid-cols-2' : 'grid-cols-1'} gap-2`}>
|
||||
{/* Reservation */}
|
||||
{res && (() => {
|
||||
const confirmed = res.status === 'confirmed'
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: confirmed ? 'rgba(22,163,74,0.08)' : 'rgba(217,119,6,0.08)' }}>
|
||||
<div style={{ width: 6, height: 6, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<span style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706' }}>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{res.title}</span>
|
||||
</div>
|
||||
<div style={{ padding: '6px 10px', display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
{res.reservation_time && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{new Date(res.reservation_time).toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })}</div>
|
||||
</div>
|
||||
)}
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{res.confirmation_number && (
|
||||
<div>
|
||||
<div style={{ fontSize: 8, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 10, fontWeight: 500, color: 'var(--text-primary)', marginTop: 1 }}>{res.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{res.notes && <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}>{res.notes}</div>}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Participants */}
|
||||
{showParticipants && (
|
||||
<ParticipantsBox
|
||||
tripMembers={tripMembers}
|
||||
participantIds={participantIds}
|
||||
allJoined={allJoined}
|
||||
onSetParticipants={onSetParticipants}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
selectedDayId={selectedDayId}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Opening hours + Files — side by side on desktop only if both exist */}
|
||||
<div className={`grid grid-cols-1 ${openingHours?.length > 0 ? 'sm:grid-cols-2' : ''} gap-2`}>
|
||||
{openingHours && openingHours.length > 0 && (
|
||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
<button
|
||||
@@ -380,24 +474,17 @@ export default function PlaceInspector({
|
||||
{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 }}>
|
||||
<a key={f.id} href={`/uploads/files/${f.filename}`} target="_blank" rel="noopener noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 8, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
{(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>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -428,7 +515,14 @@ export default function PlaceInspector({
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
|
||||
interface ChipProps {
|
||||
icon: React.ReactNode
|
||||
text: React.ReactNode
|
||||
color?: string
|
||||
bg?: string
|
||||
}
|
||||
|
||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }: ChipProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
||||
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
|
||||
@@ -437,7 +531,12 @@ function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hove
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon, children }) {
|
||||
interface RowProps {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Row({ icon, children }: RowProps) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ flexShrink: 0 }}>{icon}</div>
|
||||
@@ -446,7 +545,14 @@ function Row({ icon, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }) {
|
||||
interface ActionButtonProps {
|
||||
onClick: () => void
|
||||
variant: 'primary' | 'ghost' | 'danger'
|
||||
icon: React.ReactNode
|
||||
label: React.ReactNode
|
||||
}
|
||||
|
||||
function ActionButton({ onClick, variant, icon, label }: ActionButtonProps) {
|
||||
const base = {
|
||||
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
|
||||
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
|
||||
@@ -470,3 +576,126 @@ function ActionButton({ onClick, variant, icon, label }) {
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
interface ParticipantsBoxProps {
|
||||
tripMembers: TripMember[]
|
||||
participantIds: number[]
|
||||
allJoined: boolean
|
||||
onSetParticipants: (assignmentId: number, dayId: number, participantIds: number[]) => void
|
||||
selectedAssignmentId: number | null
|
||||
selectedDayId: number | null
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticipants, selectedAssignmentId, selectedDayId, t }: ParticipantsBoxProps) {
|
||||
const [showAdd, setShowAdd] = React.useState(false)
|
||||
const [hoveredId, setHoveredId] = React.useState(null)
|
||||
|
||||
// Active participants: if allJoined, show all members; otherwise show only those in participantIds
|
||||
const activeMembers = allJoined ? tripMembers : tripMembers.filter(m => participantIds.includes(m.id))
|
||||
const availableToAdd = allJoined ? [] : tripMembers.filter(m => !participantIds.includes(m.id))
|
||||
|
||||
const handleRemove = (userId) => {
|
||||
if (!onSetParticipants) return
|
||||
let newIds
|
||||
if (allJoined) {
|
||||
newIds = tripMembers.filter(m => m.id !== userId).map(m => m.id)
|
||||
} else {
|
||||
newIds = participantIds.filter(id => id !== userId)
|
||||
}
|
||||
if (newIds.length === tripMembers.length) newIds = []
|
||||
onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
|
||||
}
|
||||
|
||||
const handleAdd = (userId) => {
|
||||
if (!onSetParticipants) return
|
||||
const newIds = [...participantIds, userId]
|
||||
if (newIds.length === tripMembers.length) {
|
||||
onSetParticipants(selectedAssignmentId, selectedDayId, [])
|
||||
} else {
|
||||
onSetParticipants(selectedAssignmentId, selectedDayId, newIds)
|
||||
}
|
||||
setShowAdd(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 12, border: '1px solid var(--border-faint)', padding: '8px 10px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 6, display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Users size={10} /> {t('inspector.participants')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, alignItems: 'center' }}>
|
||||
{activeMembers.map(member => {
|
||||
const isHovered = hoveredId === member.id
|
||||
const canRemove = activeMembers.length > 1
|
||||
return (
|
||||
<div key={member.id}
|
||||
onMouseEnter={() => setHoveredId(member.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onClick={() => { if (canRemove) handleRemove(member.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '2px 7px 2px 3px', borderRadius: 99,
|
||||
border: `1.5px solid ${isHovered && canRemove ? 'rgba(239,68,68,0.4)' : 'var(--accent)'}`,
|
||||
background: isHovered && canRemove ? 'rgba(239,68,68,0.06)' : 'var(--bg-hover)',
|
||||
fontSize: 10, fontWeight: 500,
|
||||
color: isHovered && canRemove ? '#ef4444' : 'var(--text-primary)',
|
||||
cursor: canRemove ? 'pointer' : 'default',
|
||||
transition: 'all 0.15s',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 7, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
<span style={{ textDecoration: isHovered && canRemove ? 'line-through' : 'none' }}>{member.username}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Add button */}
|
||||
{availableToAdd.length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button onClick={() => setShowAdd(!showAdd)} style={{
|
||||
width: 22, height: 22, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', fontSize: 12, transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--text-muted)'; e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>+</button>
|
||||
|
||||
{showAdd && (
|
||||
<div style={{
|
||||
position: 'absolute', top: 26, left: 0, zIndex: 100,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 140,
|
||||
}}>
|
||||
{availableToAdd.map(member => (
|
||||
<button key={member.id} onClick={() => handleAdd(member.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{(member.avatar_url || member.avatar) ? <img src={member.avatar_url || `/uploads/avatars/${member.avatar}`} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : member.username?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
{member.username}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react'
|
||||
import DraggablePlaceCard from './DraggablePlaceCard'
|
||||
import { Search, Plus, Filter, Map, X, SlidersHorizontal } from 'lucide-react'
|
||||
|
||||
export default function PlacesPanel({
|
||||
places,
|
||||
categories,
|
||||
tags,
|
||||
assignments,
|
||||
tripId,
|
||||
onAddPlace,
|
||||
onEditPlace,
|
||||
hasMapKey,
|
||||
onSearchMaps,
|
||||
}) {
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCategory, setSelectedCategory] = useState('')
|
||||
const [selectedTags, setSelectedTags] = useState([])
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
// Get set of assigned place IDs (for any day)
|
||||
const assignedPlaceIds = useMemo(() => {
|
||||
const ids = new Set()
|
||||
Object.values(assignments || {}).forEach(dayAssignments => {
|
||||
dayAssignments.forEach(a => {
|
||||
if (a.place?.id) ids.add(a.place.id)
|
||||
})
|
||||
})
|
||||
return ids
|
||||
}, [assignments])
|
||||
|
||||
const filteredPlaces = useMemo(() => {
|
||||
return places.filter(place => {
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
if (!place.name.toLowerCase().includes(q) &&
|
||||
!place.address?.toLowerCase().includes(q) &&
|
||||
!place.description?.toLowerCase().includes(q)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (selectedCategory && place.category_id !== parseInt(selectedCategory)) {
|
||||
return false
|
||||
}
|
||||
if (selectedTags.length > 0) {
|
||||
const placeTags = (place.tags || []).map(t => t.id)
|
||||
if (!selectedTags.every(tagId => placeTags.includes(tagId))) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}, [places, search, selectedCategory, selectedTags])
|
||||
|
||||
const toggleTag = (tagId) => {
|
||||
setSelectedTags(prev =>
|
||||
prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId]
|
||||
)
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setSelectedCategory('')
|
||||
setSelectedTags([])
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || selectedCategory || selectedTags.length > 0
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white border-r border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="p-3 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-slate-800">
|
||||
Places
|
||||
<span className="ml-1.5 text-xs font-normal text-slate-400">
|
||||
({filteredPlaces.length}{filteredPlaces.length !== places.length ? `/${places.length}` : ''})
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{hasMapKey && (
|
||||
<button
|
||||
onClick={onSearchMaps}
|
||||
className="p-1.5 text-slate-400 hover:text-slate-700 hover:bg-slate-50 rounded-lg transition-colors"
|
||||
title="Search Google Maps"
|
||||
>
|
||||
<Map className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
showFilters || hasActiveFilters
|
||||
? 'text-slate-700 bg-slate-50'
|
||||
: 'text-slate-400 hover:text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
title="Filters"
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search places..."
|
||||
className="w-full pl-8 pr-3 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent transition-all"
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Category filter */}
|
||||
{categories.length > 0 && (
|
||||
<select
|
||||
value={selectedCategory}
|
||||
onChange={e => setSelectedCategory(e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-900 focus:border-transparent bg-white"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{/* Tag filters */}
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map(tag => (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`text-xs px-2 py-0.5 rounded-full font-medium transition-all ${
|
||||
selectedTags.includes(tag.id)
|
||||
? 'text-white shadow-sm'
|
||||
: 'text-white opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{ backgroundColor: tag.color || '#6366f1' }}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-slate-500 hover:text-red-500 flex items-center gap-1"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add place button */}
|
||||
<div className="px-3 py-2 border-b border-slate-100">
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 text-sm text-slate-700 hover:text-slate-900 bg-slate-50 hover:bg-slate-100 rounded-lg transition-colors font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Place
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Places list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2 scroll-container">
|
||||
{filteredPlaces.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-3">
|
||||
<Search className="w-6 h-6 text-slate-400" />
|
||||
</div>
|
||||
{places.length === 0 ? (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No places yet</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Add places and drag them to days</p>
|
||||
<button
|
||||
onClick={onAddPlace}
|
||||
className="mt-3 text-sm text-slate-700 hover:text-slate-900 font-medium"
|
||||
>
|
||||
+ Add your first place
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-slate-600">No matches found</p>
|
||||
<p className="text-xs text-slate-400 mt-1">Try adjusting your filters</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredPlaces.map(place => (
|
||||
<DraggablePlaceCard
|
||||
key={place.id}
|
||||
place={place}
|
||||
isAssigned={assignedPlaceIds.has(place.id)}
|
||||
onEdit={onEditPlace}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+36
-10
@@ -1,16 +1,35 @@
|
||||
import React, { useState } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation } from 'lucide-react'
|
||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
places: Place[]
|
||||
categories: Category[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
selectedPlaceId: number | null
|
||||
onPlaceClick: (placeId: number | null) => void
|
||||
onAddPlace: () => void
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
}
|
||||
|
||||
export default function PlacesSidebar({
|
||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
|
||||
}) {
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const ctxMenu = useContextMenu()
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilter, setCategoryFilter] = useState('')
|
||||
@@ -138,6 +157,14 @@ export default function PlacesSidebar({
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
onEditPlace && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
@@ -204,19 +231,17 @@ export default function PlacesSidebar({
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||
{days.map((day, i) => {
|
||||
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
|
||||
return (
|
||||
<button
|
||||
key={day.id}
|
||||
disabled={alreadyAssigned}
|
||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: alreadyAssigned ? 'default' : 'pointer',
|
||||
padding: '12px 14px', borderRadius: 12, border: 'none', cursor: 'pointer',
|
||||
background: 'transparent', fontFamily: 'inherit', textAlign: 'left',
|
||||
opacity: alreadyAssigned ? 0.4 : 1, transition: 'background 0.1s',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!alreadyAssigned) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||
>
|
||||
<div style={{
|
||||
@@ -230,7 +255,7 @@ export default function PlacesSidebar({
|
||||
</div>
|
||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||
</div>
|
||||
{alreadyAssigned && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
{(assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id) && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>✓</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -239,6 +264,7 @@ export default function PlacesSidebar({
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,903 +0,0 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo, useLayoutEffect } from 'react'
|
||||
import { FixedSizeList } from 'react-window'
|
||||
import {
|
||||
Plus, Search, X, Navigation, RotateCcw, ExternalLink,
|
||||
ChevronDown, ChevronRight, ChevronUp, Clock, MapPin,
|
||||
CalendarDays, FileText, Check, Pencil, Trash2,
|
||||
} from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import FileManager from '../Files/FileManager'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import WeatherWidget from '../Weather/WeatherWidget'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
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 || {}
|
||||
const placesListRef = useRef(null)
|
||||
const [placesListHeight, setPlacesListHeight] = useState(400)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!placesListRef.current) return
|
||||
const ro = new ResizeObserver(([entry]) => {
|
||||
setPlacesListHeight(entry.contentRect.height)
|
||||
})
|
||||
ro.observe(placesListRef.current)
|
||||
return () => ro.disconnect()
|
||||
}, [activeSegment])
|
||||
|
||||
// Auto-expand selected day
|
||||
useEffect(() => {
|
||||
if (selectedDayId) {
|
||||
setExpandedDays(prev => new Set([...prev, selectedDayId]))
|
||||
}
|
||||
}, [selectedDayId])
|
||||
|
||||
const toggleDay = (dayId) => {
|
||||
setExpandedDays(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(dayId)) next.delete(dayId)
|
||||
else next.add(dayId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const getDayAssignments = (dayId) =>
|
||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
|
||||
const selectedDayAssignments = selectedDayId ? getDayAssignments(selectedDayId) : []
|
||||
const selectedDay = selectedDayId ? days.find(d => d.id === selectedDayId) : null
|
||||
|
||||
const filteredPlaces = useMemo(() => places.filter(p => {
|
||||
const matchSearch = !search || p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
(p.address || '').toLowerCase().includes(search.toLowerCase())
|
||||
const matchCat = !categoryFilter || String(p.category_id) === String(categoryFilter)
|
||||
return matchSearch && matchCat
|
||||
}), [places, search, categoryFilter])
|
||||
|
||||
const isAssignedToDay = (placeId) =>
|
||||
selectedDayId && selectedDayAssignments.some(a => a.place?.id === placeId)
|
||||
|
||||
const totalCost = days.reduce((sum, d) => {
|
||||
const da = assignments[String(d.id)] || []
|
||||
return sum + da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
||||
}, 0)
|
||||
const currency = trip?.currency || 'EUR'
|
||||
|
||||
const filteredReservations = selectedDayId
|
||||
? reservations.filter(r => String(r.day_id) === String(selectedDayId) || !r.day_id)
|
||||
: reservations
|
||||
|
||||
// Get representative location for a day (first place with coords)
|
||||
const getDayLocation = (dayId) => {
|
||||
const da = getDayAssignments(dayId)
|
||||
const p = da.find(a => a.place?.lat && a.place?.lng)
|
||||
return p ? { lat: p.place.lat, lng: p.place.lng } : null
|
||||
}
|
||||
|
||||
// Route handlers
|
||||
const handleCalculateRoute = async () => {
|
||||
if (!selectedDayId) return
|
||||
const waypoints = selectedDayAssignments
|
||||
.map(a => a.place)
|
||||
.filter(p => p?.lat && p?.lng)
|
||||
.map(p => ({ lat: p.lat, lng: p.lng }))
|
||||
if (waypoints.length < 2) {
|
||||
toast.error('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)
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white relative overflow-hidden" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" }}>
|
||||
|
||||
<div className="px-4 pt-4 pb-3 flex-shrink-0 border-b border-gray-100">
|
||||
<button onClick={onEditTrip} className="w-full text-left group">
|
||||
<h1 className="font-semibold text-gray-900 text-[15px] leading-tight truncate group-hover:text-slate-600 transition-colors">
|
||||
{trip?.title}
|
||||
</h1>
|
||||
{(trip?.start_date || trip?.end_date) && (
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{trip.start_date && formatShortDate(trip.start_date)}
|
||||
{trip.start_date && trip.end_date && ' – '}
|
||||
{trip.end_date && formatShortDate(trip.end_date)}
|
||||
{days.length > 0 && ` · ${days.length} Tage`}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 flex-shrink-0 border-b border-gray-100">
|
||||
<div className="flex bg-gray-100 rounded-[10px] p-0.5 gap-0.5">
|
||||
{SEGMENTS.map(seg => (
|
||||
<button
|
||||
key={seg.id}
|
||||
onClick={() => setActiveSegment(seg.id)}
|
||||
className={`flex-1 py-[5px] text-[11px] font-medium rounded-[8px] transition-all duration-150 leading-none ${
|
||||
activeSegment === seg.id
|
||||
? 'bg-white shadow-sm text-gray-900'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{/* ── PLAN ── */}
|
||||
{activeSegment === 'plan' && (
|
||||
<div className="pb-4">
|
||||
<button
|
||||
onClick={() => onSelectDay(null)}
|
||||
className={`w-full text-left px-4 py-3 flex items-center gap-3 transition-colors border-b border-gray-50 ${
|
||||
selectedDayId === null ? 'bg-slate-100/70' : 'hover:bg-gray-50/80'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
selectedDayId === null ? 'bg-slate-900' : 'bg-gray-100'
|
||||
}`}>
|
||||
<MapPin className={`w-4 h-4 ${selectedDayId === null ? 'text-white' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium ${selectedDayId === null ? 'text-slate-900' : 'text-gray-700'}`}>
|
||||
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">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{!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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
|
||||
{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 style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div className="p-3 space-y-2 border-b border-gray-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-[9px] w-3.5 h-3.5 text-gray-400 pointer-events-none" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="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 ref={placesListRef} style={{ flex: 1, minHeight: 0 }}>
|
||||
<FixedSizeList
|
||||
height={placesListHeight}
|
||||
itemCount={filteredPlaces.length}
|
||||
itemSize={68}
|
||||
overscanCount={10}
|
||||
width="100%"
|
||||
>
|
||||
{({ index, style }) => {
|
||||
const place = filteredPlaces[index]
|
||||
const category = categories.find(c => c.id === place.category_id)
|
||||
const inDay = isAssignedToDay(place.id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
key={place.id}
|
||||
onClick={() => onPlaceClick(isSelected ? null : place.id)}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors border-b border-gray-50 ${
|
||||
isSelected ? 'bg-slate-50' : 'hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-[10px] overflow-hidden flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: (category?.color || '#6366f1') + '22' }}
|
||||
>
|
||||
{place.image_url ? (
|
||||
<img src={place.image_url} alt={place.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-lg">{category?.icon || '📍'}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<span className="font-medium text-[13px] text-gray-900 truncate">{place.name}</span>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{inDay
|
||||
? <span className="text-[11px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded-full">✓</span>
|
||||
: selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
className="text-[11px] text-slate-700 bg-slate-50 px-1.5 py-0.5 rounded hover:bg-slate-100 transition-colors"
|
||||
>
|
||||
+ 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>
|
||||
)
|
||||
}}
|
||||
</FixedSizeList>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── RESERVIERUNGEN ── */}
|
||||
{activeSegment === 'reservierungen' && (
|
||||
<div>
|
||||
<div className="px-4 py-3 flex items-center justify-between border-b border-gray-100">
|
||||
<h3 className="font-medium text-sm text-gray-900">
|
||||
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>
|
||||
)}
|
||||
|
||||
<ReservationModal
|
||||
isOpen={showReservationModal}
|
||||
onClose={() => { setShowReservationModal(false); setEditingReservation(null) }}
|
||||
onSave={handleSaveReservation}
|
||||
reservation={editingReservation}
|
||||
days={days}
|
||||
places={places}
|
||||
selectedDayId={selectedDayId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+159
-110
@@ -1,10 +1,12 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink } from 'lucide-react'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
@@ -18,19 +20,64 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
]
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
||||
function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
const options = []
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
if (da.length === 0) continue
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
// Group header (non-selectable)
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
if (!place) continue
|
||||
const timeStr = place.place_time ? ` · ${place.place_time}${place.end_time ? ' – ' + place.end_time : ''}` : ''
|
||||
options.push({
|
||||
value: da[i].id,
|
||||
label: ` ${i + 1}. ${place.name}${timeStr}`,
|
||||
searchLabel: place.name,
|
||||
groupLabel,
|
||||
dayDate: day.date || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
interface ReservationModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
places: Place[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
files?: TripFile[]
|
||||
onFileUpload: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete }: ReservationModalProps) {
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
const { t, locale } = useTranslation()
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', location: '', confirmation_number: '',
|
||||
notes: '', day_id: '', place_id: '',
|
||||
notes: '', assignment_id: '',
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
[days, assignments, t, locale]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
@@ -39,17 +86,17 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
type: reservation.type || 'other',
|
||||
status: reservation.status || 'pending',
|
||||
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
||||
reservation_end_time: reservation.reservation_end_time || '',
|
||||
location: reservation.location || '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
day_id: reservation.day_id || '',
|
||||
place_id: reservation.place_id || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', location: '', confirmation_number: '',
|
||||
notes: '', day_id: selectedDayId || '', place_id: '',
|
||||
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
}
|
||||
@@ -64,10 +111,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
try {
|
||||
const saved = await onSave({
|
||||
...form,
|
||||
day_id: form.day_id || null,
|
||||
place_id: form.place_id || null,
|
||||
assignment_id: form.assignment_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()
|
||||
@@ -83,10 +128,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
}
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
if (reservation?.id) {
|
||||
// Existing reservation — upload immediately
|
||||
setUploadingFile(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
@@ -102,7 +146,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
e.target.value = ''
|
||||
}
|
||||
} else {
|
||||
// New reservation — stage locally
|
||||
setPendingFiles(prev => [...prev, file])
|
||||
e.target.value = ''
|
||||
}
|
||||
@@ -112,29 +155,29 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
|
||||
padding: '8px 12px', 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: 'var(--text-secondary)', marginBottom: 5 }
|
||||
const labelStyle = { display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', marginBottom: 5, textTransform: 'uppercase', letterSpacing: '0.03em' }
|
||||
|
||||
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 }}>
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="2xl">
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{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',
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Icon size={12} /> {t(labelKey)}
|
||||
<Icon size={11} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -147,13 +190,66 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
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)} />
|
||||
{/* Assignment Picker + Date */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{assignmentOptions.length > 0 && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
{t('reservations.linkAssignment')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.assignment_id}
|
||||
onChange={value => {
|
||||
set('assignment_id', value)
|
||||
const opt = assignmentOptions.find(o => o.value === value)
|
||||
if (opt?.dayDate) {
|
||||
setForm(prev => {
|
||||
if (prev.reservation_time) return prev
|
||||
return { ...prev, reservation_time: opt.dayDate }
|
||||
})
|
||||
}
|
||||
}}
|
||||
placeholder={t('reservations.pickAssignment')}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.noAssignment') },
|
||||
...assignmentOptions,
|
||||
]}
|
||||
searchable
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
|
||||
{/* Start Time + End Time + Status */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const date = d || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
@@ -167,108 +263,61 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</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 }}>
|
||||
{/* Location + Booking Code */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3}
|
||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* File upload — always visible */}
|
||||
{/* Files */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('files.title')}</label>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }} title={t('common.open')}>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href={f.url} target="_blank" rel="noreferrer" style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}><ExternalLink size={11} /></a>
|
||||
{onFileDelete && (
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<X size={12} />
|
||||
<button type="button" onClick={() => onFileDelete(f.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: 'var(--bg-secondary)', borderRadius: 8, border: '1px solid var(--border-primary)' }}>
|
||||
<FileText size={13} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12.5, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{t('reservations.pendingSave')}</span>
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
|
||||
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
|
||||
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
||||
<X size={12} />
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
|
||||
<X size={11} />
|
||||
</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 var(--border-primary)', borderRadius: 8, background: 'var(--bg-card)',
|
||||
fontSize: 12.5, color: 'var(--text-muted)', cursor: uploadingFile ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!uploadingFile) { e.currentTarget.style.borderColor = 'var(--text-faint)'; e.currentTarget.style.color = 'var(--text-secondary)' } }}
|
||||
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.color = 'var(--text-muted)' }}>
|
||||
<Paperclip size={13} />
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
|
||||
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
|
||||
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -276,10 +325,10 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="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)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, 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>
|
||||
@@ -288,8 +337,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
function formatDate(dateStr, locale) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
||||
return d.toLocaleDateString(locale || 'de-DE', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
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 }
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
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 ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<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 ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
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: '#16a34a', background: 'rgba(22,163,74,0.1)', border: '1px solid rgba(22,163,74,0.2)', 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>
|
||||
)
|
||||
}
|
||||
|
||||
function PlaceReservationCard({ item, tripId, files = [], onNavigateToFiles }) {
|
||||
const { updatePlace } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const [editing, setEditing] = useState(false)
|
||||
const confirmed = item.status === 'confirmed'
|
||||
const placeFiles = files.filter(f => f.place_id === item.placeId)
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
|
||||
try {
|
||||
await updatePlace(tripId, item.placeId, {
|
||||
reservation_status: 'none',
|
||||
reservation_datetime: null,
|
||||
place_time: null,
|
||||
reservation_notes: null,
|
||||
})
|
||||
toast.success(t('reservations.toast.removed'))
|
||||
} catch { toast.error(t('reservations.toast.deleteError')) }
|
||||
}
|
||||
|
||||
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 ? 'rgba(22,163,74,0.1)' : 'rgba(161,98,7,0.1)',
|
||||
borderRight: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(161,98,7,0.2)'}`,
|
||||
}}>
|
||||
<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 ? 'rgba(22,163,74,0.12)' : 'rgba(161,98,7,0.12)',
|
||||
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>}
|
||||
|
||||
{/* Files attached to the place */}
|
||||
{placeFiles.length > 0 && (
|
||||
<div style={{ marginTop: 8, borderTop: '1px solid var(--border-secondary)', paddingTop: 8, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{placeFiles.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>
|
||||
))}
|
||||
{onNavigateToFiles && (
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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' ? 'rgba(22,163,74,0.12)' : '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} files={files} onNavigateToFiles={onNavigateToFiles} />
|
||||
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} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hinweis — einmalig wegklickbar */}
|
||||
{showHint && (
|
||||
<div style={{ margin: '12px 24px 8px', padding: '8px 12px', borderRadius: 10, background: 'var(--bg-hover)', display: 'flex', alignItems: 'flex-start', gap: 8 }}>
|
||||
<Lightbulb size={13} style={{ flexShrink: 0, marginTop: 1, color: 'var(--text-faint)' }} />
|
||||
<p style={{ fontSize: 11.5, color: 'var(--text-muted)', margin: 0, lineHeight: 1.5, flex: 1 }}>
|
||||
{t('reservations.placeHint')}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => { setShowHint(false); localStorage.setItem('hideReservationHint', '1') }}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '0 4px', color: 'var(--text-faint)', fontSize: 16, lineHeight: 1, flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||
>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '20px 24px' }}>
|
||||
{total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={40} style={{ marginBottom: 12, color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('reservations.empty')}</p>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} defaultOpen={true} accent="gray">
|
||||
{allPending.map(renderCard)}
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} defaultOpen={true} accent="green">
|
||||
{allConfirmed.map(renderCard)}
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import {
|
||||
Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, MapPin,
|
||||
Calendar, Hash, CheckCircle2, Circle, Pencil, Trash2, Plus, ChevronDown, ChevronRight, Users,
|
||||
ExternalLink, BookMarked, Lightbulb, Link2, Clock,
|
||||
} from 'lucide-react'
|
||||
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
|
||||
|
||||
interface AssignmentLookupEntry {
|
||||
dayNumber: number
|
||||
dayTitle: string | null
|
||||
dayDate: string
|
||||
placeName: string
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane, color: '#3b82f6' },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel, color: '#8b5cf6' },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils, color: '#ef4444' },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train, color: '#06b6d4' },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car, color: '#6b7280' },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship, color: '#0ea5e9' },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket, color: '#f59e0b' },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users, color: '#10b981' },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText, color: '#6b7280' },
|
||||
]
|
||||
|
||||
function getType(type) {
|
||||
return TYPE_OPTIONS.find(t => t.value === type) || TYPE_OPTIONS[TYPE_OPTIONS.length - 1]
|
||||
}
|
||||
|
||||
function buildAssignmentLookup(days, assignments) {
|
||||
const map = {}
|
||||
for (const day of (days || [])) {
|
||||
const da = (assignments?.[String(day.id)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
for (const a of da) {
|
||||
if (!a.place) continue
|
||||
map[a.id] = { dayNumber: day.day_number, dayTitle: day.title, dayDate: day.date, placeName: a.place.name, startTime: a.place.place_time, endTime: a.place.end_time }
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
interface ReservationCardProps {
|
||||
r: Reservation
|
||||
tripId: number
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
files?: TripFile[]
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
||||
const typeInfo = getType(r.type)
|
||||
const TypeIcon = typeInfo.Icon
|
||||
const confirmed = r.status === 'confirmed'
|
||||
const attachedFiles = files.filter(f => f.reservation_id === r.id)
|
||||
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||
|
||||
const handleToggle = async () => {
|
||||
try { await toggleReservationStatus(tripId, r.id) }
|
||||
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')) }
|
||||
}
|
||||
|
||||
const fmtDate = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
const fmtTime = (str) => {
|
||||
const d = new Date(str)
|
||||
return d.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ borderRadius: 12, overflow: 'hidden', border: `1px solid ${confirmed ? 'rgba(22,163,74,0.2)' : 'rgba(217,119,6,0.2)'}` }}>
|
||||
{/* Header bar */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px', background: confirmed ? 'rgba(22,163,74,0.06)' : 'rgba(217,119,6,0.06)' }}>
|
||||
<div style={{ width: 7, height: 7, borderRadius: '50%', flexShrink: 0, background: confirmed ? '#16a34a' : '#d97706' }} />
|
||||
<button onClick={handleToggle} style={{ fontSize: 10, fontWeight: 700, color: confirmed ? '#16a34a' : '#d97706', background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit' }}>
|
||||
{confirmed ? t('reservations.confirmed') : t('reservations.pending')}
|
||||
</button>
|
||||
<div style={{ width: 1, height: 10, background: 'var(--border-faint)' }} />
|
||||
<TypeIcon size={11} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{t(typeInfo.labelKey)}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title}</span>
|
||||
<button onClick={() => onEdit(r)} title={t('common.edit')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
<button onClick={handleDelete} title={t('common.delete')} style={{ padding: 3, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', flexShrink: 0 }}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
{(r.reservation_time || r.confirmation_number || r.location || linked) && (
|
||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{/* Row 1: Date, Time, Code */}
|
||||
{(r.reservation_time || r.confirmation_number) && (
|
||||
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||
{r.reservation_time && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.date')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{fmtDate(r.reservation_time)}</div>
|
||||
</div>
|
||||
)}
|
||||
{r.reservation_time?.includes('T') && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: '1px solid var(--border-faint)' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.time')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>
|
||||
{fmtTime(r.reservation_time)}{r.reservation_end_time ? ` – ${r.reservation_end_time}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{r.confirmation_number && (
|
||||
<div style={{ flex: 1, padding: '5px 10px', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{t('reservations.confirmationCode')}</div>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{r.confirmation_number}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Row 2: Location + Assignment */}
|
||||
{(r.location || linked) && (
|
||||
<div className={`grid grid-cols-1 ${r.location && linked ? 'sm:grid-cols-2' : ''} gap-2`} style={{ paddingTop: 6, borderTop: '1px solid var(--border-faint)' }}>
|
||||
{r.location && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.locationAddress')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<MapPin size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.location}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{linked && (
|
||||
<div>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.linkAssignment')}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||
<Link2 size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{linked.dayTitle || t('dayplan.dayN', { n: linked.dayNumber })} — {linked.placeName}
|
||||
{linked.startTime ? ` · ${linked.startTime}${linked.endTime ? ' – ' + linked.endTime : ''}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
{r.notes && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
|
||||
<div style={{ padding: '5px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||
{r.notes}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Files */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div style={{ padding: '0 12px 8px' }}>
|
||||
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('files.title')}</div>
|
||||
<div style={{ padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{attachedFiles.map(f => (
|
||||
<a key={f.id} href={f.url} target="_blank" rel="noreferrer" style={{ display: 'flex', alignItems: 'center', gap: 4, textDecoration: 'none', cursor: 'pointer' }}>
|
||||
<FileText size={9} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 10, color: 'var(--text-muted)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
title: string
|
||||
count: number
|
||||
children: React.ReactNode
|
||||
defaultOpen?: boolean
|
||||
accent: 'green' | string
|
||||
}
|
||||
|
||||
function Section({ title, count, children, defaultOpen = true, accent }: SectionProps) {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<button onClick={() => setOpen(o => !o)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: '4px 0', marginBottom: 8, fontFamily: 'inherit',
|
||||
}}>
|
||||
{open ? <ChevronDown size={14} style={{ color: 'var(--text-faint)' }} /> : <ChevronRight size={14} style={{ color: 'var(--text-faint)' }} />}
|
||||
<span style={{ fontWeight: 700, fontSize: 12, color: 'var(--text-primary)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{title}</span>
|
||||
<span style={{
|
||||
fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 99,
|
||||
background: accent === 'green' ? 'rgba(22,163,74,0.1)' : 'var(--bg-tertiary)',
|
||||
color: accent === 'green' ? '#16a34a' : 'var(--text-faint)',
|
||||
}}>{count}</span>
|
||||
</button>
|
||||
{open && <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{children}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ReservationsPanelProps {
|
||||
tripId: number
|
||||
reservations: Reservation[]
|
||||
days: Day[]
|
||||
assignments: AssignmentsMap
|
||||
files?: TripFile[]
|
||||
onAdd: () => void
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const [showHint, setShowHint] = useState(() => !localStorage.getItem('hideReservationHint'))
|
||||
|
||||
const assignmentLookup = useMemo(() => buildAssignmentLookup(days, assignments), [days, assignments])
|
||||
|
||||
const allPending = reservations.filter(r => r.status !== 'confirmed')
|
||||
const allConfirmed = reservations.filter(r => r.status === 'confirmed')
|
||||
const total = reservations.length
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
{/* Header */}
|
||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid var(--border-faint)', 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, 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: 5, padding: '7px 14px', borderRadius: 99,
|
||||
border: 'none', background: 'var(--accent)', color: 'var(--accent-text)',
|
||||
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}>
|
||||
<Plus size={13} /> <span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '16px 24px' }}>
|
||||
{total === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||
<BookMarked size={36} 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('reservations.empty')}</p>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('reservations.emptyHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,612 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { Plus, Search, ChevronUp, ChevronDown, X, Map, ExternalLink, Navigation, RotateCcw, Clock, Euro, FileText, Package } from 'lucide-react'
|
||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
||||
import PackingListPanel from '../Packing/PackingListPanel'
|
||||
import { ReservationModal } from './ReservationModal'
|
||||
import { PlaceDetailPanel } from './PlaceDetailPanel'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useToast } from '../shared/Toast'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
+92
-42
@@ -1,12 +1,21 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import Modal from '../shared/Modal'
|
||||
import { Calendar, Camera, X } from 'lucide-react'
|
||||
import { Calendar, Camera, X, Clipboard } from 'lucide-react'
|
||||
import { tripsApi } from '../../api/client'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import type { Trip } from '../../types'
|
||||
|
||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
|
||||
interface TripFormModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, string | number | null>) => Promise<void> | void
|
||||
trip: Trip | null
|
||||
onCoverUpdate: (tripId: number, coverUrl: string) => void
|
||||
}
|
||||
|
||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }: TripFormModalProps) {
|
||||
const isEditing = !!trip
|
||||
const fileRef = useRef(null)
|
||||
const toast = useToast()
|
||||
@@ -21,6 +30,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
const [error, setError] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [coverPreview, setCoverPreview] = useState(null)
|
||||
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||
const [uploadingCover, setUploadingCover] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,6 +46,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||
setCoverPreview(null)
|
||||
}
|
||||
setPendingCoverFile(null)
|
||||
setError('')
|
||||
}, [trip, isOpen])
|
||||
|
||||
@@ -48,23 +59,49 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
}
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onSave({
|
||||
const result = await onSave({
|
||||
title: formData.title.trim(),
|
||||
description: formData.description.trim() || null,
|
||||
start_date: formData.start_date || null,
|
||||
end_date: formData.end_date || null,
|
||||
})
|
||||
// Upload pending cover for newly created trips
|
||||
if (pendingCoverFile && result?.trip?.id) {
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('cover', pendingCoverFile)
|
||||
const data = await tripsApi.uploadCover(result.trip.id, fd)
|
||||
onCoverUpdate?.(result.trip.id, data.cover_image)
|
||||
} catch {
|
||||
// Cover upload failed but trip was created
|
||||
}
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err.message || t('places.saveError'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('places.saveError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverChange = async (e) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !trip?.id) return
|
||||
const handleCoverSelect = (file) => {
|
||||
if (!file) return
|
||||
if (isEditing && trip?.id) {
|
||||
// Existing trip: upload immediately
|
||||
uploadCoverNow(file)
|
||||
} else {
|
||||
// New trip: stage for upload after creation
|
||||
setPendingCoverFile(file)
|
||||
setCoverPreview(URL.createObjectURL(file))
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverChange = (e) => {
|
||||
handleCoverSelect((e.target as HTMLInputElement).files?.[0])
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const uploadCoverNow = async (file) => {
|
||||
setUploadingCover(true)
|
||||
try {
|
||||
const fd = new FormData()
|
||||
@@ -77,11 +114,15 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
toast.error(t('dashboard.coverUploadError'))
|
||||
} finally {
|
||||
setUploadingCover(false)
|
||||
e.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveCover = async () => {
|
||||
if (pendingCoverFile) {
|
||||
setPendingCoverFile(null)
|
||||
setCoverPreview(null)
|
||||
return
|
||||
}
|
||||
if (!trip?.id) return
|
||||
try {
|
||||
await tripsApi.update(trip.id, { cover_image: null })
|
||||
@@ -92,15 +133,26 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
}
|
||||
}
|
||||
|
||||
// Paste support for cover image
|
||||
const handlePaste = (e) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) handleCoverSelect(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const update = (field, value) => setFormData(prev => {
|
||||
const next = { ...prev, [field]: value }
|
||||
// Auto-adjust end date when start date changes
|
||||
if (field === 'start_date' && value) {
|
||||
if (!prev.end_date || prev.end_date < value) {
|
||||
// If no end date or end date is before new start, set end = start
|
||||
next.end_date = value
|
||||
} else if (prev.start_date) {
|
||||
// Preserve trip duration: shift end date by same delta
|
||||
const oldStart = new Date(prev.start_date + 'T00:00:00')
|
||||
const oldEnd = new Date(prev.end_date + 'T00:00:00')
|
||||
const duration = Math.round((oldEnd - oldStart) / 86400000)
|
||||
@@ -135,40 +187,38 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||
{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>
|
||||
{/* Cover image — available for both create and edit */}
|
||||
<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>
|
||||
) : (
|
||||
<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>
|
||||
) : (
|
||||
<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">
|
||||
+20
-6
@@ -1,13 +1,20 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { 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 { getApiErrorMessage } from '../../types'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
|
||||
function Avatar({ username, avatarUrl, size = 32 }) {
|
||||
interface AvatarProps {
|
||||
username: string
|
||||
avatarUrl: string | null
|
||||
size?: number
|
||||
}
|
||||
|
||||
function Avatar({ username, avatarUrl, size = 32 }: AvatarProps) {
|
||||
if (avatarUrl) {
|
||||
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||
}
|
||||
@@ -25,7 +32,14 @@ function Avatar({ username, avatarUrl, size = 32 }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
|
||||
interface TripMembersModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
tripId: number
|
||||
tripTitle: string
|
||||
}
|
||||
|
||||
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }: TripMembersModalProps) {
|
||||
const [data, setData] = useState(null)
|
||||
const [allUsers, setAllUsers] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -71,8 +85,8 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
||||
setSelectedUserId('')
|
||||
await loadMembers()
|
||||
toast.success(`${target.username} ${t('members.added')}`)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('members.addError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('members.addError')))
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
@@ -144,7 +158,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
||||
disabled={adding || !selectedUserId}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
||||
background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10,
|
||||
background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10,
|
||||
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
|
||||
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
||||
}}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState, useCallback } from 'react'
|
||||
import { useMemo, useState, useCallback } from 'react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
+15
-2
@@ -1,16 +1,29 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { isWeekend } from './holidays'
|
||||
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||
|
||||
const WEEKDAYS_EN = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su']
|
||||
const WEEKDAYS_DE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||
const MONTHS_EN = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
|
||||
const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
|
||||
|
||||
interface VacayMonthCardProps {
|
||||
year: number
|
||||
month: number
|
||||
holidays: HolidaysMap
|
||||
companyHolidaySet: Set<string>
|
||||
companyHolidaysEnabled?: boolean
|
||||
entryMap: Record<string, VacayEntry[]>
|
||||
onCellClick: (date: string) => void
|
||||
companyMode: boolean
|
||||
blockWeekends: boolean
|
||||
}
|
||||
|
||||
export default function VacayMonthCard({
|
||||
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||
onCellClick, companyMode, blockWeekends
|
||||
}) {
|
||||
}: VacayMonthCardProps) {
|
||||
const { language } = useTranslation()
|
||||
const weekdays = language === 'de' ? WEEKDAYS_DE : WEEKDAYS_EN
|
||||
const monthNames = language === 'de' ? MONTHS_DE : MONTHS_EN
|
||||
+5
-3
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useEffect } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { UserPlus, Unlink, Check, Loader2, Clock, X } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
@@ -46,8 +48,8 @@ export default function VacayPersons() {
|
||||
toast.success(t('vacay.inviteSent'))
|
||||
setShowInvite(false)
|
||||
setSelectedInviteUser(null)
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('vacay.inviteError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('vacay.inviteError')))
|
||||
} finally {
|
||||
setInviting(false)
|
||||
}
|
||||
+15
-3
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { MapPin, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -6,7 +6,11 @@ import { useToast } from '../shared/Toast'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import apiClient from '../../api/client'
|
||||
|
||||
export default function VacaySettings({ onClose }) {
|
||||
interface VacaySettingsProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
const { plan, updatePlan, isFused, dissolve, users } = useVacayStore()
|
||||
@@ -192,7 +196,15 @@ export default function VacaySettings({ onClose }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }) {
|
||||
interface SettingToggleProps {
|
||||
icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>
|
||||
label: string
|
||||
hint: string
|
||||
value: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}
|
||||
|
||||
function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingToggleProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
+19
-2
@@ -1,8 +1,16 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Briefcase, Pencil } from 'lucide-react'
|
||||
import { useVacayStore } from '../../store/vacayStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import type { VacayStat } from '../../types'
|
||||
|
||||
interface VacayStatExtended extends VacayStat {
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
color: string | null
|
||||
total_available: number
|
||||
}
|
||||
|
||||
export default function VacayStats() {
|
||||
const { t } = useTranslation()
|
||||
@@ -41,7 +49,16 @@ export default function VacayStats() {
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }) {
|
||||
interface StatCardProps {
|
||||
stat: VacayStatExtended
|
||||
isMe: boolean
|
||||
canEdit: boolean
|
||||
selectedYear: number
|
||||
onSave: (userId: number, year: number, days: number) => Promise<void>
|
||||
t: (key: string) => string
|
||||
}
|
||||
|
||||
function StatCard({ stat: s, isMe, canEdit, selectedYear, onSave, t }: StatCardProps) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [localDays, setLocalDays] = useState(s.vacation_days)
|
||||
const pct = s.total_available > 0 ? Math.min(100, (s.used / s.total_available) * 100) : 0
|
||||
@@ -1,146 +0,0 @@
|
||||
// German public holidays (Feiertage) calculation per Bundesland
|
||||
// Includes fixed and Easter-dependent movable holidays
|
||||
|
||||
const BUNDESLAENDER = {
|
||||
BW: 'Baden-Württemberg',
|
||||
BY: 'Bayern',
|
||||
BE: 'Berlin',
|
||||
BB: 'Brandenburg',
|
||||
HB: 'Bremen',
|
||||
HH: 'Hamburg',
|
||||
HE: 'Hessen',
|
||||
MV: 'Mecklenburg-Vorpommern',
|
||||
NI: 'Niedersachsen',
|
||||
NW: 'Nordrhein-Westfalen',
|
||||
RP: 'Rheinland-Pfalz',
|
||||
SL: 'Saarland',
|
||||
SN: 'Sachsen',
|
||||
ST: 'Sachsen-Anhalt',
|
||||
SH: 'Schleswig-Holstein',
|
||||
TH: 'Thüringen',
|
||||
};
|
||||
|
||||
// Gauss Easter algorithm
|
||||
function easterSunday(year) {
|
||||
const a = year % 19;
|
||||
const b = Math.floor(year / 100);
|
||||
const c = year % 100;
|
||||
const d = Math.floor(b / 4);
|
||||
const e = b % 4;
|
||||
const f = Math.floor((b + 8) / 25);
|
||||
const g = Math.floor((b - f + 1) / 3);
|
||||
const h = (19 * a + b - d - g + 15) % 30;
|
||||
const i = Math.floor(c / 4);
|
||||
const k = c % 4;
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7;
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451);
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31);
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function addDays(date, days) {
|
||||
const d = new Date(date);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d;
|
||||
}
|
||||
|
||||
function fmt(date) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
export function getHolidays(year, bundesland = 'NW') {
|
||||
const easter = easterSunday(year);
|
||||
const holidays = {};
|
||||
|
||||
// Fixed holidays (nationwide)
|
||||
holidays[`${year}-01-01`] = 'Neujahr';
|
||||
holidays[`${year}-05-01`] = 'Tag der Arbeit';
|
||||
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit';
|
||||
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag';
|
||||
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag';
|
||||
|
||||
// Easter-dependent (nationwide)
|
||||
holidays[fmt(addDays(easter, -2))] = 'Karfreitag';
|
||||
holidays[fmt(addDays(easter, 1))] = 'Ostermontag';
|
||||
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt';
|
||||
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag';
|
||||
|
||||
// State-specific
|
||||
const bl = bundesland.toUpperCase();
|
||||
|
||||
// Heilige Drei Könige (6. Jan) — BW, BY, ST
|
||||
if (['BW', 'BY', 'ST'].includes(bl)) {
|
||||
holidays[`${year}-01-06`] = 'Heilige Drei Könige';
|
||||
}
|
||||
|
||||
// Internationaler Frauentag (8. März) — BE, MV
|
||||
if (['BE', 'MV'].includes(bl)) {
|
||||
holidays[`${year}-03-08`] = 'Internationaler Frauentag';
|
||||
}
|
||||
|
||||
// Fronleichnam — BW, BY, HE, NW, RP, SL, SN (teilweise), TH (teilweise)
|
||||
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam';
|
||||
}
|
||||
|
||||
// Mariä Himmelfahrt (15. Aug) — SL, BY (teilweise)
|
||||
if (['SL'].includes(bl)) {
|
||||
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt';
|
||||
}
|
||||
|
||||
// Weltkindertag (20. Sep) — TH
|
||||
if (bl === 'TH') {
|
||||
holidays[`${year}-09-20`] = 'Weltkindertag';
|
||||
}
|
||||
|
||||
// Reformationstag (31. Okt) — BB, HB, HH, MV, NI, SN, ST, SH, TH
|
||||
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
|
||||
holidays[`${year}-10-31`] = 'Reformationstag';
|
||||
}
|
||||
|
||||
// Allerheiligen (1. Nov) — BW, BY, NW, RP, SL
|
||||
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[`${year}-11-01`] = 'Allerheiligen';
|
||||
}
|
||||
|
||||
// Buß- und Bettag — SN (Mittwoch vor dem 23. November)
|
||||
if (bl === 'SN') {
|
||||
const nov23 = new Date(year, 10, 23);
|
||||
let bbt = new Date(nov23);
|
||||
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1);
|
||||
holidays[fmt(bbt)] = 'Buß- und Bettag';
|
||||
}
|
||||
|
||||
return holidays;
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = d.getDay();
|
||||
return day === 0 || day === 6;
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()];
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()];
|
||||
}
|
||||
|
||||
export function daysInMonth(year, month) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
export function formatDate(dateStr) {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER };
|
||||
@@ -0,0 +1,131 @@
|
||||
const BUNDESLAENDER: Record<string, string> = {
|
||||
BW: 'Baden-Württemberg',
|
||||
BY: 'Bayern',
|
||||
BE: 'Berlin',
|
||||
BB: 'Brandenburg',
|
||||
HB: 'Bremen',
|
||||
HH: 'Hamburg',
|
||||
HE: 'Hessen',
|
||||
MV: 'Mecklenburg-Vorpommern',
|
||||
NI: 'Niedersachsen',
|
||||
NW: 'Nordrhein-Westfalen',
|
||||
RP: 'Rheinland-Pfalz',
|
||||
SL: 'Saarland',
|
||||
SN: 'Sachsen',
|
||||
ST: 'Sachsen-Anhalt',
|
||||
SH: 'Schleswig-Holstein',
|
||||
TH: 'Thüringen',
|
||||
}
|
||||
|
||||
function easterSunday(year: number): Date {
|
||||
const a = year % 19
|
||||
const b = Math.floor(year / 100)
|
||||
const c = year % 100
|
||||
const d = Math.floor(b / 4)
|
||||
const e = b % 4
|
||||
const f = Math.floor((b + 8) / 25)
|
||||
const g = Math.floor((b - f + 1) / 3)
|
||||
const h = (19 * a + b - d - g + 15) % 30
|
||||
const i = Math.floor(c / 4)
|
||||
const k = c % 4
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451)
|
||||
const month = Math.floor((h + l - 7 * m + 114) / 31)
|
||||
const day = ((h + l - 7 * m + 114) % 31) + 1
|
||||
return new Date(year, month - 1, day)
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const d = new Date(date)
|
||||
d.setDate(d.getDate() + days)
|
||||
return d
|
||||
}
|
||||
|
||||
function fmt(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
export function getHolidays(year: number, bundesland: string = 'NW'): Record<string, string> {
|
||||
const easter = easterSunday(year)
|
||||
const holidays: Record<string, string> = {}
|
||||
|
||||
holidays[`${year}-01-01`] = 'Neujahr'
|
||||
holidays[`${year}-05-01`] = 'Tag der Arbeit'
|
||||
holidays[`${year}-10-03`] = 'Tag der Deutschen Einheit'
|
||||
holidays[`${year}-12-25`] = '1. Weihnachtsfeiertag'
|
||||
holidays[`${year}-12-26`] = '2. Weihnachtsfeiertag'
|
||||
|
||||
holidays[fmt(addDays(easter, -2))] = 'Karfreitag'
|
||||
holidays[fmt(addDays(easter, 1))] = 'Ostermontag'
|
||||
holidays[fmt(addDays(easter, 39))] = 'Christi Himmelfahrt'
|
||||
holidays[fmt(addDays(easter, 50))] = 'Pfingstmontag'
|
||||
|
||||
const bl = bundesland.toUpperCase()
|
||||
|
||||
if (['BW', 'BY', 'ST'].includes(bl)) {
|
||||
holidays[`${year}-01-06`] = 'Heilige Drei Könige'
|
||||
}
|
||||
|
||||
if (['BE', 'MV'].includes(bl)) {
|
||||
holidays[`${year}-03-08`] = 'Internationaler Frauentag'
|
||||
}
|
||||
|
||||
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[fmt(addDays(easter, 60))] = 'Fronleichnam'
|
||||
}
|
||||
|
||||
if (['SL'].includes(bl)) {
|
||||
holidays[`${year}-08-15`] = 'Mariä Himmelfahrt'
|
||||
}
|
||||
|
||||
if (bl === 'TH') {
|
||||
holidays[`${year}-09-20`] = 'Weltkindertag'
|
||||
}
|
||||
|
||||
if (['BB', 'HB', 'HH', 'MV', 'NI', 'SN', 'ST', 'SH', 'TH'].includes(bl)) {
|
||||
holidays[`${year}-10-31`] = 'Reformationstag'
|
||||
}
|
||||
|
||||
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bl)) {
|
||||
holidays[`${year}-11-01`] = 'Allerheiligen'
|
||||
}
|
||||
|
||||
if (bl === 'SN') {
|
||||
const nov23 = new Date(year, 10, 23)
|
||||
const bbt = new Date(nov23)
|
||||
while (bbt.getDay() !== 3) bbt.setDate(bbt.getDate() - 1)
|
||||
holidays[fmt(bbt)] = 'Buß- und Bettag'
|
||||
}
|
||||
|
||||
return holidays
|
||||
}
|
||||
|
||||
export function isWeekend(dateStr: string): boolean {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
const day = d.getDay()
|
||||
return day === 0 || day === 6
|
||||
}
|
||||
|
||||
export function getWeekday(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][d.getDay()]
|
||||
}
|
||||
|
||||
export function getWeekdayFull(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'][d.getDay()]
|
||||
}
|
||||
|
||||
export function daysInMonth(year: number, month: number): number {
|
||||
return new Date(year, month, 0).getDate()
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00')
|
||||
return d.toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
}
|
||||
|
||||
export { BUNDESLAENDER }
|
||||
+37
-10
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { 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'
|
||||
@@ -15,7 +15,12 @@ const WEATHER_ICON_MAP = {
|
||||
Haze: Wind,
|
||||
}
|
||||
|
||||
function WeatherIcon({ main, size = 13 }) {
|
||||
interface WeatherIconProps {
|
||||
main: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
function WeatherIcon({ main, size = 13 }: WeatherIconProps) {
|
||||
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||
return <Icon size={size} strokeWidth={1.8} />
|
||||
}
|
||||
@@ -32,7 +37,14 @@ function setWeatherCache(key, value) {
|
||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
interface WeatherWidgetProps {
|
||||
lat: number | null
|
||||
lng: number | null
|
||||
date: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function WeatherWidget({ lat, lng, date, compact = false }: WeatherWidgetProps) {
|
||||
const [weather, setWeather] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [failed, setFailed] = useState(false)
|
||||
@@ -46,21 +58,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
const cached = getWeatherCache(cacheKey)
|
||||
if (cached !== undefined) {
|
||||
if (cached === null) setFailed(true)
|
||||
else setWeather(cached)
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
return
|
||||
} else {
|
||||
setWeather(cached)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setWeatherCache(cacheKey, null)
|
||||
setFailed(true)
|
||||
} else {
|
||||
setWeatherCache(cacheKey, data)
|
||||
setWeather(data)
|
||||
}
|
||||
})
|
||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
||||
.catch(() => { setFailed(true) })
|
||||
.finally(() => setLoading(false))
|
||||
}, [lat, lng, date])
|
||||
|
||||
@@ -83,20 +109,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
||||
const rawTemp = weather.temp
|
||||
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
||||
const unit = isFahrenheit ? '°F' : '°C'
|
||||
const isClimate = weather.type === 'climate'
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: '#6b7280', ...fontStyle }}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, fontSize: 11, color: isClimate ? '#a1a1aa' : '#6b7280', ...fontStyle }}>
|
||||
<WeatherIcon main={weather.main} size={12} />
|
||||
{temp !== null && <span>{temp}{unit}</span>}
|
||||
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{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 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, color: isClimate ? '#71717a' : '#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>}
|
||||
{temp !== null && <span style={{ fontWeight: 500 }}>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,101 @@
|
||||
import React, { useEffect, useCallback } from 'react'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title?: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = true,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEsc)
|
||||
}
|
||||
return () => document.removeEventListener('keydown', handleEsc)
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="rounded-2xl shadow-2xl w-full max-w-sm p-6"
|
||||
style={{
|
||||
animation: 'modalIn 0.2s ease-out forwards',
|
||||
background: 'var(--bg-card)',
|
||||
}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{danger && (
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
{title || t('common.confirm')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
{message}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium rounded-lg transition-colors"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
border: '1px solid var(--border-secondary)',
|
||||
}}
|
||||
>
|
||||
{cancelLabel || t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onConfirm(); onClose() }}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors text-white ${
|
||||
danger ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{confirmLabel || t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.95) translateY(-10px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface MenuItem {
|
||||
label?: string
|
||||
icon?: LucideIcon
|
||||
onClick?: () => void
|
||||
danger?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface MenuState {
|
||||
x: number
|
||||
y: number
|
||||
items: MenuItem[]
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
const [menu, setMenu] = useState<MenuState | null>(null)
|
||||
|
||||
const open = (e: React.MouseEvent, items: MenuItem[]) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setMenu({ x: e.clientX, y: e.clientY, items })
|
||||
}
|
||||
|
||||
const close = () => setMenu(null)
|
||||
|
||||
return { menu, open, close }
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
menu: MenuState | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ menu, onClose }: ContextMenuProps) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return
|
||||
const handler = () => onClose()
|
||||
document.addEventListener('click', handler)
|
||||
document.addEventListener('contextmenu', handler)
|
||||
return () => {
|
||||
document.removeEventListener('click', handler)
|
||||
document.removeEventListener('contextmenu', handler)
|
||||
}
|
||||
}, [menu, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu || !ref.current) return
|
||||
const el = ref.current
|
||||
const rect = el.getBoundingClientRect()
|
||||
let { x, y } = menu
|
||||
if (x + rect.width > window.innerWidth - 8) x = window.innerWidth - rect.width - 8
|
||||
if (y + rect.height > window.innerHeight - 8) y = window.innerHeight - rect.height - 8
|
||||
if (x !== menu.x || y !== menu.y) {
|
||||
el.style.left = `${x}px`
|
||||
el.style.top = `${y}px`
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
if (!menu) return null
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div ref={ref} style={{
|
||||
position: 'fixed', left: menu.x, top: menu.y, zIndex: 999999,
|
||||
background: 'var(--bg-card)', borderRadius: 10, padding: '4px',
|
||||
border: '1px solid var(--border-primary)',
|
||||
boxShadow: '0 8px 30px rgba(0,0,0,0.15)',
|
||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
minWidth: 160,
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
animation: 'ctxIn 0.1s ease-out',
|
||||
}}>
|
||||
{menu.items.filter(Boolean).map((item, i) => {
|
||||
if (item.divider) return <div key={i} style={{ height: 1, background: 'var(--border-faint)', margin: '3px 6px' }} />
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<button key={i} onClick={() => { item.onClick?.(); onClose() }} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||
padding: '7px 10px', borderRadius: 7, border: 'none',
|
||||
background: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
fontSize: 12, fontWeight: 500, textAlign: 'left',
|
||||
color: item.danger ? '#ef4444' : 'var(--text-primary)',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.background = item.danger ? 'rgba(239,68,68,0.08)' : 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
||||
>
|
||||
{Icon && <Icon size={13} style={{ flexShrink: 0, color: item.danger ? '#ef4444' : 'var(--text-faint)' }} />}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<style>{`@keyframes ctxIn { from { opacity: 0; transform: scale(0.95) } to { opacity: 1; transform: scale(1) } }`}</style>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
+30
-23
@@ -3,24 +3,30 @@ 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() }
|
||||
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
|
||||
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
|
||||
|
||||
// ── Datum-Only Picker ────────────────────────────────────────────────────────
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
interface CustomDatePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
||||
const { locale, t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(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
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (dropRef.current?.contains(e.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
@@ -36,12 +42,12 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
|
||||
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 startDay = (getWeekday(viewYear, viewMonth, 1) + 6) % 7
|
||||
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 selectDay = (day: number) => {
|
||||
const y = String(viewYear)
|
||||
const m = String(viewMonth + 1).padStart(2, '0')
|
||||
const d = String(day).padStart(2, '0')
|
||||
@@ -51,7 +57,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
|
||||
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
|
||||
const isToday = (d: number) => today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === d
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||
@@ -67,7 +73,7 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
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 || t('common.date')}</span>
|
||||
<span style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{displayValue || placeholder || t('common.date')}</span>
|
||||
</button>
|
||||
|
||||
{open && ReactDOM.createPortal(
|
||||
@@ -81,11 +87,8 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
const vh = window.innerHeight
|
||||
let left = r.left
|
||||
let top = r.bottom + 4
|
||||
// Keep within viewport horizontally
|
||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
||||
// If not enough space below, open above
|
||||
if (top + 320 > vh) top = Math.max(pad, r.top - 320)
|
||||
// On very small screens, center horizontally
|
||||
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||
return { top, left }
|
||||
})(),
|
||||
@@ -161,18 +164,23 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
|
||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
|
||||
interface CustomDateTimePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
|
||||
const { locale } = useTranslation()
|
||||
// value = "2024-03-15T14:30" oder ""
|
||||
const [datePart, timePart] = (value || '').split('T')
|
||||
|
||||
const handleDateChange = (d) => {
|
||||
const handleDateChange = (d: string) => {
|
||||
onChange(d ? `${d}T${timePart || '12:00'}` : '')
|
||||
}
|
||||
const handleTimeChange = (t) => {
|
||||
const handleTimeChange = (t: string) => {
|
||||
const d = datePart || new Date().toISOString().split('T')[0]
|
||||
onChange(t ? `${d}T${t}` : `${d}T00:00`)
|
||||
onChange(t ? `${d}T${t}` : d)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -185,5 +193,4 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
|
||||
)
|
||||
}
|
||||
|
||||
// Inline re-export for convenience
|
||||
import CustomTimePicker from './CustomTimePicker'
|
||||
+62
-11
@@ -2,29 +2,48 @@ import React, { useState, useRef, useEffect } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { ChevronDown, Check } from 'lucide-react'
|
||||
|
||||
interface SelectOption {
|
||||
value: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
isHeader?: boolean
|
||||
searchLabel?: string
|
||||
groupLabel?: string
|
||||
}
|
||||
|
||||
interface CustomSelectProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
options?: SelectOption[]
|
||||
placeholder?: string
|
||||
searchable?: boolean
|
||||
style?: React.CSSProperties
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
export default function CustomSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [], // [{ value, label, icon? }]
|
||||
options = [],
|
||||
placeholder = '',
|
||||
searchable = false,
|
||||
style = {},
|
||||
size = 'md', // 'sm' | 'md'
|
||||
}) {
|
||||
size = 'md',
|
||||
}: CustomSelectProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const ref = useRef(null)
|
||||
const dropRef = useRef(null)
|
||||
const searchRef = useRef(null)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const searchRef = useRef<HTMLInputElement>(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
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (dropRef.current?.contains(e.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handleClick)
|
||||
@@ -33,7 +52,28 @@ export default function CustomSelect({
|
||||
|
||||
const selected = options.find(o => o.value === value)
|
||||
const filtered = searchable && search
|
||||
? options.filter(o => o.label.toLowerCase().includes(search.toLowerCase()))
|
||||
? (() => {
|
||||
const q = search.toLowerCase()
|
||||
const result: SelectOption[] = []
|
||||
let currentHeader: SelectOption | null = null
|
||||
let headerAdded = false
|
||||
for (const o of options) {
|
||||
if (o.isHeader) {
|
||||
currentHeader = o
|
||||
headerAdded = false
|
||||
continue
|
||||
}
|
||||
const haystack = [o.label, o.searchLabel, o.groupLabel].filter(Boolean).join(' ').toLowerCase()
|
||||
if (haystack.includes(q)) {
|
||||
if (currentHeader && !headerAdded) {
|
||||
result.push(currentHeader)
|
||||
headerAdded = true
|
||||
}
|
||||
result.push(o)
|
||||
}
|
||||
}
|
||||
return result
|
||||
})()
|
||||
: options
|
||||
|
||||
const sm = size === 'sm'
|
||||
@@ -51,7 +91,7 @@ export default function CustomSelect({
|
||||
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',
|
||||
transition: 'border-color 0.15s', overflow: 'hidden', minWidth: 0,
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||
@@ -105,6 +145,17 @@ export default function CustomSelect({
|
||||
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
||||
) : (
|
||||
filtered.map(option => {
|
||||
if (option.isHeader) {
|
||||
return (
|
||||
<div key={option.value} style={{
|
||||
padding: '5px 10px', fontSize: 10, fontWeight: 700, color: 'var(--text-faint)',
|
||||
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||
background: 'var(--bg-tertiary)', borderRadius: 4, margin: '2px 0',
|
||||
}}>
|
||||
{option.label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const isSelected = option.value === value
|
||||
return (
|
||||
<button
|
||||
+22
-13
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'
|
||||
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
function formatDisplay(val, is12h) {
|
||||
function formatDisplay(val: string, is12h: boolean): string {
|
||||
if (!val) return ''
|
||||
const [h, m] = val.split(':').map(Number)
|
||||
if (isNaN(h) || isNaN(m)) return val
|
||||
@@ -13,28 +13,35 @@ function formatDisplay(val, is12h) {
|
||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }) {
|
||||
interface CustomTimePickerProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
export default function CustomTimePicker({ value, onChange, placeholder = '00:00', style = {} }: CustomTimePickerProps) {
|
||||
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 ref = useRef<HTMLDivElement>(null)
|
||||
const dropRef = useRef<HTMLDivElement>(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
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current?.contains(e.target as Node)) return
|
||||
if (dropRef.current?.contains(e.target as Node)) return
|
||||
setOpen(false)
|
||||
}
|
||||
if (open) document.addEventListener('mousedown', handler)
|
||||
return () => document.removeEventListener('mousedown', handler)
|
||||
}, [open])
|
||||
|
||||
const update = (newH, newM) => {
|
||||
const update = (newH: number, newM: number) => {
|
||||
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}`)
|
||||
@@ -53,16 +60,15 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
update(newH, newM)
|
||||
}
|
||||
|
||||
const btnStyle = {
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
|
||||
transition: 'color 0.15s',
|
||||
}
|
||||
|
||||
const handleInput = (e) => {
|
||||
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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))
|
||||
@@ -85,6 +91,9 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
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'))
|
||||
} else if (/^\d{1,2}$/.test(clean)) {
|
||||
const h = Math.min(23, Math.max(0, parseInt(clean)))
|
||||
onChange(String(h).padStart(2, '0') + ':00')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +145,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
animation: 'selectIn 0.15s ease-out',
|
||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||
}}>
|
||||
{/* Stunden */}
|
||||
{/* Hours */}
|
||||
<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)'}
|
||||
@@ -160,7 +169,7 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
||||
|
||||
<span style={{ fontSize: 22, fontWeight: 700, color: 'var(--text-faint)', marginTop: -2 }}>:</span>
|
||||
|
||||
{/* Minuten */}
|
||||
{/* Minutes */}
|
||||
<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)'}
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useCallback, useRef } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
const sizeClasses = {
|
||||
const sizeClasses: Record<string, string> = {
|
||||
sm: 'max-w-sm',
|
||||
md: 'max-w-md',
|
||||
lg: 'max-w-lg',
|
||||
@@ -9,6 +9,16 @@ const sizeClasses = {
|
||||
'2xl': 'max-w-4xl',
|
||||
}
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
title?: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
size?: string
|
||||
footer?: React.ReactNode
|
||||
hideCloseButton?: boolean
|
||||
}
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -17,8 +27,8 @@ export default function Modal({
|
||||
size = 'md',
|
||||
footer,
|
||||
hideCloseButton = false,
|
||||
}) {
|
||||
const handleEsc = useCallback((e) => {
|
||||
}: ModalProps) {
|
||||
const handleEsc = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}, [onClose])
|
||||
|
||||
@@ -33,7 +43,7 @@ export default function Modal({
|
||||
}
|
||||
}, [isOpen, handleEsc])
|
||||
|
||||
const mouseDownTarget = useRef(null)
|
||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
+20
-8
@@ -1,25 +1,37 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon } from './categoryIcons'
|
||||
import type { Place } from '../../types'
|
||||
|
||||
const googlePhotoCache = new Map()
|
||||
interface Category {
|
||||
color?: string
|
||||
icon?: string
|
||||
}
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
const [photoSrc, setPhotoSrc] = useState(place.image_url || null)
|
||||
interface PlaceAvatarProps {
|
||||
place: Pick<Place, 'id' | 'name' | 'image_url' | 'google_place_id'>
|
||||
size?: number
|
||||
category?: Category | null
|
||||
}
|
||||
|
||||
const googlePhotoCache = new Map<string, string>()
|
||||
|
||||
export default function PlaceAvatar({ place, size = 32, category }: PlaceAvatarProps) {
|
||||
const [photoSrc, setPhotoSrc] = useState<string | null>(place.image_url || null)
|
||||
|
||||
useEffect(() => {
|
||||
if (place.image_url) { setPhotoSrc(place.image_url); return }
|
||||
if (!place.google_place_id) return
|
||||
if (!place.google_place_id) { setPhotoSrc(null); return }
|
||||
|
||||
if (googlePhotoCache.has(place.google_place_id)) {
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id))
|
||||
setPhotoSrc(googlePhotoCache.get(place.google_place_id)!)
|
||||
return
|
||||
}
|
||||
|
||||
mapsApi.placePhoto(place.google_place_id)
|
||||
.then(data => {
|
||||
.then((data: { photoUrl?: string }) => {
|
||||
if (data.photoUrl) {
|
||||
googlePhotoCache.set(place.google_place_id, data.photoUrl)
|
||||
googlePhotoCache.set(place.google_place_id!, data.photoUrl)
|
||||
setPhotoSrc(data.photoUrl)
|
||||
}
|
||||
})
|
||||
@@ -30,7 +42,7 @@ export default function PlaceAvatar({ place, size = 32, category }) {
|
||||
const IconComp = getCategoryIcon(category?.icon)
|
||||
const iconSize = Math.round(size * 0.46)
|
||||
|
||||
const containerStyle = {
|
||||
const containerStyle: React.CSSProperties = {
|
||||
width: size, height: size,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
@@ -1,14 +1,28 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'
|
||||
import { CheckCircle, XCircle, AlertCircle, Info, X } from 'lucide-react'
|
||||
|
||||
const ToastContext = createContext(null)
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: number
|
||||
message: string
|
||||
type: ToastType
|
||||
duration: number
|
||||
removing: boolean
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__addToast?: (message: string, type?: ToastType, duration?: number) => number
|
||||
}
|
||||
}
|
||||
|
||||
let toastIdCounter = 0
|
||||
|
||||
export function ToastContainer() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 3000) => {
|
||||
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
|
||||
const id = ++toastIdCounter
|
||||
setToasts(prev => [...prev, { id, message, type, duration, removing: false }])
|
||||
|
||||
@@ -24,27 +38,26 @@ export function ToastContainer() {
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
const removeToast = useCallback((id: number) => {
|
||||
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 = {
|
||||
const icons: Record<ToastType, React.ReactNode> = {
|
||||
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 = {
|
||||
const bgColors: Record<ToastType, string> = {
|
||||
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',
|
||||
@@ -78,17 +91,17 @@ export function ToastContainer() {
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const show = useCallback((message, type, duration) => {
|
||||
const show = useCallback((message: string, type: ToastType, duration?: number) => {
|
||||
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),
|
||||
success: (message: string, duration?: number) => show(message, 'success', duration),
|
||||
error: (message: string, duration?: number) => show(message, 'error', duration),
|
||||
warning: (message: string, duration?: number) => show(message, 'warning', duration),
|
||||
info: (message: string, duration?: number) => show(message, 'info', duration),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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,
|
||||
LucideIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
export const CATEGORY_ICON_MAP: Record<string, LucideIcon> = {
|
||||
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: Record<string, string> = {
|
||||
MapPin: 'Pin', Building2: 'Building', BedDouble: 'Hotel', UtensilsCrossed: 'Restaurant',
|
||||
Landmark: 'Attraction', ShoppingBag: 'Shopping', Bus: 'Bus', Train: 'Train',
|
||||
Car: 'Car', Plane: 'Plane', Ship: 'Ship', Bike: 'Bicycle',
|
||||
Activity: 'Activity', Dumbbell: 'Fitness', Mountain: 'Mountain', Tent: 'Camping',
|
||||
Anchor: 'Harbor', Coffee: 'Cafe', Beer: 'Bar', Wine: 'Wine', Utensils: 'Food',
|
||||
Camera: 'Photo', Music: 'Music', Theater: 'Theater', Ticket: 'Events',
|
||||
TreePine: 'Nature', Waves: 'Beach', Leaf: 'Green', Flower2: 'Garden', Sun: 'Sun',
|
||||
Globe: 'World', Compass: 'Explore', Flag: 'Flag', Navigation: 'Navigation', Map: 'Map',
|
||||
Church: 'Church', Library: 'Museum', Store: 'Market', Home: 'Accommodation', Cross: 'Medicine',
|
||||
Heart: 'Favorite', Star: 'Top', CreditCard: 'Bank', Wifi: 'Internet',
|
||||
Luggage: 'Luggage', Backpack: 'Backpack', Zap: 'Adventure',
|
||||
}
|
||||
|
||||
export function getCategoryIcon(iconName: string | null | undefined): LucideIcon {
|
||||
return (iconName && CATEGORY_ICON_MAP[iconName]) || MapPin
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
import type { MergedItem, DayNotesMap, DayNote } from '../types'
|
||||
|
||||
interface NoteUiState {
|
||||
mode: 'add' | 'edit'
|
||||
noteId?: number
|
||||
text: string
|
||||
time: string
|
||||
icon: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
interface NoteUiMap {
|
||||
[dayId: string]: NoteUiState
|
||||
}
|
||||
|
||||
export function useDayNotes(tripId: number | string) {
|
||||
const [noteUi, setNoteUi] = useState<NoteUiMap>({})
|
||||
const noteInputRef = useRef<HTMLInputElement | null>(null)
|
||||
const tripStore = useTripStore()
|
||||
const toast = useToast()
|
||||
const dayNotes: DayNotesMap = tripStore.dayNotes || {}
|
||||
|
||||
const openAddNote = (dayId: number, getMergedItems: (dayId: number) => MergedItem[], expandDay?: (dayId: number) => void) => {
|
||||
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 } }))
|
||||
expandDay?.(dayId)
|
||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
||||
}
|
||||
|
||||
const openEditNote = (dayId: number, note: DayNote) => {
|
||||
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: number) => {
|
||||
setNoteUi((prev) => { const n = { ...prev }; delete n[dayId]; return n })
|
||||
}
|
||||
|
||||
const saveNote = async (dayId: number) => {
|
||||
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: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
const deleteNote = async (dayId: number, noteId: number) => {
|
||||
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
const moveNote = async (dayId: number, noteId: number, direction: 'up' | 'down', getMergedItems: (dayId: number) => MergedItem[]) => {
|
||||
const merged = getMergedItems(dayId)
|
||||
const idx = merged.findIndex((i) => i.type === 'note' && (i.data as DayNote).id === noteId)
|
||||
if (idx === -1) return
|
||||
let newSortOrder: number
|
||||
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: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
return { noteUi, setNoteUi, noteInputRef, dayNotes, openAddNote, openEditNote, cancelNote, saveNote, deleteNote, moveNote }
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
export function usePlaceSelection() {
|
||||
const [selectedPlaceId, _setSelectedPlaceId] = useState<number | null>(null)
|
||||
const [selectedAssignmentId, setSelectedAssignmentId] = useState<number | null>(null)
|
||||
|
||||
const setSelectedPlaceId = useCallback((placeId: number | null) => {
|
||||
_setSelectedPlaceId(placeId)
|
||||
setSelectedAssignmentId(null)
|
||||
}, [])
|
||||
|
||||
const selectAssignment = useCallback((assignmentId: number | null, placeId: number | null) => {
|
||||
setSelectedAssignmentId(assignmentId)
|
||||
_setSelectedPlaceId(placeId)
|
||||
}, [])
|
||||
|
||||
return { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment }
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
|
||||
export function useResizablePanels() {
|
||||
const [leftWidth, setLeftWidth] = useState<number>(() => parseInt(localStorage.getItem('sidebarLeftWidth') || '') || 340)
|
||||
const [rightWidth, setRightWidth] = useState<number>(() => parseInt(localStorage.getItem('sidebarRightWidth') || '') || 300)
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false)
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false)
|
||||
const isResizingLeft = useRef(false)
|
||||
const isResizingRight = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (isResizingLeft.current) {
|
||||
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, e.clientX - 10))
|
||||
setLeftWidth(w)
|
||||
localStorage.setItem('sidebarLeftWidth', String(w))
|
||||
}
|
||||
if (isResizingRight.current) {
|
||||
const w = Math.max(MIN_SIDEBAR, Math.min(MAX_SIDEBAR, window.innerWidth - e.clientX - 10))
|
||||
setRightWidth(w)
|
||||
localStorage.setItem('sidebarRightWidth', String(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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startResizeLeft = () => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
|
||||
const startResizeRight = () => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }
|
||||
|
||||
return { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight }
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
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(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||
const controller = new AbortController()
|
||||
routeAbortRef.current = controller
|
||||
try {
|
||||
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(segments)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [tripStore, routeCalcEnabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, tripStore.assignments])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||
import type { WebSocketEvent } from '../types'
|
||||
|
||||
export function useTripWebSocket(tripId: number | string | undefined) {
|
||||
const tripStore = useTripStore()
|
||||
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = useTripStore.getState().handleRemoteEvent
|
||||
joinTrip(tripId)
|
||||
addListener(handler)
|
||||
const collabFileSync = (event: WebSocketEvent) => {
|
||||
if (event?.type === 'collab:note:deleted' || event?.type === 'collab:note:updated') {
|
||||
tripStore.loadFiles?.(tripId)
|
||||
}
|
||||
}
|
||||
addListener(collabFileSync)
|
||||
const localFileSync = () => tripStore.loadFiles?.(tripId)
|
||||
window.addEventListener('collab-files-changed', localFileSync)
|
||||
return () => {
|
||||
leaveTrip(tripId)
|
||||
removeListener(handler)
|
||||
removeListener(collabFileSync)
|
||||
window.removeEventListener('collab-files-changed', localFileSync)
|
||||
}
|
||||
}, [tripId])
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { createContext, useContext, useMemo, ReactNode } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import de from './translations/de'
|
||||
import en from './translations/en'
|
||||
|
||||
type TranslationStrings = Record<string, string>
|
||||
|
||||
const translations: Record<string, TranslationStrings> = { de, en }
|
||||
|
||||
interface TranslationContextValue {
|
||||
t: (key: string, params?: Record<string, string | number>) => string
|
||||
language: string
|
||||
locale: string
|
||||
}
|
||||
|
||||
const TranslationContext = createContext<TranslationContextValue>({ t: (k: string) => k, language: 'de', locale: 'de-DE' })
|
||||
|
||||
interface TranslationProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function TranslationProvider({ children }: TranslationProviderProps) {
|
||||
const language = useSettingsStore((s) => s.settings.language) || 'de'
|
||||
|
||||
const value = useMemo((): TranslationContextValue => {
|
||||
const strings = translations[language] || translations.de
|
||||
const fallback = translations.de
|
||||
|
||||
function t(key: string, params?: Record<string, string | number>): string {
|
||||
let val: string = strings[key] ?? fallback[key] ?? key
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
val = val.replace(new RegExp(`\\{${k}\\}`, 'g'), String(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(): TranslationContextValue {
|
||||
return useContext(TranslationContext)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
const de = {
|
||||
const de: Record<string, string> = {
|
||||
// Allgemein
|
||||
'common.save': 'Speichern',
|
||||
'common.cancel': 'Abbrechen',
|
||||
@@ -28,6 +28,8 @@ const de = {
|
||||
'common.update': 'Aktualisieren',
|
||||
'common.change': 'Ändern',
|
||||
'common.uploading': 'Hochladen…',
|
||||
'common.backToPlanning': 'Zurück zur Planung',
|
||||
'common.reset': 'Zurücksetzen',
|
||||
|
||||
// Navbar
|
||||
'nav.trip': 'Reise',
|
||||
@@ -37,6 +39,7 @@ const de = {
|
||||
'nav.logout': 'Abmelden',
|
||||
'nav.lightMode': 'Heller Modus',
|
||||
'nav.darkMode': 'Dunkler Modus',
|
||||
'nav.autoMode': 'Automatischer Modus',
|
||||
'nav.administrator': 'Administrator',
|
||||
|
||||
// Dashboard
|
||||
@@ -120,9 +123,13 @@ const de = {
|
||||
'settings.colorMode': 'Farbmodus',
|
||||
'settings.light': 'Hell',
|
||||
'settings.dark': 'Dunkel',
|
||||
'settings.auto': 'Automatisch',
|
||||
'settings.language': 'Sprache',
|
||||
'settings.temperature': 'Temperatureinheit',
|
||||
'settings.timeFormat': 'Zeitformat',
|
||||
'settings.routeCalculation': 'Routenberechnung',
|
||||
'settings.on': 'An',
|
||||
'settings.off': 'Aus',
|
||||
'settings.account': 'Konto',
|
||||
'settings.username': 'Benutzername',
|
||||
'settings.email': 'E-Mail',
|
||||
@@ -131,12 +138,14 @@ const de = {
|
||||
'settings.oidcLinked': 'Verknüpft mit',
|
||||
'settings.changePassword': 'Passwort ändern',
|
||||
'settings.currentPassword': 'Aktuelles Passwort',
|
||||
'settings.currentPasswordRequired': 'Aktuelles Passwort wird benötigt',
|
||||
'settings.newPassword': 'Neues Passwort',
|
||||
'settings.confirmPassword': 'Neues Passwort bestätigen',
|
||||
'settings.updatePassword': 'Passwort aktualisieren',
|
||||
'settings.passwordRequired': 'Bitte aktuelles und neues Passwort eingeben',
|
||||
'settings.passwordTooShort': 'Passwort muss mindestens 8 Zeichen lang sein',
|
||||
'settings.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
'settings.passwordWeak': 'Passwort muss Groß-, Kleinbuchstaben und eine Zahl enthalten',
|
||||
'settings.passwordChanged': 'Passwort erfolgreich geändert',
|
||||
'settings.deleteAccount': 'Löschen',
|
||||
'settings.deleteAccountTitle': 'Account wirklich löschen?',
|
||||
@@ -191,6 +200,35 @@ const de = {
|
||||
'login.register': 'Registrieren',
|
||||
'login.emailPlaceholder': 'deine@email.de',
|
||||
'login.username': 'Benutzername',
|
||||
'login.oidc.registrationDisabled': 'Registrierung ist deaktiviert. Kontaktiere den Administrator.',
|
||||
'login.oidc.noEmail': 'Keine E-Mail vom Provider erhalten.',
|
||||
'login.oidc.tokenFailed': 'Authentifizierung fehlgeschlagen.',
|
||||
'login.oidc.invalidState': 'Ungültige Sitzung. Bitte erneut versuchen.',
|
||||
'login.demoFailed': 'Demo-Login fehlgeschlagen',
|
||||
'login.oidcSignIn': 'Anmelden mit {name}',
|
||||
'login.demoHint': 'Demo ausprobieren — ohne Registrierung',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwörter stimmen nicht überein',
|
||||
'register.passwordTooShort': 'Passwort muss mindestens 6 Zeichen lang sein',
|
||||
'register.failed': 'Registrierung fehlgeschlagen',
|
||||
'register.getStarted': 'Jetzt starten',
|
||||
'register.subtitle': 'Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.',
|
||||
'register.feature1': 'Unbegrenzte Reisepläne',
|
||||
'register.feature2': 'Interaktive Kartenansicht',
|
||||
'register.feature3': 'Orte und Kategorien verwalten',
|
||||
'register.feature4': 'Reservierungen tracken',
|
||||
'register.feature5': 'Packlisten erstellen',
|
||||
'register.feature6': 'Fotos und Dateien speichern',
|
||||
'register.createAccount': 'Konto erstellen',
|
||||
'register.startPlanning': 'Beginnen Sie Ihre Reiseplanung',
|
||||
'register.minChars': 'Mind. 6 Zeichen',
|
||||
'register.confirmPassword': 'Passwort bestätigen',
|
||||
'register.repeatPassword': 'Passwort wiederholen',
|
||||
'register.registering': 'Registrieren...',
|
||||
'register.register': 'Registrieren',
|
||||
'register.hasAccount': 'Bereits ein Konto?',
|
||||
'register.signIn': 'Anmelden',
|
||||
|
||||
// Admin
|
||||
'admin.title': 'Administration',
|
||||
@@ -248,6 +286,12 @@ const de = {
|
||||
'admin.oidcIssuerHint': 'Die OpenID Connect Issuer URL des Anbieters. z.B. https://accounts.google.com',
|
||||
'admin.oidcSaved': 'OIDC-Konfiguration gespeichert',
|
||||
|
||||
// File Types
|
||||
'admin.fileTypes': 'Erlaubte Dateitypen',
|
||||
'admin.fileTypesHint': 'Konfiguriere welche Dateitypen hochgeladen werden dürfen.',
|
||||
'admin.fileTypesFormat': 'Kommagetrennte Endungen (z.B. jpg,png,pdf,doc). Verwende * um alle Typen zu erlauben.',
|
||||
'admin.fileTypesSaved': 'Dateityp-Einstellungen gespeichert',
|
||||
|
||||
// Addons
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
@@ -263,6 +307,31 @@ const de = {
|
||||
'admin.addons.toast.updated': 'Addon aktualisiert',
|
||||
'admin.addons.toast.error': 'Addon konnte nicht aktualisiert werden',
|
||||
'admin.addons.noAddons': 'Keine Addons verfügbar',
|
||||
// Weather info
|
||||
'admin.weather.title': 'Wetterdaten',
|
||||
'admin.weather.badge': 'Seit 24. März 2026',
|
||||
'admin.weather.description': 'NOMAD nutzt Open-Meteo als Wetterdatenquelle. Open-Meteo ist ein kostenloser, quelloffener Wetterdienst — es wird kein API-Schlüssel benötigt.',
|
||||
'admin.weather.forecast': '16-Tage-Vorhersage',
|
||||
'admin.weather.forecastDesc': 'Statt bisher 5 Tage (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'Historische Klimadaten',
|
||||
'admin.weather.climateDesc': 'Durchschnittswerte der letzten 85 Jahre für Tage jenseits der 16-Tage-Vorhersage',
|
||||
'admin.weather.requests': '10.000 Anfragen / Tag',
|
||||
'admin.weather.requestsDesc': 'Kostenlos, kein API-Schlüssel erforderlich',
|
||||
'admin.weather.locationHint': 'Das Wetter wird anhand des ersten Ortes mit Koordinaten im jeweiligen Tag berechnet. Ist kein Ort am Tag eingeplant, wird ein beliebiger Ort aus der Ortsliste als Referenz verwendet.',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Update-Verlauf',
|
||||
'admin.github.subtitle': 'Neueste Updates von {repo}',
|
||||
'admin.github.latest': 'Aktuell',
|
||||
'admin.github.prerelease': 'Vorabversion',
|
||||
'admin.github.showDetails': 'Details anzeigen',
|
||||
'admin.github.hideDetails': 'Details ausblenden',
|
||||
'admin.github.loadMore': 'Mehr laden',
|
||||
'admin.github.loading': 'Wird geladen...',
|
||||
'admin.github.error': 'Releases konnten nicht geladen werden',
|
||||
'admin.github.by': 'von',
|
||||
|
||||
'admin.update.available': 'Update verfügbar',
|
||||
'admin.update.text': 'NOMAD {version} ist verfügbar. Du verwendest {current}.',
|
||||
'admin.update.button': 'Auf GitHub ansehen',
|
||||
@@ -415,9 +484,6 @@ const de = {
|
||||
'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',
|
||||
@@ -443,7 +509,7 @@ const de = {
|
||||
'dayplan.pdfError': 'Fehler beim PDF-Export',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Ort hinzufügen',
|
||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
@@ -466,6 +532,10 @@ const de = {
|
||||
'places.noCategory': 'Keine Kategorie',
|
||||
'places.categoryNamePlaceholder': 'Kategoriename',
|
||||
'places.formTime': 'Uhrzeit',
|
||||
'places.startTime': 'Start',
|
||||
'places.endTime': 'Ende',
|
||||
'places.endTimeBeforeStart': 'Endzeit liegt vor der Startzeit',
|
||||
'places.timeCollision': 'Zeitliche Überschneidung mit:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Persönliche Notizen...',
|
||||
'places.formReservation': 'Reservierung',
|
||||
@@ -477,11 +547,6 @@ const de = {
|
||||
'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',
|
||||
@@ -495,6 +560,9 @@ const de = {
|
||||
'inspector.pendingRes': 'Ausstehende Reservierung',
|
||||
'inspector.google': 'In Google Maps öffnen',
|
||||
'inspector.website': 'Webseite öffnen',
|
||||
'inspector.addRes': 'Reservierung',
|
||||
'inspector.editRes': 'Reservierung bearbeiten',
|
||||
'inspector.participants': 'Teilnehmer',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Buchungen',
|
||||
@@ -511,6 +579,10 @@ const de = {
|
||||
'reservations.editTitle': 'Reservierung bearbeiten',
|
||||
'reservations.status': 'Status',
|
||||
'reservations.datetime': 'Datum & Uhrzeit',
|
||||
'reservations.startTime': 'Startzeit',
|
||||
'reservations.endTime': 'Endzeit',
|
||||
'reservations.date': 'Datum',
|
||||
'reservations.time': 'Uhrzeit',
|
||||
'reservations.timeAlt': 'Uhrzeit (alternativ, z.B. 19:30)',
|
||||
'reservations.notes': 'Notizen',
|
||||
'reservations.notesPlaceholder': 'Zusätzliche Notizen...',
|
||||
@@ -538,7 +610,7 @@ const de = {
|
||||
'reservations.titlePlaceholder': 'z.B. Lufthansa LH123, Hotel Adlon, ...',
|
||||
'reservations.locationAddress': 'Ort / Adresse',
|
||||
'reservations.locationPlaceholder': 'Adresse, Flughafen, Hotel...',
|
||||
'reservations.confirmationCode': 'Bestätigungsnummer / Buchungscode',
|
||||
'reservations.confirmationCode': 'Buchungscode',
|
||||
'reservations.confirmationPlaceholder': 'z.B. ABC12345',
|
||||
'reservations.day': 'Tag',
|
||||
'reservations.noDay': 'Kein Tag',
|
||||
@@ -547,6 +619,9 @@ const de = {
|
||||
'reservations.pendingSave': 'wird gespeichert…',
|
||||
'reservations.uploading': 'Wird hochgeladen...',
|
||||
'reservations.attachFile': 'Datei anhängen',
|
||||
'reservations.linkAssignment': 'Mit Tagesplanung verknüpfen',
|
||||
'reservations.pickAssignment': 'Zuordnung aus dem Plan wählen...',
|
||||
'reservations.noAssignment': 'Keine Verknüpfung',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -562,7 +637,7 @@ const de = {
|
||||
'budget.table.days': 'Tage',
|
||||
'budget.table.perPerson': 'Pro Person',
|
||||
'budget.table.perDay': 'Pro Tag',
|
||||
'budget.table.perPersonDay': 'Pro Person/Tag',
|
||||
'budget.table.perPersonDay': 'P. p / Tag',
|
||||
'budget.table.note': 'Notiz',
|
||||
'budget.newEntry': 'Neuer Eintrag',
|
||||
'budget.defaultEntry': 'Neuer Eintrag',
|
||||
@@ -573,6 +648,10 @@ const de = {
|
||||
'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',
|
||||
'budget.perPerson': 'Pro Person',
|
||||
'budget.paid': 'Bezahlt',
|
||||
'budget.open': 'Offen',
|
||||
'budget.noMembers': 'Keine Teilnehmer zugewiesen',
|
||||
|
||||
// Files
|
||||
'files.title': 'Dateien',
|
||||
@@ -582,11 +661,14 @@ const de = {
|
||||
'files.uploadError': 'Fehler beim Hochladen',
|
||||
'files.dropzone': 'Dateien hier ablegen',
|
||||
'files.dropzoneHint': 'oder klicken zum Auswählen',
|
||||
'files.allowedTypes': 'Bilder, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
'files.uploading': 'Wird hochgeladen...',
|
||||
'files.filterAll': 'Alle',
|
||||
'files.filterPdf': 'PDFs',
|
||||
'files.filterImages': 'Bilder',
|
||||
'files.filterDocs': 'Dokumente',
|
||||
'files.filterCollab': 'Collab Notizen',
|
||||
'files.sourceCollab': 'Aus Collab Notizen',
|
||||
'files.empty': 'Keine Dateien vorhanden',
|
||||
'files.emptyHint': 'Lade Dateien hoch, um sie mit deiner Reise zu verknüpfen',
|
||||
'files.openTab': 'In neuem Tab öffnen',
|
||||
@@ -595,6 +677,8 @@ const de = {
|
||||
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
|
||||
'files.sourcePlan': 'Tagesplan',
|
||||
'files.sourceBooking': 'Buchung',
|
||||
'files.attach': 'Anhängen',
|
||||
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
|
||||
|
||||
// Packing
|
||||
'packing.title': 'Packliste',
|
||||
@@ -747,6 +831,21 @@ const de = {
|
||||
'backup.keep.30days': '30 Tage',
|
||||
'backup.keep.forever': 'Immer behalten',
|
||||
|
||||
// Photos
|
||||
'photos.allDays': 'Alle Tage',
|
||||
'photos.noPhotos': 'Noch keine Fotos',
|
||||
'photos.uploadHint': 'Lade deine Reisefotos hoch',
|
||||
'photos.clickToSelect': 'oder klicken zum Auswählen',
|
||||
'photos.linkPlace': 'Ort verknüpfen',
|
||||
'photos.noPlace': 'Kein Ort',
|
||||
'photos.uploadN': '{n} Foto(s) hochladen',
|
||||
|
||||
// Backup restore modal
|
||||
'backup.restoreConfirmTitle': 'Backup wiederherstellen?',
|
||||
'backup.restoreWarning': 'Alle aktuellen Daten (Reisen, Orte, Benutzer, Uploads) werden unwiderruflich durch das Backup ersetzt. Dieser Vorgang kann nicht rückgängig gemacht werden.',
|
||||
'backup.restoreTip': 'Tipp: Erstelle zuerst ein Backup des aktuellen Stands, bevor du wiederherstellst.',
|
||||
'backup.restoreConfirm': 'Ja, wiederherstellen',
|
||||
|
||||
// PDF
|
||||
'pdf.travelPlan': 'Reiseplan',
|
||||
'pdf.planned': 'Eingeplant',
|
||||
@@ -754,6 +853,68 @@ const de = {
|
||||
'pdf.preview': 'PDF Vorschau',
|
||||
'pdf.saveAsPdf': 'Als PDF speichern',
|
||||
|
||||
// Planner
|
||||
'planner.places': 'Orte',
|
||||
'planner.bookings': 'Buchungen',
|
||||
'planner.packingList': 'Packliste',
|
||||
'planner.documents': 'Dokumente',
|
||||
'planner.dayPlan': 'Tagesplan',
|
||||
'planner.reservations': 'Reservierungen',
|
||||
'planner.minTwoPlaces': 'Mindestens 2 Orte mit Koordinaten benötigt',
|
||||
'planner.noGeoPlaces': 'Keine Orte mit Koordinaten vorhanden',
|
||||
'planner.routeCalculated': 'Route berechnet',
|
||||
'planner.routeCalcFailed': 'Route konnte nicht berechnet werden',
|
||||
'planner.routeError': 'Fehler bei der Routenberechnung',
|
||||
'planner.routeOptimized': 'Route optimiert',
|
||||
'planner.reservationUpdated': 'Reservierung aktualisiert',
|
||||
'planner.reservationAdded': 'Reservierung hinzugefügt',
|
||||
'planner.confirmDeleteReservation': 'Reservierung löschen?',
|
||||
'planner.reservationDeleted': 'Reservierung gelöscht',
|
||||
'planner.days': 'Tage',
|
||||
'planner.allPlaces': 'Alle Orte',
|
||||
'planner.totalPlaces': '{n} Orte gesamt',
|
||||
'planner.noDaysPlanned': 'Noch keine Tage geplant',
|
||||
'planner.editTrip': 'Reise bearbeiten \u2192',
|
||||
'planner.placeOne': '1 Ort',
|
||||
'planner.placeN': '{n} Orte',
|
||||
'planner.addNote': 'Notiz hinzufügen',
|
||||
'planner.noEntries': 'Keine Einträge für diesen Tag',
|
||||
'planner.addPlace': 'Ort/Aktivität hinzufügen',
|
||||
'planner.addPlaceShort': '+ Ort/Aktivität hinzufügen',
|
||||
'planner.resPending': 'Reservierung ausstehend · ',
|
||||
'planner.resConfirmed': 'Reservierung bestätigt · ',
|
||||
'planner.notePlaceholder': 'Notiz\u2026',
|
||||
'planner.noteTimePlaceholder': 'Zeit (optional)',
|
||||
'planner.noteExamplePlaceholder': 'z.B. S3 um 14:30 ab Hauptbahnhof, Fähre ab Pier 7, Mittagspause\u2026',
|
||||
'planner.totalCost': 'Gesamtkosten',
|
||||
'planner.searchPlaces': 'Orte suchen\u2026',
|
||||
'planner.allCategories': 'Alle Kategorien',
|
||||
'planner.noPlacesFound': 'Keine Orte gefunden',
|
||||
'planner.addFirstPlace': 'Ersten Ort hinzufügen',
|
||||
'planner.noReservations': 'Keine Reservierungen',
|
||||
'planner.addFirstReservation': 'Erste Reservierung hinzufügen',
|
||||
'planner.new': 'Neu',
|
||||
'planner.addToDay': '+ Tag',
|
||||
'planner.calculating': 'Berechne\u2026',
|
||||
'planner.route': 'Route',
|
||||
'planner.optimize': 'Optimieren',
|
||||
'planner.openGoogleMaps': 'In Google Maps öffnen',
|
||||
'planner.selectDayHint': 'Wähle einen Tag aus der linken Liste um den Tagesplan zu sehen',
|
||||
'planner.noPlacesForDay': 'Noch keine Orte für diesen Tag',
|
||||
'planner.addPlacesLink': 'Orte hinzufügen \u2192',
|
||||
'planner.minTotal': 'Min. gesamt',
|
||||
'planner.noReservation': 'Keine Reservierung',
|
||||
'planner.removeFromDay': 'Aus Tag entfernen',
|
||||
'planner.addToThisDay': 'Zum Tag hinzufügen',
|
||||
'planner.overview': 'Gesamtübersicht',
|
||||
'planner.noDays': 'Noch keine Tage',
|
||||
'planner.editTripToAddDays': 'Reise bearbeiten um Tage hinzuzufügen',
|
||||
'planner.dayCount': '{n} Tage',
|
||||
'planner.clickToUnlock': 'Klicken zum Entsperren',
|
||||
'planner.keepPosition': 'Position bei Routenoptimierung beibehalten',
|
||||
'planner.dayDetails': 'Tagesdetails',
|
||||
'planner.dayN': 'Tag {n}',
|
||||
|
||||
// Dashboard Stats
|
||||
'stats.countries': 'Länder',
|
||||
'stats.cities': 'Städte',
|
||||
@@ -763,6 +924,97 @@ const de = {
|
||||
'stats.visited': 'besucht',
|
||||
'stats.remaining': 'verbleibend',
|
||||
'stats.visitedCountries': 'Besuchte Länder',
|
||||
|
||||
// Day Detail Panel
|
||||
'day.precipProb': 'Regenwahrscheinlichkeit',
|
||||
'day.precipitation': 'Niederschlag',
|
||||
'day.wind': 'Wind',
|
||||
'day.sunrise': 'Sonnenaufgang',
|
||||
'day.sunset': 'Sonnenuntergang',
|
||||
'day.hourlyForecast': 'Stündliche Vorhersage',
|
||||
'day.climateHint': 'Historische Durchschnittswerte — echte Vorhersage verfügbar innerhalb von 16 Tagen vor diesem Datum.',
|
||||
'day.noWeather': 'Keine Wetterdaten verfügbar. Füge einen Ort mit Koordinaten hinzu.',
|
||||
'day.overview': 'Tagesübersicht',
|
||||
'day.accommodation': 'Unterkunft',
|
||||
'day.addAccommodation': 'Unterkunft hinzufügen',
|
||||
'day.hotelDayRange': 'Auf Tage anwenden',
|
||||
'day.noPlacesForHotel': 'Füge zuerst Orte zu deiner Reise hinzu',
|
||||
'day.allDays': 'Alle',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Bestätigung',
|
||||
'day.editAccommodation': 'Unterkunft bearbeiten',
|
||||
'day.reservations': 'Reservierungen',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notizen',
|
||||
'collab.tabs.polls': 'Umfragen',
|
||||
'collab.whatsNext.title': 'Nächste',
|
||||
'collab.whatsNext.today': 'Heute',
|
||||
'collab.whatsNext.tomorrow': 'Morgen',
|
||||
'collab.whatsNext.empty': 'Keine anstehenden Aktivitäten',
|
||||
'collab.whatsNext.until': 'bis',
|
||||
'collab.whatsNext.emptyHint': 'Aktivitäten mit Uhrzeit erscheinen hier',
|
||||
'collab.chat.send': 'Senden',
|
||||
'collab.chat.placeholder': 'Nachricht eingeben...',
|
||||
'collab.chat.empty': 'Starte die Unterhaltung',
|
||||
'collab.chat.emptyHint': 'Nachrichten werden mit allen Reiseteilnehmern geteilt',
|
||||
'collab.chat.emptyDesc': 'Teile Ideen, Pläne und Updates mit deiner Reisegruppe',
|
||||
'collab.chat.today': 'Heute',
|
||||
'collab.chat.yesterday': 'Gestern',
|
||||
'collab.chat.deletedMessage': 'hat eine Nachricht gelöscht',
|
||||
'collab.chat.loadMore': 'Ältere Nachrichten laden',
|
||||
'collab.chat.justNow': 'gerade eben',
|
||||
'collab.chat.minutesAgo': 'vor {n} Min.',
|
||||
'collab.chat.hoursAgo': 'vor {n} Std.',
|
||||
'collab.chat.yesterday': 'gestern',
|
||||
'collab.notes.title': 'Notizen',
|
||||
'collab.notes.new': 'Neue Notiz',
|
||||
'collab.notes.empty': 'Noch keine Notizen',
|
||||
'collab.notes.emptyHint': 'Halte Ideen und Pläne fest',
|
||||
'collab.notes.all': 'Alle',
|
||||
'collab.notes.titlePlaceholder': 'Notiztitel',
|
||||
'collab.notes.contentPlaceholder': 'Schreibe etwas...',
|
||||
'collab.notes.categoryPlaceholder': 'Kategorie',
|
||||
'collab.notes.newCategory': 'Neue Kategorie...',
|
||||
'collab.notes.category': 'Kategorie',
|
||||
'collab.notes.noCategory': 'Keine Kategorie',
|
||||
'collab.notes.color': 'Farbe',
|
||||
'collab.notes.save': 'Speichern',
|
||||
'collab.notes.cancel': 'Abbrechen',
|
||||
'collab.notes.edit': 'Bearbeiten',
|
||||
'collab.notes.delete': 'Löschen',
|
||||
'collab.notes.pin': 'Anheften',
|
||||
'collab.notes.unpin': 'Loslösen',
|
||||
'collab.notes.daysAgo': 'vor {n} T.',
|
||||
'collab.notes.categorySettings': 'Kategorien verwalten',
|
||||
'collab.notes.create': 'Erstellen',
|
||||
'collab.notes.website': 'Website',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.attachFiles': 'Dateien anhängen',
|
||||
'collab.notes.noCategoriesYet': 'Noch keine Kategorien',
|
||||
'collab.notes.emptyDesc': 'Erstelle eine Notiz um loszulegen',
|
||||
'collab.polls.title': 'Umfragen',
|
||||
'collab.polls.new': 'Neue Umfrage',
|
||||
'collab.polls.empty': 'Noch keine Umfragen',
|
||||
'collab.polls.emptyHint': 'Frage die Gruppe und stimmt gemeinsam ab',
|
||||
'collab.polls.question': 'Frage',
|
||||
'collab.polls.questionPlaceholder': 'Was sollen wir machen?',
|
||||
'collab.polls.addOption': '+ Option hinzufügen',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Umfrage erstellen',
|
||||
'collab.polls.close': 'Schließen',
|
||||
'collab.polls.closed': 'Geschlossen',
|
||||
'collab.polls.votes': '{n} Stimmen',
|
||||
'collab.polls.vote': '{n} Stimme',
|
||||
'collab.polls.multipleChoice': 'Mehrfachauswahl',
|
||||
'collab.polls.multiChoice': 'Mehrfachauswahl',
|
||||
'collab.polls.deadline': 'Frist',
|
||||
'collab.polls.option': 'Option',
|
||||
'collab.polls.options': 'Optionen',
|
||||
'collab.polls.delete': 'Löschen',
|
||||
'collab.polls.closedSection': 'Geschlossen',
|
||||
}
|
||||
|
||||
export default de
|
||||
@@ -1,4 +1,4 @@
|
||||
const en = {
|
||||
const en: Record<string, string> = {
|
||||
// Common
|
||||
'common.save': 'Save',
|
||||
'common.cancel': 'Cancel',
|
||||
@@ -28,6 +28,8 @@ const en = {
|
||||
'common.update': 'Update',
|
||||
'common.change': 'Change',
|
||||
'common.uploading': 'Uploading…',
|
||||
'common.backToPlanning': 'Back to Planning',
|
||||
'common.reset': 'Reset',
|
||||
|
||||
// Navbar
|
||||
'nav.trip': 'Trip',
|
||||
@@ -37,6 +39,7 @@ const en = {
|
||||
'nav.logout': 'Log out',
|
||||
'nav.lightMode': 'Light Mode',
|
||||
'nav.darkMode': 'Dark Mode',
|
||||
'nav.autoMode': 'Auto Mode',
|
||||
'nav.administrator': 'Administrator',
|
||||
|
||||
// Dashboard
|
||||
@@ -120,9 +123,13 @@ const en = {
|
||||
'settings.colorMode': 'Color Mode',
|
||||
'settings.light': 'Light',
|
||||
'settings.dark': 'Dark',
|
||||
'settings.auto': 'Auto',
|
||||
'settings.language': 'Language',
|
||||
'settings.temperature': 'Temperature Unit',
|
||||
'settings.timeFormat': 'Time Format',
|
||||
'settings.routeCalculation': 'Route Calculation',
|
||||
'settings.on': 'On',
|
||||
'settings.off': 'Off',
|
||||
'settings.account': 'Account',
|
||||
'settings.username': 'Username',
|
||||
'settings.email': 'Email',
|
||||
@@ -131,12 +138,14 @@ const en = {
|
||||
'settings.oidcLinked': 'Linked with',
|
||||
'settings.changePassword': 'Change Password',
|
||||
'settings.currentPassword': 'Current password',
|
||||
'settings.currentPasswordRequired': 'Current password is required',
|
||||
'settings.newPassword': 'New password',
|
||||
'settings.confirmPassword': 'Confirm new password',
|
||||
'settings.updatePassword': 'Update password',
|
||||
'settings.passwordRequired': 'Please enter current and new password',
|
||||
'settings.passwordTooShort': 'Password must be at least 8 characters',
|
||||
'settings.passwordMismatch': 'Passwords do not match',
|
||||
'settings.passwordWeak': 'Password must contain uppercase, lowercase, and a number',
|
||||
'settings.passwordChanged': 'Password changed successfully',
|
||||
'settings.deleteAccount': 'Delete account',
|
||||
'settings.deleteAccountTitle': 'Delete your account?',
|
||||
@@ -191,6 +200,35 @@ const en = {
|
||||
'login.register': 'Register',
|
||||
'login.emailPlaceholder': 'your@email.com',
|
||||
'login.username': 'Username',
|
||||
'login.oidc.registrationDisabled': 'Registration is disabled. Contact your administrator.',
|
||||
'login.oidc.noEmail': 'No email received from provider.',
|
||||
'login.oidc.tokenFailed': 'Authentication failed.',
|
||||
'login.oidc.invalidState': 'Invalid session. Please try again.',
|
||||
'login.demoFailed': 'Demo login failed',
|
||||
'login.oidcSignIn': 'Sign in with {name}',
|
||||
'login.demoHint': 'Try the demo — no registration needed',
|
||||
|
||||
// Register
|
||||
'register.passwordMismatch': 'Passwords do not match',
|
||||
'register.passwordTooShort': 'Password must be at least 6 characters',
|
||||
'register.failed': 'Registration failed',
|
||||
'register.getStarted': 'Get Started',
|
||||
'register.subtitle': 'Create an account and start planning your dream trips.',
|
||||
'register.feature1': 'Unlimited trip plans',
|
||||
'register.feature2': 'Interactive map view',
|
||||
'register.feature3': 'Manage places and categories',
|
||||
'register.feature4': 'Track reservations',
|
||||
'register.feature5': 'Create packing lists',
|
||||
'register.feature6': 'Store photos and files',
|
||||
'register.createAccount': 'Create Account',
|
||||
'register.startPlanning': 'Start your trip planning',
|
||||
'register.minChars': 'Min. 6 characters',
|
||||
'register.confirmPassword': 'Confirm Password',
|
||||
'register.repeatPassword': 'Repeat password',
|
||||
'register.registering': 'Registering...',
|
||||
'register.register': 'Register',
|
||||
'register.hasAccount': 'Already have an account?',
|
||||
'register.signIn': 'Sign In',
|
||||
|
||||
// Admin
|
||||
'admin.title': 'Administration',
|
||||
@@ -248,6 +286,12 @@ const en = {
|
||||
'admin.oidcIssuerHint': 'The OpenID Connect Issuer URL of the provider. e.g. https://accounts.google.com',
|
||||
'admin.oidcSaved': 'OIDC configuration saved',
|
||||
|
||||
// File Types
|
||||
'admin.fileTypes': 'Allowed File Types',
|
||||
'admin.fileTypesHint': 'Configure which file types users can upload.',
|
||||
'admin.fileTypesFormat': 'Comma-separated extensions (e.g. jpg,png,pdf,doc). Use * to allow all types.',
|
||||
'admin.fileTypesSaved': 'File type settings saved',
|
||||
|
||||
// Addons
|
||||
'admin.tabs.addons': 'Addons',
|
||||
'admin.addons.title': 'Addons',
|
||||
@@ -263,6 +307,31 @@ const en = {
|
||||
'admin.addons.toast.updated': 'Addon updated',
|
||||
'admin.addons.toast.error': 'Failed to update addon',
|
||||
'admin.addons.noAddons': 'No addons available',
|
||||
// Weather info
|
||||
'admin.weather.title': 'Weather Data',
|
||||
'admin.weather.badge': 'Since March 24, 2026',
|
||||
'admin.weather.description': 'NOMAD uses Open-Meteo as its weather data source. Open-Meteo is a free, open-source weather service — no API key required.',
|
||||
'admin.weather.forecast': '16-day forecast',
|
||||
'admin.weather.forecastDesc': 'Previously 5 days (OpenWeatherMap)',
|
||||
'admin.weather.climate': 'Historical climate data',
|
||||
'admin.weather.climateDesc': 'Averages from the last 85 years for days beyond the 16-day forecast',
|
||||
'admin.weather.requests': '10,000 requests / day',
|
||||
'admin.weather.requestsDesc': 'Free, no API key required',
|
||||
'admin.weather.locationHint': 'Weather is based on the first place with coordinates in each day. If no place is assigned to a day, any place from the place list is used as a reference.',
|
||||
|
||||
// GitHub
|
||||
'admin.tabs.github': 'GitHub',
|
||||
'admin.github.title': 'Release History',
|
||||
'admin.github.subtitle': 'Latest updates from {repo}',
|
||||
'admin.github.latest': 'Latest',
|
||||
'admin.github.prerelease': 'Pre-release',
|
||||
'admin.github.showDetails': 'Show details',
|
||||
'admin.github.hideDetails': 'Hide details',
|
||||
'admin.github.loadMore': 'Load more',
|
||||
'admin.github.loading': 'Loading...',
|
||||
'admin.github.error': 'Failed to load releases',
|
||||
'admin.github.by': 'by',
|
||||
|
||||
'admin.update.available': 'Update available',
|
||||
'admin.update.text': 'NOMAD {version} is available. You are running {current}.',
|
||||
'admin.update.button': 'View on GitHub',
|
||||
@@ -415,9 +484,6 @@ const en = {
|
||||
'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',
|
||||
@@ -443,7 +509,7 @@ const en = {
|
||||
'dayplan.pdfError': 'Failed to export PDF',
|
||||
|
||||
// Places Sidebar
|
||||
'places.addPlace': 'Add Place',
|
||||
'places.addPlace': 'Add Place/Activity',
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
@@ -466,6 +532,10 @@ const en = {
|
||||
'places.noCategory': 'No Category',
|
||||
'places.categoryNamePlaceholder': 'Category name',
|
||||
'places.formTime': 'Time',
|
||||
'places.startTime': 'Start',
|
||||
'places.endTime': 'End',
|
||||
'places.endTimeBeforeStart': 'End time is before start time',
|
||||
'places.timeCollision': 'Time overlap with:',
|
||||
'places.formWebsite': 'Website',
|
||||
'places.formNotesPlaceholder': 'Personal notes...',
|
||||
'places.formReservation': 'Reservation',
|
||||
@@ -477,11 +547,6 @@ const en = {
|
||||
'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',
|
||||
@@ -495,6 +560,9 @@ const en = {
|
||||
'inspector.pendingRes': 'Pending Reservation',
|
||||
'inspector.google': 'Open in Google Maps',
|
||||
'inspector.website': 'Open Website',
|
||||
'inspector.addRes': 'Reservation',
|
||||
'inspector.editRes': 'Edit Reservation',
|
||||
'inspector.participants': 'Participants',
|
||||
|
||||
// Reservations
|
||||
'reservations.title': 'Bookings',
|
||||
@@ -511,6 +579,10 @@ const en = {
|
||||
'reservations.editTitle': 'Edit Reservation',
|
||||
'reservations.status': 'Status',
|
||||
'reservations.datetime': 'Date & Time',
|
||||
'reservations.startTime': 'Start time',
|
||||
'reservations.endTime': 'End time',
|
||||
'reservations.date': 'Date',
|
||||
'reservations.time': 'Time',
|
||||
'reservations.timeAlt': 'Time (alternative, e.g. 19:30)',
|
||||
'reservations.notes': 'Notes',
|
||||
'reservations.notesPlaceholder': 'Additional notes...',
|
||||
@@ -534,7 +606,7 @@ const en = {
|
||||
'reservations.titlePlaceholder': 'e.g. Lufthansa LH123, Hotel Adlon, ...',
|
||||
'reservations.locationAddress': 'Location / Address',
|
||||
'reservations.locationPlaceholder': 'Address, Airport, Hotel...',
|
||||
'reservations.confirmationCode': 'Confirmation Number / Booking Code',
|
||||
'reservations.confirmationCode': 'Booking Code',
|
||||
'reservations.confirmationPlaceholder': 'e.g. ABC12345',
|
||||
'reservations.day': 'Day',
|
||||
'reservations.noDay': 'No Day',
|
||||
@@ -547,6 +619,9 @@ const en = {
|
||||
'reservations.toast.updateError': 'Failed to update',
|
||||
'reservations.toast.deleteError': 'Failed to delete',
|
||||
'reservations.confirm.remove': 'Remove reservation for "{name}"?',
|
||||
'reservations.linkAssignment': 'Link to day assignment',
|
||||
'reservations.pickAssignment': 'Select an assignment from your plan...',
|
||||
'reservations.noAssignment': 'No link (standalone)',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -562,7 +637,7 @@ const en = {
|
||||
'budget.table.days': 'Days',
|
||||
'budget.table.perPerson': 'Per Person',
|
||||
'budget.table.perDay': 'Per Day',
|
||||
'budget.table.perPersonDay': 'Per Person/Day',
|
||||
'budget.table.perPersonDay': 'P. p / Day',
|
||||
'budget.table.note': 'Note',
|
||||
'budget.newEntry': 'New Entry',
|
||||
'budget.defaultEntry': 'New Entry',
|
||||
@@ -573,6 +648,10 @@ const en = {
|
||||
'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',
|
||||
'budget.perPerson': 'Per Person',
|
||||
'budget.paid': 'Paid',
|
||||
'budget.open': 'Open',
|
||||
'budget.noMembers': 'No members assigned',
|
||||
|
||||
// Files
|
||||
'files.title': 'Files',
|
||||
@@ -582,11 +661,14 @@ const en = {
|
||||
'files.uploadError': 'Upload failed',
|
||||
'files.dropzone': 'Drop files here',
|
||||
'files.dropzoneHint': 'or click to browse',
|
||||
'files.allowedTypes': 'Images, PDF, DOC, DOCX, XLS, XLSX, TXT, CSV · Max 50 MB',
|
||||
'files.uploading': 'Uploading...',
|
||||
'files.filterAll': 'All',
|
||||
'files.filterPdf': 'PDFs',
|
||||
'files.filterImages': 'Images',
|
||||
'files.filterDocs': 'Documents',
|
||||
'files.filterCollab': 'Collab Notes',
|
||||
'files.sourceCollab': 'From Collab Notes',
|
||||
'files.empty': 'No files yet',
|
||||
'files.emptyHint': 'Upload files to attach them to your trip',
|
||||
'files.openTab': 'Open in new tab',
|
||||
@@ -595,6 +677,8 @@ const en = {
|
||||
'files.toast.deleteError': 'Failed to delete file',
|
||||
'files.sourcePlan': 'Day Plan',
|
||||
'files.sourceBooking': 'Booking',
|
||||
'files.attach': 'Attach',
|
||||
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
|
||||
|
||||
// Packing
|
||||
'packing.title': 'Packing List',
|
||||
@@ -747,6 +831,21 @@ const en = {
|
||||
'backup.keep.30days': '30 days',
|
||||
'backup.keep.forever': 'Keep forever',
|
||||
|
||||
// Photos
|
||||
'photos.allDays': 'All Days',
|
||||
'photos.noPhotos': 'No photos yet',
|
||||
'photos.uploadHint': 'Upload your travel photos',
|
||||
'photos.clickToSelect': 'or click to select',
|
||||
'photos.linkPlace': 'Link Place',
|
||||
'photos.noPlace': 'No Place',
|
||||
'photos.uploadN': '{n} photo(s) upload',
|
||||
|
||||
// Backup restore modal
|
||||
'backup.restoreConfirmTitle': 'Restore Backup?',
|
||||
'backup.restoreWarning': 'All current data (trips, places, users, uploads) will be permanently replaced by the backup. This action cannot be undone.',
|
||||
'backup.restoreTip': 'Tip: Create a backup of the current state before restoring.',
|
||||
'backup.restoreConfirm': 'Yes, restore',
|
||||
|
||||
// PDF
|
||||
'pdf.travelPlan': 'Travel Plan',
|
||||
'pdf.planned': 'Planned',
|
||||
@@ -754,6 +853,68 @@ const en = {
|
||||
'pdf.preview': 'PDF Preview',
|
||||
'pdf.saveAsPdf': 'Save as PDF',
|
||||
|
||||
// Planner
|
||||
'planner.places': 'Places',
|
||||
'planner.bookings': 'Bookings',
|
||||
'planner.packingList': 'Packing List',
|
||||
'planner.documents': 'Documents',
|
||||
'planner.dayPlan': 'Day Plan',
|
||||
'planner.reservations': 'Reservations',
|
||||
'planner.minTwoPlaces': 'At least 2 places with coordinates needed',
|
||||
'planner.noGeoPlaces': 'No places with coordinates available',
|
||||
'planner.routeCalculated': 'Route calculated',
|
||||
'planner.routeCalcFailed': 'Route could not be calculated',
|
||||
'planner.routeError': 'Error calculating route',
|
||||
'planner.routeOptimized': 'Route optimized',
|
||||
'planner.reservationUpdated': 'Reservation updated',
|
||||
'planner.reservationAdded': 'Reservation added',
|
||||
'planner.confirmDeleteReservation': 'Delete reservation?',
|
||||
'planner.reservationDeleted': 'Reservation deleted',
|
||||
'planner.days': 'Days',
|
||||
'planner.allPlaces': 'All Places',
|
||||
'planner.totalPlaces': '{n} places total',
|
||||
'planner.noDaysPlanned': 'No days planned yet',
|
||||
'planner.editTrip': 'Edit trip \u2192',
|
||||
'planner.placeOne': '1 place',
|
||||
'planner.placeN': '{n} places',
|
||||
'planner.addNote': 'Add note',
|
||||
'planner.noEntries': 'No entries for this day',
|
||||
'planner.addPlace': 'Add place/activity',
|
||||
'planner.addPlaceShort': '+ Add place/activity',
|
||||
'planner.resPending': 'Reservation pending · ',
|
||||
'planner.resConfirmed': 'Reservation confirmed · ',
|
||||
'planner.notePlaceholder': 'Note\u2026',
|
||||
'planner.noteTimePlaceholder': 'Time (optional)',
|
||||
'planner.noteExamplePlaceholder': 'e.g. S3 at 14:30 from central station, ferry from pier 7, lunch break\u2026',
|
||||
'planner.totalCost': 'Total cost',
|
||||
'planner.searchPlaces': 'Search places\u2026',
|
||||
'planner.allCategories': 'All Categories',
|
||||
'planner.noPlacesFound': 'No places found',
|
||||
'planner.addFirstPlace': 'Add first place',
|
||||
'planner.noReservations': 'No reservations',
|
||||
'planner.addFirstReservation': 'Add first reservation',
|
||||
'planner.new': 'New',
|
||||
'planner.addToDay': '+ Day',
|
||||
'planner.calculating': 'Calculating\u2026',
|
||||
'planner.route': 'Route',
|
||||
'planner.optimize': 'Optimize',
|
||||
'planner.openGoogleMaps': 'Open in Google Maps',
|
||||
'planner.selectDayHint': 'Select a day from the left list to see the day plan',
|
||||
'planner.noPlacesForDay': 'No places for this day yet',
|
||||
'planner.addPlacesLink': 'Add places \u2192',
|
||||
'planner.minTotal': 'min. total',
|
||||
'planner.noReservation': 'No reservation',
|
||||
'planner.removeFromDay': 'Remove from day',
|
||||
'planner.addToThisDay': 'Add to day',
|
||||
'planner.overview': 'Overview',
|
||||
'planner.noDays': 'No days yet',
|
||||
'planner.editTripToAddDays': 'Edit trip to add days',
|
||||
'planner.dayCount': '{n} Days',
|
||||
'planner.clickToUnlock': 'Click to unlock',
|
||||
'planner.keepPosition': 'Keep position during route optimization',
|
||||
'planner.dayDetails': 'Day details',
|
||||
'planner.dayN': 'Day {n}',
|
||||
|
||||
// Dashboard Stats
|
||||
'stats.countries': 'Countries',
|
||||
'stats.cities': 'Cities',
|
||||
@@ -763,6 +924,97 @@ const en = {
|
||||
'stats.visited': 'visited',
|
||||
'stats.remaining': 'remaining',
|
||||
'stats.visitedCountries': 'Visited Countries',
|
||||
|
||||
// Day Detail Panel
|
||||
'day.precipProb': 'Rain probability',
|
||||
'day.precipitation': 'Precipitation',
|
||||
'day.wind': 'Wind',
|
||||
'day.sunrise': 'Sunrise',
|
||||
'day.sunset': 'Sunset',
|
||||
'day.hourlyForecast': 'Hourly Forecast',
|
||||
'day.climateHint': 'Historical averages — real forecast available within 16 days of this date.',
|
||||
'day.noWeather': 'No weather data available. Add a place with coordinates.',
|
||||
'day.overview': 'Daily Overview',
|
||||
'day.accommodation': 'Accommodation',
|
||||
'day.addAccommodation': 'Add accommodation',
|
||||
'day.hotelDayRange': 'Apply to days',
|
||||
'day.noPlacesForHotel': 'Add places to your trip first',
|
||||
'day.allDays': 'All',
|
||||
'day.checkIn': 'Check-in',
|
||||
'day.checkOut': 'Check-out',
|
||||
'day.confirmation': 'Confirmation',
|
||||
'day.editAccommodation': 'Edit accommodation',
|
||||
'day.reservations': 'Reservations',
|
||||
|
||||
// Collab Addon
|
||||
'collab.tabs.chat': 'Chat',
|
||||
'collab.tabs.notes': 'Notes',
|
||||
'collab.tabs.polls': 'Polls',
|
||||
'collab.whatsNext.title': "What's Next",
|
||||
'collab.whatsNext.today': 'Today',
|
||||
'collab.whatsNext.tomorrow': 'Tomorrow',
|
||||
'collab.whatsNext.empty': 'No upcoming activities',
|
||||
'collab.whatsNext.until': 'to',
|
||||
'collab.whatsNext.emptyHint': 'Activities with times will appear here',
|
||||
'collab.chat.send': 'Send',
|
||||
'collab.chat.placeholder': 'Type a message...',
|
||||
'collab.chat.empty': 'Start the conversation',
|
||||
'collab.chat.emptyHint': 'Messages are shared with all trip members',
|
||||
'collab.chat.emptyDesc': 'Share ideas, plans, and updates with your travel group',
|
||||
'collab.chat.today': 'Today',
|
||||
'collab.chat.yesterday': 'Yesterday',
|
||||
'collab.chat.deletedMessage': 'deleted a message',
|
||||
'collab.chat.loadMore': 'Load older messages',
|
||||
'collab.chat.justNow': 'just now',
|
||||
'collab.chat.minutesAgo': '{n}m ago',
|
||||
'collab.chat.hoursAgo': '{n}h ago',
|
||||
'collab.chat.yesterday': 'yesterday',
|
||||
'collab.notes.title': 'Notes',
|
||||
'collab.notes.new': 'New Note',
|
||||
'collab.notes.empty': 'No notes yet',
|
||||
'collab.notes.emptyHint': 'Start capturing ideas and plans',
|
||||
'collab.notes.all': 'All',
|
||||
'collab.notes.titlePlaceholder': 'Note title',
|
||||
'collab.notes.contentPlaceholder': 'Write something...',
|
||||
'collab.notes.categoryPlaceholder': 'Category',
|
||||
'collab.notes.newCategory': 'New category...',
|
||||
'collab.notes.category': 'Category',
|
||||
'collab.notes.noCategory': 'No category',
|
||||
'collab.notes.color': 'Color',
|
||||
'collab.notes.save': 'Save',
|
||||
'collab.notes.cancel': 'Cancel',
|
||||
'collab.notes.edit': 'Edit',
|
||||
'collab.notes.delete': 'Delete',
|
||||
'collab.notes.pin': 'Pin',
|
||||
'collab.notes.unpin': 'Unpin',
|
||||
'collab.notes.daysAgo': '{n}d ago',
|
||||
'collab.notes.categorySettings': 'Manage Categories',
|
||||
'collab.notes.create': 'Create',
|
||||
'collab.notes.website': 'Website',
|
||||
'collab.notes.websitePlaceholder': 'https://...',
|
||||
'collab.notes.attachFiles': 'Attach files',
|
||||
'collab.notes.noCategoriesYet': 'No categories yet',
|
||||
'collab.notes.emptyDesc': 'Create a note to get started',
|
||||
'collab.polls.title': 'Polls',
|
||||
'collab.polls.new': 'New Poll',
|
||||
'collab.polls.empty': 'No polls yet',
|
||||
'collab.polls.emptyHint': 'Ask the group and vote together',
|
||||
'collab.polls.question': 'Question',
|
||||
'collab.polls.questionPlaceholder': 'What should we do?',
|
||||
'collab.polls.addOption': '+ Add option',
|
||||
'collab.polls.optionPlaceholder': 'Option {n}',
|
||||
'collab.polls.create': 'Create Poll',
|
||||
'collab.polls.close': 'Close',
|
||||
'collab.polls.closed': 'Closed',
|
||||
'collab.polls.votes': '{n} votes',
|
||||
'collab.polls.vote': '{n} vote',
|
||||
'collab.polls.multipleChoice': 'Multiple choice',
|
||||
'collab.polls.multiChoice': 'Multiple choice',
|
||||
'collab.polls.deadline': 'Deadline',
|
||||
'collab.polls.option': 'Option',
|
||||
'collab.polls.options': 'Options',
|
||||
'collab.polls.delete': 'Delete',
|
||||
'collab.polls.closedSection': 'Closed',
|
||||
}
|
||||
|
||||
export default en
|
||||
@@ -290,6 +290,11 @@ body {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
height: 0;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
@@ -305,6 +310,11 @@ body {
|
||||
background: var(--scrollbar-hover);
|
||||
}
|
||||
|
||||
.route-info-pill { background: none !important; border: none !important; box-shadow: none !important; width: auto !important; height: auto !important; margin: 0 !important; }
|
||||
.chat-scroll { overflow-y: auto !important; scrollbar-width: none; -webkit-overflow-scrolling: touch; }
|
||||
.chat-scroll::-webkit-scrollbar { width: 0; background: transparent; }
|
||||
|
||||
|
||||
/* Einheitliche Formular-Inputs */
|
||||
.form-input {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.jsx'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
@@ -4,16 +4,52 @@ import { adminApi, authApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
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 GitHubPanel from '../components/Admin/GitHubPanel'
|
||||
import AddonManager from '../components/Admin/AddonManager'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import { Users, Map, Briefcase, Shield, Trash2, Edit2, Camera, FileText, Eye, EyeOff, Save, CheckCircle, XCircle, Loader2, UserPlus, ArrowUpCircle, ExternalLink, Download, AlertTriangle, RefreshCw, GitBranch, Sun } from 'lucide-react'
|
||||
import CustomSelect from '../components/shared/CustomSelect'
|
||||
|
||||
export default function AdminPage() {
|
||||
interface AdminUser {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: 'admin' | 'user'
|
||||
created_at: string
|
||||
last_login?: string | null
|
||||
online?: boolean
|
||||
oidc_issuer?: string | null
|
||||
}
|
||||
|
||||
interface AdminStats {
|
||||
totalUsers: number
|
||||
totalTrips: number
|
||||
totalPlaces: number
|
||||
totalFiles: number
|
||||
}
|
||||
|
||||
interface OidcConfig {
|
||||
issuer: string
|
||||
client_id: string
|
||||
client_secret: string
|
||||
client_secret_set: boolean
|
||||
display_name: string
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
update_available: boolean
|
||||
latest: string
|
||||
current: string
|
||||
release_url?: string
|
||||
is_docker?: boolean
|
||||
}
|
||||
|
||||
export default function AdminPage(): React.ReactElement {
|
||||
const { demoMode } = useAuthStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const hour12 = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||
@@ -23,37 +59,42 @@ export default function AdminPage() {
|
||||
{ id: 'addons', label: t('admin.tabs.addons') },
|
||||
{ id: 'settings', label: t('admin.tabs.settings') },
|
||||
{ id: 'backup', label: t('admin.tabs.backup') },
|
||||
{ id: 'github', label: t('admin.tabs.github') },
|
||||
]
|
||||
|
||||
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: '' })
|
||||
const [showCreateUser, setShowCreateUser] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', role: 'user' })
|
||||
const [activeTab, setActiveTab] = useState<string>('users')
|
||||
const [users, setUsers] = useState<AdminUser[]>([])
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [editingUser, setEditingUser] = useState<AdminUser | null>(null)
|
||||
const [editForm, setEditForm] = useState<{ username: string; email: string; role: string; password: string }>({ username: '', email: '', role: 'user', password: '' })
|
||||
const [showCreateUser, setShowCreateUser] = useState<boolean>(false)
|
||||
const [createForm, setCreateForm] = useState<{ username: string; email: string; password: string; role: string }>({ username: '', email: '', password: '', role: 'user' })
|
||||
|
||||
// OIDC config
|
||||
const [oidcConfig, setOidcConfig] = useState({ issuer: '', client_id: '', client_secret: '', display_name: '' })
|
||||
const [savingOidc, setSavingOidc] = useState(false)
|
||||
const [oidcConfig, setOidcConfig] = useState<OidcConfig>({ issuer: '', client_id: '', client_secret: '', client_secret_set: false, display_name: '' })
|
||||
const [savingOidc, setSavingOidc] = useState<boolean>(false)
|
||||
|
||||
// Registration toggle
|
||||
const [allowRegistration, setAllowRegistration] = useState(true)
|
||||
const [allowRegistration, setAllowRegistration] = useState<boolean>(true)
|
||||
|
||||
// File types
|
||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string>('jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv')
|
||||
const [savingFileTypes, setSavingFileTypes] = useState<boolean>(false)
|
||||
|
||||
// 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 [mapsKey, setMapsKey] = useState<string>('')
|
||||
const [weatherKey, setWeatherKey] = useState<string>('')
|
||||
const [showKeys, setShowKeys] = useState<Record<string, boolean>>({})
|
||||
const [savingKeys, setSavingKeys] = useState<boolean>(false)
|
||||
const [validating, setValidating] = useState<Record<string, boolean>>({})
|
||||
const [validation, setValidation] = useState<Record<string, boolean | undefined>>({})
|
||||
|
||||
// Version check & update
|
||||
const [updateInfo, setUpdateInfo] = useState(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false)
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [updateResult, setUpdateResult] = useState(null) // 'success' | 'error'
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
|
||||
const [showUpdateModal, setShowUpdateModal] = useState<boolean>(false)
|
||||
const [updating, setUpdating] = useState<boolean>(false)
|
||||
const [updateResult, setUpdateResult] = useState<'success' | 'error' | null>(null)
|
||||
|
||||
const { user: currentUser, updateApiKeys } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
@@ -78,7 +119,7 @@ export default function AdminPage() {
|
||||
])
|
||||
setUsers(usersData.users)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('admin.toast.loadError'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -89,7 +130,8 @@ export default function AdminPage() {
|
||||
try {
|
||||
const config = await authApi.getAppConfig()
|
||||
setAllowRegistration(config.allow_registration)
|
||||
} catch (err) {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -99,7 +141,7 @@ export default function AdminPage() {
|
||||
const data = await authApi.getSettings()
|
||||
setMapsKey(data.settings?.maps_api_key || '')
|
||||
setWeatherKey(data.settings?.openweather_api_key || '')
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
@@ -128,9 +170,9 @@ export default function AdminPage() {
|
||||
setAllowRegistration(value)
|
||||
try {
|
||||
await authApi.updateAppSettings({ allow_registration: value })
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
setAllowRegistration(!value)
|
||||
toast.error(err.response?.data?.error || t('common.error'))
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +188,8 @@ export default function AdminPage() {
|
||||
openweather_api_key: weatherKey,
|
||||
})
|
||||
toast.success(t('admin.keySaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Unknown error')
|
||||
} finally {
|
||||
setSavingKeys(false)
|
||||
}
|
||||
@@ -160,7 +202,7 @@ export default function AdminPage() {
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(result)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setValidating({})
|
||||
@@ -174,7 +216,7 @@ export default function AdminPage() {
|
||||
await updateApiKeys({ maps_api_key: mapsKey, openweather_api_key: weatherKey })
|
||||
const result = await authApi.validateKeys()
|
||||
setValidation(prev => ({ ...prev, [keyType]: result[keyType] }))
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
toast.error(t('common.error'))
|
||||
} finally {
|
||||
setValidating(prev => ({ ...prev, [keyType]: false }))
|
||||
@@ -192,8 +234,8 @@ export default function AdminPage() {
|
||||
setShowCreateUser(false)
|
||||
setCreateForm({ username: '', email: '', password: '', role: 'user' })
|
||||
toast.success(t('admin.toast.userCreated'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('admin.toast.createError'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('admin.toast.createError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,8 +256,8 @@ export default function AdminPage() {
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('admin.toast.updateError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +271,8 @@ export default function AdminPage() {
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('admin.toast.deleteError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,6 +533,39 @@ export default function AdminPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allowed File Types */}
|
||||
<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.fileTypes')}</h2>
|
||||
<p className="text-xs text-slate-400 mt-1">{t('admin.fileTypesHint')}</p>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<input
|
||||
type="text"
|
||||
value={allowedFileTypes}
|
||||
onChange={e => setAllowedFileTypes(e.target.value)}
|
||||
placeholder="jpg,png,pdf,doc,docx,xls,xlsx,txt,csv"
|
||||
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-2">{t('admin.fileTypesFormat')}</p>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSavingFileTypes(true)
|
||||
try {
|
||||
await authApi.updateAppSettings({ allowed_file_types: allowedFileTypes })
|
||||
toast.success(t('admin.fileTypesSaved'))
|
||||
} catch { toast.error(t('common.error')) }
|
||||
finally { setSavingFileTypes(false) }
|
||||
}}
|
||||
disabled={savingFileTypes}
|
||||
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 mt-3"
|
||||
>
|
||||
{savingFileTypes ? <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>
|
||||
|
||||
{/* 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">
|
||||
@@ -502,7 +577,7 @@ export default function AdminPage() {
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm font-medium text-slate-700 mb-1.5">
|
||||
{t('admin.mapsKey')}
|
||||
<span style={{ fontSize: 10, fontWeight: 500, padding: '1px 7px', borderRadius: 99, background: '#dbeafe', color: '#1d4ed8' }}>{t('admin.recommended')}</span>
|
||||
<span className="text-[9px] font-medium px-1.5 py-px rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.recommended')}</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
@@ -551,54 +626,35 @@ export default function AdminPage() {
|
||||
)}
|
||||
</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>
|
||||
{/* Open-Meteo Weather Info */}
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800 overflow-hidden">
|
||||
<div className="px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-emerald-500 flex items-center justify-center flex-shrink-0">
|
||||
<Sun className="w-3.5 h-3.5 text-white" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.title')}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-emerald-200 dark:bg-emerald-800 text-emerald-800 dark:text-emerald-200">{t('admin.weather.badge')}</span>
|
||||
</div>
|
||||
<div className="px-4 pb-3">
|
||||
<p className="text-xs text-emerald-800 dark:text-emerald-300 leading-relaxed">{t('admin.weather.description')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-1.5 leading-relaxed">{t('admin.weather.locationHint')}</p>
|
||||
<div className="mt-3 grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.forecast')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.forecastDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.climate')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.climateDesc')}</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-white dark:bg-emerald-900/40 px-3 py-2 border border-emerald-100 dark:border-emerald-800">
|
||||
<p className="text-xs font-semibold text-emerald-900 dark:text-emerald-200">{t('admin.weather.requests')}</p>
|
||||
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 mt-0.5">{t('admin.weather.requestsDesc')}</p>
|
||||
</div>
|
||||
</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
|
||||
@@ -655,6 +711,7 @@ export default function AdminPage() {
|
||||
type="password"
|
||||
value={oidcConfig.client_secret}
|
||||
onChange={e => setOidcConfig(c => ({ ...c, client_secret: e.target.value }))}
|
||||
placeholder={oidcConfig.client_secret_set ? '••••••••' : ''}
|
||||
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>
|
||||
@@ -662,10 +719,12 @@ export default function AdminPage() {
|
||||
onClick={async () => {
|
||||
setSavingOidc(true)
|
||||
try {
|
||||
await adminApi.updateOidc(oidcConfig)
|
||||
const payload = { issuer: oidcConfig.issuer, client_id: oidcConfig.client_id, display_name: oidcConfig.display_name }
|
||||
if (oidcConfig.client_secret) payload.client_secret = oidcConfig.client_secret
|
||||
await adminApi.updateOidc(payload)
|
||||
toast.success(t('admin.oidcSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('common.error'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
} finally {
|
||||
setSavingOidc(false)
|
||||
}
|
||||
@@ -682,6 +741,8 @@ export default function AdminPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'backup' && <BackupPanel />}
|
||||
|
||||
{activeTab === 'github' && <GitHubPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,9 +6,43 @@ import Navbar from '../components/Layout/Navbar'
|
||||
import apiClient from '../api/client'
|
||||
import { Globe, MapPin, Briefcase, Calendar, Flag, ChevronRight, PanelLeftOpen, PanelLeftClose, X } from 'lucide-react'
|
||||
import L from 'leaflet'
|
||||
import type { AtlasPlace, GeoJsonFeatureCollection, TranslationFn } from '../types'
|
||||
|
||||
// Convert country code to flag emoji
|
||||
function MobileStats({ data, stats, countries, resolveName, t, dark }) {
|
||||
interface AtlasCountry {
|
||||
code: string
|
||||
tripCount: number
|
||||
placeCount: number
|
||||
firstVisit?: string | null
|
||||
lastVisit?: string | null
|
||||
}
|
||||
|
||||
interface AtlasStats {
|
||||
totalTrips: number
|
||||
totalPlaces: number
|
||||
totalCountries: number
|
||||
totalDays: number
|
||||
totalCities?: number
|
||||
}
|
||||
|
||||
interface AtlasData {
|
||||
countries: AtlasCountry[]
|
||||
stats: AtlasStats
|
||||
mostVisited?: AtlasCountry | null
|
||||
continents?: Record<string, number>
|
||||
lastTrip?: { id: number; title: string; countryCode?: string } | null
|
||||
nextTrip?: { id: number; title: string; countryCode?: string } | null
|
||||
streak?: number
|
||||
firstYear?: number
|
||||
tripsThisYear?: number
|
||||
}
|
||||
|
||||
interface CountryDetail {
|
||||
places: AtlasPlace[]
|
||||
trips: { id: number; title: string }[]
|
||||
}
|
||||
|
||||
function MobileStats({ data, stats, countries, resolveName, t, dark }: { data: AtlasData | null; stats: AtlasStats; countries: AtlasCountry[]; resolveName: (code: string) => string; t: TranslationFn; dark: boolean }): React.ReactElement {
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
const tf = dark ? '#475569' : '#94a3b8'
|
||||
const { continents, lastTrip, nextTrip, streak, firstYear, tripsThisYear } = data || {}
|
||||
@@ -57,39 +91,40 @@ function MobileStats({ data, stats, countries, resolveName, t, dark }) {
|
||||
)
|
||||
}
|
||||
|
||||
function countryCodeToFlag(code) {
|
||||
function countryCodeToFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return ''
|
||||
return String.fromCodePoint(...[...code.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65))
|
||||
}
|
||||
|
||||
function useCountryNames(language) {
|
||||
const [resolver, setResolver] = useState(() => (code) => code)
|
||||
function useCountryNames(language: string): (code: string) => string {
|
||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const dn = new Intl.DisplayNames([language === 'de' ? 'de' : 'en'], { type: 'region' })
|
||||
setResolver(() => (code) => { try { return dn.of(code) } catch { return code } })
|
||||
setResolver(() => (code: string) => { try { return dn.of(code) || code } catch { return code } })
|
||||
} catch { /* */ }
|
||||
}, [language])
|
||||
return resolver
|
||||
}
|
||||
|
||||
// Map visited country codes to ISO-3166 alpha3 (GeoJSON uses alpha3)
|
||||
const A2_TO_A3 = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
|
||||
const A2_TO_A3: Record<string, string> = {"AF":"AFG","AL":"ALB","DZ":"DZA","AD":"AND","AO":"AGO","AR":"ARG","AM":"ARM","AU":"AUS","AT":"AUT","AZ":"AZE","BR":"BRA","BE":"BEL","BG":"BGR","CA":"CAN","CL":"CHL","CN":"CHN","CO":"COL","HR":"HRV","CZ":"CZE","DK":"DNK","EG":"EGY","EE":"EST","FI":"FIN","FR":"FRA","DE":"DEU","GR":"GRC","HU":"HUN","IS":"ISL","IN":"IND","ID":"IDN","IR":"IRN","IQ":"IRQ","IE":"IRL","IL":"ISR","IT":"ITA","JP":"JPN","KE":"KEN","KR":"KOR","LV":"LVA","LT":"LTU","LU":"LUX","MY":"MYS","MX":"MEX","MA":"MAR","NL":"NLD","NZ":"NZL","NO":"NOR","PK":"PAK","PE":"PER","PH":"PHL","PL":"POL","PT":"PRT","RO":"ROU","RU":"RUS","SA":"SAU","RS":"SRB","SK":"SVK","SI":"SVN","ZA":"ZAF","ES":"ESP","SE":"SWE","CH":"CHE","TH":"THA","TR":"TUR","UA":"UKR","AE":"ARE","GB":"GBR","US":"USA","VN":"VNM","NG":"NGA"}
|
||||
|
||||
export default function AtlasPage() {
|
||||
export default function AtlasPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation()
|
||||
const { settings } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
const resolveName = useCountryNames(language)
|
||||
const dark = settings.dark_mode
|
||||
const mapRef = useRef(null)
|
||||
const mapInstance = useRef(null)
|
||||
const geoLayerRef = useRef(null)
|
||||
const glareRef = useRef(null)
|
||||
const borderGlareRef = useRef(null)
|
||||
const panelRef = useRef(null)
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const mapRef = useRef<HTMLDivElement>(null)
|
||||
const mapInstance = useRef<L.Map | null>(null)
|
||||
const geoLayerRef = useRef<L.GeoJSON | null>(null)
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderGlareRef = useRef<HTMLDivElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handlePanelMouseMove = (e) => {
|
||||
const handlePanelMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (!panelRef.current || !glareRef.current || !borderGlareRef.current) return
|
||||
const rect = panelRef.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
@@ -107,13 +142,13 @@ export default function AtlasPage() {
|
||||
if (borderGlareRef.current) borderGlareRef.current.style.opacity = '0'
|
||||
}
|
||||
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false)
|
||||
const [selectedCountry, setSelectedCountry] = useState(null)
|
||||
const [countryDetail, setCountryDetail] = useState(null)
|
||||
const [geoData, setGeoData] = useState(null)
|
||||
const [data, setData] = useState<AtlasData | null>(null)
|
||||
const [loading, setLoading] = useState<boolean>(true)
|
||||
const [sidebarOpen, setSidebarOpen] = useState<boolean>(true)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<boolean>(false)
|
||||
const [selectedCountry, setSelectedCountry] = useState<string | null>(null)
|
||||
const [countryDetail, setCountryDetail] = useState<CountryDetail | null>(null)
|
||||
const [geoData, setGeoData] = useState<GeoJsonFeatureCollection | null>(null)
|
||||
|
||||
// Load atlas data
|
||||
useEffect(() => {
|
||||
@@ -255,7 +290,7 @@ export default function AtlasPage() {
|
||||
}).addTo(mapInstance.current)
|
||||
}, [geoData, data, dark])
|
||||
|
||||
const loadCountryDetail = async (code) => {
|
||||
const loadCountryDetail = async (code: string): Promise<void> => {
|
||||
setSelectedCountry(code)
|
||||
try {
|
||||
const r = await apiClient.get(`/addons/atlas/country/${code}`)
|
||||
@@ -344,7 +379,20 @@ export default function AtlasPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }) {
|
||||
interface SidebarContentProps {
|
||||
data: AtlasData | null
|
||||
stats: AtlasStats
|
||||
countries: AtlasCountry[]
|
||||
selectedCountry: string | null
|
||||
countryDetail: CountryDetail | null
|
||||
resolveName: (code: string) => string
|
||||
onCountryClick: (code: string) => void
|
||||
onTripClick: (id: number) => void
|
||||
t: TranslationFn
|
||||
dark: boolean
|
||||
}
|
||||
|
||||
function SidebarContent({ data, stats, countries, selectedCountry, countryDetail, resolveName, onCountryClick, onTripClick, t, dark }: SidebarContentProps): React.ReactElement {
|
||||
const bg = (o) => dark ? `rgba(255,255,255,${o})` : `rgba(0,0,0,${o})`
|
||||
const tp = dark ? '#f1f5f9' : '#0f172a'
|
||||
const tm = dark ? '#94a3b8' : '#64748b'
|
||||
@@ -4,6 +4,7 @@ import { tripsApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DemoBanner from '../components/Layout/DemoBanner'
|
||||
import CurrencyWidget from '../components/Dashboard/CurrencyWidget'
|
||||
@@ -15,16 +16,33 @@ import {
|
||||
Archive, ArchiveRestore, Clock, MapPin, Settings, X, ArrowRightLeft,
|
||||
} from 'lucide-react'
|
||||
|
||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
interface DashboardTrip {
|
||||
id: number
|
||||
title: string
|
||||
description?: string | null
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
cover_image?: string | null
|
||||
is_archived?: boolean
|
||||
is_owner?: boolean
|
||||
owner_username?: string
|
||||
day_count?: number
|
||||
place_count?: number
|
||||
[key: string]: string | number | boolean | null | undefined
|
||||
}
|
||||
|
||||
function daysUntil(dateStr) {
|
||||
const font: React.CSSProperties = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
||||
|
||||
const MS_PER_DAY = 86400000
|
||||
|
||||
function daysUntil(dateStr: string | null | undefined): number | null {
|
||||
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)
|
||||
return Math.round((d - today) / MS_PER_DAY)
|
||||
}
|
||||
|
||||
function getTripStatus(trip) {
|
||||
function getTripStatus(trip: DashboardTrip): string | null {
|
||||
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)
|
||||
@@ -35,17 +53,17 @@ function getTripStatus(trip) {
|
||||
return 'past'
|
||||
}
|
||||
|
||||
function formatDate(dateStr, locale = 'de-DE') {
|
||||
function formatDate(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
|
||||
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') {
|
||||
function formatDateShort(dateStr: string | null | undefined, locale: string = 'de-DE'): string | null {
|
||||
if (!dateStr) return null
|
||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function sortTrips(trips) {
|
||||
function sortTrips(trips: DashboardTrip[]): DashboardTrip[] {
|
||||
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
|
||||
@@ -72,15 +90,23 @@ const GRADIENTS = [
|
||||
'linear-gradient(135deg, #ffecd2 0%, #fcb69f 100%)',
|
||||
'linear-gradient(135deg, #96fbc4 0%, #f9f586 100%)',
|
||||
]
|
||||
function tripGradient(id) { return GRADIENTS[id % GRADIENTS.length] }
|
||||
function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.length] }
|
||||
|
||||
// ── Liquid Glass hover effect ────────────────────────────────────────────────
|
||||
function LiquidGlass({ children, dark, style, className = '', onClick }) {
|
||||
const ref = useRef(null)
|
||||
const glareRef = useRef(null)
|
||||
const borderRef = useRef(null)
|
||||
interface LiquidGlassProps {
|
||||
children: React.ReactNode
|
||||
dark: boolean
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const onMove = (e) => {
|
||||
function LiquidGlass({ children, dark, style, className = '', onClick }: LiquidGlassProps): React.ReactElement {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const glareRef = useRef<HTMLDivElement>(null)
|
||||
const borderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const onMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
if (!ref.current || !glareRef.current || !borderRef.current) return
|
||||
const rect = ref.current.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
@@ -109,7 +135,18 @@ function LiquidGlass({ children, dark, style, className = '', onClick }) {
|
||||
}
|
||||
|
||||
// ── Spotlight Card (next upcoming trip) ─────────────────────────────────────
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }) {
|
||||
interface TripCardProps {
|
||||
trip: DashboardTrip
|
||||
onEdit: (trip: DashboardTrip) => void
|
||||
onDelete: (trip: DashboardTrip) => void
|
||||
onArchive: (id: number) => void
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||
locale: string
|
||||
dark?: boolean
|
||||
}
|
||||
|
||||
function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale, dark }: TripCardProps): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
|
||||
const coverBg = trip.cover_image
|
||||
@@ -190,7 +227,7 @@ function SpotlightCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale,
|
||||
}
|
||||
|
||||
// ── Regular Trip Card ────────────────────────────────────────────────────────
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
||||
function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }: Omit<TripCardProps, 'dark'>): React.ReactElement {
|
||||
const status = getTripStatus(trip)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
|
||||
@@ -279,7 +316,17 @@ function TripCard({ trip, onEdit, onDelete, onArchive, onClick, t, locale }) {
|
||||
}
|
||||
|
||||
// ── Archived Trip Row ────────────────────────────────────────────────────────
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }) {
|
||||
interface ArchivedRowProps {
|
||||
trip: DashboardTrip
|
||||
onEdit: (trip: DashboardTrip) => void
|
||||
onUnarchive: (id: number) => void
|
||||
onDelete: (trip: DashboardTrip) => void
|
||||
onClick: (trip: DashboardTrip) => void
|
||||
t: (key: string, params?: Record<string, string | number | null>) => string
|
||||
locale: string
|
||||
}
|
||||
|
||||
function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }: ArchivedRowProps): React.ReactElement {
|
||||
return (
|
||||
<div onClick={() => onClick(trip)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 12, padding: '10px 16px',
|
||||
@@ -322,7 +369,7 @@ function ArchivedRow({ trip, onEdit, onUnarchive, onDelete, onClick, t, locale }
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function Stat({ value, label }) {
|
||||
function Stat({ value, label }: { value: number | string; label: string }): React.ReactElement {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#374151' }}>{value}</span>
|
||||
@@ -331,7 +378,7 @@ function Stat({ value, label }) {
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ onClick, icon, label, danger }) {
|
||||
function CardAction({ onClick, icon, label, danger }: { onClick: () => void; icon: React.ReactNode; label: string; danger?: boolean }): React.ReactElement {
|
||||
return (
|
||||
<button onClick={onClick} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 8,
|
||||
@@ -345,7 +392,7 @@ function CardAction({ onClick, icon, label, danger }) {
|
||||
)
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, title, danger, loading, children }) {
|
||||
function IconBtn({ onClick, title, danger, loading, children }: { onClick: () => void; title: string; danger?: boolean; loading?: boolean; children: React.ReactNode }): React.ReactElement {
|
||||
return (
|
||||
<button onClick={onClick} title={title} disabled={loading} style={{
|
||||
width: 32, height: 32, borderRadius: 99, border: '1px solid rgba(255,255,255,0.25)',
|
||||
@@ -361,7 +408,7 @@ function IconBtn({ onClick, title, danger, loading, children }) {
|
||||
}
|
||||
|
||||
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
||||
function SkeletonCard() {
|
||||
function SkeletonCard(): React.ReactElement {
|
||||
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' }} />
|
||||
@@ -374,21 +421,22 @@ function SkeletonCard() {
|
||||
}
|
||||
|
||||
// ── 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 [showWidgetSettings, setShowWidgetSettings] = useState(false)
|
||||
export default function DashboardPage(): React.ReactElement {
|
||||
const [trips, setTrips] = useState<DashboardTrip[]>([])
|
||||
const [archivedTrips, setArchivedTrips] = useState<DashboardTrip[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
const [showForm, setShowForm] = useState<boolean>(false)
|
||||
const [editingTrip, setEditingTrip] = useState<DashboardTrip | null>(null)
|
||||
const [showArchived, setShowArchived] = useState<boolean>(false)
|
||||
const [showWidgetSettings, setShowWidgetSettings] = useState<boolean | 'mobile'>(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
const { demoMode } = useAuthStore()
|
||||
const { settings, updateSetting } = useSettingsStore()
|
||||
const dark = settings.dark_mode
|
||||
const dm = settings.dark_mode
|
||||
const dark = dm === true || dm === 'dark' || (dm === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
const showCurrency = settings.dashboard_currency !== 'off'
|
||||
const showTimezone = settings.dashboard_timezone !== 'off'
|
||||
const showSidebar = showCurrency || showTimezone
|
||||
@@ -425,8 +473,9 @@ export default function DashboardPage() {
|
||||
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'))
|
||||
return data
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, t('dashboard.toast.createError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,8 +484,8 @@ export default function DashboardPage() {
|
||||
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'))
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, t('dashboard.toast.updateError')))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -474,8 +523,8 @@ export default function DashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCoverUpdate = (tripId, coverImage) => {
|
||||
const update = t => t.id === tripId ? { ...t, cover_image: coverImage } : t
|
||||
const handleCoverUpdate = (tripId: number, coverImage: string | null): void => {
|
||||
const update = (t: DashboardTrip) => t.id === tripId ? { ...t, cover_image: coverImage } : t
|
||||
setTrips(prev => prev.map(update))
|
||||
setArchivedTrips(prev => prev.map(update))
|
||||
}
|
||||
@@ -5,22 +5,25 @@ import { tripsApi, placesApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import type { Trip, Place, TripFile } from '../types'
|
||||
|
||||
export default function FilesPage() {
|
||||
const { id: tripId } = useParams()
|
||||
export default function FilesPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const tripStore = useTripStore()
|
||||
|
||||
const [trip, setTrip] = useState(null)
|
||||
const [places, setPlaces] = useState([])
|
||||
const [files, setFiles] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [trip, setTrip] = useState<Trip | null>(null)
|
||||
const [places, setPlaces] = useState<Place[]>([])
|
||||
const [files, setFiles] = useState<TripFile[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [tripId])
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = async (): Promise<void> => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tripData, placesData] = await Promise.all([
|
||||
@@ -30,7 +33,7 @@ export default function FilesPage() {
|
||||
setTrip(tripData.trip)
|
||||
setPlaces(placesData.places)
|
||||
await tripStore.loadFiles(tripId)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
navigate('/dashboard')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -41,11 +44,11 @@ export default function FilesPage() {
|
||||
setFiles(tripStore.files)
|
||||
}, [tripStore.files])
|
||||
|
||||
const handleUpload = async (formData) => {
|
||||
const handleUpload = async (formData: FormData): Promise<void> => {
|
||||
await tripStore.addFile(tripId, formData)
|
||||
}
|
||||
|
||||
const handleDelete = async (fileId) => {
|
||||
const handleDelete = async (fileId: number): Promise<void> => {
|
||||
await tripStore.deleteFile(tripId, fileId)
|
||||
}
|
||||
|
||||
@@ -59,7 +62,7 @@ export default function FilesPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-5xl mx-auto px-4 py-6">
|
||||
@@ -69,14 +72,14 @@ export default function FilesPage() {
|
||||
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
|
||||
{t('common.backToPlanning')}
|
||||
</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>
|
||||
<p className="text-gray-500 text-sm">{files.length} Dateien für {trip?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,71 +6,85 @@ import { useTranslation } from '../i18n'
|
||||
import { authApi } from '../api/client'
|
||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield } from 'lucide-react'
|
||||
|
||||
export default function LoginPage() {
|
||||
interface AppConfig {
|
||||
has_users: boolean
|
||||
allow_registration: boolean
|
||||
demo_mode: boolean
|
||||
oidc_configured: boolean
|
||||
oidc_display_name?: string
|
||||
}
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
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 [mode, setMode] = useState<'login' | 'register'>('login')
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [appConfig, setAppConfig] = useState<AppConfig | null>(null)
|
||||
|
||||
const { login, register, demoLogin } = useAuthStore()
|
||||
const { setLanguageLocal } = useSettingsStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
authApi.getAppConfig?.().catch(() => null).then(config => {
|
||||
authApi.getAppConfig?.().catch(() => null).then((config: AppConfig | null) => {
|
||||
if (config) {
|
||||
setAppConfig(config)
|
||||
if (!config.has_users) setMode('register')
|
||||
}
|
||||
})
|
||||
|
||||
// Handle OIDC callback token (via URL fragment to avoid logging)
|
||||
const hash = window.location.hash.substring(1)
|
||||
const hashParams = new URLSearchParams(hash)
|
||||
const token = hashParams.get('token')
|
||||
// Handle OIDC callback via short-lived auth code (secure exchange)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const oidcCode = params.get('oidc_code')
|
||||
const oidcError = params.get('oidc_error')
|
||||
if (token) {
|
||||
localStorage.setItem('auth_token', token)
|
||||
if (oidcCode) {
|
||||
window.history.replaceState({}, '', '/login')
|
||||
login.__fromOidc = true
|
||||
navigate('/dashboard')
|
||||
window.location.reload()
|
||||
fetch('/api/auth/oidc/exchange?code=' + encodeURIComponent(oidcCode))
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.token) {
|
||||
localStorage.setItem('auth_token', data.token)
|
||||
navigate('/dashboard')
|
||||
window.location.reload()
|
||||
} else {
|
||||
setError(data.error || 'OIDC login failed')
|
||||
}
|
||||
})
|
||||
.catch(() => setError('OIDC login failed'))
|
||||
}
|
||||
if (oidcError) {
|
||||
const errorMessages = {
|
||||
registration_disabled: language === 'de' ? 'Registrierung ist deaktiviert. Kontaktiere den Administrator.' : 'Registration is disabled. Contact your administrator.',
|
||||
no_email: language === 'de' ? 'Keine E-Mail vom Provider erhalten.' : 'No email received from provider.',
|
||||
token_failed: language === 'de' ? 'Authentifizierung fehlgeschlagen.' : 'Authentication failed.',
|
||||
invalid_state: language === 'de' ? 'Ungueltige Sitzung. Bitte erneut versuchen.' : 'Invalid session. Please try again.',
|
||||
const errorMessages: Record<string, string> = {
|
||||
registration_disabled: t('login.oidc.registrationDisabled'),
|
||||
no_email: t('login.oidc.noEmail'),
|
||||
token_failed: t('login.oidc.tokenFailed'),
|
||||
invalid_state: t('login.oidc.invalidState'),
|
||||
}
|
||||
setError(errorMessages[oidcError] || oidcError)
|
||||
window.history.replaceState({}, '', '/login')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDemoLogin = async () => {
|
||||
const handleDemoLogin = async (): Promise<void> => {
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await demoLogin()
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Demo-Login fehlgeschlagen')
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('login.demoFailed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const [showTakeoff, setShowTakeoff] = useState(false)
|
||||
const [showTakeoff, setShowTakeoff] = useState<boolean>(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setIsLoading(true)
|
||||
@@ -84,15 +98,15 @@ export default function LoginPage() {
|
||||
}
|
||||
setShowTakeoff(true)
|
||||
setTimeout(() => navigate('/dashboard'), 2600)
|
||||
} catch (err) {
|
||||
setError(err.message || t('login.error'))
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('login.error'))
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showRegisterOption = appConfig?.allow_registration || !appConfig?.has_users
|
||||
|
||||
const inputBase = {
|
||||
const inputBase: React.CSSProperties = {
|
||||
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',
|
||||
@@ -264,11 +278,11 @@ export default function LoginPage() {
|
||||
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)'}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.1)'}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'rgba(0,0,0,0.06)'}
|
||||
>
|
||||
<Globe size={14} />
|
||||
{language === 'en' ? 'DE' : 'EN'}
|
||||
{language === 'en' ? 'EN' : 'DE'}
|
||||
</button>
|
||||
|
||||
{/* Left — branding */}
|
||||
@@ -392,8 +406,8 @@ export default function LoginPage() {
|
||||
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
||||
].map(({ Icon, label, desc }) => (
|
||||
<div key={label} style={{ background: 'rgba(255,255,255,0.04)', borderRadius: 14, padding: '14px 12px', border: '1px solid rgba(255,255,255,0.06)', textAlign: 'left', transition: 'all 0.2s' }}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.08)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => { e.currentTarget.style.background = 'rgba(255,255,255,0.04)'; e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)' }}>
|
||||
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
|
||||
<div style={{ fontSize: 12.5, color: 'white', fontWeight: 600, marginBottom: 2 }}>{label}</div>
|
||||
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.35)', lineHeight: 1.4 }}>{desc}</div>
|
||||
@@ -441,10 +455,10 @@ export default function LoginPage() {
|
||||
<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
|
||||
type="text" value={username} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)} required
|
||||
placeholder="admin" style={inputBase}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,10 +470,10 @@ export default function LoginPage() {
|
||||
<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
|
||||
type="email" value={email} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)} required
|
||||
placeholder={t('login.emailPlaceholder')} style={inputBase}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -470,10 +484,10 @@ export default function LoginPage() {
|
||||
<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
|
||||
type={showPassword ? 'text' : 'password'} value={password} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)} required
|
||||
placeholder="••••••••" style={{ ...inputBase, paddingRight: 44 }}
|
||||
onFocus={e => e.target.style.borderColor = '#111827'}
|
||||
onBlur={e => e.target.style.borderColor = '#e5e7eb'}
|
||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword(v => !v)} style={{
|
||||
position: 'absolute', right: 12, top: '50%', transform: 'translateY(-50%)',
|
||||
@@ -490,8 +504,8 @@ export default function LoginPage() {
|
||||
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'}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.background = '#1f2937' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => 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')}</>
|
||||
@@ -517,7 +531,7 @@ export default function LoginPage() {
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginTop: 16 }}>
|
||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{language === 'de' ? 'oder' : 'or'}</span>
|
||||
<span style={{ fontSize: 12, color: '#9ca3af' }}>{t('common.or')}</span>
|
||||
<div style={{ flex: 1, height: 1, background: '#e5e7eb' }} />
|
||||
</div>
|
||||
<a href="/api/auth/oidc/login"
|
||||
@@ -530,11 +544,11 @@ export default function LoginPage() {
|
||||
textDecoration: 'none', transition: 'all 0.15s',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = '#f9fafb'; e.currentTarget.style.borderColor = '#9ca3af' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLAnchorElement>) => { e.currentTarget.style.background = 'white'; e.currentTarget.style.borderColor = '#d1d5db' }}
|
||||
>
|
||||
<Shield size={16} />
|
||||
{language === 'de' ? `Anmelden mit ${appConfig.oidc_display_name}` : `Sign in with ${appConfig.oidc_display_name}`}
|
||||
{t('login.oidcSignIn', { name: appConfig.oidc_display_name })}
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
@@ -551,11 +565,11 @@ export default function LoginPage() {
|
||||
opacity: isLoading ? 0.7 : 1, transition: 'all 0.2s',
|
||||
boxShadow: '0 2px 12px rgba(245, 158, 11, 0.3)',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { if (!isLoading) e.currentTarget.style.transform = 'translateY(-1px)'; e.currentTarget.style.boxShadow = '0 4px 16px rgba(245, 158, 11, 0.4)' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'translateY(0)'; e.currentTarget.style.boxShadow = '0 2px 12px rgba(245, 158, 11, 0.3)' }}
|
||||
>
|
||||
<Plane size={18} />
|
||||
{language === 'de' ? 'Demo ausprobieren — ohne Registrierung' : 'Try the demo — no registration needed'}
|
||||
{t('login.demoHint')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -5,23 +5,26 @@ import { tripsApi, daysApi, placesApi } from '../api/client'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import PhotoGallery from '../components/Photos/PhotoGallery'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import type { Trip, Day, Place, Photo } from '../types'
|
||||
|
||||
export default function PhotosPage() {
|
||||
const { id: tripId } = useParams()
|
||||
export default function PhotosPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
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)
|
||||
const [trip, setTrip] = useState<Trip | null>(null)
|
||||
const [days, setDays] = useState<Day[]>([])
|
||||
const [places, setPlaces] = useState<Place[]>([])
|
||||
const [photos, setPhotos] = useState<Photo[]>([])
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [tripId])
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = async (): Promise<void> => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const [tripData, daysData, placesData] = await Promise.all([
|
||||
@@ -35,7 +38,7 @@ export default function PhotosPage() {
|
||||
|
||||
// Load photos
|
||||
await tripStore.loadPhotos(tripId)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
navigate('/dashboard')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -47,15 +50,15 @@ export default function PhotosPage() {
|
||||
setPhotos(tripStore.photos)
|
||||
}, [tripStore.photos])
|
||||
|
||||
const handleUpload = async (formData) => {
|
||||
const handleUpload = async (formData: FormData): Promise<void> => {
|
||||
await tripStore.addPhoto(tripId, formData)
|
||||
}
|
||||
|
||||
const handleDelete = async (photoId) => {
|
||||
const handleDelete = async (photoId: number): Promise<void> => {
|
||||
await tripStore.deletePhoto(tripId, photoId)
|
||||
}
|
||||
|
||||
const handleUpdate = async (photoId, data) => {
|
||||
const handleUpdate = async (photoId: number, data: Record<string, string | number | null>): Promise<void> => {
|
||||
await tripStore.updatePhoto(tripId, photoId, data)
|
||||
}
|
||||
|
||||
@@ -69,7 +72,7 @@ export default function PhotosPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<Navbar tripTitle={trip?.title} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
<Navbar tripTitle={trip?.name} tripId={tripId} showBack onBack={() => navigate(`/trips/${tripId}`)} />
|
||||
|
||||
<div style={{ paddingTop: 'var(--nav-h)' }}>
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
@@ -80,14 +83,14 @@ export default function PhotosPage() {
|
||||
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
|
||||
{t('common.backToPlanning')}
|
||||
</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>
|
||||
<p className="text-gray-500 text-sm">{photos.length} Fotos für {trip?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,31 +1,33 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from '../i18n'
|
||||
import { Map, Eye, EyeOff, Mail, Lock, User } from 'lucide-react'
|
||||
|
||||
export default function RegisterPage() {
|
||||
const [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('')
|
||||
export default function RegisterPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState<string>('')
|
||||
const [email, setEmail] = useState<string>('')
|
||||
const [password, setPassword] = useState<string>('')
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
|
||||
const { register } = useAuthStore()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwörter stimmen nicht überein')
|
||||
setError(t('register.passwordMismatch'))
|
||||
return
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Passwort muss mindestens 6 Zeichen lang sein')
|
||||
setError(t('register.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -33,8 +35,8 @@ export default function RegisterPage() {
|
||||
try {
|
||||
await register(username, email, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err) {
|
||||
setError(err.message || 'Registrierung fehlgeschlagen')
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : t('register.failed'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -48,19 +50,19 @@ export default function RegisterPage() {
|
||||
<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>
|
||||
<h1 className="text-4xl font-bold mb-4">{t('register.getStarted')}</h1>
|
||||
<p className="text-slate-300 text-lg leading-relaxed">
|
||||
Erstellen Sie ein Konto und beginnen Sie, Ihre Traumreisen zu planen.
|
||||
{t('register.subtitle')}
|
||||
</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',
|
||||
`✓ ${t('register.feature1')}`,
|
||||
`✓ ${t('register.feature2')}`,
|
||||
`✓ ${t('register.feature3')}`,
|
||||
`✓ ${t('register.feature4')}`,
|
||||
`✓ ${t('register.feature5')}`,
|
||||
`✓ ${t('register.feature6')}`,
|
||||
].map(item => (
|
||||
<p key={item} className="text-slate-200 text-sm">{item}</p>
|
||||
))}
|
||||
@@ -77,8 +79,8 @@ export default function RegisterPage() {
|
||||
</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>
|
||||
<h2 className="text-2xl font-bold text-slate-900 mb-1">{t('register.createAccount')}</h2>
|
||||
<p className="text-slate-500 mb-8">{t('register.startPlanning')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
@@ -88,15 +90,15 @@ export default function RegisterPage() {
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Benutzername</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.username')}</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)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername(e.target.value)}
|
||||
required
|
||||
placeholder="maxmustermann"
|
||||
placeholder="johndoe"
|
||||
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"
|
||||
/>
|
||||
@@ -104,30 +106,30 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">E-Mail</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.email')}</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)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="ihre@email.de"
|
||||
placeholder="your@email.com"
|
||||
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>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')}</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)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Mind. 6 Zeichen"
|
||||
placeholder={t('register.minChars')}
|
||||
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
|
||||
@@ -141,15 +143,15 @@ export default function RegisterPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Passwort bestätigen</label>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('register.confirmPassword')}</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)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder="Passwort wiederholen"
|
||||
placeholder={t('register.repeatPassword')}
|
||||
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>
|
||||
@@ -163,17 +165,17 @@ export default function RegisterPage() {
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
Registrieren...
|
||||
{t('register.registering')}
|
||||
</>
|
||||
) : 'Registrieren'}
|
||||
) : t('register.register')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-slate-500">
|
||||
Bereits ein Konto?{' '}
|
||||
{t('register.hasAccount')}{' '}
|
||||
<Link to="/login" className="text-slate-900 hover:text-slate-700 font-medium">
|
||||
Anmelden
|
||||
{t('register.signIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
@@ -6,10 +6,18 @@ 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, Lock } from 'lucide-react'
|
||||
import { Save, Map, Palette, User, Moon, Sun, Monitor, Shield, Camera, Trash2, Lock } from 'lucide-react'
|
||||
import { authApi, adminApi } from '../api/client'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { UserWithOidc } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
const MAP_PRESETS = [
|
||||
interface MapPreset {
|
||||
name: string
|
||||
url: string
|
||||
}
|
||||
|
||||
const MAP_PRESETS: MapPreset[] = [
|
||||
{ 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' },
|
||||
@@ -17,7 +25,13 @@ const MAP_PRESETS = [
|
||||
{ name: 'Stadia Smooth', url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png' },
|
||||
]
|
||||
|
||||
function Section({ title, icon: Icon, children }) {
|
||||
interface SectionProps {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function Section({ title, icon: Icon, children }: SectionProps): React.ReactElement {
|
||||
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)' }}>
|
||||
@@ -31,31 +45,32 @@ function Section({ title, icon: Icon, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
export default function SettingsPage(): React.ReactElement {
|
||||
const { user, updateProfile, uploadAvatar, deleteAvatar, logout } = useAuthStore()
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const avatarInputRef = React.useRef(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<boolean | 'blocked'>(false)
|
||||
const avatarInputRef = React.useRef<HTMLInputElement>(null)
|
||||
const { settings, updateSetting, updateSettings } = useSettingsStore()
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [saving, setSaving] = useState({})
|
||||
const [saving, setSaving] = useState<Record<string, boolean>>({})
|
||||
|
||||
// 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)
|
||||
const [mapTileUrl, setMapTileUrl] = useState<string>(settings.map_tile_url || '')
|
||||
const [defaultLat, setDefaultLat] = useState<number | string>(settings.default_lat || 48.8566)
|
||||
const [defaultLng, setDefaultLng] = useState<number | string>(settings.default_lng || 2.3522)
|
||||
const [defaultZoom, setDefaultZoom] = useState<number | string>(settings.default_zoom || 10)
|
||||
|
||||
// Display
|
||||
const [tempUnit, setTempUnit] = useState(settings.temperature_unit || 'celsius')
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || 'celsius')
|
||||
|
||||
// Account
|
||||
const [username, setUsername] = useState(user?.username || '')
|
||||
const [email, setEmail] = useState(user?.email || '')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [username, setUsername] = useState<string>(user?.username || '')
|
||||
const [email, setEmail] = useState<string>(user?.email || '')
|
||||
const [currentPassword, setCurrentPassword] = useState<string>('')
|
||||
const [newPassword, setNewPassword] = useState<string>('')
|
||||
const [confirmPassword, setConfirmPassword] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
setMapTileUrl(settings.map_tile_url || '')
|
||||
@@ -70,36 +85,36 @@ export default function SettingsPage() {
|
||||
setEmail(user?.email || '')
|
||||
}, [user])
|
||||
|
||||
const saveMapSettings = async () => {
|
||||
const saveMapSettings = async (): Promise<void> => {
|
||||
setSaving(s => ({ ...s, map: true }))
|
||||
try {
|
||||
await updateSettings({
|
||||
map_tile_url: mapTileUrl,
|
||||
default_lat: parseFloat(defaultLat),
|
||||
default_lng: parseFloat(defaultLng),
|
||||
default_zoom: parseInt(defaultZoom),
|
||||
default_lat: parseFloat(String(defaultLat)),
|
||||
default_lng: parseFloat(String(defaultLng)),
|
||||
default_zoom: parseInt(String(defaultZoom)),
|
||||
})
|
||||
toast.success(t('settings.toast.mapSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, map: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const saveDisplay = async () => {
|
||||
const saveDisplay = async (): Promise<void> => {
|
||||
setSaving(s => ({ ...s, display: true }))
|
||||
try {
|
||||
await updateSetting('temperature_unit', tempUnit)
|
||||
toast.success(t('settings.toast.displaySaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, display: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAvatarUpload = async (e) => {
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
try {
|
||||
@@ -111,7 +126,7 @@ export default function SettingsPage() {
|
||||
if (avatarInputRef.current) avatarInputRef.current.value = ''
|
||||
}
|
||||
|
||||
const handleAvatarRemove = async () => {
|
||||
const handleAvatarRemove = async (): Promise<void> => {
|
||||
try {
|
||||
await deleteAvatar()
|
||||
toast.success(t('settings.avatarRemoved'))
|
||||
@@ -120,13 +135,13 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
const saveProfile = async (): Promise<void> => {
|
||||
setSaving(s => ({ ...s, profile: true }))
|
||||
try {
|
||||
await updateProfile({ username, email })
|
||||
toast.success(t('settings.toast.profileSaved'))
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : 'Error')
|
||||
} finally {
|
||||
setSaving(s => ({ ...s, profile: false }))
|
||||
}
|
||||
@@ -149,7 +164,7 @@ export default function SettingsPage() {
|
||||
<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) }}
|
||||
onChange={(value: string) => { if (value) setMapTileUrl(value) }}
|
||||
placeholder={t('settings.mapTemplatePlaceholder.select')}
|
||||
options={MAP_PRESETS.map(p => ({
|
||||
value: p.url,
|
||||
@@ -161,7 +176,7 @@ export default function SettingsPage() {
|
||||
<input
|
||||
type="text"
|
||||
value={mapTileUrl}
|
||||
onChange={e => setMapTileUrl(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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"
|
||||
/>
|
||||
@@ -175,7 +190,7 @@ export default function SettingsPage() {
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLat}
|
||||
onChange={e => setDefaultLat(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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>
|
||||
@@ -185,7 +200,7 @@ export default function SettingsPage() {
|
||||
type="number"
|
||||
step="any"
|
||||
value={defaultLng}
|
||||
onChange={e => setDefaultLng(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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>
|
||||
@@ -208,30 +223,35 @@ export default function SettingsPage() {
|
||||
<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>
|
||||
))}
|
||||
{ value: 'light', label: t('settings.light'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.dark'), icon: Moon },
|
||||
{ value: 'auto', label: t('settings.auto'), icon: Monitor },
|
||||
].map(opt => {
|
||||
const current = settings.dark_mode
|
||||
const isActive = current === opt.value || (opt.value === 'light' && current === false) || (opt.value === 'dark' && current === true)
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await updateSetting('dark_mode', opt.value)
|
||||
} catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: isActive ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: isActive ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
<opt.icon size={16} />
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -247,7 +267,7 @@ export default function SettingsPage() {
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('language', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -278,7 +298,7 @@ export default function SettingsPage() {
|
||||
onClick={async () => {
|
||||
setTempUnit(opt.value)
|
||||
try { await updateSetting('temperature_unit', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -308,7 +328,7 @@ export default function SettingsPage() {
|
||||
key={opt.value}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('time_format', opt.value) }
|
||||
catch (e) { toast.error(e.message) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
@@ -325,6 +345,35 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* Route Calculation */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>{t('settings.routeCalculation')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('route_calculation', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Error') }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.route_calculation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.route_calculation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Account */}
|
||||
@@ -334,7 +383,7 @@ export default function SettingsPage() {
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={e => setUsername(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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>
|
||||
@@ -343,7 +392,7 @@ export default function SettingsPage() {
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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>
|
||||
@@ -352,37 +401,45 @@ export default function SettingsPage() {
|
||||
<div style={{ paddingTop: 8, marginTop: 8, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">{t('settings.changePassword')}</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setCurrentPassword(e.target.value)}
|
||||
placeholder={t('settings.currentPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={e => setNewPassword(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewPassword(e.target.value)}
|
||||
placeholder={t('settings.newPassword')}
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={e => setConfirmPassword(e.target.value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setConfirmPassword(e.target.value)}
|
||||
placeholder={t('settings.confirmPassword')}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!currentPassword) return toast.error(t('settings.currentPasswordRequired'))
|
||||
if (!newPassword) return toast.error(t('settings.passwordRequired'))
|
||||
if (newPassword.length < 8) return toast.error(t('settings.passwordTooShort'))
|
||||
if (newPassword !== confirmPassword) return toast.error(t('settings.passwordMismatch'))
|
||||
try {
|
||||
await authApi.changePassword({ new_password: newPassword })
|
||||
await authApi.changePassword({ current_password: currentPassword, new_password: newPassword })
|
||||
toast.success(t('settings.passwordChanged'))
|
||||
setNewPassword(''); setConfirmPassword('')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('common.error'))
|
||||
setCurrentPassword(''); setNewPassword(''); setConfirmPassword('')
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-lg text-sm 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)'}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => e.currentTarget.style.background = 'var(--bg-card)'}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t('settings.updatePassword')}
|
||||
@@ -421,8 +478,8 @@ export default function SettingsPage() {
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', padding: 0, transition: 'transform 0.15s, opacity 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'scale(1.15)'; e.currentTarget.style.opacity = '0.85' }}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLButtonElement>) => { e.currentTarget.style.transform = 'scale(1)'; e.currentTarget.style.opacity = '1' }}
|
||||
>
|
||||
<Camera size={14} />
|
||||
</button>
|
||||
@@ -447,7 +504,7 @@ export default function SettingsPage() {
|
||||
<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>
|
||||
{user?.oidc_issuer && (
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||
fontSize: 10, fontWeight: 500, padding: '1px 8px', borderRadius: 99,
|
||||
@@ -457,9 +514,9 @@ export default function SettingsPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user?.oidc_issuer && (
|
||||
{(user as UserWithOidc)?.oidc_issuer && (
|
||||
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: -2 }}>
|
||||
{t('settings.oidcLinked')} {user.oidc_issuer.replace('https://', '').replace(/\/+$/, '')}
|
||||
{t('settings.oidcLinked')} {(user as UserWithOidc).oidc_issuer!.replace('https://', '').replace(/\/+$/, '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -479,7 +536,7 @@ export default function SettingsPage() {
|
||||
if (user?.role === 'admin') {
|
||||
try {
|
||||
const data = await adminApi.stats()
|
||||
const adminUsers = (await adminApi.users()).users.filter(u => u.role === 'admin')
|
||||
const adminUsers = (await adminApi.users()).users.filter((u: { role: string }) => u.role === 'admin')
|
||||
if (adminUsers.length <= 1) {
|
||||
setShowDeleteConfirm('blocked')
|
||||
return
|
||||
@@ -507,7 +564,7 @@ export default function SettingsPage() {
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef3c7', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Shield size={18} style={{ color: '#d97706' }} />
|
||||
@@ -542,7 +599,7 @@ export default function SettingsPage() {
|
||||
<div style={{
|
||||
background: 'var(--bg-card)', borderRadius: 16, padding: '28px 24px',
|
||||
maxWidth: 400, width: '100%', boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
}} onClick={e => e.stopPropagation()}>
|
||||
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
||||
<div style={{ width: 36, height: 36, borderRadius: 10, background: '#fef2f2', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Trash2 size={18} style={{ color: '#ef4444' }} />
|
||||
@@ -569,8 +626,8 @@ export default function SettingsPage() {
|
||||
await authApi.deleteOwnAccount()
|
||||
logout()
|
||||
navigate('/login')
|
||||
} catch (err) {
|
||||
toast.error(err.response?.data?.error || t('common.error'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
@@ -7,6 +7,7 @@ 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 DayDetailPanel from '../components/Planner/DayDetailPanel'
|
||||
import PlaceFormModal from '../components/Planner/PlaceFormModal'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import TripMembersModal from '../components/Trips/TripMembersModal'
|
||||
@@ -15,18 +16,21 @@ 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 CollabPanel from '../components/Collab/CollabPanel'
|
||||
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'
|
||||
import { joinTrip, leaveTrip, addListener, removeListener } from '../api/websocket'
|
||||
import { addonsApi } from '../api/client'
|
||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import { useResizablePanels } from '../hooks/useResizablePanels'
|
||||
import { useTripWebSocket } from '../hooks/useTripWebSocket'
|
||||
import { useRouteCalculation } from '../hooks/useRouteCalculation'
|
||||
import { usePlaceSelection } from '../hooks/usePlaceSelection'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
|
||||
|
||||
const MIN_SIDEBAR = 200
|
||||
const MAX_SIDEBAR = 520
|
||||
|
||||
export default function TripPlannerPage() {
|
||||
const { id: tripId } = useParams()
|
||||
export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
const { t } = useTranslation()
|
||||
@@ -34,13 +38,23 @@ export default function TripPlannerPage() {
|
||||
const tripStore = useTripStore()
|
||||
const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore
|
||||
|
||||
const [enabledAddons, setEnabledAddons] = useState({ packing: true, budget: true, documents: true })
|
||||
const [enabledAddons, setEnabledAddons] = useState<Record<string, boolean>>({ packing: true, budget: true, documents: true })
|
||||
const [tripAccommodations, setTripAccommodations] = useState<Accommodation[]>([])
|
||||
const [allowedFileTypes, setAllowedFileTypes] = useState<string | null>(null)
|
||||
const [tripMembers, setTripMembers] = useState<TripMember[]>([])
|
||||
|
||||
const loadAccommodations = useCallback(() => {
|
||||
if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {})
|
||||
}, [tripId])
|
||||
|
||||
useEffect(() => {
|
||||
addonsApi.enabled().then(data => {
|
||||
const map = {}
|
||||
data.addons.forEach(a => { map[a.id] = true })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents })
|
||||
setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab })
|
||||
}).catch(() => {})
|
||||
authApi.getAppConfig().then(config => {
|
||||
if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types)
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
@@ -50,39 +64,45 @@ export default function TripPlannerPage() {
|
||||
...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []),
|
||||
...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []),
|
||||
...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []),
|
||||
]
|
||||
|
||||
const [activeTab, setActiveTab] = useState('plan')
|
||||
const [activeTab, setActiveTab] = useState<string>(() => {
|
||||
const saved = sessionStorage.getItem(`trip-tab-${tripId}`)
|
||||
return saved || 'plan'
|
||||
})
|
||||
|
||||
const handleTabChange = (tabId) => {
|
||||
const handleTabChange = (tabId: string): void => {
|
||||
setActiveTab(tabId)
|
||||
sessionStorage.setItem(`trip-tab-${tripId}`, 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)
|
||||
|
||||
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
|
||||
const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels()
|
||||
const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection()
|
||||
const [showDayDetail, setShowDayDetail] = useState<Day | null>(null)
|
||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
const [showTripForm, setShowTripForm] = useState<boolean>(false)
|
||||
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||
const [fitKey, setFitKey] = useState<number>(0)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(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)
|
||||
loadAccommodations()
|
||||
tripsApi.getMembers(tripId).then(d => {
|
||||
// Combine owner + members into one list
|
||||
const all = [d.owner, ...(d.members || [])].filter(Boolean)
|
||||
setTripMembers(all)
|
||||
}).catch(() => {})
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
@@ -90,60 +110,13 @@ export default function TripPlannerPage() {
|
||||
if (tripId) tripStore.loadReservations(tripId)
|
||||
}, [tripId])
|
||||
|
||||
// WebSocket: join trip and listen for remote events
|
||||
useEffect(() => {
|
||||
if (!tripId) return
|
||||
const handler = useTripStore.getState().handleRemoteEvent
|
||||
joinTrip(tripId)
|
||||
addListener(handler)
|
||||
return () => {
|
||||
leaveTrip(tripId)
|
||||
removeListener(handler)
|
||||
}
|
||||
}, [tripId])
|
||||
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
useTripWebSocket(tripId)
|
||||
|
||||
const mapPlaces = useCallback(() => {
|
||||
return places.filter(p => p.lat && p.lng)
|
||||
}, [places])
|
||||
|
||||
const updateRouteForDay = useCallback((dayId) => {
|
||||
if (!dayId) { setRoute(null); setRouteInfo(null); return }
|
||||
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)
|
||||
}
|
||||
setRouteInfo(null)
|
||||
}, [tripStore])
|
||||
const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId)
|
||||
|
||||
const handleSelectDay = useCallback((dayId, skipFit) => {
|
||||
const changed = dayId !== selectedDayId
|
||||
@@ -153,11 +126,14 @@ export default function TripPlannerPage() {
|
||||
updateRouteForDay(dayId)
|
||||
}, [tripStore, updateRouteForDay, selectedDayId])
|
||||
|
||||
const handlePlaceClick = useCallback((placeId) => {
|
||||
setSelectedPlaceId(placeId)
|
||||
if (placeId) { setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, updateRouteForDay])
|
||||
const handlePlaceClick = useCallback((placeId, assignmentId) => {
|
||||
if (assignmentId) {
|
||||
selectAssignment(assignmentId, placeId)
|
||||
} else {
|
||||
setSelectedPlaceId(placeId)
|
||||
}
|
||||
if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) }
|
||||
}, [selectAssignment, setSelectedPlaceId])
|
||||
|
||||
const handleMarkerClick = useCallback((placeId) => {
|
||||
const opening = placeId !== undefined
|
||||
@@ -170,23 +146,53 @@ export default function TripPlannerPage() {
|
||||
}, [])
|
||||
|
||||
const handleSavePlace = useCallback(async (data) => {
|
||||
const pendingFiles = data._pendingFiles
|
||||
delete data._pendingFiles
|
||||
if (editingPlace) {
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, data)
|
||||
// Always strip time fields from place update — time is per-assignment only
|
||||
const { place_time, end_time, ...placeData } = data
|
||||
await tripStore.updatePlace(tripId, editingPlace.id, placeData)
|
||||
// If editing from assignment context, save time per-assignment
|
||||
if (editingAssignmentId) {
|
||||
await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null })
|
||||
await tripStore.refreshDays(tripId)
|
||||
}
|
||||
// Upload pending files with place_id
|
||||
if (pendingFiles?.length > 0) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', editingPlace.id)
|
||||
try { await tripStore.addFile(tripId, fd) } catch {}
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeUpdated'))
|
||||
} else {
|
||||
await tripStore.addPlace(tripId, data)
|
||||
const place = await tripStore.addPlace(tripId, data)
|
||||
if (pendingFiles?.length > 0 && place?.id) {
|
||||
for (const file of pendingFiles) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
fd.append('place_id', place.id)
|
||||
try { await tripStore.addFile(tripId, fd) } catch {}
|
||||
}
|
||||
}
|
||||
toast.success(t('trip.toast.placeAdded'))
|
||||
}
|
||||
}, [editingPlace, tripId, tripStore, toast])
|
||||
}, [editingPlace, editingAssignmentId, tripId, tripStore, toast])
|
||||
|
||||
const handleDeletePlace = useCallback(async (placeId) => {
|
||||
if (!confirm(t('trip.confirm.deletePlace'))) return
|
||||
const handleDeletePlace = useCallback((placeId) => {
|
||||
setDeletePlaceId(placeId)
|
||||
}, [])
|
||||
|
||||
const confirmDeletePlace = useCallback(async () => {
|
||||
if (!deletePlaceId) return
|
||||
try {
|
||||
await tripStore.deletePlace(tripId, placeId)
|
||||
if (selectedPlaceId === placeId) setSelectedPlaceId(null)
|
||||
await tripStore.deletePlace(tripId, deletePlaceId)
|
||||
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
} catch (err) { toast.error(err.message) }
|
||||
}, [tripId, tripStore, toast, selectedPlaceId])
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||
const target = dayId || selectedDayId
|
||||
@@ -195,21 +201,20 @@ export default function TripPlannerPage() {
|
||||
await tripStore.assignPlaceToDay(tripId, target, placeId, position)
|
||||
toast.success(t('trip.toast.assignedToDay'))
|
||||
updateRouteForDay(target)
|
||||
} catch (err) { toast.error(err.message) }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [selectedDayId, tripId, tripStore, toast, updateRouteForDay])
|
||||
|
||||
const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => {
|
||||
try {
|
||||
await tripStore.removeAssignment(tripId, dayId, assignmentId)
|
||||
updateRouteForDay(dayId)
|
||||
}
|
||||
catch (err) { toast.error(err.message) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [tripId, tripStore, toast, updateRouteForDay])
|
||||
|
||||
const handleReorder = useCallback(async (dayId, orderedIds) => {
|
||||
const handleReorder = useCallback((dayId, orderedIds) => {
|
||||
try {
|
||||
await tripStore.reorderAssignments(tripId, dayId, orderedIds)
|
||||
// Build route directly from orderedIds to avoid stale closure
|
||||
tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {})
|
||||
// Update route immediately from orderedIds
|
||||
const dayItems = tripStore.assignments[String(dayId)] || []
|
||||
const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean)
|
||||
const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng)
|
||||
@@ -222,7 +227,7 @@ export default function TripPlannerPage() {
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripStore.updateDayTitle(tripId, dayId, title) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}, [tripId, tripStore, toast])
|
||||
|
||||
const handleSaveReservation = async (data) => {
|
||||
@@ -238,12 +243,12 @@ export default function TripPlannerPage() {
|
||||
setShowReservationModal(false)
|
||||
return r
|
||||
}
|
||||
} catch (err) { toast.error(err.message) }
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) }
|
||||
catch (err) { toast.error(err.message) }
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') }
|
||||
}
|
||||
|
||||
const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null
|
||||
@@ -254,7 +259,11 @@ export default function TripPlannerPage() {
|
||||
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 })
|
||||
sorted.forEach((a, i) => {
|
||||
if (!a.place?.id) return
|
||||
if (!map[a.place.id]) map[a.place.id] = []
|
||||
map[a.place.id].push(i + 1)
|
||||
})
|
||||
return map
|
||||
}, [selectedDayId, assignments])
|
||||
|
||||
@@ -332,6 +341,7 @@ export default function TripPlannerPage() {
|
||||
places={mapPlaces()}
|
||||
dayPlaces={dayPlaces}
|
||||
route={route}
|
||||
routeSegments={routeSegments}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
onMarkerClick={handleMarkerClick}
|
||||
onMapClick={handleMapClick}
|
||||
@@ -345,24 +355,11 @@ export default function TripPlannerPage() {
|
||||
hasInspector={!!selectedPlace}
|
||||
/>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
style={{
|
||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: 25,
|
||||
position: leftCollapsed ? 'fixed' : 'absolute', top: leftCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, left: leftCollapsed ? 10 : undefined, right: leftCollapsed ? undefined : -28, zIndex: -1,
|
||||
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',
|
||||
@@ -394,18 +391,24 @@ export default function TripPlannerPage() {
|
||||
assignments={assignments}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedPlaceId={selectedPlaceId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
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) } }}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
reservations={reservations}
|
||||
onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }}
|
||||
onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
accommodations={tripAccommodations}
|
||||
/>
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => { isResizingLeft.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
|
||||
onMouseDown={startResizeLeft}
|
||||
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'}
|
||||
@@ -417,7 +420,7 @@ export default function TripPlannerPage() {
|
||||
<div className="hidden md:block" style={{ position: 'absolute', right: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setRightCollapsed(c => !c)}
|
||||
style={{
|
||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: 25,
|
||||
position: rightCollapsed ? 'fixed' : 'absolute', top: rightCollapsed ? 'calc(var(--nav-h) + 44px + 14px)' : 14, right: rightCollapsed ? 10 : undefined, left: rightCollapsed ? undefined : -28, zIndex: -1,
|
||||
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',
|
||||
@@ -442,7 +445,7 @@ export default function TripPlannerPage() {
|
||||
}}>
|
||||
{!rightCollapsed && (
|
||||
<div
|
||||
onMouseDown={() => { isResizingRight.current = true; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none' }}
|
||||
onMouseDown={startResizeRight}
|
||||
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'}
|
||||
@@ -458,6 +461,8 @@ export default function TripPlannerPage() {
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -478,20 +483,69 @@ export default function TripPlannerPage() {
|
||||
document.body
|
||||
)}
|
||||
|
||||
{showDayDetail && !selectedPlace && (() => {
|
||||
const currentDay = days.find(d => d.id === showDayDetail.id) || showDayDetail
|
||||
const dayAssignments = assignments[String(currentDay.id)] || []
|
||||
const geoPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place || places.find(p => p.lat && p.lng)
|
||||
return (
|
||||
<DayDetailPanel
|
||||
day={currentDay}
|
||||
days={days}
|
||||
places={places}
|
||||
categories={categories}
|
||||
tripId={tripId}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
lat={geoPlace?.lat}
|
||||
lng={geoPlace?.lng}
|
||||
onClose={() => setShowDayDetail(null)}
|
||||
onAccommodationChange={loadAccommodations}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
|
||||
{selectedPlace && (
|
||||
<PlaceInspector
|
||||
place={selectedPlace}
|
||||
categories={categories}
|
||||
days={days}
|
||||
selectedDayId={selectedDayId}
|
||||
selectedAssignmentId={selectedAssignmentId}
|
||||
assignments={assignments}
|
||||
reservations={reservations}
|
||||
onClose={() => setSelectedPlaceId(null)}
|
||||
onEdit={() => { setEditingPlace(selectedPlace); setShowPlaceForm(true) }}
|
||||
onEdit={() => {
|
||||
// When editing from assignment context, use assignment-level times
|
||||
if (selectedAssignmentId) {
|
||||
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||
setEditingPlace(placeWithAssignmentTimes)
|
||||
} else {
|
||||
setEditingPlace(selectedPlace)
|
||||
}
|
||||
setEditingAssignmentId(selectedAssignmentId || null)
|
||||
setShowPlaceForm(true)
|
||||
}}
|
||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRemoveAssignment={handleRemoveAssignment}
|
||||
files={files}
|
||||
onFileUpload={(fd) => tripStore.addFile(tripId, fd)}
|
||||
tripMembers={tripMembers}
|
||||
onSetParticipants={async (assignmentId, dayId, userIds) => {
|
||||
try {
|
||||
const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds)
|
||||
useTripStore.setState(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: (state.assignments[String(dayId)] || []).map(a =>
|
||||
a.id === assignmentId ? { ...a, participants: data.participants } : a
|
||||
),
|
||||
}
|
||||
}))
|
||||
} catch {}
|
||||
}}
|
||||
onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -506,7 +560,7 @@ export default function TripPlannerPage() {
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
{mobileSidebarOpen === 'left'
|
||||
? <DayPlanSidebar tripId={tripId} 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) }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} 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) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} />
|
||||
: <PlacesSidebar places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={handlePlaceClick} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile />
|
||||
}
|
||||
</div>
|
||||
@@ -540,8 +594,8 @@ export default function TripPlannerPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'finanzplan' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} />
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -555,15 +609,29 @@ export default function TripPlannerPage() {
|
||||
places={places}
|
||||
reservations={reservations}
|
||||
tripId={tripId}
|
||||
allowedFileTypes={allowedFileTypes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'collab' && (
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
<CollabPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null) }} onSave={handleSavePlace} place={editingPlace} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} />
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} 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)} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} />
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
onConfirm={confirmDeletePlace}
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlace')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -11,17 +11,17 @@ import VacaySettings from '../components/Vacay/VacaySettings'
|
||||
import { Plus, Minus, ChevronLeft, ChevronRight, Settings, CalendarDays, AlertTriangle, Users, Eye, Pencil, Trash2, Unlink, ShieldCheck, SlidersHorizontal } from 'lucide-react'
|
||||
import Modal from '../components/shared/Modal'
|
||||
|
||||
export default function VacayPage() {
|
||||
export default function VacayPage(): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const { years, selectedYear, setSelectedYear, addYear, removeYear, loadAll, loadPlan, loadEntries, loadStats, loadHolidays, loading, incomingInvites, acceptInvite, declineInvite, plan } = useVacayStore()
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [deleteYear, setDeleteYear] = useState(null)
|
||||
const [showMobileSidebar, setShowMobileSidebar] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState<boolean>(false)
|
||||
const [deleteYear, setDeleteYear] = useState<number | null>(null)
|
||||
const [showMobileSidebar, setShowMobileSidebar] = useState<boolean>(false)
|
||||
|
||||
useEffect(() => { loadAll() }, [])
|
||||
|
||||
// Live sync via WebSocket
|
||||
const handleWsMessage = useCallback((msg) => {
|
||||
const handleWsMessage = useCallback((msg: { type: string }) => {
|
||||
if (msg.type === 'vacay:update' || msg.type === 'vacay:settings') {
|
||||
loadPlan()
|
||||
loadEntries(selectedYear)
|
||||
@@ -263,7 +263,7 @@ export default function VacayPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function InfoItem({ icon: Icon, text }) {
|
||||
function InfoItem({ icon: Icon, text }: { icon: React.ComponentType<{ size?: number; className?: string; style?: React.CSSProperties }>; text: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-start gap-3 px-3 py-2 rounded-lg" style={{ background: 'var(--bg-secondary)' }}>
|
||||
<Icon size={15} className="shrink-0 mt-0.5" style={{ color: 'var(--text-muted)' }} />
|
||||
@@ -272,7 +272,7 @@ function InfoItem({ icon: Icon, text }) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegendItem({ color, label }) {
|
||||
function LegendItem({ color, label }: { color: string; label: string }): React.ReactElement {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-4 h-3 rounded" style={{ background: color, border: `1px solid ${color}` }} />
|
||||
@@ -1,8 +1,42 @@
|
||||
import { create } from 'zustand'
|
||||
import { authApi } from '../api/client'
|
||||
import { connect, disconnect } from '../api/websocket'
|
||||
import type { User } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
export const useAuthStore = create((set, get) => ({
|
||||
interface AuthResponse {
|
||||
user: User
|
||||
token: string
|
||||
}
|
||||
|
||||
interface AvatarResponse {
|
||||
avatar_url: string
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: User | null
|
||||
token: string | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
demoMode: boolean
|
||||
hasMapsKey: boolean
|
||||
|
||||
login: (email: string, password: string) => Promise<AuthResponse>
|
||||
register: (username: string, email: string, password: string) => Promise<AuthResponse>
|
||||
logout: () => void
|
||||
loadUser: () => Promise<void>
|
||||
updateMapsKey: (key: string | null) => Promise<void>
|
||||
updateApiKeys: (keys: Record<string, string | null>) => Promise<void>
|
||||
updateProfile: (profileData: Partial<User>) => Promise<void>
|
||||
uploadAvatar: (file: File) => Promise<AvatarResponse>
|
||||
deleteAvatar: () => Promise<void>
|
||||
setDemoMode: (val: boolean) => void
|
||||
setHasMapsKey: (val: boolean) => void
|
||||
demoLogin: () => Promise<AuthResponse>
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
token: localStorage.getItem('auth_token') || null,
|
||||
isAuthenticated: !!localStorage.getItem('auth_token'),
|
||||
@@ -11,7 +45,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
demoMode: localStorage.getItem('demo_mode') === 'true',
|
||||
hasMapsKey: false,
|
||||
|
||||
login: async (email, password) => {
|
||||
login: async (email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.login({ email, password })
|
||||
@@ -25,14 +59,14 @@ export const useAuthStore = create((set, get) => ({
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Anmeldung fehlgeschlagen'
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Login failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username, email, password) => {
|
||||
register: async (username: string, email: string, password: string) => {
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const data = await authApi.register({ username, email, password })
|
||||
@@ -46,8 +80,8 @@ export const useAuthStore = create((set, get) => ({
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Registrierung fehlgeschlagen'
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Registration failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
@@ -79,7 +113,7 @@ export const useAuthStore = create((set, get) => ({
|
||||
isLoading: false,
|
||||
})
|
||||
connect(token)
|
||||
} catch (err) {
|
||||
} catch (err: unknown) {
|
||||
localStorage.removeItem('auth_token')
|
||||
set({
|
||||
user: null,
|
||||
@@ -90,55 +124,55 @@ export const useAuthStore = create((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
updateMapsKey: async (key) => {
|
||||
updateMapsKey: async (key: string | null) => {
|
||||
try {
|
||||
await authApi.updateMapsKey(key)
|
||||
set(state => ({
|
||||
user: { ...state.user, maps_api_key: key || null }
|
||||
set((state) => ({
|
||||
user: state.user ? { ...state.user, maps_api_key: key || null } : null,
|
||||
}))
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.error || 'Fehler beim Speichern des API-Schlüssels')
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving API key'))
|
||||
}
|
||||
},
|
||||
|
||||
updateApiKeys: async (keys) => {
|
||||
updateApiKeys: async (keys: Record<string, string | null>) => {
|
||||
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')
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving API keys'))
|
||||
}
|
||||
},
|
||||
|
||||
updateProfile: async (profileData) => {
|
||||
updateProfile: async (profileData: Partial<User>) => {
|
||||
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')
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating profile'))
|
||||
}
|
||||
},
|
||||
|
||||
uploadAvatar: async (file) => {
|
||||
uploadAvatar: async (file: 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 } }))
|
||||
set((state) => ({ user: state.user ? { ...state.user, avatar_url: data.avatar_url } : null }))
|
||||
return data
|
||||
},
|
||||
|
||||
deleteAvatar: async () => {
|
||||
await authApi.deleteAvatar()
|
||||
set(state => ({ user: { ...state.user, avatar_url: null } }))
|
||||
set((state) => ({ user: state.user ? { ...state.user, avatar_url: null } : null }))
|
||||
},
|
||||
|
||||
setDemoMode: (val) => {
|
||||
setDemoMode: (val: boolean) => {
|
||||
if (val) localStorage.setItem('demo_mode', 'true')
|
||||
else localStorage.removeItem('demo_mode')
|
||||
set({ demoMode: val })
|
||||
},
|
||||
|
||||
setHasMapsKey: (val) => set({ hasMapsKey: val }),
|
||||
setHasMapsKey: (val: boolean) => set({ hasMapsKey: val }),
|
||||
|
||||
demoLogin: async () => {
|
||||
set({ isLoading: true, error: null })
|
||||
@@ -155,8 +189,8 @@ export const useAuthStore = create((set, get) => ({
|
||||
})
|
||||
connect(data.token)
|
||||
return data
|
||||
} catch (err) {
|
||||
const error = err.response?.data?.error || 'Demo-Login fehlgeschlagen'
|
||||
} catch (err: unknown) {
|
||||
const error = getApiErrorMessage(err, 'Demo login failed')
|
||||
set({ isLoading: false, error })
|
||||
throw new Error(error)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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')
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,73 @@
|
||||
import { create } from 'zustand'
|
||||
import { settingsApi } from '../api/client'
|
||||
import type { Settings } from '../types'
|
||||
import { getApiErrorMessage } from '../types'
|
||||
|
||||
interface SettingsState {
|
||||
settings: Settings
|
||||
isLoaded: boolean
|
||||
|
||||
loadSettings: () => Promise<void>
|
||||
updateSetting: (key: keyof Settings, value: Settings[keyof Settings]) => Promise<void>
|
||||
setLanguageLocal: (lang: string) => void
|
||||
updateSettings: (settingsObj: Partial<Settings>) => Promise<void>
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((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: unknown) {
|
||||
set({ isLoaded: true })
|
||||
console.error('Failed to load settings:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateSetting: async (key: keyof Settings, value: Settings[keyof Settings]) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, [key]: value },
|
||||
}))
|
||||
if (key === 'language') localStorage.setItem('app_language', value as string)
|
||||
try {
|
||||
await settingsApi.set(key, value)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save setting:', err)
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving setting'))
|
||||
}
|
||||
},
|
||||
|
||||
setLanguageLocal: (lang: string) => {
|
||||
localStorage.setItem('app_language', lang)
|
||||
set((state) => ({ settings: { ...state.settings, language: lang } }))
|
||||
},
|
||||
|
||||
updateSettings: async (settingsObj: Partial<Settings>) => {
|
||||
set((state) => ({
|
||||
settings: { ...state.settings, ...settingsObj },
|
||||
}))
|
||||
try {
|
||||
await settingsApi.setBulk(settingsObj)
|
||||
} catch (err: unknown) {
|
||||
console.error('Failed to save settings:', err)
|
||||
throw new Error(getApiErrorMessage(err, 'Error saving settings'))
|
||||
}
|
||||
},
|
||||
}))
|
||||
@@ -0,0 +1,168 @@
|
||||
import { assignmentsApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Assignment, AssignmentsMap } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface AssignmentsSlice {
|
||||
assignPlaceToDay: (tripId: number | string, dayId: number | string, placeId: number | string, position?: number | null) => Promise<Assignment | undefined>
|
||||
removeAssignment: (tripId: number | string, dayId: number | string, assignmentId: number) => Promise<void>
|
||||
reorderAssignments: (tripId: number | string, dayId: number | string, orderedIds: number[]) => Promise<void>
|
||||
moveAssignment: (tripId: number | string, assignmentId: number, fromDayId: number | string, toDayId: number | string, toOrderIndex?: number | null) => Promise<void>
|
||||
setAssignments: (assignments: AssignmentsMap) => void
|
||||
}
|
||||
|
||||
export const createAssignmentsSlice = (set: SetState, get: GetState): AssignmentsSlice => ({
|
||||
assignPlaceToDay: async (tripId, dayId, placeId, position) => {
|
||||
const state = get()
|
||||
const place = state.places.find(p => p.id === parseInt(String(placeId)))
|
||||
if (!place) return
|
||||
|
||||
const tempId = Date.now() * -1
|
||||
const current = [...(state.assignments[String(dayId)] || [])]
|
||||
const insertIdx = position != null ? position : current.length
|
||||
const tempAssignment: Assignment = {
|
||||
id: tempId,
|
||||
day_id: parseInt(String(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: Assignment = {
|
||||
...data.assignment,
|
||||
place: data.assignment.place || place,
|
||||
order_index: position != null ? insertIdx : data.assignment.order_index,
|
||||
}
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].map(
|
||||
a => a.id === tempId ? newAssignment : a
|
||||
),
|
||||
}
|
||||
}))
|
||||
if (position != null) {
|
||||
const updated = get().assignments[String(dayId)] || []
|
||||
const orderedIds = updated.map(a => a.id).filter(id => id > 0)
|
||||
if (orderedIds.length > 0) {
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
set(state => {
|
||||
const items = state.assignments[String(dayId)] || []
|
||||
const reordered = orderedIds.map((id, idx) => {
|
||||
const item = items.find(a => a.id === id)
|
||||
return item ? { ...item, order_index: idx } : null
|
||||
}).filter((item): item is Assignment => item !== null)
|
||||
return {
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return data.assignment
|
||||
} catch (err: unknown) {
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: state.assignments[String(dayId)].filter(a => a.id !== tempId),
|
||||
}
|
||||
}))
|
||||
throw new Error(getApiErrorMessage(err, 'Error assigning place'))
|
||||
}
|
||||
},
|
||||
|
||||
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: unknown) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(getApiErrorMessage(err, 'Error removing assignment'))
|
||||
}
|
||||
},
|
||||
|
||||
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((item): item is Assignment => item !== null)
|
||||
|
||||
set(state => ({
|
||||
assignments: {
|
||||
...state.assignments,
|
||||
[String(dayId)]: reordered,
|
||||
}
|
||||
}))
|
||||
|
||||
try {
|
||||
await assignmentsApi.reorder(tripId, dayId, orderedIds)
|
||||
} catch (err: unknown) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(getApiErrorMessage(err, 'Error reordering'))
|
||||
}
|
||||
},
|
||||
|
||||
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
|
||||
|
||||
const newToItems = [...toItems]
|
||||
newToItems.splice(insertAt, 0, { ...assignment, day_id: parseInt(String(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: unknown) {
|
||||
set({ assignments: prevAssignments })
|
||||
throw new Error(getApiErrorMessage(err, 'Error moving assignment'))
|
||||
}
|
||||
},
|
||||
|
||||
setAssignments: (assignments) => {
|
||||
set({ assignments })
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { budgetApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { BudgetItem, BudgetMember } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface BudgetSlice {
|
||||
loadBudgetItems: (tripId: number | string) => Promise<void>
|
||||
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
||||
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
|
||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetMember[]; item: BudgetItem }>
|
||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||
}
|
||||
|
||||
export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice => ({
|
||||
loadBudgetItems: async (tripId) => {
|
||||
try {
|
||||
const data = await budgetApi.list(tripId)
|
||||
set({ budgetItems: data.items })
|
||||
} catch (err: unknown) {
|
||||
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: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding budget item'))
|
||||
}
|
||||
},
|
||||
|
||||
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: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error updating budget item'))
|
||||
}
|
||||
},
|
||||
|
||||
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: unknown) {
|
||||
set({ budgetItems: prev })
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting budget item'))
|
||||
}
|
||||
},
|
||||
|
||||
setBudgetItemMembers: async (tripId, itemId, userIds) => {
|
||||
const result = await budgetApi.setMembers(tripId, itemId, userIds);
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
|
||||
)
|
||||
}));
|
||||
return result;
|
||||
},
|
||||
|
||||
toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
|
||||
await budgetApi.togglePaid(tripId, itemId, userId, paid);
|
||||
set(state => ({
|
||||
budgetItems: state.budgetItems.map(item =>
|
||||
item.id === itemId
|
||||
? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
|
||||
: item
|
||||
)
|
||||
}));
|
||||
},
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user