Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7272e0bbfd | |||
| c7eaf3aa79 | |||
| deef5e6b81 | |||
| 6d72006b28 | |||
| 26c1676cdd | |||
| 4ddfa92c14 | |||
| 19c9e17884 | |||
| 14ef2d4a4a | |||
| de859318fa | |||
| bcbb516448 | |||
| 71870e4567 | |||
| 9819473157 | |||
| eb7984f40d | |||
| 9caa0acc24 | |||
| 8ddfa8fde0 | |||
| 41d4b2a8be | |||
| 10ebf46a98 | |||
| 70809d6c27 | |||
| a314ba2b80 | |||
| d8f03f6bea | |||
| 533d6f84d8 | |||
| 095cb1b9d1 | |||
| 0a0205fcf9 | |||
| 9aed5ff2ed | |||
| d189d6d776 | |||
| 262905e357 | |||
| 4a4643f33f | |||
| a6a7edf0b2 | |||
| 949d0967d2 | |||
| cd634093af | |||
| 7201380504 | |||
| 1166a09835 | |||
| 6f2d7c8f5e | |||
| e6c4c22a1d | |||
| 9a044ada28 | |||
| da5e77f78d | |||
| cc8be328f9 | |||
| f1c4155d81 | |||
| d4899a8dee | |||
| a973a1b4f8 | |||
| 73b0534053 | |||
| 931c5bd990 | |||
| ee54308819 | |||
| 66b00c24e2 | |||
| f6d08582ec | |||
| 8d9a511edf | |||
| 3059d53d11 | |||
| 3074724f2f | |||
| 21ed7ea4a2 | |||
| 267271d97a | |||
| 874c1292c7 | |||
| a9948499e4 | |||
| 90301e62ce | |||
| 377422a9d5 | |||
| d90a059dfa | |||
| 1e20f024d5 | |||
| 9a81baa809 | |||
| 11b85a2d70 | |||
| d04629605e | |||
| 187989cc1d | |||
| 6444b2b4ce | |||
| 42ebc7c298 | |||
| 8bca921b30 | |||
| 12f8b6eb55 | |||
| 202cfb6a63 | |||
| b6f9664ec2 | |||
| 9f8075171d | |||
| 02b907e764 | |||
| e05e021f41 | |||
| 615c6bae58 | |||
| 62fbc26811 | |||
| 2171203a4c | |||
| b28b483b90 | |||
| 020cafade1 | |||
| e4b2262d4d | |||
| d2efd960b5 | |||
| c51a27371b | |||
| 252d2d22a8 | |||
| 80c2486570 | |||
| 7dcd89fb71 | |||
| 8458481950 | |||
| 808b7f7a72 | |||
| f4ee7b868d | |||
| e99960c3b6 | |||
| c39d242cfb | |||
| 2f8a189319 | |||
| 44138af11a | |||
| bc6c59f358 | |||
| 54804d0e5f | |||
| 631e47944b | |||
| 3abcc0ec76 | |||
| 530f233b7d | |||
| fbb3bb862c | |||
| 3c3b7b9136 | |||
| 99514ddce1 | |||
| b0ffb63d67 | |||
| d909aac751 | |||
| e91b79ebfc | |||
| 2d7babcba3 | |||
| e56ea068ef | |||
| a091051387 | |||
| df3e62af5c | |||
| 399e4acf03 | |||
| e0fd9830d9 | |||
| 7a445583d7 | |||
| 1d9d628e2d | |||
| 005c08dcea | |||
| e25fec4e4a | |||
| 85e69b8a3d | |||
| 1d57eacfa4 | |||
| ecf7433980 | |||
| 433d780f74 | |||
| 27f8856e9b | |||
| f2c90ee0f4 | |||
| 83d256ebac | |||
| 3c4f5f7193 | |||
| 31124a604a | |||
| 0d9dbb6286 | |||
| 66ae577b7b | |||
| 706548c45d | |||
| aa32df5ee1 | |||
| 1f9ae8e4b5 | |||
| d69585a820 | |||
| 723f8a1c3d | |||
| 678fe2d12c | |||
| e97ecd558f | |||
| 3d33191925 | |||
| 48e1b732d8 | |||
| d50c84b755 | |||
| fcbfeb6793 | |||
| 77f2c616de | |||
| 9f8d3f8d99 | |||
| 3f26a68f64 | |||
| a3b6a89471 | |||
| ee54d89144 | |||
| e78c2a97bd | |||
| 5940b7f24e | |||
| 1c3a1ba8da | |||
| b6d927a3d6 | |||
| c5e41f2228 | |||
| 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 | |||
| 10662e0b63 | |||
| 8286fa8591 | |||
| 5f2bd51824 | |||
| 4d3ee08481 | |||
| aeb530515e | |||
| f4d3542d99 | |||
| d604ad1c5b | |||
| 3919c61eb6 | |||
| 98556c9aaf | |||
| 7e4ec82d3e | |||
| 5f891c83e8 | |||
| 3d6ae6811c | |||
| dd3d4263a7 | |||
| 5f4e7f9487 | |||
| e652efee8a | |||
| 47028798b1 | |||
| 6076a482b8 | |||
| a590db8e10 | |||
| 502c334cbf | |||
| 031cc3587b | |||
| f55f5ea449 | |||
| 557de4cd5a | |||
| 544ac796d5 | |||
| 5b6e3d6c1a | |||
| df695ee8d8 | |||
| d845057f84 | |||
| e70fe50ae3 | |||
| 2000371844 | |||
| d45d9c2cfa | |||
| d24f0b3ccd | |||
| c1fb745627 | |||
| 384d583628 | |||
| 3edf65957b | |||
| c887acddee |
@@ -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.
|
||||||
@@ -7,8 +7,19 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.runner }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
steps:
|
steps:
|
||||||
|
- name: Prepare platform tag-safe name
|
||||||
|
run: echo "PLATFORM_PAIR=$(echo ${{ matrix.platform }} | sed 's|/|-|g')" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
@@ -18,8 +29,63 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
- name: Build and push by digest
|
||||||
|
id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
platforms: ${{ matrix.platform }}
|
||||||
tags: mauriceboe/nomad:latest
|
outputs: type=image,name=mauriceboe/trek,push-by-digest=true,name-canonical=true,push=true
|
||||||
|
no-cache: true
|
||||||
|
|
||||||
|
- name: Export digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
digest="${{ steps.build.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: digests-${{ env.PLATFORM_PAIR }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
merge:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Get version from package.json
|
||||||
|
id: version
|
||||||
|
run: echo "VERSION=$(node -p "require('./server/package.json').version")" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Download build digests
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digests-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Create and push multi-arch manifest
|
||||||
|
working-directory: /tmp/digests
|
||||||
|
run: |
|
||||||
|
mapfile -t digests < <(printf 'mauriceboe/trek@sha256:%s\n' *)
|
||||||
|
docker buildx imagetools create \
|
||||||
|
-t mauriceboe/trek:latest \
|
||||||
|
-t mauriceboe/trek:${{ steps.version.outputs.VERSION }} \
|
||||||
|
-t mauriceboe/nomad:latest \
|
||||||
|
-t mauriceboe/nomad:${{ steps.version.outputs.VERSION }} \
|
||||||
|
"${digests[@]}"
|
||||||
|
|
||||||
|
- name: Inspect manifest
|
||||||
|
run: docker buildx imagetools inspect mauriceboe/trek:latest
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ node_modules/
|
|||||||
# Build output
|
# Build output
|
||||||
client/dist/
|
client/dist/
|
||||||
|
|
||||||
|
# Generated PWA icons (built from SVG via prebuild)
|
||||||
|
client/public/icons/*.png
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
*.db
|
*.db
|
||||||
*.db-shm
|
*.db-shm
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ FROM node:22-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Server-Dependencies installieren
|
# Timezone support + Server-Dependencies (better-sqlite3 braucht Build-Tools)
|
||||||
COPY server/package*.json ./
|
COPY server/package*.json ./
|
||||||
RUN npm ci --production
|
RUN apk add --no-cache tzdata python3 make g++ && \
|
||||||
|
npm ci --production && \
|
||||||
|
apk del python3 make g++
|
||||||
|
|
||||||
# Server-Code kopieren
|
# Server-Code kopieren
|
||||||
COPY server/ ./
|
COPY server/ ./
|
||||||
@@ -24,8 +26,12 @@ COPY --from=client-builder /app/client/dist ./public
|
|||||||
# Fonts für PDF-Export kopieren
|
# Fonts für PDF-Export kopieren
|
||||||
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./public/fonts
|
||||||
|
|
||||||
# Verzeichnisse erstellen
|
# 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
|
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
|
||||||
|
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
USER node
|
||||||
|
|
||||||
# Umgebung setzen
|
# Umgebung setzen
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
@@ -33,4 +39,4 @@ ENV PORT=3000
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["node", "--experimental-sqlite", "src/index.js"]
|
CMD ["node", "--import", "tsx", "src/index.ts"]
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
# NOMAD
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="client/public/logo-light.svg" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="client/public/logo-dark.svg" />
|
||||||
|
<img src="client/public/logo-light.svg" alt="TREK" height="60" />
|
||||||
|
</picture>
|
||||||
|
<br />
|
||||||
|
<em>Your Trips. Your Plan.</em>
|
||||||
|
</p>
|
||||||
|
|
||||||
**Navigation Organizer for Maps, Activities & Destinations**
|
<p align="center">
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg" alt="License: AGPL v3" /></a>
|
||||||
|
<a href="https://hub.docker.com/r/mauriceboe/trek"><img src="https://img.shields.io/docker/pulls/mauriceboe/trek" alt="Docker Pulls" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/TREK"><img src="https://img.shields.io/github/stars/mauriceboe/TREK" alt="GitHub Stars" /></a>
|
||||||
|
<a href="https://github.com/mauriceboe/TREK/commits"><img src="https://img.shields.io/github/last-commit/mauriceboe/TREK" alt="Last Commit" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
A self-hosted, real-time collaborative travel planner for organizing trips with interactive maps, budgets, packing lists, and more.
|
<p align="center">
|
||||||
|
A self-hosted, real-time collaborative travel planner with interactive maps, budgets, packing lists, and more.
|
||||||
|
<br />
|
||||||
|
<strong><a href="https://demo-nomad.pakulat.org">Live Demo</a></strong> — Try TREK without installing. Resets hourly.
|
||||||
|
</p>
|
||||||
|
|
||||||
[](LICENSE)
|

|
||||||
[](https://hub.docker.com/r/mauriceboe/nomad)
|

|
||||||
[](https://github.com/mauriceboe/NOMAD)
|
|
||||||
[](https://github.com/mauriceboe/NOMAD/commits)
|
|
||||||
|
|
||||||
**[Live Demo](https://demo-nomad.pakulat.org)** — Try NOMAD without installing. Resets hourly.
|
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>More Screenshots</summary>
|
<summary>More Screenshots</summary>
|
||||||
@@ -19,65 +30,106 @@ A self-hosted, real-time collaborative travel planner for organizing trips with
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
|  |  |
|
|  |  |
|
||||||
|  |  |
|
|  |  |
|
||||||
|  | |
|
|  | |
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Real-Time Collaboration** — Plan together via WebSocket live sync — changes appear instantly across all connected users
|
### Trip Planning
|
||||||
- **Interactive Map** — Leaflet map with marker clustering, route visualization, and customizable tile sources
|
|
||||||
- **Google Places Integration** — Search places, auto-fill details including ratings, reviews, opening hours, and photos (requires API key)
|
|
||||||
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
- **Drag & Drop Planner** — Organize places into day plans with reordering and cross-day moves
|
||||||
- **Weather Forecasts** — Current weather and 5-day forecasts with smart caching (requires API key)
|
- **Interactive Map** — Leaflet map with photo markers, clustering, route visualization, and customizable tile sources
|
||||||
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
- **Place Search** — Search via Google Places (with photos, ratings, opening hours) or OpenStreetMap (free, no API key needed)
|
||||||
- **Packing Lists** — Categorized checklists with progress tracking, color coding, and smart suggestions
|
- **Day Notes** — Add timestamped, icon-tagged notes to individual days with drag & drop reordering
|
||||||
- **Reservations & Bookings** — Track flights, hotels, restaurants with status, confirmation numbers, and file attachments
|
|
||||||
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
|
||||||
- **PDF Export** — Export complete trip plans as PDF with images and notes
|
|
||||||
- **Multi-User** — Invite members to collaborate on shared trips with role-based access
|
|
||||||
- **Admin Panel** — User management, create users, global categories, API key configuration, and backups
|
|
||||||
- **Auto-Backups** — Scheduled backups with configurable interval and retention
|
|
||||||
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
- **Route Optimization** — Auto-optimize place order and export to Google Maps
|
||||||
- **Day Notes** — Add timestamped notes to individual days
|
- **Weather Forecasts** — 16-day forecasts via Open-Meteo (no API key needed) with historical climate averages as fallback
|
||||||
- **Dark Mode** — Full light and dark theme support
|
- **Map Category Filter** — Filter places by category and see only matching pins on the map
|
||||||
- **Multilingual** — English and German (i18n)
|
|
||||||
- **Mobile Friendly** — Responsive design with touch-optimized controls
|
### Travel Management
|
||||||
|
- **Reservations & Bookings** — Track flights, accommodations, restaurants with status, confirmation numbers, and file attachments
|
||||||
|
- **Budget Tracking** — Category-based expenses with pie chart, per-person/per-day splitting, and multi-currency support
|
||||||
|
- **Packing Lists** — Category-based checklists with user assignment, packing templates, and progress tracking
|
||||||
|
- **Packing Templates** — Create reusable packing templates in the admin panel with categories and items, apply to any trip
|
||||||
|
- **Bag Tracking** — Optional weight tracking and bag assignment for packing items with iOS-style weight distribution (admin-toggleable)
|
||||||
|
- **Document Manager** — Attach documents, tickets, and PDFs to trips, places, or reservations (up to 50 MB per file)
|
||||||
|
- **PDF Export** — Export complete trip plans as PDF with cover page, images, notes, and TREK branding
|
||||||
|
|
||||||
|
### Mobile & PWA
|
||||||
|
- **Progressive Web App** — Install on iOS and Android directly from the browser, no App Store needed
|
||||||
|
- **Offline Support** — Service Worker caches map tiles, API data, uploads, and static assets via Workbox
|
||||||
|
- **Native App Feel** — Fullscreen standalone mode, custom app icon, themed status bar, and splash screen
|
||||||
|
- **Touch Optimized** — Responsive design with mobile-specific layouts, touch-friendly controls, and safe area handling
|
||||||
|
|
||||||
|
### Collaboration
|
||||||
|
- **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
|
||||||
|
- **Invite Links** — Create one-time registration links with configurable max uses and expiry for easy onboarding
|
||||||
|
- **Single Sign-On (OIDC)** — Login with Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
|
- **Two-Factor Authentication (MFA)** — TOTP-based 2FA with QR code setup, works with Google Authenticator, Authy, etc.
|
||||||
|
- **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, bucket list with planned travel dates, 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
|
||||||
|
- **Dashboard Views** — Toggle between card grid and compact list view on the My Trips page
|
||||||
|
- **Dark Mode** — Full light and dark theme with dynamic status bar color matching
|
||||||
|
- **Multilingual** — English, German, Spanish, French, Russian, Chinese (Simplified), Dutch, Arabic (with RTL support)
|
||||||
|
- **Admin Panel** — User management, invite links, packing templates, 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
|
- **Customizable** — Temperature units, time format (12h/24h), map tile sources, default coordinates
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Backend**: Node.js 22 + Express + SQLite (`node:sqlite`)
|
- **Backend**: Node.js 22 + Express + SQLite (`better-sqlite3`)
|
||||||
- **Frontend**: React 18 + Vite + Tailwind CSS
|
- **Frontend**: React 18 + Vite + Tailwind CSS
|
||||||
|
- **PWA**: vite-plugin-pwa + Workbox
|
||||||
- **Real-Time**: WebSocket (`ws`)
|
- **Real-Time**: WebSocket (`ws`)
|
||||||
- **State**: Zustand
|
- **State**: Zustand
|
||||||
- **Auth**: JWT
|
- **Auth**: JWT + OIDC + TOTP (MFA)
|
||||||
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
- **Maps**: Leaflet + react-leaflet-cluster + Google Places API (optional)
|
||||||
- **Weather**: OpenWeatherMap API (optional)
|
- **Weather**: Open-Meteo API (free, no key required)
|
||||||
- **Icons**: lucide-react
|
- **Icons**: lucide-react
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/nomad
|
docker run -d -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads mauriceboe/trek
|
||||||
```
|
```
|
||||||
|
|
||||||
The app runs on port `3000`. The first user to register becomes the admin.
|
The app runs on port `3000`. The first user to register becomes the admin.
|
||||||
|
|
||||||
|
### Install as App (PWA)
|
||||||
|
|
||||||
|
TREK works as a Progressive Web App — no App Store needed:
|
||||||
|
|
||||||
|
1. Open your TREK instance in the browser (HTTPS required)
|
||||||
|
2. **iOS**: Share button → "Add to Home Screen"
|
||||||
|
3. **Android**: Menu → "Install app" or "Add to Home Screen"
|
||||||
|
4. TREK launches fullscreen with its own icon, just like a native app
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Docker Compose (recommended for production)</summary>
|
<summary>Docker Compose (recommended for production)</summary>
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
image: mauriceboe/nomad:latest
|
image: mauriceboe/trek:latest
|
||||||
container_name: nomad
|
container_name: trek
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
|
# - OIDC_ISSUER=https://auth.example.com
|
||||||
|
# - OIDC_CLIENT_ID=trek
|
||||||
|
# - OIDC_CLIENT_SECRET=supersecret
|
||||||
|
# - OIDC_DISPLAY_NAME="SSO"
|
||||||
|
# - OIDC_ONLY=true # disable password auth entirely
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
@@ -92,21 +144,29 @@ docker compose up -d
|
|||||||
|
|
||||||
### Updating
|
### Updating
|
||||||
|
|
||||||
|
**Docker Compose** (recommended):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull mauriceboe/nomad
|
docker compose pull && docker compose up -d
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Or with Docker Compose: `docker compose pull && docker compose up -d`
|
**Docker Run** — use the same volume paths from your original `docker run` command:
|
||||||
|
|
||||||
Your data is persisted in the mounted `data` and `uploads` volumes.
|
```bash
|
||||||
|
docker pull mauriceboe/trek
|
||||||
|
docker rm -f trek
|
||||||
|
docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/uploads --restart unless-stopped mauriceboe/trek
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Tip:** Not sure which paths you used? Run `docker inspect trek --format '{{json .Mounts}}'` before removing the container.
|
||||||
|
|
||||||
|
Your data is persisted in the mounted `data` and `uploads` volumes — updates never touch your existing data.
|
||||||
|
|
||||||
### Reverse Proxy (recommended)
|
### Reverse Proxy (recommended)
|
||||||
|
|
||||||
For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
For production, put TREK behind a reverse proxy with HTTPS (e.g. Nginx, Caddy, Traefik).
|
||||||
|
|
||||||
> **Important:** NOMAD uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
> **Important:** TREK uses WebSockets for real-time sync. Your reverse proxy must support WebSocket upgrades on the `/ws` path.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Nginx</summary>
|
<summary>Nginx</summary>
|
||||||
@@ -114,13 +174,13 @@ For production, put NOMAD behind a reverse proxy with HTTPS (e.g. Nginx, Caddy,
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 443 ssl http2;
|
listen 443 ssl http2;
|
||||||
server_name nomad.yourdomain.com;
|
server_name trek.yourdomain.com;
|
||||||
|
|
||||||
ssl_certificate /path/to/fullchain.pem;
|
ssl_certificate /path/to/fullchain.pem;
|
||||||
ssl_certificate_key /path/to/privkey.pem;
|
ssl_certificate_key /path/to/privkey.pem;
|
||||||
@@ -155,13 +215,29 @@ server {
|
|||||||
Caddy handles WebSocket upgrades automatically:
|
Caddy handles WebSocket upgrades automatically:
|
||||||
|
|
||||||
```
|
```
|
||||||
nomad.yourdomain.com {
|
trek.yourdomain.com {
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `PORT` | Server port | `3000` |
|
||||||
|
| `NODE_ENV` | Environment | `production` |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | Auto-generated |
|
||||||
|
| `FORCE_HTTPS` | Redirect HTTP to HTTPS | `false` |
|
||||||
|
| `OIDC_ISSUER` | OIDC provider URL | — |
|
||||||
|
| `OIDC_CLIENT_ID` | OIDC client ID | — |
|
||||||
|
| `OIDC_CLIENT_SECRET` | OIDC client secret | — |
|
||||||
|
| `OIDC_DISPLAY_NAME` | SSO button label | `SSO` |
|
||||||
|
| `OIDC_ONLY` | Disable password auth | `false` |
|
||||||
|
| `TRUST_PROXY` | Trust proxy headers | `1` |
|
||||||
|
| `DEMO_MODE` | Enable demo mode | `false` |
|
||||||
|
|
||||||
## Optional API Keys
|
## Optional API Keys
|
||||||
|
|
||||||
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
API keys are configured in the **Admin Panel** after login. Keys set by the admin are automatically shared with all users — no per-user configuration needed.
|
||||||
@@ -171,20 +247,14 @@ API keys are configured in the **Admin Panel** after login. Keys set by the admi
|
|||||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||||
2. Create a project and enable the **Places API (New)**
|
2. Create a project and enable the **Places API (New)**
|
||||||
3. Create an API key under Credentials
|
3. Create an API key under Credentials
|
||||||
4. In NOMAD: Admin Panel → Settings → Google Maps
|
4. In TREK: 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
|
## Building from Source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/mauriceboe/NOMAD.git
|
git clone https://github.com/mauriceboe/TREK.git
|
||||||
cd NOMAD
|
cd TREK
|
||||||
docker build -t nomad .
|
docker build -t trek .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Data & Backups
|
## Data & Backups
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ You will receive a response within 48 hours. Once confirmed, a fix will be relea
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This policy covers the NOMAD application and its Docker image (`mauriceboe/nomad`).
|
This policy covers the TREK application and its Docker image (`mauriceboe/trek`).
|
||||||
|
|
||||||
Third-party dependencies are monitored via GitHub Dependabot.
|
Third-party dependencies are monitored via GitHub Dependabot.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
apiVersion: v2
|
||||||
|
name: trek
|
||||||
|
version: 0.1.0
|
||||||
|
description: Minimal Helm chart for TREK app
|
||||||
|
appVersion: "latest"
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# TREK Helm Chart
|
||||||
|
|
||||||
|
This is a minimal Helm chart for deploying the TREK app.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Deploys the TREK container
|
||||||
|
- Exposes port 3000 via Service
|
||||||
|
- Optional persistent storage for `/app/data` and `/app/uploads`
|
||||||
|
- Configurable environment variables and secrets
|
||||||
|
- Optional generic Ingress support
|
||||||
|
- Health checks on `/api/health`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
helm install trek ./chart \
|
||||||
|
--set secretEnv.JWT_SECRET=your_jwt_secret \
|
||||||
|
--set ingress.enabled=true \
|
||||||
|
--set ingress.hosts[0].host=yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
See `values.yaml` for more options.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `Chart.yaml` — chart metadata
|
||||||
|
- `values.yaml` — configuration values
|
||||||
|
- `templates/` — Kubernetes manifests
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- Ingress is off by default. Enable and configure hosts for your domain.
|
||||||
|
- PVCs require a default StorageClass or specify one as needed.
|
||||||
|
- JWT_SECRET must be set for production use.
|
||||||
|
- If using ingress, you must manually keep `env.ALLOWED_ORIGINS` and `ingress.hosts` in sync to ensure CORS works correctly. The chart does not sync these automatically.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
1. JWT_SECRET handling:
|
||||||
|
- By default, the chart creates a secret with the value from `values.yaml: secretEnv.JWT_SECRET`.
|
||||||
|
- To generate a random JWT_SECRET at install, set `generateJwtSecret: true`.
|
||||||
|
- To use an existing Kubernetes secret, set `existingSecret` to the secret name. The secret must have a key matching `existingSecretKey` (defaults to `JWT_SECRET`).
|
||||||
|
|
||||||
|
2. Example usage:
|
||||||
|
- Set a custom secret: `--set secretEnv.JWT_SECRET=your_secret`
|
||||||
|
- Generate a random secret: `--set generateJwtSecret=true`
|
||||||
|
- Use an existing secret: `--set existingSecret=my-k8s-secret`
|
||||||
|
- Use a custom key in the existing secret: `--set existingSecret=my-k8s-secret --set existingSecretKey=MY_KEY`
|
||||||
|
|
||||||
|
3. Only one method should be used at a time. If both `generateJwtSecret` and `existingSecret` are set, `existingSecret` takes precedence.
|
||||||
|
If using `existingSecret`, ensure the referenced secret and key exist in the target namespace.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{/*
|
||||||
|
Expand the name of the chart.
|
||||||
|
*/}}
|
||||||
|
{{- define "trek.name" -}}
|
||||||
|
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create a default fully qualified app name.
|
||||||
|
*/}}
|
||||||
|
{{- define "trek.fullname" -}}
|
||||||
|
{{- if .Values.fullnameOverride -}}
|
||||||
|
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $name := default .Chart.Name .Values.nameOverride -}}
|
||||||
|
{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-config
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
data:
|
||||||
|
NODE_ENV: {{ .Values.env.NODE_ENV | quote }}
|
||||||
|
PORT: {{ .Values.env.PORT | quote }}
|
||||||
|
{{- if .Values.env.ALLOWED_ORIGINS }}
|
||||||
|
ALLOWED_ORIGINS: {{ .Values.env.ALLOWED_ORIGINS | quote }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.imagePullSecrets }}
|
||||||
|
imagePullSecrets:
|
||||||
|
{{- range .Values.imagePullSecrets }}
|
||||||
|
- name: {{ .name }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
containers:
|
||||||
|
- name: trek
|
||||||
|
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
|
||||||
|
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
envFrom:
|
||||||
|
- configMapRef:
|
||||||
|
name: {{ include "trek.fullname" . }}-config
|
||||||
|
env:
|
||||||
|
- name: JWT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ default (printf "%s-secret" (include "trek.fullname" .)) .Values.existingSecret }}
|
||||||
|
key: {{ .Values.existingSecretKey | default "JWT_SECRET" }}
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /app/data
|
||||||
|
- name: uploads
|
||||||
|
mountPath: /app/uploads
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 15
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "trek.fullname" . }}-data
|
||||||
|
- name: uploads
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "trek.fullname" . }}-uploads
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{- if .Values.ingress.enabled }}
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
{{- with .Values.ingress.annotations }}
|
||||||
|
annotations:
|
||||||
|
{{- toYaml . | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
spec:
|
||||||
|
{{- if .Values.ingress.tls }}
|
||||||
|
tls:
|
||||||
|
{{- toYaml .Values.ingress.tls | nindent 4 }}
|
||||||
|
{{- end }}
|
||||||
|
rules:
|
||||||
|
{{- range .Values.ingress.hosts }}
|
||||||
|
- host: {{ .host }}
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
{{- range .paths }}
|
||||||
|
- path: {{ . }}
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: {{ include "trek.fullname" $ }}
|
||||||
|
port:
|
||||||
|
number: {{ $.Values.service.port }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-data
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.data.size }}
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-uploads
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.persistence.uploads.size }}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{{- if and (not .Values.existingSecret) (not .Values.generateJwtSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-secret
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ .Values.secretEnv.JWT_SECRET | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{- if and (not .Values.existingSecret) (.Values.generateJwtSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}-secret
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
{{ .Values.existingSecretKey | default "JWT_SECRET" }}: {{ randAlphaNum 32 }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: {{ include "trek.fullname" . }}
|
||||||
|
labels:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
|
spec:
|
||||||
|
type: {{ .Values.service.type }}
|
||||||
|
ports:
|
||||||
|
- port: {{ .Values.service.port }}
|
||||||
|
targetPort: 3000
|
||||||
|
protocol: TCP
|
||||||
|
name: http
|
||||||
|
selector:
|
||||||
|
app: {{ include "trek.name" . }}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
|
||||||
|
image:
|
||||||
|
repository: mauriceboe/trek
|
||||||
|
tag: latest
|
||||||
|
pullPolicy: IfNotPresent
|
||||||
|
|
||||||
|
# Optional image pull secrets for private registries
|
||||||
|
imagePullSecrets: []
|
||||||
|
# - name: my-registry-secret
|
||||||
|
|
||||||
|
service:
|
||||||
|
type: ClusterIP
|
||||||
|
port: 3000
|
||||||
|
|
||||||
|
env:
|
||||||
|
NODE_ENV: production
|
||||||
|
PORT: 3000
|
||||||
|
# ALLOWED_ORIGINS: ""
|
||||||
|
# NOTE: If using ingress, ensure env.ALLOWED_ORIGINS matches the domains in ingress.hosts for proper CORS configuration.
|
||||||
|
|
||||||
|
|
||||||
|
# JWT secret configuration
|
||||||
|
secretEnv:
|
||||||
|
# If set, use this value for JWT_SECRET (base64-encoded in secret.yaml)
|
||||||
|
JWT_SECRET: ""
|
||||||
|
|
||||||
|
# If true, a random JWT_SECRET will be generated during install (overrides secretEnv.JWT_SECRET)
|
||||||
|
generateJwtSecret: false
|
||||||
|
|
||||||
|
# If set, use an existing Kubernetes secret for JWT_SECRET
|
||||||
|
existingSecret: ""
|
||||||
|
existingSecretKey: JWT_SECRET
|
||||||
|
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
data:
|
||||||
|
size: 1Gi
|
||||||
|
uploads:
|
||||||
|
size: 1Gi
|
||||||
|
|
||||||
|
resources: {}
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: false
|
||||||
|
annotations: {}
|
||||||
|
hosts:
|
||||||
|
- host: chart-example.local
|
||||||
|
paths:
|
||||||
|
- /
|
||||||
|
tls: []
|
||||||
|
# - secretName: chart-example-tls
|
||||||
|
# hosts:
|
||||||
|
# - chart-example.local
|
||||||
@@ -1,14 +1,30 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23111827' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M17.8 19.2 16 11l3.5-3.5C21 6 21 4 19 2c-2-2-4-2-5.5-.5L10 5 1.8 6.2c-.5.1-.9.6-.6 1.1l1.9 2.9 2.5-.9 4-4 2.7 2.7-4 4 .9 2.5 2.9 1.9c.5.3 1 0 1.1-.5z'/></svg>" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>NOMAD</title>
|
<title>TREK</title>
|
||||||
|
|
||||||
|
<!-- PWA / iOS -->
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta name="apple-mobile-web-app-title" content="TREK" />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180x180.png" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-dark.svg" />
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=MuseoModerno:wght@400;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Leaflet -->
|
||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "nomad-client",
|
"name": "trek-client",
|
||||||
"version": "2.0.0",
|
"version": "2.7.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "node scripts/generate-icons.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
@@ -18,8 +19,10 @@
|
|||||||
"react-dropzone": "^14.4.1",
|
"react-dropzone": "^14.4.1",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^6.22.2",
|
"react-router-dom": "^6.22.2",
|
||||||
"react-window": "^2.2.7",
|
"react-window": "^2.2.7",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
@@ -27,10 +30,14 @@
|
|||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
"@types/react": "^18.2.61",
|
"@types/react": "^18.2.61",
|
||||||
"@types/react-dom": "^18.2.19",
|
"@types/react-dom": "^18.2.19",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"postcss": "^8.4.35",
|
"postcss": "^8.4.35",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"vite": "^5.1.4"
|
"typescript": "^6.0.2",
|
||||||
|
"vite": "^5.1.4",
|
||||||
|
"vite-plugin-pwa": "^0.21.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="a5b4275efd"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="61932b752f"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.753906 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.753906 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#a5b4275efd)"><g clip-path="url(#61932b752f)"><path fill="#000000" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="2000" zoomAndPan="magnify" viewBox="0 0 1500 1499.999933" height="2000" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><clipPath id="ff6253e8fa"><path d="M 45 5.265625 L 1455 5.265625 L 1455 1494.765625 L 45 1494.765625 Z M 45 5.265625 " clip-rule="nonzero"/></clipPath><clipPath id="c6b14a8188"><path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z M 1056.105469 333.019531 " clip-rule="nonzero"/></clipPath></defs><g clip-path="url(#ff6253e8fa)"><g clip-path="url(#c6b14a8188)"><path fill="#ffffff" d="M 40.597656 5.328125 L 40.597656 1494.671875 L 1459.472656 1494.671875 L 1459.472656 5.328125 Z M 40.597656 5.328125 " fill-opacity="1" fill-rule="nonzero"/></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#1e293b"/>
|
||||||
|
<stop offset="100%" stop-color="#0f172a"/>
|
||||||
|
</linearGradient>
|
||||||
|
<clipPath id="icon">
|
||||||
|
<path d="M 855.636719 699.203125 L 222.246094 699.203125 C 197.679688 699.203125 179.90625 675.75 186.539062 652.101562 L 360.429688 32.390625 C 364.921875 16.386719 379.511719 5.328125 396.132812 5.328125 L 1029.527344 5.328125 C 1054.089844 5.328125 1071.867188 28.777344 1065.230469 52.429688 L 891.339844 672.136719 C 886.851562 688.140625 872.257812 699.203125 855.636719 699.203125 Z M 444.238281 1166.980469 L 533.773438 847.898438 C 540.410156 824.246094 522.632812 800.796875 498.070312 800.796875 L 172.472656 800.796875 C 155.851562 800.796875 141.261719 811.855469 136.769531 827.859375 L 47.234375 1146.941406 C 40.597656 1170.59375 58.375 1194.042969 82.9375 1194.042969 L 408.535156 1194.042969 C 425.15625 1194.042969 439.75 1182.984375 444.238281 1166.980469 Z M 609.003906 827.859375 L 435.113281 1447.570312 C 428.476562 1471.21875 446.253906 1494.671875 470.816406 1494.671875 L 1104.210938 1494.671875 C 1120.832031 1494.671875 1135.421875 1483.609375 1139.914062 1467.605469 L 1313.804688 847.898438 C 1320.441406 824.246094 1302.664062 800.796875 1278.101562 800.796875 L 644.707031 800.796875 C 628.085938 800.796875 613.492188 811.855469 609.003906 827.859375 Z M 1056.105469 333.019531 L 966.570312 652.101562 C 959.933594 675.75 977.710938 699.203125 1002.273438 699.203125 L 1327.871094 699.203125 C 1344.492188 699.203125 1359.085938 688.140625 1363.574219 672.136719 L 1453.109375 353.054688 C 1459.746094 329.40625 1441.96875 305.953125 1417.40625 305.953125 L 1091.808594 305.953125 C 1075.1875 305.953125 1060.597656 317.015625 1056.105469 333.019531 Z"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" fill="url(#bg)"/>
|
||||||
|
<g transform="translate(56,51) scale(0.267)">
|
||||||
|
<rect width="1500" height="1500" fill="#ffffff" clip-path="url(#icon)"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="c5c1a398e1"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="9b226024c5"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#c5c1a398e1)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#9b226024c5)"><g fill="#000000" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#000000" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="230" zoomAndPan="magnify" viewBox="49 2 124 56" height="80" preserveAspectRatio="xMidYMid meet" version="1.0"><defs><g/><clipPath id="7fc4e3f80b"><path d="M 49 0.015625 L 174 0.015625 L 174 59.984375 L 49 59.984375 Z M 49 0.015625 " clip-rule="nonzero"/></clipPath><clipPath id="086ce69399"><rect x="0" width="125" y="0" height="60"/></clipPath></defs><g clip-path="url(#7fc4e3f80b)"><g transform="matrix(1, 0, 0, 1, 49, -0.000000000000011803)"><g clip-path="url(#086ce69399)"><g fill="#ffffff" fill-opacity="1"><g transform="translate(0.740932, 54.900246)"><g><path d="M 17.53125 0 C 14.15625 0 11.515625 -0.960938 9.609375 -2.890625 C 7.710938 -4.816406 6.765625 -7.414062 6.765625 -10.6875 L 6.765625 -45.484375 L 17.890625 -45.484375 L 17.890625 -11.328125 C 17.890625 -10.765625 18.09375 -10.28125 18.5 -9.875 C 18.90625 -9.46875 19.390625 -9.265625 19.953125 -9.265625 L 27.9375 -9.265625 L 27.9375 0 Z M 0.78125 -27.515625 L 0.78125 -36.5625 L 27.9375 -36.5625 L 27.9375 -27.515625 Z M 0.78125 -27.515625 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(26.403702, 54.900246)"><g><path d="M 3.84375 0 L 3.84375 -25.796875 C 3.84375 -29.128906 4.789062 -31.742188 6.6875 -33.640625 C 8.59375 -35.546875 11.234375 -36.5 14.609375 -36.5 L 25.234375 -36.5 L 25.234375 -27.515625 L 17.328125 -27.515625 C 16.660156 -27.515625 16.085938 -27.285156 15.609375 -26.828125 C 15.128906 -26.378906 14.890625 -25.800781 14.890625 -25.09375 L 14.890625 0 Z M 3.84375 0 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(47.504187, 54.900246)"><g><path d="M 23.234375 0 C 19.097656 0 15.460938 -0.769531 12.328125 -2.3125 C 9.191406 -3.863281 6.753906 -6.003906 5.015625 -8.734375 C 3.285156 -11.460938 2.421875 -14.632812 2.421875 -18.25 C 2.421875 -22.238281 3.25 -25.660156 4.90625 -28.515625 C 6.570312 -31.367188 8.796875 -33.566406 11.578125 -35.109375 C 14.359375 -36.648438 17.4375 -37.421875 20.8125 -37.421875 C 24.664062 -37.421875 27.882812 -36.613281 30.46875 -35 C 33.0625 -33.382812 35.019531 -31.1875 36.34375 -28.40625 C 37.675781 -25.625 38.34375 -22.453125 38.34375 -18.890625 C 38.34375 -18.273438 38.304688 -17.550781 38.234375 -16.71875 C 38.171875 -15.882812 38.09375 -15.226562 38 -14.75 L 14.046875 -14.75 C 14.328125 -13.519531 14.867188 -12.472656 15.671875 -11.609375 C 16.484375 -10.753906 17.503906 -10.125 18.734375 -9.71875 C 19.972656 -9.320312 21.351562 -9.125 22.875 -9.125 L 34.078125 -9.125 L 34.078125 0 Z M 13.75 -21.671875 L 27.65625 -21.671875 C 27.5625 -22.429688 27.414062 -23.164062 27.21875 -23.875 C 27.03125 -24.59375 26.734375 -25.222656 26.328125 -25.765625 C 25.929688 -26.316406 25.472656 -26.789062 24.953125 -27.1875 C 24.429688 -27.59375 23.820312 -27.914062 23.125 -28.15625 C 22.4375 -28.394531 21.664062 -28.515625 20.8125 -28.515625 C 19.71875 -28.515625 18.742188 -28.320312 17.890625 -27.9375 C 17.035156 -27.5625 16.320312 -27.050781 15.75 -26.40625 C 15.175781 -25.769531 14.734375 -25.035156 14.421875 -24.203125 C 14.117188 -23.367188 13.894531 -22.523438 13.75 -21.671875 Z M 13.75 -21.671875 "/></g></g></g><g fill="#ffffff" fill-opacity="1"><g transform="translate(83.218247, 54.900246)"><g><path d="M 4.140625 0 L 4.140625 -52.03125 L 15.1875 -52.03125 L 15.1875 -22.734375 L 20.03125 -22.734375 L 28.375 -36.5625 L 40.703125 -36.5625 L 30.859375 -21.3125 C 33.710938 -20.125 35.9375 -18.304688 37.53125 -15.859375 C 39.125 -13.410156 39.921875 -10.546875 39.921875 -7.265625 L 39.921875 0 L 28.796875 0 L 28.796875 -7.265625 C 28.796875 -8.597656 28.472656 -9.796875 27.828125 -10.859375 C 27.191406 -11.929688 26.347656 -12.773438 25.296875 -13.390625 C 24.253906 -14.015625 23.066406 -14.328125 21.734375 -14.328125 L 15.1875 -14.328125 L 15.1875 0 Z M 4.140625 0 "/></g></g></g></g></g></g></svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Generates PNG icons for PWA from the master SVG icon.
|
||||||
|
* Run: node scripts/generate-icons.mjs
|
||||||
|
* Called automatically via the "prebuild" npm script.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const iconsDir = join(__dirname, '..', 'public', 'icons');
|
||||||
|
const svgBuffer = readFileSync(join(iconsDir, 'icon.svg'));
|
||||||
|
|
||||||
|
const sizes = [
|
||||||
|
{ name: 'apple-touch-icon-180x180.png', size: 180 },
|
||||||
|
{ name: 'icon-192x192.png', size: 192 },
|
||||||
|
{ name: 'icon-512x512.png', size: 512 },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { name, size } of sizes) {
|
||||||
|
await sharp(svgBuffer, { density: 300 })
|
||||||
|
.resize(size, size)
|
||||||
|
.png({ compressionLevel: 9 })
|
||||||
|
.toFile(join(iconsDir, name));
|
||||||
|
console.log(` \u2713 ${name} (${size}x${size})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('PWA icons generated.');
|
||||||
@@ -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 { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from './store/authStore'
|
import { useAuthStore } from './store/authStore'
|
||||||
import { useSettingsStore } from './store/settingsStore'
|
import { useSettingsStore } from './store/settingsStore'
|
||||||
@@ -6,24 +6,32 @@ import LoginPage from './pages/LoginPage'
|
|||||||
import RegisterPage from './pages/RegisterPage'
|
import RegisterPage from './pages/RegisterPage'
|
||||||
import DashboardPage from './pages/DashboardPage'
|
import DashboardPage from './pages/DashboardPage'
|
||||||
import TripPlannerPage from './pages/TripPlannerPage'
|
import TripPlannerPage from './pages/TripPlannerPage'
|
||||||
// PhotosPage removed - replaced by Finanzplan
|
|
||||||
import FilesPage from './pages/FilesPage'
|
import FilesPage from './pages/FilesPage'
|
||||||
import AdminPage from './pages/AdminPage'
|
import AdminPage from './pages/AdminPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
|
import VacayPage from './pages/VacayPage'
|
||||||
|
import AtlasPage from './pages/AtlasPage'
|
||||||
|
import SharedTripPage from './pages/SharedTripPage'
|
||||||
import { ToastContainer } from './components/shared/Toast'
|
import { ToastContainer } from './components/shared/Toast'
|
||||||
import { TranslationProvider } from './i18n'
|
import { TranslationProvider, useTranslation } from './i18n'
|
||||||
import DemoBanner from './components/Layout/DemoBanner'
|
import DemoBanner from './components/Layout/DemoBanner'
|
||||||
import { authApi } from './api/client'
|
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 { isAuthenticated, user, isLoading } = useAuthStore()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -33,11 +41,11 @@ function ProtectedRoute({ children, adminRequired = false }) {
|
|||||||
return <Navigate to="/login" replace />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (adminRequired && user?.role !== 'admin') {
|
if (adminRequired && user && user.role !== 'admin') {
|
||||||
return <Navigate to="/dashboard" replace />
|
return <Navigate to="/dashboard" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RootRedirect() {
|
function RootRedirect() {
|
||||||
@@ -55,15 +63,37 @@ function RootRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { loadUser, token, isAuthenticated, demoMode, setDemoMode } = useAuthStore()
|
const { loadUser, token, isAuthenticated, demoMode, setDemoMode, setHasMapsKey, setServerTimezone } = useAuthStore()
|
||||||
const { loadSettings } = useSettingsStore()
|
const { loadSettings } = useSettingsStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token) {
|
if (token) {
|
||||||
loadUser()
|
loadUser()
|
||||||
}
|
}
|
||||||
authApi.getAppConfig().then(config => {
|
authApi.getAppConfig().then(async (config: { demo_mode?: boolean; has_maps_key?: boolean; version?: string; timezone?: string }) => {
|
||||||
if (config?.demo_mode) setDemoMode(true)
|
if (config?.demo_mode) setDemoMode(true)
|
||||||
|
if (config?.has_maps_key !== undefined) setHasMapsKey(config.has_maps_key)
|
||||||
|
if (config?.timezone) setServerTimezone(config.timezone)
|
||||||
|
|
||||||
|
if (config?.version) {
|
||||||
|
const storedVersion = localStorage.getItem('trek_app_version')
|
||||||
|
if (storedVersion && storedVersion !== config.version) {
|
||||||
|
try {
|
||||||
|
if ('caches' in window) {
|
||||||
|
const names = await caches.keys()
|
||||||
|
await Promise.all(names.map(n => caches.delete(n)))
|
||||||
|
}
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const regs = await navigator.serviceWorker.getRegistrations()
|
||||||
|
await Promise.all(regs.map(r => r.unregister()))
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
window.location.reload()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
localStorage.setItem('trek_app_version', config.version)
|
||||||
|
}
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
@@ -75,13 +105,22 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [isAuthenticated])
|
}, [isAuthenticated])
|
||||||
|
|
||||||
// Apply dark mode class to <html>
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settings.dark_mode) {
|
const mode = settings.dark_mode
|
||||||
document.documentElement.classList.add('dark')
|
const applyDark = (isDark: boolean) => {
|
||||||
} else {
|
document.documentElement.classList.toggle('dark', isDark)
|
||||||
document.documentElement.classList.remove('dark')
|
const meta = document.querySelector('meta[name="theme-color"]')
|
||||||
|
if (meta) meta.setAttribute('content', isDark ? '#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])
|
}, [settings.dark_mode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +129,8 @@ export default function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<RootRedirect />} />
|
<Route path="/" element={<RootRedirect />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<Navigate to="/login" replace />} />
|
<Route path="/shared/:token" element={<SharedTripPage />} />
|
||||||
|
<Route path="/register" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
path="/dashboard"
|
path="/dashboard"
|
||||||
element={
|
element={
|
||||||
@@ -131,6 +171,22 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/vacay"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<VacayPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/atlas"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AtlasPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</TranslationProvider>
|
</TranslationProvider>
|
||||||
@@ -1,202 +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),
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
|
|
||||||
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,307 @@
|
|||||||
|
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; invite_token?: string }) => apiClient.post('/auth/register', data).then(r => r.data),
|
||||||
|
validateInvite: (token: string) => apiClient.get(`/auth/invite/${token}`).then(r => r.data),
|
||||||
|
login: (data: { email: string; password: string }) => apiClient.post('/auth/login', data).then(r => r.data),
|
||||||
|
verifyMfaLogin: (data: { mfa_token: string; code: string }) => apiClient.post('/auth/mfa/verify-login', data).then(r => r.data),
|
||||||
|
mfaSetup: () => apiClient.post('/auth/mfa/setup', {}).then(r => r.data),
|
||||||
|
mfaEnable: (data: { code: string }) => apiClient.post('/auth/mfa/enable', data).then(r => r.data),
|
||||||
|
mfaDisable: (data: { password: string; code: string }) => apiClient.post('/auth/mfa/disable', 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),
|
||||||
|
importGpx: (tripId: number | string, file: File) => {
|
||||||
|
const fd = new FormData(); fd.append('file', file)
|
||||||
|
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).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),
|
||||||
|
bulkImport: (tripId: number | string, items: { name: string; category?: string; quantity?: number }[]) => apiClient.post(`/trips/${tripId}/packing/import`, { items }).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),
|
||||||
|
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).then(r => r.data),
|
||||||
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds }).then(r => r.data),
|
||||||
|
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||||
|
listBags: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/bags`).then(r => r.data),
|
||||||
|
createBag: (tripId: number | string, data: { name: string; color?: string }) => apiClient.post(`/trips/${tripId}/packing/bags`, data).then(r => r.data),
|
||||||
|
updateBag: (tripId: number | string, bagId: number, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}`, data).then(r => r.data),
|
||||||
|
deleteBag: (tripId: number | string, bagId: number) => apiClient.delete(`/trips/${tripId}/packing/bags/${bagId}`).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),
|
||||||
|
getBagTracking: () => apiClient.get('/admin/bag-tracking').then(r => r.data),
|
||||||
|
updateBagTracking: (enabled: boolean) => apiClient.put('/admin/bag-tracking', { enabled }).then(r => r.data),
|
||||||
|
packingTemplates: () => apiClient.get('/admin/packing-templates').then(r => r.data),
|
||||||
|
getPackingTemplate: (id: number) => apiClient.get(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||||
|
createPackingTemplate: (data: { name: string }) => apiClient.post('/admin/packing-templates', data).then(r => r.data),
|
||||||
|
updatePackingTemplate: (id: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${id}`, data).then(r => r.data),
|
||||||
|
deletePackingTemplate: (id: number) => apiClient.delete(`/admin/packing-templates/${id}`).then(r => r.data),
|
||||||
|
addTemplateCategory: (templateId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories`, data).then(r => r.data),
|
||||||
|
updateTemplateCategory: (templateId: number, catId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/categories/${catId}`, data).then(r => r.data),
|
||||||
|
deleteTemplateCategory: (templateId: number, catId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/categories/${catId}`).then(r => r.data),
|
||||||
|
addTemplateItem: (templateId: number, catId: number, data: { name: string }) => apiClient.post(`/admin/packing-templates/${templateId}/categories/${catId}/items`, data).then(r => r.data),
|
||||||
|
updateTemplateItem: (templateId: number, itemId: number, data: { name: string }) => apiClient.put(`/admin/packing-templates/${templateId}/items/${itemId}`, data).then(r => r.data),
|
||||||
|
deleteTemplateItem: (templateId: number, itemId: number) => apiClient.delete(`/admin/packing-templates/${templateId}/items/${itemId}`).then(r => r.data),
|
||||||
|
listInvites: () => apiClient.get('/admin/invites').then(r => r.data),
|
||||||
|
createInvite: (data: { max_uses: number; expires_in_days?: number }) => apiClient.post('/admin/invites', data).then(r => r.data),
|
||||||
|
deleteInvite: (id: number) => apiClient.delete(`/admin/invites/${id}`).then(r => r.data),
|
||||||
|
auditLog: (params?: { limit?: number; offset?: number }) =>
|
||||||
|
apiClient.get('/admin/audit-log', { params }).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/${encodeURIComponent(placeId)}`, { params: { lang } }).then(r => r.data),
|
||||||
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => r.data),
|
||||||
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => r.data),
|
||||||
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).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),
|
||||||
|
settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filesApi = {
|
||||||
|
list: (tripId: number | string, trash?: boolean) => apiClient.get(`/trips/${tripId}/files`, { params: trash ? { trash: 'true' } : {} }).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),
|
||||||
|
toggleStar: (tripId: number | string, id: number) => apiClient.patch(`/trips/${tripId}/files/${id}/star`).then(r => r.data),
|
||||||
|
restore: (tripId: number | string, id: number) => apiClient.post(`/trips/${tripId}/files/${id}/restore`).then(r => r.data),
|
||||||
|
permanentDelete: (tripId: number | string, id: number) => apiClient.delete(`/trips/${tripId}/files/${id}/permanent`).then(r => r.data),
|
||||||
|
emptyTrash: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/files/trash/empty`).then(r => r.data),
|
||||||
|
addLink: (tripId: number | string, fileId: number, data: { reservation_id?: number; assignment_id?: number }) => apiClient.post(`/trips/${tripId}/files/${fileId}/link`, data).then(r => r.data),
|
||||||
|
removeLink: (tripId: number | string, fileId: number, linkId: number) => apiClient.delete(`/trips/${tripId}/files/${fileId}/link/${linkId}`).then(r => r.data),
|
||||||
|
getLinks: (tripId: number | string, fileId: number) => apiClient.get(`/trips/${tripId}/files/${fileId}/links`).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),
|
||||||
|
updatePositions: (tripId: number | string, positions: { id: number; day_plan_position: number }[]) => apiClient.put(`/trips/${tripId}/reservations/positions`, { positions }).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 const shareApi = {
|
||||||
|
getLink: (tripId: number | string) => apiClient.get(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
createLink: (tripId: number | string, perms?: Record<string, boolean>) => apiClient.post(`/trips/${tripId}/share-link`, perms || {}).then(r => r.data),
|
||||||
|
deleteLink: (tripId: number | string) => apiClient.delete(`/trips/${tripId}/share-link`).then(r => r.data),
|
||||||
|
getSharedTrip: (token: string) => apiClient.get(`/shared/${token}`).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const notificationsApi = {
|
||||||
|
getPreferences: () => apiClient.get('/notifications/preferences').then(r => r.data),
|
||||||
|
updatePreferences: (prefs: Record<string, boolean>) => apiClient.put('/notifications/preferences', prefs).then(r => r.data),
|
||||||
|
testSmtp: (email?: string) => apiClient.post('/notifications/test-smtp', { email }).then(r => r.data),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default apiClient
|
||||||
@@ -1,47 +1,47 @@
|
|||||||
// Singleton WebSocket manager for real-time collaboration
|
// Singleton WebSocket manager for real-time collaboration
|
||||||
|
|
||||||
let socket = null
|
type WebSocketListener = (event: Record<string, unknown>) => void
|
||||||
let reconnectTimer = null
|
type RefetchCallback = (tripId: string) => void
|
||||||
|
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
let reconnectDelay = 1000
|
let reconnectDelay = 1000
|
||||||
const MAX_RECONNECT_DELAY = 30000
|
const MAX_RECONNECT_DELAY = 30000
|
||||||
const listeners = new Set()
|
const listeners = new Set<WebSocketListener>()
|
||||||
const activeTrips = new Set()
|
const activeTrips = new Set<string>()
|
||||||
let currentToken = null
|
let currentToken: string | null = null
|
||||||
let refetchCallback = null
|
let refetchCallback: RefetchCallback | null = null
|
||||||
let mySocketId = null
|
let mySocketId: string | null = null
|
||||||
|
|
||||||
export function getSocketId() {
|
export function getSocketId(): string | null {
|
||||||
return mySocketId
|
return mySocketId
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setRefetchCallback(fn) {
|
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
function getWsUrl(token) {
|
function getWsUrl(token: string): string {
|
||||||
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
return `${protocol}://${location.host}/ws?token=${token}`
|
return `${protocol}://${location.host}/ws?token=${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleMessage(event) {
|
function handleMessage(event: MessageEvent): void {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(event.data)
|
const parsed = JSON.parse(event.data)
|
||||||
// Store our socket ID from welcome message
|
|
||||||
if (parsed.type === 'welcome') {
|
if (parsed.type === 'welcome') {
|
||||||
mySocketId = parsed.socketId
|
mySocketId = parsed.socketId
|
||||||
console.log('[WS] Got socketId:', mySocketId)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log('[WS] Received:', parsed.type, parsed)
|
|
||||||
listeners.forEach(fn => {
|
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)
|
console.error('WebSocket message parse error:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleReconnect() {
|
function scheduleReconnect(): void {
|
||||||
if (reconnectTimer) return
|
if (reconnectTimer) return
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null
|
reconnectTimer = null
|
||||||
@@ -52,7 +52,7 @@ function scheduleReconnect() {
|
|||||||
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY)
|
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)) {
|
if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -61,20 +61,16 @@ function connectInternal(token, isReconnect = false) {
|
|||||||
socket = new WebSocket(url)
|
socket = new WebSocket(url)
|
||||||
|
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
console.log('[WS] Connected', isReconnect ? '(reconnect)' : '(initial)')
|
|
||||||
reconnectDelay = 1000
|
reconnectDelay = 1000
|
||||||
// Join active trips on any connect (initial or reconnect)
|
|
||||||
if (activeTrips.size > 0) {
|
if (activeTrips.size > 0) {
|
||||||
activeTrips.forEach(tripId => {
|
activeTrips.forEach(tripId => {
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'join', tripId }))
|
socket.send(JSON.stringify({ type: 'join', tripId }))
|
||||||
console.log('[WS] Joined trip', tripId)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
// Refetch trip data for active trips
|
|
||||||
if (refetchCallback) {
|
if (refetchCallback) {
|
||||||
activeTrips.forEach(tripId => {
|
activeTrips.forEach(tripId => {
|
||||||
try { refetchCallback(tripId) } catch (err) {
|
try { refetchCallback!(tripId) } catch (err: unknown) {
|
||||||
console.error('Failed to refetch trip data on reconnect:', err)
|
console.error('Failed to refetch trip data on reconnect:', err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -96,7 +92,7 @@ function connectInternal(token, isReconnect = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function connect(token) {
|
export function connect(token: string): void {
|
||||||
currentToken = token
|
currentToken = token
|
||||||
reconnectDelay = 1000
|
reconnectDelay = 1000
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
@@ -106,7 +102,7 @@ export function connect(token) {
|
|||||||
connectInternal(token, false)
|
connectInternal(token, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnect() {
|
export function disconnect(): void {
|
||||||
currentToken = null
|
currentToken = null
|
||||||
if (reconnectTimer) {
|
if (reconnectTimer) {
|
||||||
clearTimeout(reconnectTimer)
|
clearTimeout(reconnectTimer)
|
||||||
@@ -114,30 +110,30 @@ export function disconnect() {
|
|||||||
}
|
}
|
||||||
activeTrips.clear()
|
activeTrips.clear()
|
||||||
if (socket) {
|
if (socket) {
|
||||||
socket.onclose = null // prevent reconnect
|
socket.onclose = null
|
||||||
socket.close()
|
socket.close()
|
||||||
socket = null
|
socket = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function joinTrip(tripId) {
|
export function joinTrip(tripId: number | string): void {
|
||||||
activeTrips.add(String(tripId))
|
activeTrips.add(String(tripId))
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
|
socket.send(JSON.stringify({ type: 'join', tripId: String(tripId) }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function leaveTrip(tripId) {
|
export function leaveTrip(tripId: number | string): void {
|
||||||
activeTrips.delete(String(tripId))
|
activeTrips.delete(String(tripId))
|
||||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
|
socket.send(JSON.stringify({ type: 'leave', tripId: String(tripId) }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function addListener(fn) {
|
export function addListener(fn: WebSocketListener): void {
|
||||||
listeners.add(fn)
|
listeners.add(fn)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeListener(fn) {
|
export function removeListener(fn: WebSocketListener): void {
|
||||||
listeners.delete(fn)
|
listeners.delete(fn)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image } from 'lucide-react'
|
||||||
|
|
||||||
|
const ICON_MAP = {
|
||||||
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image,
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ bagTrackingEnabled, onToggleBagTracking }: { bagTrackingEnabled?: boolean; onToggleBagTracking?: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAddons()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadAddons = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addons()
|
||||||
|
setAddons(data.addons)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(t('admin.addons.toast.error'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggle = async (addon) => {
|
||||||
|
const newEnabled = !addon.enabled
|
||||||
|
// Optimistic update
|
||||||
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: newEnabled } : a))
|
||||||
|
try {
|
||||||
|
await adminApi.updateAddon(addon.id, { enabled: newEnabled })
|
||||||
|
window.dispatchEvent(new Event('addons-changed'))
|
||||||
|
toast.success(t('admin.addons.toast.updated'))
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Rollback
|
||||||
|
setAddons(prev => prev.map(a => a.id === addon.id ? { ...a, enabled: !newEnabled } : a))
|
||||||
|
toast.error(t('admin.addons.toast.error'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tripAddons = addons.filter(a => a.type === 'trip')
|
||||||
|
const globalAddons = addons.filter(a => a.type === 'global')
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center">
|
||||||
|
<div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" style={{ borderTopColor: 'var(--text-primary)' }}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="px-6 py-4 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('admin.addons.title')}</h2>
|
||||||
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
|
||||||
|
{t('admin.addons.subtitleBefore')}<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 11, display: 'inline', verticalAlign: 'middle', opacity: 0.7 }} />{t('admin.addons.subtitleAfter')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{addons.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('admin.addons.noAddons')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{/* Trip Addons */}
|
||||||
|
{tripAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Briefcase size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.trip')} — {t('admin.addons.tripHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tripAddons.map(addon => (
|
||||||
|
<div key={addon.id}>
|
||||||
|
<AddonRow addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
{addon.id === 'packing' && addon.enabled && onToggleBagTracking && (
|
||||||
|
<div className="flex items-center gap-4 px-6 py-3 border-b" style={{ borderColor: 'var(--border-secondary)', background: 'var(--bg-secondary)', paddingLeft: 70 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>{t('admin.bagTracking.title')}</div>
|
||||||
|
<div className="text-xs mt-0.5" style={{ color: 'var(--text-faint)' }}>{t('admin.bagTracking.subtitle')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="hidden sm:inline text-xs font-medium" style={{ color: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--text-faint)' }}>
|
||||||
|
{bagTrackingEnabled ? t('admin.addons.enabled') : t('admin.addons.disabled')}
|
||||||
|
</span>
|
||||||
|
<button onClick={onToggleBagTracking}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: bagTrackingEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-0.5 h-5 w-5 rounded-full bg-white transition-transform duration-200"
|
||||||
|
style={{ transform: bagTrackingEnabled ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Addons */}
|
||||||
|
{globalAddons.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="px-6 py-2.5 border-b border-t flex items-center gap-2" style={{ background: 'var(--bg-secondary)', borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<Globe size={13} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span className="text-xs font-medium uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{t('admin.addons.type.global')} — {t('admin.addons.globalHint')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{globalAddons.map(addon => (
|
||||||
|
<AddonRow key={addon.id} addon={addon} onToggle={handleToggle} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddonRowProps {
|
||||||
|
addon: Addon
|
||||||
|
onToggle: (addonId: string) => void
|
||||||
|
t: (key: string) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAddonLabel(t: (key: string) => string, addon: Addon): { name: string; description: string } {
|
||||||
|
const nameKey = `admin.addons.catalog.${addon.id}.name`
|
||||||
|
const descKey = `admin.addons.catalog.${addon.id}.description`
|
||||||
|
const translatedName = t(nameKey)
|
||||||
|
const translatedDescription = t(descKey)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: translatedName !== nameKey ? translatedName : addon.name,
|
||||||
|
description: translatedDescription !== descKey ? translatedDescription : addon.description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddonRow({ addon, onToggle, t }: AddonRowProps) {
|
||||||
|
const isComingSoon = false
|
||||||
|
const label = getAddonLabel(t, addon)
|
||||||
|
return (
|
||||||
|
<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} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<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)' }}>{label.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)',
|
||||||
|
}}>
|
||||||
|
{addon.type === 'global' ? t('admin.addons.type.global') : t('admin.addons.type.trip')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: 'var(--text-muted)' }}>{label.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
<span className="hidden sm:inline 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={() => !isComingSoon && onToggle(addon)}
|
||||||
|
disabled={isComingSoon}
|
||||||
|
className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
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: 'var(--bg-card)',
|
||||||
|
transform: (addon.enabled && !isComingSoon) ? 'translateX(22px)' : 'translateX(4px)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { RefreshCw, ClipboardList } from 'lucide-react'
|
||||||
|
|
||||||
|
interface AuditEntry {
|
||||||
|
id: number
|
||||||
|
created_at: string
|
||||||
|
user_id: number | null
|
||||||
|
username: string | null
|
||||||
|
user_email: string | null
|
||||||
|
action: string
|
||||||
|
resource: string | null
|
||||||
|
details: Record<string, unknown> | null
|
||||||
|
ip: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditLogPanel(): React.ReactElement {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const [entries, setEntries] = useState<AuditEntry[]>([])
|
||||||
|
const [total, setTotal] = useState(0)
|
||||||
|
const [offset, setOffset] = useState(0)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const limit = 100
|
||||||
|
|
||||||
|
const loadFirstPage = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.auditLog({ limit, offset: 0 }) as {
|
||||||
|
entries: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
setEntries(data.entries || [])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setOffset(0)
|
||||||
|
} catch {
|
||||||
|
setEntries([])
|
||||||
|
setTotal(0)
|
||||||
|
setOffset(0)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
const nextOffset = offset + limit
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.auditLog({ limit, offset: nextOffset }) as {
|
||||||
|
entries: AuditEntry[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
setEntries((prev) => [...prev, ...(data.entries || [])])
|
||||||
|
setTotal(data.total ?? 0)
|
||||||
|
setOffset(nextOffset)
|
||||||
|
} catch {
|
||||||
|
/* keep existing */
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [offset])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFirstPage()
|
||||||
|
}, [loadFirstPage])
|
||||||
|
|
||||||
|
const fmtTime = (iso: string) => {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(locale, {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'medium',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtDetails = (d: Record<string, unknown> | null) => {
|
||||||
|
if (!d || Object.keys(d).length === 0) return '—'
|
||||||
|
try {
|
||||||
|
return JSON.stringify(d)
|
||||||
|
} catch {
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userLabel = (e: AuditEntry) => {
|
||||||
|
if (e.username) return e.username
|
||||||
|
if (e.user_email) return e.user_email
|
||||||
|
if (e.user_id != null) return `#${e.user_id}`
|
||||||
|
return '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg m-0 flex items-center gap-2" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
<ClipboardList size={20} />
|
||||||
|
{t('admin.tabs.audit')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm m-0 mt-1" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadFirstPage()}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium border transition-opacity disabled:opacity-50"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', color: 'var(--text-primary)', background: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
||||||
|
{t('admin.audit.refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs m-0" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('admin.audit.showing', { count: entries.length, total })}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('common.loading')}</div>
|
||||||
|
) : entries.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-muted)' }}>{t('admin.audit.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border overflow-x-auto" style={{ borderColor: 'var(--border-primary)', background: 'var(--bg-card)' }}>
|
||||||
|
<table className="w-full text-sm border-collapse min-w-[720px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-left" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.time')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.user')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.action')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.resource')}</th>
|
||||||
|
<th className="p-3 font-semibold whitespace-nowrap" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.ip')}</th>
|
||||||
|
<th className="p-3 font-semibold" style={{ color: 'var(--text-secondary)' }}>{t('admin.audit.col.details')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b align-top" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<td className="p-3 whitespace-nowrap font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{fmtTime(e.created_at)}</td>
|
||||||
|
<td className="p-3" style={{ color: 'var(--text-primary)' }}>{userLabel(e)}</td>
|
||||||
|
<td className="p-3 font-mono text-xs" style={{ color: 'var(--text-primary)' }}>{e.action}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[140px]" style={{ color: 'var(--text-muted)' }}>{e.resource || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs whitespace-nowrap" style={{ color: 'var(--text-muted)' }}>{e.ip || '—'}</td>
|
||||||
|
<td className="p-3 font-mono text-xs break-all max-w-[280px]" style={{ color: 'var(--text-faint)' }}>{fmtDetails(e.details)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{entries.length < total && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() => loadMore()}
|
||||||
|
className="text-sm font-medium underline-offset-2 hover:underline disabled:opacity-50"
|
||||||
|
style={{ color: 'var(--text-secondary)' }}
|
||||||
|
>
|
||||||
|
{t('admin.audit.loadMore')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { backupApi } from '../../api/client'
|
import { backupApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive } from 'lucide-react'
|
import { Download, Trash2, Plus, RefreshCw, RotateCcw, Upload, Clock, Check, HardDrive, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
const INTERVAL_OPTIONS = [
|
const INTERVAL_OPTIONS = [
|
||||||
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
{ value: 'hourly', labelKey: 'backup.interval.hourly' },
|
||||||
@@ -20,18 +23,35 @@ const KEEP_OPTIONS = [
|
|||||||
{ value: 0, labelKey: 'backup.keep.forever' },
|
{ value: 0, labelKey: 'backup.keep.forever' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 0, labelKey: 'backup.dow.sunday' },
|
||||||
|
{ value: 1, labelKey: 'backup.dow.monday' },
|
||||||
|
{ value: 2, labelKey: 'backup.dow.tuesday' },
|
||||||
|
{ value: 3, labelKey: 'backup.dow.wednesday' },
|
||||||
|
{ value: 4, labelKey: 'backup.dow.thursday' },
|
||||||
|
{ value: 5, labelKey: 'backup.dow.friday' },
|
||||||
|
{ value: 6, labelKey: 'backup.dow.saturday' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const HOURS = Array.from({ length: 24 }, (_, i) => i)
|
||||||
|
|
||||||
|
const DAYS_OF_MONTH = Array.from({ length: 28 }, (_, i) => i + 1)
|
||||||
|
|
||||||
export default function BackupPanel() {
|
export default function BackupPanel() {
|
||||||
const [backups, setBackups] = useState([])
|
const [backups, setBackups] = useState([])
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [isCreating, setIsCreating] = useState(false)
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
const [restoringFile, setRestoringFile] = useState(null)
|
const [restoringFile, setRestoringFile] = useState(null)
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
const [isUploading, setIsUploading] = useState(false)
|
||||||
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7 })
|
const [autoSettings, setAutoSettings] = useState({ enabled: false, interval: 'daily', keep_days: 7, hour: 2, day_of_week: 0, day_of_month: 1 })
|
||||||
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
const [autoSettingsSaving, setAutoSettingsSaving] = useState(false)
|
||||||
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
const [autoSettingsDirty, setAutoSettingsDirty] = useState(false)
|
||||||
|
const [serverTimezone, setServerTimezone] = useState('')
|
||||||
|
const [restoreConfirm, setRestoreConfirm] = useState(null) // { type: 'file'|'upload', filename, file? }
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
|
||||||
const loadBackups = async () => {
|
const loadBackups = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -49,6 +69,7 @@ export default function BackupPanel() {
|
|||||||
try {
|
try {
|
||||||
const data = await backupApi.getAutoSettings()
|
const data = await backupApi.getAutoSettings()
|
||||||
setAutoSettings(data.settings)
|
setAutoSettings(data.settings)
|
||||||
|
if (data.timezone) setServerTimezone(data.timezone)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,32 +88,42 @@ export default function BackupPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRestore = async (filename) => {
|
const handleRestore = (filename) => {
|
||||||
if (!confirm(t('backup.confirm.restore', { name: filename }))) return
|
setRestoreConfirm({ type: 'file', filename })
|
||||||
setRestoringFile(filename)
|
|
||||||
try {
|
|
||||||
await backupApi.restore(filename)
|
|
||||||
toast.success(t('backup.toast.restored'))
|
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err.response?.data?.error || t('backup.toast.restoreError'))
|
|
||||||
setRestoringFile(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleUploadRestore = async (e) => {
|
const handleUploadRestore = (e) => {
|
||||||
const file = e.target.files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
e.target.value = ''
|
e.target.value = ''
|
||||||
if (!confirm(t('backup.confirm.uploadRestore', { name: file.name }))) return
|
setRestoreConfirm({ type: 'upload', filename: file.name, file })
|
||||||
setIsUploading(true)
|
}
|
||||||
try {
|
|
||||||
await backupApi.uploadRestore(file)
|
const executeRestore = async () => {
|
||||||
toast.success(t('backup.toast.restored'))
|
if (!restoreConfirm) return
|
||||||
setTimeout(() => window.location.reload(), 1500)
|
const { type, filename, file } = restoreConfirm
|
||||||
} catch (err) {
|
setRestoreConfirm(null)
|
||||||
toast.error(err.response?.data?.error || t('backup.toast.uploadError'))
|
|
||||||
setIsUploading(false)
|
if (type === 'file') {
|
||||||
|
setRestoringFile(filename)
|
||||||
|
try {
|
||||||
|
await backupApi.restore(filename)
|
||||||
|
toast.success(t('backup.toast.restored'))
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('backup.toast.restoreError')))
|
||||||
|
setRestoringFile(null)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setIsUploading(true)
|
||||||
|
try {
|
||||||
|
await backupApi.uploadRestore(file)
|
||||||
|
toast.success(t('backup.toast.restored'))
|
||||||
|
setTimeout(() => window.location.reload(), 1500)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('backup.toast.uploadError')))
|
||||||
|
setIsUploading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,10 +166,12 @@ export default function BackupPanel() {
|
|||||||
const formatDate = (dateStr) => {
|
const formatDate = (dateStr) => {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleString(locale, {
|
const opts: Intl.DateTimeFormatOptions = {
|
||||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
hour: '2-digit', minute: '2-digit',
|
hour: '2-digit', minute: '2-digit',
|
||||||
})
|
}
|
||||||
|
if (serverTimezone) opts.timeZone = serverTimezone
|
||||||
|
return new Date(dateStr).toLocaleString(locale, opts)
|
||||||
} catch { return dateStr }
|
} catch { return dateStr }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,8 +186,8 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<HardDrive className="w-5 h-5 text-gray-400" />
|
<HardDrive className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('backup.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">{t('backup.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -179,26 +212,28 @@ export default function BackupPanel() {
|
|||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
className="flex items-center gap-2 border border-gray-200 text-gray-700 px-3 py-2 rounded-lg hover:bg-gray-50 text-sm font-medium disabled:opacity-60"
|
||||||
|
title={isUploading ? t('backup.uploading') : t('backup.upload')}
|
||||||
>
|
>
|
||||||
{isUploading ? (
|
{isUploading ? (
|
||||||
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{isUploading ? t('backup.uploading') : t('backup.upload')}
|
<span className="hidden sm:inline">{isUploading ? t('backup.uploading') : t('backup.upload')}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
className="flex items-center gap-2 bg-slate-700 text-white px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-60"
|
||||||
|
title={isCreating ? t('backup.creating') : t('backup.create')}
|
||||||
>
|
>
|
||||||
{isCreating ? (
|
{isCreating ? (
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
{isCreating ? t('backup.creating') : t('backup.create')}
|
<span className="hidden sm:inline">{isCreating ? t('backup.creating') : t('backup.create')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,23 +310,23 @@ export default function BackupPanel() {
|
|||||||
<div className="flex items-center gap-3 mb-6">
|
<div className="flex items-center gap-3 mb-6">
|
||||||
<Clock className="w-5 h-5 text-gray-400" />
|
<Clock className="w-5 h-5 text-gray-400" />
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('backup.auto.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('backup.auto.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">{t('backup.auto.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('backup.auto.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5">
|
<div className="flex flex-col gap-5">
|
||||||
{/* Enable toggle */}
|
{/* Enable toggle */}
|
||||||
<label className="flex items-center justify-between cursor-pointer">
|
<label className="flex items-center justify-between gap-4 cursor-pointer">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
<span className="text-sm font-medium text-gray-900">{t('backup.auto.enable')}</span>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
<p className="text-xs text-gray-500 mt-0.5">{t('backup.auto.enableHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
onClick={() => handleAutoSettingsChange('enabled', !autoSettings.enabled)}
|
||||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-700' : 'bg-gray-200'}`}
|
className={`relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors ${autoSettings.enabled ? 'bg-slate-900 dark:bg-slate-100' : 'bg-gray-200 dark:bg-gray-600'}`}
|
||||||
>
|
>
|
||||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${autoSettings.enabled ? 'translate-x-6' : 'translate-x-1'}`} />
|
<span className={`absolute left-1 h-4 w-4 rounded-full bg-white shadow transition-transform duration-200 ${autoSettings.enabled ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@@ -307,7 +342,7 @@ export default function BackupPanel() {
|
|||||||
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
onClick={() => handleAutoSettingsChange('interval', opt.value)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
autoSettings.interval === opt.value
|
autoSettings.interval === opt.value
|
||||||
? 'bg-slate-700 text-white border-slate-700'
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -317,6 +352,68 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Hour picker (for daily, weekly, monthly) */}
|
||||||
|
{autoSettings.interval !== 'hourly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.hour')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.hour)}
|
||||||
|
onChange={v => handleAutoSettingsChange('hour', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={HOURS.map(h => {
|
||||||
|
let label: string
|
||||||
|
if (is12h) {
|
||||||
|
const period = h >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
||||||
|
label = `${h12}:00 ${period}`
|
||||||
|
} else {
|
||||||
|
label = `${String(h).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
return { value: String(h), label }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{t('backup.auto.hourHint', { format: is12h ? '12h' : '24h' })}{serverTimezone ? ` (Timezone: ${serverTimezone})` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of week (for weekly) */}
|
||||||
|
{autoSettings.interval === 'weekly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfWeek')}</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYS_OF_WEEK.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleAutoSettingsChange('day_of_week', opt.value)}
|
||||||
|
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
|
autoSettings.day_of_week === opt.value
|
||||||
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Day of month (for monthly) */}
|
||||||
|
{autoSettings.interval === 'monthly' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.dayOfMonth')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={String(autoSettings.day_of_month)}
|
||||||
|
onChange={v => handleAutoSettingsChange('day_of_month', parseInt(v, 10))}
|
||||||
|
size="sm"
|
||||||
|
options={DAYS_OF_MONTH.map(d => ({ value: String(d), label: String(d) }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{t('backup.auto.dayOfMonthHint')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keep duration */}
|
{/* Keep duration */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
<label className="block text-sm font-medium text-gray-700 mb-2">{t('backup.auto.keepLabel')}</label>
|
||||||
@@ -327,7 +424,7 @@ export default function BackupPanel() {
|
|||||||
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
onClick={() => handleAutoSettingsChange('keep_days', opt.value)}
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
className={`px-4 py-2 rounded-lg text-sm font-medium border transition-colors ${
|
||||||
autoSettings.keep_days === opt.value
|
autoSettings.keep_days === opt.value
|
||||||
? 'bg-slate-700 text-white border-slate-700'
|
? 'bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 border-slate-700'
|
||||||
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
: 'bg-white text-gray-600 border-gray-200 hover:border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -344,7 +441,7 @@ export default function BackupPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleSaveAutoSettings}
|
onClick={handleSaveAutoSettings}
|
||||||
disabled={autoSettingsSaving || !autoSettingsDirty}
|
disabled={autoSettingsSaving || !autoSettingsDirty}
|
||||||
className="flex items-center gap-2 bg-slate-700 text-white px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
className="flex items-center gap-2 bg-slate-900 dark:bg-slate-100 text-white dark:text-slate-900 px-5 py-2 rounded-lg hover:bg-slate-900 text-sm font-medium disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
{autoSettingsSaving
|
{autoSettingsSaving
|
||||||
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
? <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
@@ -355,6 +452,67 @@ export default function BackupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Restore Warning Modal */}
|
||||||
|
{restoreConfirm && (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 9999, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={() => setRestoreConfirm(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
style={{ width: '100%', maxWidth: 440, borderRadius: 16, overflow: 'hidden' }}
|
||||||
|
className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700"
|
||||||
|
>
|
||||||
|
{/* Red header */}
|
||||||
|
<div style={{ background: 'linear-gradient(135deg, #dc2626, #b91c1c)', padding: '20px 24px', display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<AlertTriangle size={20} style={{ color: 'white' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 700, color: 'white' }}>
|
||||||
|
{t('backup.restoreConfirmTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'rgba(255,255,255,0.8)' }}>
|
||||||
|
{restoreConfirm.filename}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div style={{ padding: '20px 24px' }}>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300" style={{ fontSize: 13, lineHeight: 1.6, margin: 0 }}>
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
{t('backup.restoreTip')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{ padding: '0 24px 20px', display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setRestoreConfirm(null)}
|
||||||
|
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' }}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={executeRestore}
|
||||||
|
style={{ padding: '9px 20px', borderRadius: 10, fontSize: 13, fontWeight: 600, border: 'none', cursor: 'pointer', fontFamily: 'inherit', background: '#dc2626', color: 'white' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = '#b91c1c'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = '#dc2626'}
|
||||||
|
>
|
||||||
|
{t('backup.restoreConfirm')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { categoriesApi } from '../../api/client'
|
import { categoriesApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
import { Plus, Edit2, Trash2, Pipette } from 'lucide-react'
|
||||||
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
import { CATEGORY_ICON_MAP, ICON_LABELS, getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { getApiErrorMessage } from '../../types'
|
||||||
|
|
||||||
const PRESET_COLORS = [
|
const PRESET_COLORS = [
|
||||||
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
|
'#6366f1', '#8b5cf6', '#ec4899', '#ef4444', '#f97316',
|
||||||
@@ -31,7 +32,7 @@ export default function CategoryManager() {
|
|||||||
try {
|
try {
|
||||||
const data = await categoriesApi.list()
|
const data = await categoriesApi.list()
|
||||||
setCategories(data.categories || [])
|
setCategories(data.categories || [])
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(t('categories.toast.loadError'))
|
toast.error(t('categories.toast.loadError'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
@@ -71,8 +72,8 @@ export default function CategoryManager() {
|
|||||||
toast.success(t('categories.toast.created'))
|
toast.success(t('categories.toast.created'))
|
||||||
}
|
}
|
||||||
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
setForm({ name: '', color: '#6366f1', icon: 'MapPin' })
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.error || t('categories.toast.saveError'))
|
toast.error(getApiErrorMessage(err, t('categories.toast.saveError')))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
@@ -84,8 +85,8 @@ export default function CategoryManager() {
|
|||||||
await categoriesApi.delete(id)
|
await categoriesApi.delete(id)
|
||||||
setCategories(prev => prev.filter(c => c.id !== id))
|
setCategories(prev => prev.filter(c => c.id !== id))
|
||||||
toast.success(t('categories.toast.deleted'))
|
toast.success(t('categories.toast.deleted'))
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.error || t('categories.toast.deleteError'))
|
toast.error(getApiErrorMessage(err, t('categories.toast.deleteError')))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,13 +191,13 @@ export default function CategoryManager() {
|
|||||||
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
<div className="bg-white rounded-2xl border border-gray-200 p-6">
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{t('categories.title')}</h2>
|
<h2 className="font-semibold" style={{ color: 'var(--text-primary)' }}>{t('categories.title')}</h2>
|
||||||
<p className="text-sm text-gray-500 mt-0.5">{t('categories.subtitle')}</p>
|
<p className="text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{t('categories.subtitle')}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={handleStartCreate}
|
<button onClick={handleStartCreate}
|
||||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
className="flex items-center gap-2 bg-slate-900 text-white px-3 sm:px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
{t('categories.new')}
|
<span className="hidden sm:inline">{t('categories.new')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Tag, Calendar, ExternalLink, ChevronDown, ChevronUp, Loader2, Heart, Coffee } from 'lucide-react'
|
||||||
|
import { getLocaleForLanguage, useTranslation } from '../../i18n'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
|
||||||
|
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 apiClient.get(`/admin/github-releases`, { params: { per_page: PER_PAGE, page: pageNum } })
|
||||||
|
const data = Array.isArray(res.data) ? res.data : []
|
||||||
|
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(getLocaleForLanguage(language), { 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Support cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<a
|
||||||
|
href="https://ko-fi.com/mauriceboe"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||||
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ff5e5b'; e.currentTarget.style.boxShadow = '0 0 0 1px #ff5e5b22' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ff5e5b15', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Coffee size={20} style={{ color: '#ff5e5b' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Ko-fi</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://buymeacoffee.com/mauriceboe"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-xl border overflow-hidden flex items-center gap-4 px-5 py-4 transition-all"
|
||||||
|
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', textDecoration: 'none' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#ffdd00'; e.currentTarget.style.boxShadow = '0 0 0 1px #ffdd0022' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--border-primary)'; e.currentTarget.style.boxShadow = 'none' }}
|
||||||
|
>
|
||||||
|
<div style={{ width: 40, height: 40, borderRadius: 10, background: '#ffdd0015', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
<Heart size={20} style={{ color: '#ffdd00' }} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>Buy Me a Coffee</div>
|
||||||
|
<div className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('admin.github.support')}</div>
|
||||||
|
</div>
|
||||||
|
<ExternalLink size={14} className="ml-auto flex-shrink-0" style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading / Error / Releases */}
|
||||||
|
{loading ? (
|
||||||
|
<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>
|
||||||
|
) : error ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { adminApi } from '../../api/client'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
|
||||||
|
|
||||||
|
interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
|
||||||
|
interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
|
||||||
|
interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
|
||||||
|
|
||||||
|
export default function PackingTemplateManager() {
|
||||||
|
const [templates, setTemplates] = useState<Template[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
const [createName, setCreateName] = useState('')
|
||||||
|
|
||||||
|
// Expanded template state
|
||||||
|
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||||
|
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||||
|
const [items, setItems] = useState<TemplateItem[]>([])
|
||||||
|
|
||||||
|
// Editing states
|
||||||
|
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||||
|
const [editTemplateName, setEditTemplateName] = useState('')
|
||||||
|
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||||
|
const [editCatName, setEditCatName] = useState('')
|
||||||
|
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||||
|
const [editItemName, setEditItemName] = useState('')
|
||||||
|
|
||||||
|
// Adding states
|
||||||
|
const [addingCategory, setAddingCategory] = useState(false)
|
||||||
|
const [newCatName, setNewCatName] = useState('')
|
||||||
|
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||||
|
const [newItemName, setNewItemName] = useState('')
|
||||||
|
const addItemRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
useEffect(() => { loadTemplates() }, [])
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.packingTemplates()
|
||||||
|
setTemplates(data.templates || [])
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||||
|
finally { setIsLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExpand = async (id: number) => {
|
||||||
|
if (expandedId === id) { setExpandedId(null); return }
|
||||||
|
setExpandedId(id)
|
||||||
|
setAddingCategory(false)
|
||||||
|
setAddingItemToCatId(null)
|
||||||
|
try {
|
||||||
|
const data = await adminApi.getPackingTemplate(id)
|
||||||
|
setCategories(data.categories || [])
|
||||||
|
setItems(data.items || [])
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.loadError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template CRUD
|
||||||
|
const handleCreateTemplate = async () => {
|
||||||
|
if (!createName.trim()) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.createPackingTemplate({ name: createName.trim() })
|
||||||
|
setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
|
||||||
|
setCreateName(''); setShowCreate(false)
|
||||||
|
setExpandedId(data.template.id); setCategories([]); setItems([])
|
||||||
|
toast.success(t('admin.packingTemplates.created'))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.createError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteTemplate = async (id: number) => {
|
||||||
|
try {
|
||||||
|
await adminApi.deletePackingTemplate(id)
|
||||||
|
setTemplates(prev => prev.filter(t => t.id !== id))
|
||||||
|
if (expandedId === id) setExpandedId(null)
|
||||||
|
toast.success(t('admin.packingTemplates.deleted'))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameTemplate = async (id: number) => {
|
||||||
|
if (!editTemplateName.trim()) { setEditingTemplate(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
|
||||||
|
setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
|
||||||
|
setEditingTemplate(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category CRUD
|
||||||
|
const handleAddCategory = async () => {
|
||||||
|
if (!newCatName.trim() || !expandedId) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
|
||||||
|
setCategories(prev => [...prev, data.category])
|
||||||
|
setNewCatName(''); setAddingCategory(false)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameCategory = async (catId: number) => {
|
||||||
|
if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
|
||||||
|
setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
|
||||||
|
setEditingCatId(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteCategory = async (catId: number) => {
|
||||||
|
if (!expandedId) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTemplateCategory(expandedId, catId)
|
||||||
|
setCategories(prev => prev.filter(c => c.id !== catId))
|
||||||
|
setItems(prev => prev.filter(i => i.category_id !== catId))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item CRUD
|
||||||
|
const handleAddItem = async (catId: number) => {
|
||||||
|
if (!newItemName.trim() || !expandedId) return
|
||||||
|
try {
|
||||||
|
const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
|
||||||
|
setItems(prev => [...prev, data.item])
|
||||||
|
setNewItemName('')
|
||||||
|
setTimeout(() => addItemRef.current?.focus(), 30)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRenameItem = async (itemId: number) => {
|
||||||
|
if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
|
||||||
|
try {
|
||||||
|
await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
|
||||||
|
setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
|
||||||
|
setEditingItemId(null)
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.saveError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteItem = async (itemId: number) => {
|
||||||
|
if (!expandedId) return
|
||||||
|
try {
|
||||||
|
await adminApi.deleteTemplateItem(expandedId, itemId)
|
||||||
|
setItems(prev => prev.filter(i => i.id !== itemId))
|
||||||
|
} catch { toast.error(t('admin.packingTemplates.deleteError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
|
||||||
|
const btnIcon = 'p-1.5 rounded-lg transition-colors'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-slate-900">{t('admin.packingTemplates.title')}</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-1">{t('admin.packingTemplates.subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(true)}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-slate-900 text-white rounded-lg hover:bg-slate-700 transition-colors">
|
||||||
|
<Plus className="w-4 h-4" /> <span className="hidden sm:inline">{t('admin.packingTemplates.create')}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create template */}
|
||||||
|
{showCreate && (
|
||||||
|
<div className="px-5 py-3 border-b border-slate-100 flex items-center gap-3">
|
||||||
|
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||||
|
<input autoFocus value={createName} onChange={e => setCreateName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
|
||||||
|
placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
|
||||||
|
<button onClick={handleCreateTemplate} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={16} /></button>
|
||||||
|
<button onClick={() => setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}><X size={16} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Template list */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center"><div className="w-8 h-8 border-2 border-slate-200 border-t-slate-900 rounded-full animate-spin mx-auto" /></div>
|
||||||
|
) : templates.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-slate-400">{t('admin.packingTemplates.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-slate-100">
|
||||||
|
{templates.map(tmpl => (
|
||||||
|
<div key={tmpl.id}>
|
||||||
|
{/* Template row */}
|
||||||
|
<div className="px-5 py-3 flex items-center gap-3 hover:bg-slate-50 transition-colors">
|
||||||
|
<button onClick={() => toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
|
||||||
|
{expandedId === tmpl.id ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
||||||
|
</button>
|
||||||
|
<Package size={16} className="text-slate-400 flex-shrink-0" />
|
||||||
|
{editingTemplate === tmpl.id ? (
|
||||||
|
<input autoFocus value={editTemplateName} onChange={e => setEditTemplateName(e.target.value)}
|
||||||
|
onBlur={() => handleRenameTemplate(tmpl.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
|
||||||
|
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
|
||||||
|
) : (
|
||||||
|
<span onClick={() => toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400 px-2 py-0.5 bg-slate-100 rounded-full">
|
||||||
|
{tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
|
||||||
|
</span>
|
||||||
|
<button onClick={() => { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
|
||||||
|
className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}><Edit2 size={14} /></button>
|
||||||
|
<button onClick={() => handleDeleteTemplate(tmpl.id)}
|
||||||
|
className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded content */}
|
||||||
|
{expandedId === tmpl.id && (
|
||||||
|
<div className="px-5 pb-4 ml-8 space-y-3">
|
||||||
|
{categories.map(cat => {
|
||||||
|
const catItems = items.filter(i => i.category_id === cat.id)
|
||||||
|
return (
|
||||||
|
<div key={cat.id} className="border border-slate-200 rounded-lg overflow-hidden">
|
||||||
|
{/* Category header */}
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50">
|
||||||
|
{editingCatId === cat.id ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={editCatName} onChange={e => setEditCatName(e.target.value)}
|
||||||
|
onBlur={() => handleRenameCategory(cat.id)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
|
||||||
|
className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 text-xs font-bold text-slate-500 uppercase tracking-wider">{cat.name}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-slate-400">{catItems.length}</span>
|
||||||
|
<button onClick={() => { setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Plus size={13} /></button>
|
||||||
|
<button onClick={() => { setEditingCatId(cat.id); setEditCatName(cat.name) }}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-slate-700`}><Edit2 size={13} /></button>
|
||||||
|
<button onClick={() => handleDeleteCategory(cat.id)}
|
||||||
|
className={`${btnIcon} text-slate-400 hover:text-red-500`}><Trash2 size={13} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
|
||||||
|
<div className="divide-y divide-slate-50">
|
||||||
|
{catItems.map(item => (
|
||||||
|
<div key={item.id} className="flex items-center gap-3 px-4 py-2 group">
|
||||||
|
{editingItemId === item.id ? (
|
||||||
|
<>
|
||||||
|
<input autoFocus value={editItemName} onChange={e => setEditItemName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleRenameItem(item.id); if (e.key === 'Escape') setEditingItemId(null) }}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={() => handleRenameItem(item.id)} className="p-1 text-slate-600 hover:text-slate-900"><Check size={13} /></button>
|
||||||
|
<button onClick={() => setEditingItemId(null)} className="p-1 text-slate-400"><X size={13} /></button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-sm text-slate-700">{item.name}</span>
|
||||||
|
<button onClick={() => { setEditingItemId(item.id); setEditItemName(item.name) }}
|
||||||
|
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-slate-700 transition-all"><Edit2 size={12} /></button>
|
||||||
|
<button onClick={() => handleDeleteItem(item.id)}
|
||||||
|
className="p-1 rounded opacity-0 group-hover:opacity-100 text-slate-400 hover:text-red-500 transition-all"><Trash2 size={12} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Add item inline */}
|
||||||
|
{addingItemToCatId === cat.id && (
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2">
|
||||||
|
<input ref={addItemRef} value={newItemName} onChange={e => setNewItemName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter' && newItemName.trim()) handleAddItem(cat.id); if (e.key === 'Escape') { setAddingItemToCatId(null); setNewItemName('') } }}
|
||||||
|
placeholder={t('admin.packingTemplates.itemName')}
|
||||||
|
className="flex-1 px-2 py-1 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={() => handleAddItem(cat.id)} disabled={!newItemName.trim()}
|
||||||
|
className="p-1.5 rounded-lg bg-slate-900 text-white disabled:bg-slate-300 hover:bg-slate-700 transition-colors"><Plus size={13} /></button>
|
||||||
|
<button onClick={() => { setAddingItemToCatId(null); setNewItemName('') }}
|
||||||
|
className="p-1 text-slate-400 hover:text-slate-600"><X size={13} /></button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Add category button */}
|
||||||
|
{addingCategory ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input autoFocus value={newCatName} onChange={e => setNewCatName(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
|
||||||
|
placeholder={t('admin.packingTemplates.categoryName')}
|
||||||
|
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
|
||||||
|
<button onClick={handleAddCategory} className={`${btnIcon} text-slate-600 hover:text-slate-900`}><Check size={15} /></button>
|
||||||
|
<button onClick={() => { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}><X size={15} /></button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setAddingCategory(true)}
|
||||||
|
className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
|
||||||
|
<FolderPlus size={14} /> {t('admin.packingTemplates.addCategory')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,57 @@
|
|||||||
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 { useTripStore } from '../../store/tripStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Plus, Trash2, Calculator, Wallet } from 'lucide-react'
|
import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { budgetApi } from '../../api/client'
|
||||||
|
import type { BudgetItem, BudgetMember } from '../../types'
|
||||||
|
import { currencyDecimals } from '../../utils/formatters'
|
||||||
|
|
||||||
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD']
|
const CURRENCIES = [
|
||||||
const SYMBOLS = { EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł', SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$' }
|
'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
|
||||||
|
'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
|
||||||
|
'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
|
||||||
|
'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
|
||||||
|
'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
|
||||||
|
]
|
||||||
|
const SYMBOLS = {
|
||||||
|
EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
|
||||||
|
SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
|
||||||
|
NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
|
||||||
|
PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
|
||||||
|
ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
|
||||||
|
HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
|
||||||
|
UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
|
||||||
|
PEN: 'S/.', ARS: 'AR$',
|
||||||
|
}
|
||||||
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
|
||||||
|
|
||||||
const fmtNum = (v, locale, cur) => {
|
const fmtNum = (v, locale, cur) => {
|
||||||
if (v == null || isNaN(v)) return '-'
|
if (v == null || isNaN(v)) return '-'
|
||||||
return Number(v).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ' + (SYMBOLS[cur] || cur)
|
const d = currencyDecimals(cur)
|
||||||
|
return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
|
||||||
}
|
}
|
||||||
|
|
||||||
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
const calcPP = (p, n) => (n > 0 ? p / n : null)
|
||||||
@@ -58,7 +98,12 @@ function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Add Item Row ─────────────────────────────────────────────────────────────
|
// ── 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 [name, setName] = useState('')
|
||||||
const [price, setPrice] = useState('')
|
const [price, setPrice] = useState('')
|
||||||
const [persons, setPersons] = useState('')
|
const [persons, setPersons] = useState('')
|
||||||
@@ -110,8 +155,220 @@ function AddItemRow({ onAdd, t }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Chip with custom tooltip ─────────────────────────────────────────────────
|
||||||
|
interface ChipWithTooltipProps {
|
||||||
|
label: string
|
||||||
|
avatarUrl: string | null
|
||||||
|
size?: number
|
||||||
|
paid?: boolean
|
||||||
|
onClick?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
|
||||||
|
const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => setHover(false)}
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
|
||||||
|
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
|
||||||
|
overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
|
||||||
|
transition: 'border-color 0.15s, background 0.15s',
|
||||||
|
}}>
|
||||||
|
{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',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
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}
|
||||||
|
{paid && (
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 700, padding: '1px 5px', borderRadius: 4,
|
||||||
|
background: 'rgba(34,197,94,0.15)', color: '#16a34a',
|
||||||
|
textTransform: 'uppercase', letterSpacing: '0.03em',
|
||||||
|
}}>Paid</span>
|
||||||
|
)}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Budget Member Chips (for Persons column) ────────────────────────────────
|
||||||
|
interface BudgetMemberChipsProps {
|
||||||
|
members?: BudgetMember[]
|
||||||
|
tripMembers?: TripMember[]
|
||||||
|
onSetMembers: (memberIds: number[]) => void
|
||||||
|
onTogglePaid?: (userId: number, paid: boolean) => void
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, 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}
|
||||||
|
paid={!!m.paid}
|
||||||
|
onClick={onTogglePaid ? () => onTogglePaid(m.user_id, !m.paid) : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<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) ──────────────────────────────────────
|
// ── 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
|
if (!segments.length) return null
|
||||||
|
|
||||||
const total = segments.reduce((s, x) => s + x.value, 0)
|
const total = segments.reduce((s, x) => s + x.value, 0)
|
||||||
@@ -148,13 +405,28 @@ function PieChart({ segments, size = 200, totalLabel }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Main Component ───────────────────────────────────────────────────────────
|
// ── Main Component ───────────────────────────────────────────────────────────
|
||||||
export default function BudgetPanel({ tripId }) {
|
interface BudgetPanelProps {
|
||||||
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore()
|
tripId: number
|
||||||
|
tripMembers?: TripMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
|
||||||
|
const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid } = useTripStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
|
const [editingCat, setEditingCat] = useState(null) // { name, value }
|
||||||
|
const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
|
||||||
|
const [settlementOpen, setSettlementOpen] = useState(false)
|
||||||
const currency = trip?.currency || 'EUR'
|
const currency = trip?.currency || 'EUR'
|
||||||
|
|
||||||
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
const fmt = (v, cur) => fmtNum(v, locale, cur)
|
||||||
|
const hasMultipleMembers = tripMembers.length > 1
|
||||||
|
|
||||||
|
// Load settlement data whenever budget items change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasMultipleMembers) return
|
||||||
|
budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
|
||||||
|
}, [tripId, budgetItems, hasMultipleMembers])
|
||||||
|
|
||||||
const setCurrency = (cur) => {
|
const setCurrency = (cur) => {
|
||||||
if (tripId) updateTrip(tripId, { currency: cur })
|
if (tripId) updateTrip(tripId, { currency: cur })
|
||||||
@@ -163,7 +435,7 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
|
||||||
|
|
||||||
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
const grouped = useMemo(() => (budgetItems || []).reduce((acc, item) => {
|
||||||
const cat = item.category || 'Sonstiges'
|
const cat = item.category || 'Other'
|
||||||
if (!acc[cat]) acc[cat] = []
|
if (!acc[cat]) acc[cat] = []
|
||||||
acc[cat].push(item)
|
acc[cat].push(item)
|
||||||
return acc
|
return acc
|
||||||
@@ -185,12 +457,17 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
|
const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
|
||||||
const handleDeleteCategory = async (cat) => {
|
const handleDeleteCategory = async (cat) => {
|
||||||
const items = grouped[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 = () => {
|
const handleAddCategory = () => {
|
||||||
if (!newCategoryName.trim()) return
|
if (!newCategoryName.trim()) return
|
||||||
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
|
||||||
setNewCategoryName(''); setShowAddCategory(false)
|
setNewCategoryName('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
|
||||||
@@ -239,9 +516,27 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
return (
|
return (
|
||||||
<div key={cat} style={{ marginBottom: 16 }}>
|
<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', 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 }} />
|
<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>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
<span style={{ fontSize: 13, fontWeight: 500, opacity: 0.9 }}>{fmt(subtotal, currency)}</span>
|
||||||
@@ -258,8 +553,8 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||||
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
|
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
|
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
||||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</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: 90 }}>{t('budget.table.perPerson')}</th>
|
||||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||||
@@ -273,16 +568,40 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
const pp = calcPP(item.total_price, item.persons)
|
const pp = calcPP(item.total_price, item.persons)
|
||||||
const pd = calcPD(item.total_price, item.days)
|
const pd = calcPD(item.total_price, item.days)
|
||||||
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
const ppd = calcPPD(item.total_price, item.persons, item.days)
|
||||||
|
const hasMembers = item.members?.length > 0
|
||||||
return (
|
return (
|
||||||
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
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}>
|
||||||
<td style={{ ...td, textAlign: 'center' }}>
|
<InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||||
<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')} />
|
{/* 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)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
|
compact={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
<td 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')} />
|
<InlineEditCell value={item.total_price} type="number" decimals={currencyDecimals(currency)} onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder={currencyDecimals(currency) === 0 ? '0' : '0,00'} locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||||
|
</td>
|
||||||
|
<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)}
|
||||||
|
onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
<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')} />
|
<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')} />
|
||||||
@@ -348,9 +667,97 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
<div style={{ fontSize: 28, fontWeight: 700, lineHeight: 1, marginBottom: 4 }}>
|
||||||
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: currencyDecimals(currency), maximumFractionDigits: currencyDecimals(currency) })}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</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} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settlement dropdown inside the total card */}
|
||||||
|
{hasMultipleMembers && settlement && settlement.flows.length > 0 && (
|
||||||
|
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 12 }}>
|
||||||
|
<button onClick={() => setSettlementOpen(v => !v)} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit',
|
||||||
|
color: 'rgba(255,255,255,0.6)', fontSize: 11, fontWeight: 600, letterSpacing: 0.5,
|
||||||
|
}}>
|
||||||
|
{settlementOpen ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||||
|
{t('budget.settlement')}
|
||||||
|
<span style={{ position: 'relative', display: 'inline-flex', marginLeft: 2 }}>
|
||||||
|
<span style={{ display: 'flex', cursor: 'help' }}
|
||||||
|
onMouseEnter={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'block' }}
|
||||||
|
onMouseLeave={e => { const tip = e.currentTarget.nextElementSibling as HTMLElement; if (tip) tip.style.display = 'none' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Info size={11} strokeWidth={2} />
|
||||||
|
</span>
|
||||||
|
<div style={{
|
||||||
|
display: 'none', position: 'absolute', top: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginTop: 6, width: 220, padding: '10px 12px', borderRadius: 10, zIndex: 100,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-faint)',
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
||||||
|
fontSize: 11, fontWeight: 400, color: 'var(--text-secondary)', lineHeight: 1.5, textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
{t('budget.settlementInfo')}
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{settlementOpen && (
|
||||||
|
<div style={{ marginTop: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{settlement.flows.map((flow, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
|
||||||
|
padding: '8px 10px', borderRadius: 10,
|
||||||
|
background: 'rgba(255,255,255,0.06)',
|
||||||
|
}}>
|
||||||
|
<ChipWithTooltip label={flow.from.username} avatarUrl={flow.from.avatar_url} size={28} />
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#f87171', whiteSpace: 'nowrap' }}>
|
||||||
|
{fmt(flow.amount, currency)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }}>→</span>
|
||||||
|
</div>
|
||||||
|
<ChipWithTooltip label={flow.to.username} avatarUrl={flow.to.avatar_url} size={28} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && (
|
||||||
|
<div style={{ marginTop: 4, borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'rgba(255,255,255,0.35)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 6 }}>
|
||||||
|
{t('budget.netBalances')}
|
||||||
|
</div>
|
||||||
|
{settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => (
|
||||||
|
<div key={b.user_id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '2px 0' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 20, height: 20, borderRadius: '50%', flexShrink: 0,
|
||||||
|
background: 'rgba(255,255,255,0.1)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 8, fontWeight: 700, color: 'rgba(255,255,255,0.6)', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
{b.avatar_url
|
||||||
|
? <img src={b.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: b.username?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1, fontSize: 11, color: 'rgba(255,255,255,0.6)', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{b.username}
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
fontSize: 11, fontWeight: 600, flexShrink: 0,
|
||||||
|
color: b.balance > 0 ? '#4ade80' : '#f87171',
|
||||||
|
}}>
|
||||||
|
{b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{pieSegments.length > 0 && (
|
{pieSegments.length > 0 && (
|
||||||
@@ -358,34 +765,28 @@ export default function BudgetPanel({ tripId }) {
|
|||||||
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
|
||||||
border: '1px solid var(--border-primary)',
|
border: '1px solid var(--border-primary)',
|
||||||
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
|
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>
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||||
|
|
||||||
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
<PieChart segments={pieSegments} size={180} totalLabel={t('budget.total')} />
|
||||||
|
|
||||||
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ marginTop: 20, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
{pieSegments.map(seg => {
|
{pieSegments.map(seg => {
|
||||||
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0'
|
||||||
return (
|
return (
|
||||||
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
<div key={seg.name} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
<div style={{ width: 10, height: 10, borderRadius: 3, background: seg.color, flexShrink: 0 }} />
|
||||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>{seg.name}</span>
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap' }}>{pct}%</span>
|
<span style={{ fontSize: 11, color: 'var(--text-faint)', fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(seg.value, currency)}</span>
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)', fontWeight: 600, whiteSpace: 'nowrap', minWidth: 38, textAlign: 'right' }}>{pct}%</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginTop: 12, borderTop: '1px solid var(--border-secondary)', paddingTop: 12, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{pieSegments.map(seg => (
|
|
||||||
<div key={seg.name} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{seg.name}</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 600 }}>{fmt(seg.value, currency)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { ArrowRightLeft, RefreshCw } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
|
||||||
|
const CURRENCIES = [
|
||||||
|
'EUR','USD','GBP','JPY','CHF','CAD','AUD','NZD','CNY','HKD',
|
||||||
|
'SGD','THB','TRY','SEK','NOK','DKK','PLN','CZK','HUF','RON',
|
||||||
|
'BGN','HRK','ISK','RUB','UAH','BRL','MXN','ARS','CLP','COP',
|
||||||
|
'INR','IDR','MYR','PHP','KRW','TWD','VND','ZAR','EGP','MAD',
|
||||||
|
'NGN','KES','AED','SAR','QAR','KWD','BHD','OMR','ILS',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CURRENCY_OPTIONS = CURRENCIES.map(c => ({ value: c, label: c }))
|
||||||
|
|
||||||
|
export default function CurrencyWidget() {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const [from, setFrom] = useState(() => localStorage.getItem('currency_from') || 'EUR')
|
||||||
|
const [to, setTo] = useState(() => localStorage.getItem('currency_to') || 'USD')
|
||||||
|
const [amount, setAmount] = useState('100')
|
||||||
|
const [rate, setRate] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const fetchRate = useCallback(async () => {
|
||||||
|
if (from === to) { setRate(1); return }
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||||
|
const data = await resp.json()
|
||||||
|
setRate(data.rates?.[to] || null)
|
||||||
|
} catch { setRate(null) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}, [from, to])
|
||||||
|
|
||||||
|
useEffect(() => { fetchRate() }, [fetchRate])
|
||||||
|
useEffect(() => { localStorage.setItem('currency_from', from) }, [from])
|
||||||
|
useEffect(() => { localStorage.setItem('currency_to', to) }, [to])
|
||||||
|
|
||||||
|
const swap = () => { setFrom(to); setTo(from) }
|
||||||
|
const rawResult = rate && amount ? (parseFloat(amount) * rate).toFixed(2) : null
|
||||||
|
const formatNumber = (num) => {
|
||||||
|
if (!num || num === '—') return '—'
|
||||||
|
return parseFloat(num).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
const result = rawResult
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.currency')}</span>
|
||||||
|
<button onClick={fetchRate} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="rounded-xl px-4 py-3 mb-3" style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={amount}
|
||||||
|
onChange={e => setAmount(e.target.value)}
|
||||||
|
className="w-full text-2xl font-black tabular-nums outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ color: 'var(--text-primary)', background: 'transparent', border: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* From / Swap / To */}
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||||
|
<CustomSelect value={from} onChange={setFrom} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||||
|
</div>
|
||||||
|
<button onClick={swap} className="p-1.5 rounded-lg shrink-0 transition-colors" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
<ArrowRightLeft size={13} />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" style={{ '--bg-input': 'transparent', '--border-primary': 'transparent' }}>
|
||||||
|
<CustomSelect value={to} onChange={setTo} options={CURRENCY_OPTIONS} searchable size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Result */}
|
||||||
|
<div className="rounded-xl p-3" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<p className="text-xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{formatNumber(result)} <span className="text-sm font-semibold" style={{ color: 'var(--text-muted)' }}>{to}</span>
|
||||||
|
</p>
|
||||||
|
{rate && <p className="text-[10px] mt-0.5" style={{ color: 'var(--text-faint)' }}>1 {from} = {rate.toFixed(4)} {to}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Clock, Plus, X } from 'lucide-react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
|
const POPULAR_ZONES = [
|
||||||
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
|
{ label: 'London', tz: 'Europe/London' },
|
||||||
|
{ label: 'Berlin', tz: 'Europe/Berlin' },
|
||||||
|
{ label: 'Paris', tz: 'Europe/Paris' },
|
||||||
|
{ label: 'Dubai', tz: 'Asia/Dubai' },
|
||||||
|
{ label: 'Mumbai', tz: 'Asia/Kolkata' },
|
||||||
|
{ label: 'Bangkok', tz: 'Asia/Bangkok' },
|
||||||
|
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||||
|
{ label: 'Sydney', tz: 'Australia/Sydney' },
|
||||||
|
{ label: 'Los Angeles', tz: 'America/Los_Angeles' },
|
||||||
|
{ label: 'Chicago', tz: 'America/Chicago' },
|
||||||
|
{ label: 'São Paulo', tz: 'America/Sao_Paulo' },
|
||||||
|
{ label: 'Istanbul', tz: 'Europe/Istanbul' },
|
||||||
|
{ label: 'Singapore', tz: 'Asia/Singapore' },
|
||||||
|
{ label: 'Hong Kong', tz: 'Asia/Hong_Kong' },
|
||||||
|
{ label: 'Seoul', tz: 'Asia/Seoul' },
|
||||||
|
{ label: 'Moscow', tz: 'Europe/Moscow' },
|
||||||
|
{ label: 'Cairo', tz: 'Africa/Cairo' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function getTime(tz, locale, is12h) {
|
||||||
|
try {
|
||||||
|
return new Date().toLocaleTimeString(locale, { timeZone: tz, hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
|
} catch { return '—' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOffset(tz) {
|
||||||
|
try {
|
||||||
|
const now = new Date()
|
||||||
|
const local = new Date(now.toLocaleString('en-US', { timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone }))
|
||||||
|
const remote = new Date(now.toLocaleString('en-US', { timeZone: tz }))
|
||||||
|
const diff = (remote - local) / 3600000
|
||||||
|
const sign = diff >= 0 ? '+' : ''
|
||||||
|
return `${sign}${diff}h`
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TimezoneWidget() {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const [zones, setZones] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('dashboard_timezones')
|
||||||
|
return saved ? JSON.parse(saved) : [
|
||||||
|
{ label: 'New York', tz: 'America/New_York' },
|
||||||
|
{ label: 'Tokyo', tz: 'Asia/Tokyo' },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const [now, setNow] = useState(Date.now())
|
||||||
|
const [showAdd, setShowAdd] = useState(false)
|
||||||
|
const [customLabel, setCustomLabel] = useState('')
|
||||||
|
const [customTz, setCustomTz] = useState('')
|
||||||
|
const [customError, setCustomError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const i = setInterval(() => setNow(Date.now()), 10000)
|
||||||
|
return () => clearInterval(i)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('dashboard_timezones', JSON.stringify(zones))
|
||||||
|
}, [zones])
|
||||||
|
|
||||||
|
const isValidTz = (tz: string) => {
|
||||||
|
try { Intl.DateTimeFormat('en-US', { timeZone: tz }).format(new Date()); return true } catch { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const addCustomZone = () => {
|
||||||
|
const tz = customTz.trim()
|
||||||
|
if (!tz) { setCustomError(t('dashboard.timezoneCustomErrorEmpty')); return }
|
||||||
|
if (!isValidTz(tz)) { setCustomError(t('dashboard.timezoneCustomErrorInvalid')); return }
|
||||||
|
if (zones.find(z => z.tz === tz)) { setCustomError(t('dashboard.timezoneCustomErrorDuplicate')); return }
|
||||||
|
const label = customLabel.trim() || tz.split('/').pop()?.replace(/_/g, ' ') || tz
|
||||||
|
setZones([...zones, { label, tz }])
|
||||||
|
setCustomLabel(''); setCustomTz(''); setCustomError(''); setShowAdd(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addZone = (zone) => {
|
||||||
|
if (!zones.find(z => z.tz === zone.tz)) {
|
||||||
|
setZones([...zones, zone])
|
||||||
|
}
|
||||||
|
setShowAdd(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeZone = (tz) => setZones(zones.filter(z => z.tz !== tz))
|
||||||
|
|
||||||
|
const localTime = new Date().toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: is12h })
|
||||||
|
const rawZone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
|
const localZone = rawZone.split('/').pop().replace(/_/g, ' ')
|
||||||
|
// Show abbreviated timezone name (e.g. CET, CEST, EST)
|
||||||
|
const tzAbbr = new Date().toLocaleTimeString('en-US', { timeZoneName: 'short' }).split(' ').pop()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border p-4" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezone')}</span>
|
||||||
|
<button onClick={() => setShowAdd(!showAdd)} className="p-1 rounded-md transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<Plus size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Local time */}
|
||||||
|
<div className="mb-3 pb-3" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<p className="text-2xl font-black tabular-nums" style={{ color: 'var(--text-primary)' }}>{localTime}</p>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide" style={{ color: 'var(--text-faint)' }}>{localZone} ({tzAbbr}) · {t('dashboard.localTime')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Zone list */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{zones.map(z => (
|
||||||
|
<div key={z.tz} className="flex items-center justify-between group">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold tabular-nums" style={{ color: 'var(--text-primary)' }}>{getTime(z.tz, locale, is12h)}</p>
|
||||||
|
<p className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{z.label} <span style={{ color: 'var(--text-muted)' }}>{getOffset(z.tz)}</span></p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => removeZone(z.tz)} className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add zone dropdown */}
|
||||||
|
{showAdd && (
|
||||||
|
<div className="mt-2 rounded-xl p-2 max-h-[280px] overflow-auto" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
{/* Custom timezone */}
|
||||||
|
<div className="px-2 py-2 mb-2 rounded-lg" style={{ background: 'var(--bg-card)' }}>
|
||||||
|
<p className="text-[10px] font-semibold uppercase tracking-wide mb-2" style={{ color: 'var(--text-faint)' }}>{t('dashboard.timezoneCustomTitle')}</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<input value={customLabel} onChange={e => setCustomLabel(e.target.value)}
|
||||||
|
placeholder={t('dashboard.timezoneCustomLabelPlaceholder')}
|
||||||
|
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-secondary)' }} />
|
||||||
|
<input value={customTz} onChange={e => { setCustomTz(e.target.value); setCustomError('') }}
|
||||||
|
placeholder={t('dashboard.timezoneCustomTzPlaceholder')}
|
||||||
|
className="w-full px-2 py-1.5 rounded-lg text-xs outline-none"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: `1px solid ${customError ? '#ef4444' : 'var(--border-secondary)'}` }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') addCustomZone() }} />
|
||||||
|
{customError && <p className="text-[10px]" style={{ color: '#ef4444' }}>{customError}</p>}
|
||||||
|
<button onClick={addCustomZone}
|
||||||
|
className="w-full py-1.5 rounded-lg text-xs font-medium transition-colors"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('dashboard.timezoneCustomAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Popular zones */}
|
||||||
|
{POPULAR_ZONES.filter(z => !zones.find(existing => existing.tz === z.tz)).map(z => (
|
||||||
|
<button key={z.tz} onClick={() => addZone(z)}
|
||||||
|
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left transition-colors"
|
||||||
|
style={{ color: 'var(--text-primary)' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||||
|
<span className="font-medium">{z.label}</span>
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{getTime(z.tz, locale, is12h)}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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","144":"Sri Lanka","729":"Sudan","740":"Suriname","748":"Swaziland","752":"Sweden","756":"Switzerland","760":"Syria","762":"Tajikistan","764":"Thailand","768":"Togo","780":"Trinidad and Tobago","788":"Tunisia","792":"Turkey","795":"Turkmenistan","800":"Uganda","804":"Ukraine","784":"United Arab Emirates","826":"United Kingdom","840":"United States of America","858":"Uruguay","860":"Uzbekistan","862":"Venezuela","704":"Vietnam","887":"Yemen","894":"Zambia","716":"Zimbabwe"}
|
|
||||||
|
|
||||||
// Our country names from addresses → match against GeoJSON names
|
|
||||||
function isCountryMatch(geoName, visitedCountries) {
|
|
||||||
if (!geoName) return false
|
|
||||||
const lower = geoName.toLowerCase()
|
|
||||||
return visitedCountries.some(c => {
|
|
||||||
const cl = c.toLowerCase()
|
|
||||||
return lower === cl || lower.includes(cl) || cl.includes(lower)
|
|
||||||
// Handle common mismatches
|
|
||||||
|| (cl === 'usa' && lower.includes('united states'))
|
|
||||||
|| (cl === 'uk' && lower === 'united kingdom')
|
|
||||||
|| (cl === 'south korea' && lower === 'korea' || lower === 'south korea')
|
|
||||||
|| (cl === 'deutschland' && lower === 'germany')
|
|
||||||
|| (cl === 'frankreich' && lower === 'france')
|
|
||||||
|| (cl === 'italien' && lower === 'italy')
|
|
||||||
|| (cl === 'spanien' && lower === 'spain')
|
|
||||||
|| (cl === 'österreich' && lower === 'austria')
|
|
||||||
|| (cl === 'schweiz' && lower === 'switzerland')
|
|
||||||
|| (cl === 'niederlande' && lower === 'netherlands')
|
|
||||||
|| (cl === 'türkei' && (lower === 'turkey' || lower === 'türkiye'))
|
|
||||||
|| (cl === 'griechenland' && lower === 'greece')
|
|
||||||
|| (cl === 'tschechien' && (lower === 'czech republic' || lower === 'czechia'))
|
|
||||||
|| (cl === 'ägypten' && lower === 'egypt')
|
|
||||||
|| (cl === 'südkorea' && lower.includes('korea'))
|
|
||||||
|| (cl === 'indien' && lower === 'india')
|
|
||||||
|| (cl === 'brasilien' && lower === 'brazil')
|
|
||||||
|| (cl === 'argentinien' && lower === 'argentina')
|
|
||||||
|| (cl === 'russland' && lower === 'russia')
|
|
||||||
|| (cl === 'australien' && lower === 'australia')
|
|
||||||
|| (cl === 'kanada' && lower === 'canada')
|
|
||||||
|| (cl === 'mexiko' && lower === 'mexico')
|
|
||||||
|| (cl === 'neuseeland' && lower === 'new zealand')
|
|
||||||
|| (cl === 'singapur' && lower === 'singapore')
|
|
||||||
|| (cl === 'kroatien' && lower === 'croatia')
|
|
||||||
|| (cl === 'ungarn' && lower === 'hungary')
|
|
||||||
|| (cl === 'rumänien' && lower === 'romania')
|
|
||||||
|| (cl === 'polen' && lower === 'poland')
|
|
||||||
|| (cl === 'schweden' && lower === 'sweden')
|
|
||||||
|| (cl === 'norwegen' && lower === 'norway')
|
|
||||||
|| (cl === 'dänemark' && lower === 'denmark')
|
|
||||||
|| (cl === 'finnland' && lower === 'finland')
|
|
||||||
|| (cl === 'irland' && lower === 'ireland')
|
|
||||||
|| (cl === 'portugal' && lower === 'portugal')
|
|
||||||
|| (cl === 'belgien' && lower === 'belgium')
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const TOTAL_COUNTRIES = 195
|
|
||||||
|
|
||||||
// Simple Mercator projection for SVG
|
|
||||||
function project(lon, lat, width, height) {
|
|
||||||
const clampedLat = Math.max(-75, Math.min(83, lat))
|
|
||||||
const x = ((lon + 180) / 360) * width
|
|
||||||
const latRad = (clampedLat * Math.PI) / 180
|
|
||||||
const mercN = Math.log(Math.tan(Math.PI / 4 + latRad / 2))
|
|
||||||
const y = (height / 2) - (width * mercN) / (2 * Math.PI)
|
|
||||||
return [x, y]
|
|
||||||
}
|
|
||||||
|
|
||||||
function geoToPath(coords, width, height) {
|
|
||||||
return coords.map((ring) => {
|
|
||||||
// Split ring at dateline crossings to avoid horizontal stripes
|
|
||||||
const segments = [[]]
|
|
||||||
for (let i = 0; i < ring.length; i++) {
|
|
||||||
const [lon, lat] = ring[i]
|
|
||||||
if (i > 0) {
|
|
||||||
const prevLon = ring[i - 1][0]
|
|
||||||
if (Math.abs(lon - prevLon) > 180) {
|
|
||||||
// Dateline crossing — start new segment
|
|
||||||
segments.push([])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const [x, y] = project(lon, Math.max(-75, Math.min(83, lat)), width, height)
|
|
||||||
segments[segments.length - 1].push(`${x.toFixed(1)},${y.toFixed(1)}`)
|
|
||||||
}
|
|
||||||
return segments
|
|
||||||
.filter(s => s.length > 2)
|
|
||||||
.map(s => 'M' + s.join('L') + 'Z')
|
|
||||||
.join(' ')
|
|
||||||
}).join(' ')
|
|
||||||
}
|
|
||||||
|
|
||||||
let geoJsonCache = null
|
|
||||||
async function loadGeoJson() {
|
|
||||||
if (geoJsonCache) return geoJsonCache
|
|
||||||
try {
|
|
||||||
const res = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
|
|
||||||
const topo = await res.json()
|
|
||||||
const { feature } = await import('topojson-client')
|
|
||||||
const geo = feature(topo, topo.objects.countries)
|
|
||||||
geo.features.forEach(f => {
|
|
||||||
f.properties.name = NUMERIC_TO_NAME[f.id] || f.properties?.name || ''
|
|
||||||
})
|
|
||||||
geoJsonCache = geo
|
|
||||||
return geo
|
|
||||||
} catch { return null }
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TravelStats() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const dark = useSettingsStore(s => s.settings.dark_mode)
|
|
||||||
const [stats, setStats] = useState(null)
|
|
||||||
const [geoData, setGeoData] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
authApi.travelStats().then(setStats).catch(() => {})
|
|
||||||
loadGeoJson().then(setGeoData)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const countryCount = stats?.countries?.length || 0
|
|
||||||
const worldPercent = ((countryCount / TOTAL_COUNTRIES) * 100).toFixed(1)
|
|
||||||
|
|
||||||
if (!stats || stats.totalPlaces === 0) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: 340 }}>
|
|
||||||
{/* Stats Card */}
|
|
||||||
<div style={{
|
|
||||||
borderRadius: 20, overflow: 'hidden', height: 300,
|
|
||||||
display: 'flex', flexDirection: 'column', justifyContent: 'center',
|
|
||||||
border: '1px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-card)',
|
|
||||||
padding: 16,
|
|
||||||
}}>
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 6 }}>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('stats.worldProgress')}</span>
|
|
||||||
<span style={{ fontSize: 20, fontWeight: 800, color: 'var(--text-primary)' }}>{worldPercent}%</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
|
||||||
<div style={{
|
|
||||||
height: '100%', borderRadius: 99,
|
|
||||||
background: dark ? 'linear-gradient(90deg, #e2e8f0, #cbd5e1)' : 'linear-gradient(90deg, #111827, #374151)',
|
|
||||||
width: `${Math.max(1, parseFloat(worldPercent))}%`,
|
|
||||||
transition: 'width 0.5s ease',
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 4 }}>
|
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{countryCount} {t('stats.visited')}</span>
|
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)' }}>{TOTAL_COUNTRIES - countryCount} {t('stats.remaining')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stat grid */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 14 }}>
|
|
||||||
<StatBox icon={Globe} value={countryCount} label={t('stats.countries')} />
|
|
||||||
<StatBox icon={MapPin} value={stats.cities.length} label={t('stats.cities')} />
|
|
||||||
<StatBox icon={Plane} value={stats.totalTrips} label={t('stats.trips')} />
|
|
||||||
<StatBox icon={MapPin} value={stats.totalPlaces} label={t('stats.places')} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Country tags */}
|
|
||||||
{stats.countries.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6 }}>{t('stats.visitedCountries')}</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
||||||
{stats.countries.map(c => (
|
|
||||||
<span key={c} style={{
|
|
||||||
fontSize: 10.5, fontWeight: 500, color: 'var(--text-secondary)',
|
|
||||||
background: 'var(--bg-hover)', borderRadius: 99, padding: '3px 9px',
|
|
||||||
}}>{c}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatBox({ icon: Icon, value, label }) {
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8, padding: '8px 10px',
|
|
||||||
borderRadius: 10, background: 'var(--bg-hover)',
|
|
||||||
}}>
|
|
||||||
<Icon size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1 }}>{value}</div>
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text-faint)', marginTop: 1 }}>{label}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
|
||||||
import { useDropzone } from 'react-dropzone'
|
|
||||||
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket } from 'lucide-react'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
|
|
||||||
function isImage(mimeType) {
|
|
||||||
if (!mimeType) return false
|
|
||||||
return mimeType.startsWith('image/') // covers jpg, png, gif, webp, etc.
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFileIcon(mimeType) {
|
|
||||||
if (!mimeType) return File
|
|
||||||
if (mimeType === 'application/pdf') return FileText
|
|
||||||
if (isImage(mimeType)) return FileImage
|
|
||||||
return File
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatSize(bytes) {
|
|
||||||
if (!bytes) return ''
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateWithLocale(dateStr, locale) {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
|
||||||
} catch { return '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Image lightbox
|
|
||||||
function ImageLightbox({ file, onClose }) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
|
||||||
<img
|
|
||||||
src={file.url}
|
|
||||||
alt={file.original_name}
|
|
||||||
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
|
||||||
/>
|
|
||||||
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
|
||||||
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
|
||||||
<ExternalLink size={16} />
|
|
||||||
</a>
|
|
||||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source badge — unified style for both place and reservation
|
|
||||||
function SourceBadge({ icon: Icon, label }) {
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
||||||
fontSize: 10.5, color: '#4b5563',
|
|
||||||
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
|
||||||
borderRadius: 6, padding: '2px 7px',
|
|
||||||
fontWeight: 500, whiteSpace: 'nowrap',
|
|
||||||
}}>
|
|
||||||
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, reservations = [], tripId }) {
|
|
||||||
const [uploading, setUploading] = useState(false)
|
|
||||||
const [filterType, setFilterType] = useState('all')
|
|
||||||
const [lightboxFile, setLightboxFile] = useState(null)
|
|
||||||
const toast = useToast()
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
|
|
||||||
const onDrop = useCallback(async (acceptedFiles) => {
|
|
||||||
if (acceptedFiles.length === 0) return
|
|
||||||
setUploading(true)
|
|
||||||
try {
|
|
||||||
for (const file of acceptedFiles) {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append('file', file)
|
|
||||||
await onUpload(formData)
|
|
||||||
}
|
|
||||||
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.uploadError'))
|
|
||||||
} finally {
|
|
||||||
setUploading(false)
|
|
||||||
}
|
|
||||||
}, [onUpload, toast, t])
|
|
||||||
|
|
||||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
|
||||||
onDrop,
|
|
||||||
maxSize: 50 * 1024 * 1024,
|
|
||||||
noClick: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredFiles = files.filter(f => {
|
|
||||||
if (filterType === 'pdf') return f.mime_type === 'application/pdf'
|
|
||||||
if (filterType === 'image') return isImage(f.mime_type)
|
|
||||||
if (filterType === 'doc') return (f.mime_type || '').includes('word') || (f.mime_type || '').includes('excel') || (f.mime_type || '').includes('text')
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
|
||||||
if (!confirm(t('files.confirm.delete'))) return
|
|
||||||
try {
|
|
||||||
await onDelete(id)
|
|
||||||
toast.success(t('files.toast.deleted'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('files.toast.deleteError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [previewFile, setPreviewFile] = useState(null)
|
|
||||||
|
|
||||||
const openFile = (file) => {
|
|
||||||
if (isImage(file.mime_type)) {
|
|
||||||
setLightboxFile(file)
|
|
||||||
} else {
|
|
||||||
setPreviewFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full" style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
|
||||||
{/* Lightbox */}
|
|
||||||
{lightboxFile && <ImageLightbox file={lightboxFile} onClose={() => setLightboxFile(null)} />}
|
|
||||||
|
|
||||||
{/* Datei-Vorschau Modal */}
|
|
||||||
{previewFile && (
|
|
||||||
<div
|
|
||||||
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 8 }}
|
|
||||||
onClick={() => setPreviewFile(null)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{ width: '100%', maxWidth: 950, height: '95vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
|
||||||
<ExternalLink size={13} /> {t('files.openTab')}
|
|
||||||
</a>
|
|
||||||
<button onClick={() => setPreviewFile(null)}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<object
|
|
||||||
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
|
||||||
type="application/pdf"
|
|
||||||
style={{ flex: 1, width: '100%', border: 'none' }}
|
|
||||||
title={previewFile.original_name}
|
|
||||||
>
|
|
||||||
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
|
||||||
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
|
||||||
</p>
|
|
||||||
</object>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
|
||||||
<div>
|
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('files.title')}</h2>
|
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
|
||||||
{files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Upload zone */}
|
|
||||||
<div
|
|
||||||
{...getRootProps()}
|
|
||||||
style={{
|
|
||||||
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
|
||||||
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
|
||||||
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
|
||||||
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input {...getInputProps()} />
|
|
||||||
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
|
||||||
{uploading ? (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
|
||||||
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
|
||||||
{t('files.uploading')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter tabs */}
|
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0 }}>
|
|
||||||
{[
|
|
||||||
{ id: 'all', label: t('files.filterAll') },
|
|
||||||
{ id: 'pdf', label: t('files.filterPdf') },
|
|
||||||
{ id: 'image', label: t('files.filterImages') },
|
|
||||||
{ id: 'doc', label: t('files.filterDocs') },
|
|
||||||
].map(tab => (
|
|
||||||
<button key={tab.id} onClick={() => setFilterType(tab.id)} style={{
|
|
||||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer', fontSize: 12,
|
|
||||||
fontFamily: 'inherit', transition: 'all 0.12s',
|
|
||||||
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
|
||||||
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
||||||
fontWeight: filterType === tab.id ? 600 : 400,
|
|
||||||
}}>{tab.label}</button>
|
|
||||||
))}
|
|
||||||
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
|
||||||
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File list */}
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
|
||||||
{filteredFiles.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
|
||||||
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{filteredFiles.map(file => {
|
|
||||||
const FileIcon = getFileIcon(file.mime_type)
|
|
||||||
const linkedPlace = places?.find(p => p.id === file.place_id)
|
|
||||||
const linkedReservation = file.reservation_id
|
|
||||||
? (reservations?.find(r => r.id === file.reservation_id) || { title: file.reservation_title })
|
|
||||||
: null
|
|
||||||
const fileUrl = file.url || `/uploads/files/${file.filename}`
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={file.id} style={{
|
|
||||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
|
||||||
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
|
||||||
transition: 'border-color 0.12s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
|
||||||
className="group"
|
|
||||||
>
|
|
||||||
{/* Icon or thumbnail */}
|
|
||||||
<div
|
|
||||||
onClick={() => openFile({ ...file, url: fileUrl })}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
|
||||||
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
cursor: 'pointer', overflow: 'hidden',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isImage(file.mime_type)
|
|
||||||
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
: <FileIcon size={16} style={{ color: 'var(--text-muted)' }} />
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div
|
|
||||||
onClick={() => openFile({ ...file, url: fileUrl })}
|
|
||||||
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{file.original_name}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
|
||||||
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
|
||||||
|
|
||||||
{linkedPlace && (
|
|
||||||
<SourceBadge
|
|
||||||
icon={MapPin}
|
|
||||||
label={`${t('files.sourcePlan')} · ${linkedPlace.name}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{linkedReservation && (
|
|
||||||
<SourceBadge
|
|
||||||
icon={Ticket}
|
|
||||||
label={`${t('files.sourceBooking')} · ${linkedReservation.title || t('files.sourceBooking')}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{file.description && !linkedReservation && (
|
|
||||||
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: 'flex', gap: 2, flexShrink: 0, opacity: 0, transition: 'opacity 0.12s' }} className="file-actions">
|
|
||||||
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<ExternalLink size={14} />
|
|
||||||
</button>
|
|
||||||
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = '#9ca3af'}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
|
||||||
div:hover > .file-actions { opacity: 1 !important; }
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,782 @@
|
|||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import { Upload, Trash2, ExternalLink, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check } from 'lucide-react'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { filesApi } from '../../api/client'
|
||||||
|
import type { Place, Reservation, TripFile, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
|
function isImage(mimeType) {
|
||||||
|
if (!mimeType) return false
|
||||||
|
return mimeType.startsWith('image/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(mimeType) {
|
||||||
|
if (!mimeType) return File
|
||||||
|
if (mimeType === 'application/pdf') return FileText
|
||||||
|
if (isImage(mimeType)) return FileImage
|
||||||
|
return File
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes) {
|
||||||
|
if (!bytes) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateWithLocale(dateStr, locale) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
try {
|
||||||
|
return new Date(dateStr).toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
} catch { return '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image lightbox
|
||||||
|
interface ImageLightboxProps {
|
||||||
|
file: TripFile & { url: string }
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImageLightbox({ file, onClose }: ImageLightboxProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.88)', zIndex: 2000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }} onClick={e => e.stopPropagation()}>
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.original_name}
|
||||||
|
style={{ maxWidth: '90vw', maxHeight: '90vh', objectFit: 'contain', borderRadius: 8, display: 'block' }}
|
||||||
|
/>
|
||||||
|
<div style={{ position: 'absolute', top: -40, left: 0, right: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 4px' }}>
|
||||||
|
<span style={{ fontSize: 12, color: 'rgba(255,255,255,0.7)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: '80%' }}>{file.original_name}</span>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<a href={file.url} target="_blank" rel="noreferrer" style={{ color: 'rgba(255,255,255,0.7)', display: 'flex' }} title={t('files.openTab')}>
|
||||||
|
<ExternalLink size={16} />
|
||||||
|
</a>
|
||||||
|
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'rgba(255,255,255,0.7)', display: 'flex', padding: 0 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source badge
|
||||||
|
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,
|
||||||
|
fontSize: 10.5, color: '#4b5563',
|
||||||
|
background: 'var(--bg-tertiary)', border: '1px solid var(--border-primary)',
|
||||||
|
borderRadius: 6, padding: '2px 7px',
|
||||||
|
fontWeight: 500, maxWidth: '100%', overflow: 'hidden',
|
||||||
|
}}>
|
||||||
|
<Icon size={10} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?: string | null; size?: number }) {
|
||||||
|
const [hover, setHover] = useState(false)
|
||||||
|
const [pos, setPos] = useState({ top: 0, left: 0 })
|
||||||
|
const ref = useRef<HTMLDivElement>(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,
|
||||||
|
cursor: 'default',
|
||||||
|
}}>
|
||||||
|
{avatarUrl
|
||||||
|
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: name?.[0]?.toUpperCase()
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{hover && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||||
|
background: 'var(--bg-elevated)', color: 'var(--text-primary)',
|
||||||
|
fontSize: 11, fontWeight: 500, padding: '3px 8px', borderRadius: 6,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.15)', whiteSpace: 'nowrap', zIndex: 9999,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileManagerProps {
|
||||||
|
files?: TripFile[]
|
||||||
|
onUpload: (fd: FormData) => Promise<any>
|
||||||
|
onDelete: (fileId: number) => Promise<void>
|
||||||
|
onUpdate: (fileId: number, data: Partial<TripFile>) => Promise<void>
|
||||||
|
places: Place[]
|
||||||
|
days?: Day[]
|
||||||
|
assignments?: AssignmentsMap
|
||||||
|
reservations?: Reservation[]
|
||||||
|
tripId: number
|
||||||
|
allowedFileTypes: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileManager({ files = [], onUpload, onDelete, onUpdate, places, days = [], assignments = {}, reservations = [], tripId, allowedFileTypes }: FileManagerProps) {
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
const [filterType, setFilterType] = useState('all')
|
||||||
|
const [lightboxFile, setLightboxFile] = useState(null)
|
||||||
|
const [showTrash, setShowTrash] = useState(false)
|
||||||
|
const [trashFiles, setTrashFiles] = useState<TripFile[]>([])
|
||||||
|
const [loadingTrash, setLoadingTrash] = useState(false)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
|
const loadTrash = useCallback(async () => {
|
||||||
|
setLoadingTrash(true)
|
||||||
|
try {
|
||||||
|
const data = await filesApi.list(tripId, true)
|
||||||
|
setTrashFiles(data.files || [])
|
||||||
|
} catch { /* */ }
|
||||||
|
setLoadingTrash(false)
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const toggleTrash = useCallback(() => {
|
||||||
|
if (!showTrash) loadTrash()
|
||||||
|
setShowTrash(v => !v)
|
||||||
|
}, [showTrash, loadTrash])
|
||||||
|
|
||||||
|
const refreshFiles = useCallback(async () => {
|
||||||
|
if (onUpdate) onUpdate(0, {} as any)
|
||||||
|
}, [onUpdate])
|
||||||
|
|
||||||
|
const handleStar = async (fileId: number) => {
|
||||||
|
try {
|
||||||
|
await filesApi.toggleStar(tripId, fileId)
|
||||||
|
refreshFiles()
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestore = async (fileId: number) => {
|
||||||
|
try {
|
||||||
|
await filesApi.restore(tripId, fileId)
|
||||||
|
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
|
refreshFiles()
|
||||||
|
toast.success(t('files.toast.restored'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.restoreError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePermanentDelete = async (fileId: number) => {
|
||||||
|
if (!confirm(t('files.confirm.permanentDelete'))) return
|
||||||
|
try {
|
||||||
|
await filesApi.permanentDelete(tripId, fileId)
|
||||||
|
setTrashFiles(prev => prev.filter(f => f.id !== fileId))
|
||||||
|
toast.success(t('files.toast.deleted'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEmptyTrash = async () => {
|
||||||
|
if (!confirm(t('files.confirm.emptyTrash'))) return
|
||||||
|
try {
|
||||||
|
await filesApi.emptyTrash(tripId)
|
||||||
|
setTrashFiles([])
|
||||||
|
toast.success(t('files.toast.trashEmptied') || 'Trash emptied')
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lastUploadedIds, setLastUploadedIds] = useState<number[]>([])
|
||||||
|
|
||||||
|
const onDrop = useCallback(async (acceptedFiles) => {
|
||||||
|
if (acceptedFiles.length === 0) return
|
||||||
|
setUploading(true)
|
||||||
|
const uploadedIds: number[] = []
|
||||||
|
try {
|
||||||
|
for (const file of acceptedFiles) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const result = await onUpload(formData)
|
||||||
|
const fileObj = result?.file || result
|
||||||
|
if (fileObj?.id) uploadedIds.push(fileObj.id)
|
||||||
|
}
|
||||||
|
toast.success(t('files.uploaded', { count: acceptedFiles.length }))
|
||||||
|
// Open assign modal for the last uploaded file
|
||||||
|
const lastId = uploadedIds[uploadedIds.length - 1]
|
||||||
|
if (lastId && (places.length > 0 || reservations.length > 0)) {
|
||||||
|
setAssignFileId(lastId)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.uploadError'))
|
||||||
|
} finally {
|
||||||
|
setUploading(false)
|
||||||
|
}
|
||||||
|
}, [onUpload, toast, t, places, reservations])
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||||
|
onDrop,
|
||||||
|
maxSize: 50 * 1024 * 1024,
|
||||||
|
noClick: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const handlePaste = useCallback((e) => {
|
||||||
|
const items = e.clipboardData?.items
|
||||||
|
if (!items) return
|
||||||
|
const pastedFiles = []
|
||||||
|
for (const item of Array.from(items)) {
|
||||||
|
if (item.kind === 'file') {
|
||||||
|
const file = item.getAsFile()
|
||||||
|
if (file) pastedFiles.push(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (pastedFiles.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
onDrop(pastedFiles)
|
||||||
|
}
|
||||||
|
}, [onDrop])
|
||||||
|
|
||||||
|
const filteredFiles = files.filter(f => {
|
||||||
|
if (filterType === 'starred') return !!f.starred
|
||||||
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleDelete = async (id) => {
|
||||||
|
try {
|
||||||
|
await onDelete(id)
|
||||||
|
toast.success(t('files.toast.trashed') || 'Moved to trash')
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.deleteError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [previewFile, setPreviewFile] = useState(null)
|
||||||
|
const [assignFileId, setAssignFileId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleAssign = async (fileId: number, data: { place_id?: number | null; reservation_id?: number | null }) => {
|
||||||
|
try {
|
||||||
|
await filesApi.update(tripId, fileId, data)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {
|
||||||
|
toast.error(t('files.toast.assignError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openFile = (file) => {
|
||||||
|
if (isImage(file.mime_type)) {
|
||||||
|
setLightboxFile(file)
|
||||||
|
} else {
|
||||||
|
setPreviewFile(file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderFileRow = (file: TripFile, isTrash = false) => {
|
||||||
|
const FileIcon = getFileIcon(file.mime_type)
|
||||||
|
const allLinkedPlaceIds = new Set<number>()
|
||||||
|
if (file.place_id) allLinkedPlaceIds.add(file.place_id)
|
||||||
|
for (const pid of (file.linked_place_ids || [])) allLinkedPlaceIds.add(pid)
|
||||||
|
const linkedPlaces = [...allLinkedPlaceIds].map(pid => places?.find(p => p.id === pid)).filter(Boolean)
|
||||||
|
// All linked reservations (primary + file_links)
|
||||||
|
const allLinkedResIds = new Set<number>()
|
||||||
|
if (file.reservation_id) allLinkedResIds.add(file.reservation_id)
|
||||||
|
for (const rid of (file.linked_reservation_ids || [])) allLinkedResIds.add(rid)
|
||||||
|
const linkedReservations = [...allLinkedResIds].map(rid => reservations?.find(r => r.id === rid)).filter(Boolean)
|
||||||
|
const fileUrl = file.url || (file.filename?.startsWith('files/') ? `/uploads/${file.filename}` : `/uploads/files/${file.filename}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={file.id} style={{
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12,
|
||||||
|
padding: '10px 12px', display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||||
|
transition: 'border-color 0.12s',
|
||||||
|
opacity: isTrash ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.borderColor = 'var(--border-primary)'}
|
||||||
|
className="group"
|
||||||
|
>
|
||||||
|
{/* Icon or thumbnail */}
|
||||||
|
<div
|
||||||
|
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0, width: 36, height: 36, borderRadius: 8,
|
||||||
|
background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
cursor: isTrash ? 'default' : 'pointer', overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage(file.mime_type)
|
||||||
|
? <img src={fileUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
: (() => {
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{file.uploaded_by_name && (
|
||||||
|
<AvatarChip name={file.uploaded_by_name} avatarUrl={file.uploaded_by_avatar} size={20} />
|
||||||
|
)}
|
||||||
|
{!isTrash && file.starred ? <Star size={12} fill="#facc15" color="#facc15" style={{ flexShrink: 0 }} /> : null}
|
||||||
|
<span
|
||||||
|
onClick={() => !isTrash && openFile({ ...file, url: fileUrl })}
|
||||||
|
style={{ fontWeight: 500, fontSize: 13, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', cursor: isTrash ? 'default' : 'pointer' }}
|
||||||
|
>
|
||||||
|
{file.original_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{file.description && (
|
||||||
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', margin: '2px 0 0', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: 6, marginTop: 4 }}>
|
||||||
|
{file.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatSize(file.file_size)}</span>}
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formatDateWithLocale(file.created_at, locale)}</span>
|
||||||
|
|
||||||
|
{linkedPlaces.map(p => (
|
||||||
|
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
|
||||||
|
))}
|
||||||
|
{linkedReservations.map(r => (
|
||||||
|
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
|
||||||
|
))}
|
||||||
|
{file.note_id && (
|
||||||
|
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions — always visible on mobile, hover on desktop */}
|
||||||
|
<div className="file-actions" style={{ display: 'flex', gap: 2, flexShrink: 0 }}>
|
||||||
|
{isTrash ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleRestore(file.id)} title={t('files.restore') || 'Restore'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#22c55e'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<RotateCcw size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handlePermanentDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => handleStar(file.id)} title={file.starred ? t('files.unstar') || 'Unstar' : t('files.star') || 'Star'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: file.starred ? '#facc15' : 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => { if (!file.starred) e.currentTarget.style.color = '#facc15' }} onMouseLeave={e => { if (!file.starred) e.currentTarget.style.color = 'var(--text-faint)' }}>
|
||||||
|
<Star size={14} fill={file.starred ? '#facc15' : 'none'} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setAssignFileId(file.id)} title={t('files.assign') || 'Assign'} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => openFile({ ...file, url: fileUrl })} title={t('common.open')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<ExternalLink size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(file.id)} title={t('common.delete')} style={{ padding: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', borderRadius: 6, display: 'flex' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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)} />}
|
||||||
|
|
||||||
|
{/* Assign modal */}
|
||||||
|
{assignFileId && ReactDOM.createPortal(
|
||||||
|
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 5000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||||
|
onClick={() => setAssignFileId(null)}>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-card)', borderRadius: 16, boxShadow: '0 20px 60px rgba(0,0,0,0.2)',
|
||||||
|
width: 'min(600px, calc(100vw - 32px))', maxHeight: '70vh', overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ padding: '16px 20px 12px', borderBottom: '1px solid var(--border-primary)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>{t('files.assignTitle')}</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-faint)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
|
{files.find(f => f.id === assignFileId)?.original_name || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setAssignFileId(null)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', padding: 4, display: 'flex', flexShrink: 0 }}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px 0' }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '0 2px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.noteLabel') || 'Note'}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('files.notePlaceholder')}
|
||||||
|
defaultValue={files.find(f => f.id === assignFileId)?.description || ''}
|
||||||
|
onBlur={e => {
|
||||||
|
const val = e.target.value.trim()
|
||||||
|
const file = files.find(f => f.id === assignFileId)
|
||||||
|
if (file && val !== (file.description || '')) {
|
||||||
|
handleAssign(file.id, { description: val } as any)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '7px 10px', fontSize: 13, borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-secondary)',
|
||||||
|
color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ overflowY: 'auto', padding: 8 }}>
|
||||||
|
{(() => {
|
||||||
|
const file = files.find(f => f.id === assignFileId)
|
||||||
|
if (!file) return null
|
||||||
|
const assignedPlaceIds = new Set<number>()
|
||||||
|
const dayGroups: { day: Day; dayPlaces: Place[] }[] = []
|
||||||
|
for (const day of days) {
|
||||||
|
const da = assignments[String(day.id)] || []
|
||||||
|
const dayPlaces = da.map(a => places.find(p => p.id === a.place?.id || p.id === a.place_id)).filter(Boolean) as Place[]
|
||||||
|
if (dayPlaces.length > 0) {
|
||||||
|
dayGroups.push({ day, dayPlaces })
|
||||||
|
dayPlaces.forEach(p => assignedPlaceIds.add(p.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const unassigned = places.filter(p => !assignedPlaceIds.has(p.id))
|
||||||
|
const placeBtn = (p: Place) => {
|
||||||
|
const isLinked = file.place_id === p.id || (file.linked_place_ids || []).includes(p.id)
|
||||||
|
return (
|
||||||
|
<button key={p.id} onClick={async () => {
|
||||||
|
if (isLinked) {
|
||||||
|
if (file.place_id === p.id) {
|
||||||
|
await handleAssign(file.id, { place_id: null })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
|
const link = (linksRes.links || []).find((l: any) => l.place_id === p.id)
|
||||||
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!file.place_id) {
|
||||||
|
await handleAssign(file.id, { place_id: p.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { place_id: p.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<MapPin size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const placesSection = places.length > 0 && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.assignPlace')}
|
||||||
|
</div>
|
||||||
|
{dayGroups.map(({ day, dayPlaces }) => (
|
||||||
|
<div key={day.id}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>
|
||||||
|
{day.title || `${t('dayplan.dayN', { n: day.day_number })}${day.date ? ` · ${day.date}` : ''}`}
|
||||||
|
</div>
|
||||||
|
{dayPlaces.map(placeBtn)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{unassigned.length > 0 && (
|
||||||
|
<div>
|
||||||
|
{dayGroups.length > 0 && <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', padding: '8px 10px 2px' }}>{t('files.unassigned')}</div>}
|
||||||
|
{unassigned.map(placeBtn)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const bookingsSection = reservations.length > 0 && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
||||||
|
{t('files.assignBooking')}
|
||||||
|
</div>
|
||||||
|
{reservations.map(r => {
|
||||||
|
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
|
||||||
|
return (
|
||||||
|
<button key={r.id} onClick={async () => {
|
||||||
|
if (isLinked) {
|
||||||
|
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
|
||||||
|
if (file.reservation_id === r.id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: null })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const linksRes = await filesApi.getLinks(tripId, file.id)
|
||||||
|
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
|
||||||
|
if (link) await filesApi.removeLink(tripId, file.id, link.id)
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Link: if no primary, set it; otherwise use file_links
|
||||||
|
if (!file.reservation_id) {
|
||||||
|
await handleAssign(file.id, { reservation_id: r.id })
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
|
||||||
|
refreshFiles()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}} style={{
|
||||||
|
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
|
||||||
|
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
|
||||||
|
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
|
||||||
|
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
|
||||||
|
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const hasBoth = placesSection && bookingsSection
|
||||||
|
return (
|
||||||
|
<div className={hasBoth ? 'md:flex' : ''}>
|
||||||
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingRight: hasBoth ? 6 : 0 }}>{placesSection}</div>
|
||||||
|
{hasBoth && <div className="hidden md:block" style={{ width: 1, background: 'var(--border-primary)', flexShrink: 0 }} />}
|
||||||
|
{hasBoth && <div className="block md:hidden" style={{ height: 1, background: 'var(--border-primary)', margin: '8px 0' }} />}
|
||||||
|
<div className={hasBoth ? 'md:w-1/2' : ''} style={{ overflowY: 'auto', maxHeight: '55vh', paddingLeft: hasBoth ? 6 : 0 }}>{bookingsSection}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF preview modal */}
|
||||||
|
{previewFile && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.85)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
||||||
|
onClick={() => setPreviewFile(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: '100%', maxWidth: 950, height: '94vh', background: 'var(--bg-card)', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid var(--border-primary)', flexShrink: 0 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>{previewFile.original_name}</span>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
|
||||||
|
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noreferrer"
|
||||||
|
style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-muted)', textDecoration: 'none', padding: '4px 8px', borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}>
|
||||||
|
<ExternalLink size={13} /> {t('files.openTab')}
|
||||||
|
</a>
|
||||||
|
<button onClick={() => setPreviewFile(null)}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 4, borderRadius: 6, transition: 'color 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<object
|
||||||
|
data={`${previewFile.url || `/uploads/files/${previewFile.filename}`}#view=FitH`}
|
||||||
|
type="application/pdf"
|
||||||
|
style={{ flex: 1, width: '100%', border: 'none' }}
|
||||||
|
title={previewFile.original_name}
|
||||||
|
>
|
||||||
|
<p style={{ padding: 24, textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
<a href={previewFile.url || `/uploads/files/${previewFile.filename}`} target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-primary)', textDecoration: 'underline' }}>PDF herunterladen</a>
|
||||||
|
</p>
|
||||||
|
</object>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{showTrash ? (t('files.trash') || 'Trash') : t('files.title')}</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
||||||
|
{showTrash
|
||||||
|
? `${trashFiles.length} ${trashFiles.length === 1 ? 'file' : 'files'}`
|
||||||
|
: (files.length === 1 ? t('files.countSingular') : t('files.count', { count: files.length }))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={toggleTrash} style={{
|
||||||
|
padding: '6px 12px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
|
background: showTrash ? 'var(--accent)' : 'var(--bg-card)',
|
||||||
|
color: showTrash ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
fontSize: 12, fontWeight: 500, cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={13} /> {t('files.trash') || 'Trash'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showTrash ? (
|
||||||
|
/* Trash view */
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||||
|
{trashFiles.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 12 }}>
|
||||||
|
<button onClick={handleEmptyTrash} style={{
|
||||||
|
padding: '5px 12px', borderRadius: 8, border: '1px solid #fecaca',
|
||||||
|
background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
{t('files.emptyTrash') || 'Empty Trash'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingTrash ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: 40, color: 'var(--text-faint)' }}>
|
||||||
|
<div style={{ width: 20, height: 20, border: '2px solid var(--text-faint)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite', margin: '0 auto' }} />
|
||||||
|
</div>
|
||||||
|
) : trashFiles.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
|
<Trash2 size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.trashEmpty') || 'Trash is empty'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{trashFiles.map(file => renderFileRow(file, true))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Upload zone */}
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
style={{
|
||||||
|
margin: '16px 16px 0', border: '2px dashed', borderRadius: 14, padding: '20px 16px',
|
||||||
|
textAlign: 'center', cursor: 'pointer', transition: 'all 0.15s',
|
||||||
|
borderColor: isDragActive ? 'var(--text-secondary)' : 'var(--border-primary)',
|
||||||
|
background: isDragActive ? 'var(--bg-secondary)' : 'var(--bg-card)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<Upload size={24} style={{ margin: '0 auto 8px', color: isDragActive ? 'var(--text-secondary)' : 'var(--text-faint)', display: 'block' }} />
|
||||||
|
{uploading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||||
|
<div style={{ width: 14, height: 14, border: '2px solid var(--text-secondary)', borderTopColor: 'transparent', borderRadius: '50%', animation: 'spin 0.8s linear infinite' }} />
|
||||||
|
{t('files.uploading')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-secondary)', fontWeight: 500, margin: 0 }}>{t('files.dropzone')}</p>
|
||||||
|
<p style={{ fontSize: 11.5, color: 'var(--text-faint)', marginTop: 3 }}>{t('files.dropzoneHint')}</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 4, padding: '12px 16px 0', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: t('files.filterAll') },
|
||||||
|
...(files.some(f => f.starred) ? [{ id: 'starred', icon: Star }] : []),
|
||||||
|
{ 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,
|
||||||
|
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||||
|
background: filterType === tab.id ? 'var(--accent)' : 'transparent',
|
||||||
|
color: filterType === tab.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
||||||
|
fontWeight: filterType === tab.id ? 600 : 400,
|
||||||
|
}}>{tab.icon ? <tab.icon size={13} fill={filterType === tab.id ? '#facc15' : 'none'} color={filterType === tab.id ? '#facc15' : 'currentColor'} /> : tab.label}</button>
|
||||||
|
))}
|
||||||
|
<span style={{ marginLeft: 'auto', fontSize: 11.5, color: 'var(--text-faint)', alignSelf: 'center' }}>
|
||||||
|
{filteredFiles.length === 1 ? t('files.countSingular') : t('files.count', { count: filteredFiles.length })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* File list */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '12px 16px 16px' }}>
|
||||||
|
{filteredFiles.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: 'var(--text-faint)' }}>
|
||||||
|
<FileText size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 12px' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('files.empty')}</p>
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('files.emptyHint')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{filteredFiles.map(file => renderFileRow(file))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.file-actions button { padding: 8px !important; }
|
||||||
|
.file-actions svg { width: 18px !important; height: 18px !important; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
import { Info, Github, Shield, Key, Users, Database, Upload, Clock } from 'lucide-react'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
|
|
||||||
const texts = {
|
|
||||||
de: {
|
|
||||||
title: 'Willkommen zur NOMAD Demo',
|
|
||||||
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
|
||||||
resetIn: 'Naechster Reset in',
|
|
||||||
minutes: 'Minuten',
|
|
||||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
|
||||||
fullVersionTitle: 'In der Vollversion zusaetzlich verfuegbar:',
|
|
||||||
features: [
|
|
||||||
'Datei-Uploads (Fotos, Dokumente, Reise-Cover)',
|
|
||||||
'API-Schluessel verwalten (Google Maps, Wetter)',
|
|
||||||
'Benutzer & Rechte verwalten',
|
|
||||||
'Automatische Backups & Wiederherstellung',
|
|
||||||
],
|
|
||||||
selfHost: 'NOMAD ist Open Source — ',
|
|
||||||
selfHostLink: 'selbst hosten',
|
|
||||||
close: 'Verstanden',
|
|
||||||
},
|
|
||||||
en: {
|
|
||||||
title: 'Welcome to the NOMAD Demo',
|
|
||||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
|
||||||
resetIn: 'Next reset in',
|
|
||||||
minutes: 'minutes',
|
|
||||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
|
||||||
fullVersionTitle: 'Additionally available in the full version:',
|
|
||||||
features: [
|
|
||||||
'File uploads (photos, documents, trip covers)',
|
|
||||||
'API key management (Google Maps, Weather)',
|
|
||||||
'User & permission management',
|
|
||||||
'Automatic backups & restore',
|
|
||||||
],
|
|
||||||
selfHost: 'NOMAD is open source — ',
|
|
||||||
selfHostLink: 'self-host it',
|
|
||||||
close: 'Got it',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
const featureIcons = [Upload, Key, Users, Database]
|
|
||||||
|
|
||||||
export default function DemoBanner() {
|
|
||||||
const [dismissed, setDismissed] = useState(false)
|
|
||||||
const [minutesLeft, setMinutesLeft] = useState(59 - new Date().getMinutes())
|
|
||||||
const { language } = useTranslation()
|
|
||||||
const t = texts[language] || texts.en
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
if (dismissed) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 9999,
|
|
||||||
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
padding: 24,
|
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
|
||||||
}} onClick={() => setDismissed(true)}>
|
|
||||||
<div style={{
|
|
||||||
background: 'white', borderRadius: 20, padding: '32px 28px 24px',
|
|
||||||
maxWidth: 440, width: '100%',
|
|
||||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
|
||||||
}} onClick={e => e.stopPropagation()}>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 16 }}>
|
|
||||||
<div style={{
|
|
||||||
width: 36, height: 36, borderRadius: 10,
|
|
||||||
background: 'linear-gradient(135deg, #f59e0b, #d97706)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
<Info size={20} style={{ color: 'white' }} />
|
|
||||||
</div>
|
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>
|
|
||||||
{t.title}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={{ fontSize: 14, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
|
||||||
{t.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8, margin: '0 0 12px',
|
|
||||||
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '10px 12px',
|
|
||||||
}}>
|
|
||||||
<Clock size={15} style={{ flexShrink: 0, color: '#0284c7' }} />
|
|
||||||
<span style={{ fontSize: 13, color: '#0369a1', fontWeight: 600 }}>
|
|
||||||
{t.resetIn} {minutesLeft} {t.minutes}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p style={{
|
|
||||||
fontSize: 13, color: '#b45309', lineHeight: 1.5, margin: '0 0 20px',
|
|
||||||
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '10px 12px',
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
}}>
|
|
||||||
<Upload size={15} style={{ flexShrink: 0 }} />
|
|
||||||
{t.uploadNote}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p style={{ fontSize: 12, fontWeight: 700, color: '#374151', margin: '0 0 10px', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
|
||||||
{t.fullVersionTitle}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 20 }}>
|
|
||||||
{t.features.map((text, i) => {
|
|
||||||
const Icon = featureIcons[i]
|
|
||||||
return (
|
|
||||||
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 10, fontSize: 13, color: '#4b5563' }}>
|
|
||||||
<Icon size={15} style={{ flexShrink: 0, color: '#d97706' }} />
|
|
||||||
<span>{text}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{
|
|
||||||
paddingTop: 16, borderTop: '1px solid #e5e7eb',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#9ca3af' }}>
|
|
||||||
<Github size={14} />
|
|
||||||
<span>{t.selfHost}</span>
|
|
||||||
<a href="https://github.com/mauriceboe/NOMAD" target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ color: '#d97706', fontWeight: 600, textDecoration: 'none' }}>
|
|
||||||
{t.selfHostLink}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={() => setDismissed(true)} style={{
|
|
||||||
background: '#111827', color: 'white', border: 'none',
|
|
||||||
borderRadius: 10, padding: '8px 20px', fontSize: 13,
|
|
||||||
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
{t.close}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
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: '',
|
||||||
|
title: 'Willkommen zur TREK Demo',
|
||||||
|
description: 'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||||
|
resetIn: 'Naechster Reset in',
|
||||||
|
minutes: 'Minuten',
|
||||||
|
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||||
|
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||||
|
features: [
|
||||||
|
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||||
|
'API-Schluessel (Google Maps, Wetter)',
|
||||||
|
'Benutzer- & Rechteverwaltung',
|
||||||
|
'Automatische Backups',
|
||||||
|
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||||
|
'OIDC / SSO Single Sign-On',
|
||||||
|
],
|
||||||
|
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||||
|
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||||
|
['Packliste', 'Checklisten pro Reise'],
|
||||||
|
['Budget', 'Kostenplanung mit Splitting'],
|
||||||
|
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||||
|
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||||
|
],
|
||||||
|
whatIs: 'Was ist TREK?',
|
||||||
|
whatIsDesc: 'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||||
|
selfHost: 'Open Source — ',
|
||||||
|
selfHostLink: 'selbst hosten',
|
||||||
|
close: 'Verstanden',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
titleBefore: 'Welcome to ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: 'Welcome to the TREK Demo',
|
||||||
|
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||||
|
resetIn: 'Next reset in',
|
||||||
|
minutes: 'minutes',
|
||||||
|
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||||
|
fullVersionTitle: 'Additionally in the full version:',
|
||||||
|
features: [
|
||||||
|
'File uploads (photos, documents, covers)',
|
||||||
|
'API key management (Google Maps, Weather)',
|
||||||
|
'User & permission management',
|
||||||
|
'Automatic backups',
|
||||||
|
'Addon management (enable/disable)',
|
||||||
|
'OIDC / SSO single sign-on',
|
||||||
|
],
|
||||||
|
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||||
|
['Atlas', 'World map with visited countries & travel stats'],
|
||||||
|
['Packing', 'Checklists per trip'],
|
||||||
|
['Budget', 'Expense tracking with splitting'],
|
||||||
|
['Documents', 'Attach files to trips'],
|
||||||
|
['Widgets', 'Currency converter & timezones'],
|
||||||
|
],
|
||||||
|
whatIs: 'What is TREK?',
|
||||||
|
whatIsDesc: 'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||||
|
selfHost: 'Open source — ',
|
||||||
|
selfHostLink: 'self-host it',
|
||||||
|
close: 'Got it',
|
||||||
|
},
|
||||||
|
es: {
|
||||||
|
titleBefore: 'Bienvenido a ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: 'Bienvenido a la demo de TREK',
|
||||||
|
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||||||
|
resetIn: 'Próximo reinicio en',
|
||||||
|
minutes: 'minutos',
|
||||||
|
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||||||
|
fullVersionTitle: 'Además, en la versión completa:',
|
||||||
|
features: [
|
||||||
|
'Subida de archivos (fotos, documentos, portadas)',
|
||||||
|
'Gestión de claves API (Google Maps, tiempo)',
|
||||||
|
'Gestión de usuarios y permisos',
|
||||||
|
'Copias de seguridad automáticas',
|
||||||
|
'Gestión de addons (activar/desactivar)',
|
||||||
|
'Inicio de sesión único OIDC / SSO',
|
||||||
|
],
|
||||||
|
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||||||
|
addons: [
|
||||||
|
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||||||
|
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||||||
|
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||||||
|
['Presupuesto', 'Control de gastos con reparto'],
|
||||||
|
['Documentos', 'Adjunta archivos a los viajes'],
|
||||||
|
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||||||
|
],
|
||||||
|
whatIs: '¿Qué es TREK?',
|
||||||
|
whatIsDesc: 'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||||
|
selfHost: 'Código abierto — ',
|
||||||
|
selfHostLink: 'alójalo tú mismo',
|
||||||
|
close: 'Entendido',
|
||||||
|
},
|
||||||
|
ar: {
|
||||||
|
titleBefore: 'مرحبًا بك في ',
|
||||||
|
titleAfter: '',
|
||||||
|
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||||||
|
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||||||
|
resetIn: 'إعادة الضبط التالية خلال',
|
||||||
|
minutes: 'دقيقة',
|
||||||
|
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||||||
|
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||||||
|
features: [
|
||||||
|
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||||||
|
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||||||
|
'إدارة المستخدمين والصلاحيات',
|
||||||
|
'نسخ احتياطية تلقائية',
|
||||||
|
'إدارة الإضافات (تفعيل/تعطيل)',
|
||||||
|
'تسجيل دخول موحد OIDC / SSO',
|
||||||
|
],
|
||||||
|
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||||||
|
addons: [
|
||||||
|
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||||||
|
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||||||
|
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||||||
|
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||||||
|
['Documents', 'إرفاق الملفات بالرحلات'],
|
||||||
|
['Widgets', 'محول عملات ومناطق زمنية'],
|
||||||
|
],
|
||||||
|
whatIs: 'ما هو TREK؟',
|
||||||
|
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||||||
|
selfHost: 'مفتوح المصدر — ',
|
||||||
|
selfHostLink: 'استضفه بنفسك',
|
||||||
|
close: 'فهمت',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield]
|
||||||
|
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft]
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (dismissed) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 9999,
|
||||||
|
background: 'rgba(0,0,0,0.6)', backdropFilter: 'blur(8px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
padding: 16, overflow: 'auto',
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}} onClick={() => setDismissed(true)}>
|
||||||
|
<div style={{
|
||||||
|
background: 'white', borderRadius: 20, padding: '28px 24px 20px',
|
||||||
|
maxWidth: 480, width: '100%',
|
||||||
|
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||||
|
maxHeight: '90vh', overflow: 'auto',
|
||||||
|
}} onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||||
|
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: 17, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||||
|
{t.titleBefore}<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />{t.titleAfter}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style={{ fontSize: 13, color: '#6b7280', lineHeight: 1.6, margin: '0 0 12px' }}>
|
||||||
|
{t.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Timer + Upload note */}
|
||||||
|
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
background: '#f0f9ff', border: '1px solid #bae6fd', borderRadius: 10, padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||||
|
<span style={{ fontSize: 11, color: '#0369a1', fontWeight: 600 }}>
|
||||||
|
{t.resetIn} {minutesLeft} {t.minutes}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
|
||||||
|
background: '#fffbeb', border: '1px solid #fde68a', borderRadius: 10, padding: '8px 10px',
|
||||||
|
}}>
|
||||||
|
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||||
|
<span style={{ fontSize: 11, color: '#b45309' }}>{t.uploadNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* What is TREK */}
|
||||||
|
<div style={{
|
||||||
|
background: '#f8fafc', borderRadius: 12, padding: '12px 14px', marginBottom: 16,
|
||||||
|
border: '1px solid #e2e8f0',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||||
|
<Map size={14} style={{ color: '#111827' }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 700, color: '#111827', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{t.whatIs}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 12, color: '#64748b', lineHeight: 1.5, margin: 0 }}>{t.whatIsDesc}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Addons */}
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Puzzle size={12} />
|
||||||
|
{t.addonsTitle}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||||
|
{t.addons.map(([name, desc], i) => {
|
||||||
|
const Icon = addonIcons[i]
|
||||||
|
return (
|
||||||
|
<div key={name} style={{
|
||||||
|
background: '#f8fafc', borderRadius: 10, padding: '8px 10px',
|
||||||
|
border: '1px solid #f1f5f9',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||||
|
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||||
|
<span style={{ fontSize: 11, fontWeight: 700, color: '#111827' }}>{name}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 10, color: '#94a3b8', margin: 0, lineHeight: 1.3, paddingLeft: 18 }}>{desc}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Full version features */}
|
||||||
|
<p style={{ fontSize: 10, fontWeight: 700, color: '#374151', margin: '0 0 8px', textTransform: 'uppercase', letterSpacing: '0.08em', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Shield size={12} />
|
||||||
|
{t.fullVersionTitle}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||||
|
{t.features.map((text, i) => {
|
||||||
|
const Icon = featureIcons[i]
|
||||||
|
return (
|
||||||
|
<div key={text} style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 11, color: '#4b5563', padding: '4px 0' }}>
|
||||||
|
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||||
|
<span>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div style={{
|
||||||
|
paddingTop: 14, borderTop: '1px solid #e5e7eb',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: '#9ca3af' }}>
|
||||||
|
<Github size={13} />
|
||||||
|
<span>{t.selfHost}</span>
|
||||||
|
<a href="https://github.com/mauriceboe/TREK" target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}>
|
||||||
|
{t.selfHostLink}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setDismissed(true)} style={{
|
||||||
|
background: '#111827', color: 'white', border: 'none',
|
||||||
|
borderRadius: 10, padding: '8px 20px', fontSize: 12,
|
||||||
|
fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
{t.close}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,18 +1,56 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import ReactDOM from 'react-dom'
|
||||||
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun } from 'lucide-react'
|
import { addonsApi } from '../../api/client'
|
||||||
|
import { Plane, LogOut, Settings, ChevronDown, Shield, ArrowLeft, Users, Moon, Sun, Monitor, CalendarDays, Briefcase, Globe } from 'lucide-react'
|
||||||
|
import type { LucideIcon } from 'lucide-react'
|
||||||
|
|
||||||
export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare }) {
|
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe }
|
||||||
|
|
||||||
|
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 { user, logout } = useAuthStore()
|
||||||
const { settings, updateSetting } = useSettingsStore()
|
const { settings, updateSetting } = useSettingsStore()
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
const location = useLocation()
|
||||||
const [appVersion, setAppVersion] = useState(null)
|
const [userMenuOpen, setUserMenuOpen] = useState<boolean>(false)
|
||||||
const dark = settings.dark_mode
|
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) {
|
||||||
|
addonsApi.enabled().then(data => {
|
||||||
|
setGlobalAddons(data.addons.filter(a => a.type === 'global'))
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
useEffect(loadAddons, [user, location.pathname])
|
||||||
|
// Listen for addon changes from AddonManager
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => loadAddons()
|
||||||
|
window.addEventListener('addons-changed', handler)
|
||||||
|
return () => window.removeEventListener('addons-changed', handler)
|
||||||
|
}, [user])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('../../api/client').then(({ authApi }) => {
|
import('../../api/client').then(({ authApi }) => {
|
||||||
@@ -25,8 +63,14 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleDark = () => {
|
const toggleDarkMode = () => {
|
||||||
updateSetting('dark_mode', !dark).catch(() => {})
|
updateSetting('dark_mode', dark ? 'light' : 'dark').catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAddonName = (addon: Addon): string => {
|
||||||
|
const key = `admin.addons.catalog.${addon.id}.name`
|
||||||
|
const translated = t(key)
|
||||||
|
return translated !== key ? translated : addon.name
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +79,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
borderBottom: `1px solid ${dark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)'}`,
|
||||||
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
boxShadow: dark ? '0 1px 12px rgba(0,0,0,0.2)' : '0 1px 12px rgba(0,0,0,0.05)',
|
||||||
}} className="h-14 flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
touchAction: 'manipulation',
|
||||||
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
|
height: 'var(--nav-h)',
|
||||||
|
}} className="flex items-center px-4 gap-4 fixed top-0 left-0 right-0 z-[200]">
|
||||||
{/* Left side */}
|
{/* Left side */}
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
{showBack && (
|
{showBack && (
|
||||||
@@ -49,12 +96,47 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link to="/dashboard" className="flex items-center gap-2 transition-colors flex-shrink-0"
|
<Link to="/dashboard" className="flex items-center transition-colors flex-shrink-0">
|
||||||
style={{ color: 'var(--text-primary)' }}>
|
<img src={dark ? '/icons/icon-white.svg' : '/icons/icon-dark.svg'} alt="TREK" className="sm:hidden" style={{ height: 22, width: 22 }} />
|
||||||
<Plane className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
|
<img src={dark ? '/logo-light.svg' : '/logo-dark.svg'} alt="TREK" className="hidden sm:block" style={{ height: 28 }} />
|
||||||
<span className="font-bold text-sm hidden sm:inline">NOMAD</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Global addon nav items */}
|
||||||
|
{globalAddons.length > 0 && !tripTitle && (
|
||||||
|
<>
|
||||||
|
<span style={{ color: 'var(--text-faint)' }}>|</span>
|
||||||
|
<Link to="/dashboard"
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: location.pathname === '/dashboard' ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
background: location.pathname === '/dashboard' ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => { if (location.pathname !== '/dashboard') e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
<Briefcase className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">{t('nav.myTrips')}</span>
|
||||||
|
</Link>
|
||||||
|
{globalAddons.map(addon => {
|
||||||
|
const Icon = ADDON_ICONS[addon.icon] || CalendarDays
|
||||||
|
const path = `/${addon.id}`
|
||||||
|
const isActive = location.pathname === path
|
||||||
|
return (
|
||||||
|
<Link key={addon.id} to={path}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium transition-colors flex-shrink-0"
|
||||||
|
style={{
|
||||||
|
color: isActive ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
background: isActive ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
<span className="hidden md:inline">{getAddonName(addon)}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{tripTitle && (
|
{tripTitle && (
|
||||||
<>
|
<>
|
||||||
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
<span className="hidden sm:inline" style={{ color: 'var(--text-faint)' }}>/</span>
|
||||||
@@ -80,8 +162,8 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dark mode toggle */}
|
{/* Dark mode toggle (light ↔ dark, overrides auto) */}
|
||||||
<button onClick={toggleDark} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
<button onClick={toggleDarkMode} title={dark ? t('nav.lightMode') : t('nav.darkMode')}
|
||||||
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
className="p-2 rounded-lg transition-colors flex-shrink-0"
|
||||||
style={{ color: 'var(--text-muted)' }}
|
style={{ color: 'var(--text-muted)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
@@ -110,11 +192,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
<ChevronDown className="w-4 h-4" style={{ color: 'var(--text-faint)' }} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{userMenuOpen && (
|
{userMenuOpen && ReactDOM.createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-10" onClick={() => setUserMenuOpen(false)} />
|
<div style={{ position: 'fixed', inset: 0, zIndex: 9998 }} onClick={() => setUserMenuOpen(false)} />
|
||||||
<div className="absolute right-0 top-full mt-2 w-52 rounded-xl shadow-xl border z-20 overflow-hidden"
|
<div className="w-52 rounded-xl shadow-xl border overflow-hidden" style={{ position: 'fixed', top: 'var(--nav-h)', right: 8, zIndex: 9999, background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
|
||||||
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
<div className="px-4 py-3 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{user.username}</p>
|
||||||
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
<p className="text-xs truncate" style={{ color: 'var(--text-muted)' }}>{user.email}</p>
|
||||||
@@ -154,13 +235,17 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onShare })
|
|||||||
{t('nav.logout')}
|
{t('nav.logout')}
|
||||||
</button>
|
</button>
|
||||||
{appVersion && (
|
{appVersion && (
|
||||||
<div className="px-4 py-1.5 text-center" style={{ fontSize: 10, color: 'var(--text-faint)' }}>
|
<div className="px-4 pt-2 pb-2.5 text-center" style={{ marginTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||||
NOMAD v{appVersion}
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 5, background: 'var(--bg-tertiary)', borderRadius: 99, padding: '4px 12px' }}>
|
||||||
|
<img src={dark ? '/text-light.svg' : '/text-dark.svg'} alt="TREK" style={{ height: 10, opacity: 0.5 }} />
|
||||||
|
<span style={{ fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>v{appVersion}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
|
||||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, useMap } from 'react-leaflet'
|
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
|
||||||
import L from 'leaflet'
|
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'
|
|
||||||
import { mapsApi } from '../../api/client'
|
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
|
||||||
|
|
||||||
// Fix default marker icons for vite
|
|
||||||
delete L.Icon.Default.prototype._getIconUrl
|
|
||||||
L.Icon.Default.mergeOptions({
|
|
||||||
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
|
||||||
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
|
||||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a round photo-circle marker.
|
|
||||||
* Shows image_url if available, otherwise category icon in colored circle.
|
|
||||||
*/
|
|
||||||
function createPlaceIcon(place, orderNumber, isSelected) {
|
|
||||||
const size = isSelected ? 44 : 36
|
|
||||||
const borderColor = isSelected ? '#111827' : 'white'
|
|
||||||
const borderWidth = isSelected ? 3 : 2.5
|
|
||||||
const shadow = isSelected
|
|
||||||
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
|
||||||
: '0 2px 8px rgba(0,0,0,0.22)'
|
|
||||||
const bgColor = place.category_color || '#6b7280'
|
|
||||||
const icon = place.category_icon || '📍'
|
|
||||||
|
|
||||||
// White semi-transparent number badge (bottom-right), only when orderNumber is set
|
|
||||||
const badgeHtml = orderNumber != null ? `
|
|
||||||
<span style="
|
|
||||||
position:absolute;bottom:-3px;right:-3px;
|
|
||||||
min-width:18px;height:18px;border-radius:9px;
|
|
||||||
padding:0 3px;
|
|
||||||
background:rgba(255,255,255,0.92);
|
|
||||||
border:1.5px solid rgba(0,0,0,0.18);
|
|
||||||
box-shadow:0 1px 4px rgba(0,0,0,0.18);
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
font-size:9px;font-weight:800;color:#111827;
|
|
||||||
font-family:-apple-system,system-ui,sans-serif;line-height:1;
|
|
||||||
box-sizing:border-box;
|
|
||||||
">${orderNumber}</span>` : ''
|
|
||||||
|
|
||||||
if (place.image_url) {
|
|
||||||
return L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: `<div style="
|
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
|
||||||
border:${borderWidth}px solid ${borderColor};
|
|
||||||
box-shadow:${shadow};
|
|
||||||
overflow:visible;background:${bgColor};
|
|
||||||
cursor:pointer;flex-shrink:0;position:relative;
|
|
||||||
">
|
|
||||||
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
|
||||||
<img src="${place.image_url}" style="width:100%;height:100%;object-fit:cover;" />
|
|
||||||
</div>
|
|
||||||
${badgeHtml}
|
|
||||||
</div>`,
|
|
||||||
iconSize: [size, size],
|
|
||||||
iconAnchor: [size / 2, size / 2],
|
|
||||||
tooltipAnchor: [size / 2 + 6, 0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: `<div style="
|
|
||||||
width:${size}px;height:${size}px;border-radius:50%;
|
|
||||||
border:${borderWidth}px solid ${borderColor};
|
|
||||||
box-shadow:${shadow};
|
|
||||||
background:${bgColor};
|
|
||||||
display:flex;align-items:center;justify-content:center;
|
|
||||||
cursor:pointer;position:relative;
|
|
||||||
">
|
|
||||||
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
|
||||||
${badgeHtml}
|
|
||||||
</div>`,
|
|
||||||
iconSize: [size, size],
|
|
||||||
iconAnchor: [size / 2, size / 2],
|
|
||||||
tooltipAnchor: [size / 2 + 6, 0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function SelectionController({ places, selectedPlaceId }) {
|
|
||||||
const map = useMap()
|
|
||||||
const prev = useRef(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
|
||||||
const place = places.find(p => p.id === selectedPlaceId)
|
|
||||||
if (place?.lat && place?.lng) {
|
|
||||||
map.setView([place.lat, place.lng], Math.max(map.getZoom(), 15), { animate: true, duration: 0.5 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prev.current = selectedPlaceId
|
|
||||||
}, [selectedPlaceId, places, map])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapController({ center, zoom }) {
|
|
||||||
const map = useMap()
|
|
||||||
const prevCenter = useRef(center)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
|
|
||||||
map.setView(center, zoom)
|
|
||||||
prevCenter.current = center
|
|
||||||
}
|
|
||||||
}, [center, zoom, map])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fit bounds when places change (fitKey triggers re-fit)
|
|
||||||
function BoundsController({ places, fitKey }) {
|
|
||||||
const map = useMap()
|
|
||||||
const prevFitKey = useRef(-1)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fitKey === prevFitKey.current) return
|
|
||||||
prevFitKey.current = fitKey
|
|
||||||
if (places.length === 0) return
|
|
||||||
try {
|
|
||||||
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
|
||||||
if (bounds.isValid()) map.fitBounds(bounds, { padding: [60, 60], maxZoom: 15, animate: true })
|
|
||||||
} catch {}
|
|
||||||
}, [fitKey, places, map])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function MapClickHandler({ onClick }) {
|
|
||||||
const map = useMap()
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onClick) return
|
|
||||||
map.on('click', onClick)
|
|
||||||
return () => map.off('click', onClick)
|
|
||||||
}, [map, onClick])
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level photo cache shared with PlaceAvatar
|
|
||||||
const mapPhotoCache = new Map()
|
|
||||||
|
|
||||||
export function MapView({
|
|
||||||
places = [],
|
|
||||||
route = null,
|
|
||||||
selectedPlaceId = null,
|
|
||||||
onMarkerClick,
|
|
||||||
onMapClick,
|
|
||||||
center = [48.8566, 2.3522],
|
|
||||||
zoom = 10,
|
|
||||||
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
|
||||||
fitKey = 0,
|
|
||||||
dayOrderMap = {},
|
|
||||||
}) {
|
|
||||||
const [photoUrls, setPhotoUrls] = useState({})
|
|
||||||
|
|
||||||
// Fetch Google photos for places that have google_place_id but no image_url
|
|
||||||
useEffect(() => {
|
|
||||||
places.forEach(place => {
|
|
||||||
if (place.image_url || !place.google_place_id) return
|
|
||||||
if (mapPhotoCache.has(place.google_place_id)) {
|
|
||||||
const cached = mapPhotoCache.get(place.google_place_id)
|
|
||||||
if (cached) setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: cached }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
mapsApi.placePhoto(place.google_place_id)
|
|
||||||
.then(data => {
|
|
||||||
if (data.photoUrl) {
|
|
||||||
mapPhotoCache.set(place.google_place_id, data.photoUrl)
|
|
||||||
setPhotoUrls(prev => ({ ...prev, [place.google_place_id]: data.photoUrl }))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => { mapPhotoCache.set(place.google_place_id, null) })
|
|
||||||
})
|
|
||||||
}, [places])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MapContainer
|
|
||||||
center={center}
|
|
||||||
zoom={zoom}
|
|
||||||
zoomControl={false}
|
|
||||||
className="w-full h-full"
|
|
||||||
style={{ background: '#e5e7eb' }}
|
|
||||||
>
|
|
||||||
<TileLayer
|
|
||||||
url={tileUrl}
|
|
||||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
||||||
maxZoom={19}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<MapController center={center} zoom={zoom} />
|
|
||||||
<BoundsController places={places} fitKey={fitKey} />
|
|
||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} />
|
|
||||||
<MapClickHandler onClick={onMapClick} />
|
|
||||||
|
|
||||||
<MarkerClusterGroup
|
|
||||||
chunkedLoading
|
|
||||||
maxClusterRadius={30}
|
|
||||||
disableClusteringAtZoom={11}
|
|
||||||
spiderfyOnMaxZoom
|
|
||||||
showCoverageOnHover={false}
|
|
||||||
zoomToBoundsOnClick
|
|
||||||
iconCreateFunction={(cluster) => {
|
|
||||||
const count = cluster.getChildCount()
|
|
||||||
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
|
||||||
return L.divIcon({
|
|
||||||
html: `<div class="marker-cluster-custom"
|
|
||||||
style="width:${size}px;height:${size}px;">
|
|
||||||
<span>${count}</span>
|
|
||||||
</div>`,
|
|
||||||
className: 'marker-cluster-wrapper',
|
|
||||||
iconSize: L.point(size, size),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{places.map((place) => {
|
|
||||||
const isSelected = place.id === selectedPlaceId
|
|
||||||
const resolvedPhotoUrl = place.image_url || (place.google_place_id && photoUrls[place.google_place_id]) || null
|
|
||||||
const orderNumber = dayOrderMap[place.id] ?? null
|
|
||||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumber, isSelected)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
key={place.id}
|
|
||||||
position={[place.lat, place.lng]}
|
|
||||||
icon={icon}
|
|
||||||
eventHandlers={{
|
|
||||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
|
||||||
}}
|
|
||||||
zIndexOffset={isSelected ? 1000 : 0}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
direction="right"
|
|
||||||
offset={[0, 0]}
|
|
||||||
opacity={1}
|
|
||||||
className="map-tooltip"
|
|
||||||
>
|
|
||||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
|
||||||
{place.name}
|
|
||||||
</div>
|
|
||||||
{place.category_name && (() => {
|
|
||||||
const CatIcon = getCategoryIcon(place.category_icon)
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
|
||||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{place.address && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
|
||||||
{place.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</Marker>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</MarkerClusterGroup>
|
|
||||||
|
|
||||||
{route && route.length > 1 && (
|
|
||||||
<Polyline
|
|
||||||
positions={route}
|
|
||||||
color="#111827"
|
|
||||||
weight={3}
|
|
||||||
opacity={0.9}
|
|
||||||
dashArray="6, 5"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</MapContainer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
import { useEffect, useRef, useState, useMemo, useCallback } from 'react'
|
||||||
|
import DOM from 'react-dom'
|
||||||
|
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||||
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
|
import L from 'leaflet'
|
||||||
|
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
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
|
||||||
|
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
|
||||||
|
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a round photo-circle marker.
|
||||||
|
* Shows image_url if available, otherwise category icon in colored circle.
|
||||||
|
*/
|
||||||
|
function escAttr(s) {
|
||||||
|
if (!s) return ''
|
||||||
|
return s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||||
|
const size = isSelected ? 44 : 36
|
||||||
|
const borderColor = isSelected ? '#111827' : 'white'
|
||||||
|
const borderWidth = isSelected ? 3 : 2.5
|
||||||
|
const shadow = isSelected
|
||||||
|
? '0 0 0 3px rgba(17,24,39,0.25), 0 4px 14px rgba(0,0,0,0.3)'
|
||||||
|
: '0 2px 8px rgba(0,0,0,0.22)'
|
||||||
|
const bgColor = place.category_color || '#6b7280'
|
||||||
|
const icon = place.category_icon || '📍'
|
||||||
|
|
||||||
|
// 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:${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;white-space:nowrap;
|
||||||
|
">${label}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (place.image_url) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
border:${borderWidth}px solid ${borderColor};
|
||||||
|
box-shadow:${shadow};
|
||||||
|
overflow:visible;background:${bgColor};
|
||||||
|
cursor:pointer;flex-shrink:0;position:relative;
|
||||||
|
">
|
||||||
|
<div style="width:100%;height:100%;border-radius:50%;overflow:hidden;">
|
||||||
|
<img src="${escAttr(place.image_url)}" loading="lazy" style="width:100%;height:100%;object-fit:cover;" />
|
||||||
|
</div>
|
||||||
|
${badgeHtml}
|
||||||
|
</div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
tooltipAnchor: [size / 2 + 6, 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: `<div style="
|
||||||
|
width:${size}px;height:${size}px;border-radius:50%;
|
||||||
|
border:${borderWidth}px solid ${borderColor};
|
||||||
|
box-shadow:${shadow};
|
||||||
|
background:${bgColor};
|
||||||
|
display:flex;align-items:center;justify-content:center;
|
||||||
|
cursor:pointer;position:relative;
|
||||||
|
">
|
||||||
|
<span style="font-size:${isSelected ? 18 : 15}px;line-height:1;">${icon}</span>
|
||||||
|
${badgeHtml}
|
||||||
|
</div>`,
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
tooltipAnchor: [size / 2 + 6, 0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
|
// Pan to the selected place without changing zoom
|
||||||
|
const selected = places.find(p => p.id === selectedPlaceId)
|
||||||
|
if (selected?.lat && selected?.lng) {
|
||||||
|
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prev.current = selectedPlaceId
|
||||||
|
}, [selectedPlaceId, places, map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapControllerProps {
|
||||||
|
center: [number, number]
|
||||||
|
zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapController({ center, zoom }: MapControllerProps) {
|
||||||
|
const map = useMap()
|
||||||
|
const prevCenter = useRef(center)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevCenter.current[0] !== center[0] || prevCenter.current[1] !== center[1]) {
|
||||||
|
map.setView(center, zoom)
|
||||||
|
prevCenter.current = center
|
||||||
|
}
|
||||||
|
}, [center, zoom, map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fit bounds when places change (fitKey triggers re-fit)
|
||||||
|
interface BoundsControllerProps {
|
||||||
|
places: Place[]
|
||||||
|
fitKey: number
|
||||||
|
paddingOpts: Record<string, number>
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoundsController({ places, fitKey, paddingOpts }: BoundsControllerProps) {
|
||||||
|
const map = useMap()
|
||||||
|
const prevFitKey = useRef(-1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fitKey === prevFitKey.current) return
|
||||||
|
prevFitKey.current = fitKey
|
||||||
|
if (places.length === 0) return
|
||||||
|
try {
|
||||||
|
const bounds = L.latLngBounds(places.map(p => [p.lat, p.lng]))
|
||||||
|
if (bounds.isValid()) map.fitBounds(bounds, { ...paddingOpts, maxZoom: 16, animate: true })
|
||||||
|
} catch {}
|
||||||
|
}, [fitKey, places, paddingOpts, map])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapClickHandlerProps {
|
||||||
|
onClick: ((e: L.LeafletMouseEvent) => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapClickHandler({ onClick }: MapClickHandlerProps) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onClick) return
|
||||||
|
map.on('click', onClick)
|
||||||
|
return () => map.off('click', onClick)
|
||||||
|
}, [map, onClick])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function MapContextMenuHandler({ onContextMenu }: { onContextMenu: ((e: L.LeafletMouseEvent) => void) | null }) {
|
||||||
|
const map = useMap()
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onContextMenu) return
|
||||||
|
map.on('contextmenu', onContextMenu)
|
||||||
|
return () => map.off('contextmenu', onContextMenu)
|
||||||
|
}, [map, onContextMenu])
|
||||||
|
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()
|
||||||
|
const mapPhotoInFlight = new Set()
|
||||||
|
|
||||||
|
// Live location tracker — blue dot with pulse animation (like Apple/Google Maps)
|
||||||
|
function LocationTracker() {
|
||||||
|
const map = useMap()
|
||||||
|
const [position, setPosition] = useState<[number, number] | null>(null)
|
||||||
|
const [accuracy, setAccuracy] = useState(0)
|
||||||
|
const [tracking, setTracking] = useState(false)
|
||||||
|
const watchId = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const startTracking = useCallback(() => {
|
||||||
|
if (!('geolocation' in navigator)) return
|
||||||
|
setTracking(true)
|
||||||
|
watchId.current = navigator.geolocation.watchPosition(
|
||||||
|
(pos) => {
|
||||||
|
const latlng: [number, number] = [pos.coords.latitude, pos.coords.longitude]
|
||||||
|
setPosition(latlng)
|
||||||
|
setAccuracy(pos.coords.accuracy)
|
||||||
|
},
|
||||||
|
() => setTracking(false),
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 5000 }
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopTracking = useCallback(() => {
|
||||||
|
if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current)
|
||||||
|
watchId.current = null
|
||||||
|
setTracking(false)
|
||||||
|
setPosition(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleTracking = useCallback(() => {
|
||||||
|
if (tracking) { stopTracking() } else { startTracking() }
|
||||||
|
}, [tracking, startTracking, stopTracking])
|
||||||
|
|
||||||
|
// Center map on position when first acquired
|
||||||
|
const centered = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (position && !centered.current) {
|
||||||
|
map.setView(position, 15)
|
||||||
|
centered.current = true
|
||||||
|
}
|
||||||
|
}, [position, map])
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => () => { if (watchId.current !== null) navigator.geolocation.clearWatch(watchId.current) }, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Location button */}
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 20, right: 10, zIndex: 1000,
|
||||||
|
}}>
|
||||||
|
<button onClick={toggleTracking} style={{
|
||||||
|
width: 36, height: 36, borderRadius: '50%',
|
||||||
|
border: 'none', cursor: 'pointer',
|
||||||
|
background: tracking ? '#3b82f6' : 'var(--bg-card, white)',
|
||||||
|
color: tracking ? 'white' : 'var(--text-muted, #6b7280)',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'background 0.2s, color 0.2s',
|
||||||
|
}}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 2v4M12 18v4M2 12h4M18 12h4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Blue dot + accuracy circle */}
|
||||||
|
{position && (
|
||||||
|
<>
|
||||||
|
{accuracy < 500 && (
|
||||||
|
<Circle center={position} radius={accuracy} pathOptions={{ color: '#3b82f6', fillColor: '#3b82f6', fillOpacity: 0.06, weight: 0.5, opacity: 0.3 }} />
|
||||||
|
)}
|
||||||
|
<CircleMarker center={position} radius={7} pathOptions={{ color: 'white', fillColor: '#3b82f6', fillOpacity: 1, weight: 2.5 }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pulse animation CSS */}
|
||||||
|
{position && (
|
||||||
|
<style>{`
|
||||||
|
@keyframes location-pulse {
|
||||||
|
0% { transform: scale(1); opacity: 0.6; }
|
||||||
|
100% { transform: scale(2.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MapView({
|
||||||
|
places = [],
|
||||||
|
dayPlaces = [],
|
||||||
|
route = null,
|
||||||
|
routeSegments = [],
|
||||||
|
selectedPlaceId = null,
|
||||||
|
onMarkerClick,
|
||||||
|
onMapClick,
|
||||||
|
onMapContextMenu = null,
|
||||||
|
center = [48.8566, 2.3522],
|
||||||
|
zoom = 10,
|
||||||
|
tileUrl = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
|
||||||
|
fitKey = 0,
|
||||||
|
dayOrderMap = {},
|
||||||
|
leftWidth = 0,
|
||||||
|
rightWidth = 0,
|
||||||
|
hasInspector = false,
|
||||||
|
}) {
|
||||||
|
// Dynamic padding: account for sidebars + bottom inspector
|
||||||
|
const paddingOpts = useMemo(() => {
|
||||||
|
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
|
||||||
|
if (isMobile) return { padding: [40, 20] }
|
||||||
|
const top = 60
|
||||||
|
const bottom = hasInspector ? 320 : 60
|
||||||
|
const left = leftWidth + 40
|
||||||
|
const right = rightWidth + 40
|
||||||
|
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||||
|
}, [leftWidth, rightWidth, hasInspector])
|
||||||
|
const [photoUrls, setPhotoUrls] = useState({})
|
||||||
|
|
||||||
|
// Fetch photos for places with concurrency limit to avoid blocking map rendering
|
||||||
|
useEffect(() => {
|
||||||
|
const queue = places.filter(place => {
|
||||||
|
if (place.image_url) return false
|
||||||
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
if (!cacheKey) return false
|
||||||
|
if (mapPhotoCache.has(cacheKey)) {
|
||||||
|
const cached = mapPhotoCache.get(cacheKey)
|
||||||
|
if (cached) setPhotoUrls(prev => prev[cacheKey] === cached ? prev : ({ ...prev, [cacheKey]: cached }))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (mapPhotoInFlight.has(cacheKey)) return false
|
||||||
|
const photoId = place.google_place_id || place.osm_id
|
||||||
|
if (!photoId && !(place.lat && place.lng)) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
let active = 0
|
||||||
|
const MAX_CONCURRENT = 3
|
||||||
|
let idx = 0
|
||||||
|
|
||||||
|
const fetchNext = () => {
|
||||||
|
while (active < MAX_CONCURRENT && idx < queue.length) {
|
||||||
|
const place = queue[idx++]
|
||||||
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
const photoId = place.google_place_id || place.osm_id
|
||||||
|
mapPhotoInFlight.add(cacheKey)
|
||||||
|
active++
|
||||||
|
mapsApi.placePhoto(photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||||
|
.then(data => {
|
||||||
|
if (data.photoUrl) {
|
||||||
|
mapPhotoCache.set(cacheKey, data.photoUrl)
|
||||||
|
setPhotoUrls(prev => ({ ...prev, [cacheKey]: data.photoUrl }))
|
||||||
|
} else {
|
||||||
|
mapPhotoCache.set(cacheKey, null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => { mapPhotoCache.set(cacheKey, null) })
|
||||||
|
.finally(() => { mapPhotoInFlight.delete(cacheKey); active--; fetchNext() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchNext()
|
||||||
|
}, [places])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={center}
|
||||||
|
zoom={zoom}
|
||||||
|
zoomControl={false}
|
||||||
|
className="w-full h-full"
|
||||||
|
style={{ background: '#e5e7eb' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
url={tileUrl}
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||||
|
maxZoom={19}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<MapController center={center} zoom={zoom} />
|
||||||
|
<BoundsController places={dayPlaces.length > 0 ? dayPlaces : places} fitKey={fitKey} paddingOpts={paddingOpts} />
|
||||||
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
|
<MapClickHandler onClick={onMapClick} />
|
||||||
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
|
<LocationTracker />
|
||||||
|
|
||||||
|
<MarkerClusterGroup
|
||||||
|
chunkedLoading
|
||||||
|
maxClusterRadius={30}
|
||||||
|
disableClusteringAtZoom={11}
|
||||||
|
spiderfyOnMaxZoom
|
||||||
|
showCoverageOnHover={false}
|
||||||
|
zoomToBoundsOnClick
|
||||||
|
singleMarkerMode
|
||||||
|
iconCreateFunction={(cluster) => {
|
||||||
|
const count = cluster.getChildCount()
|
||||||
|
const size = count < 10 ? 36 : count < 50 ? 42 : 48
|
||||||
|
return L.divIcon({
|
||||||
|
html: `<div class="marker-cluster-custom"
|
||||||
|
style="width:${size}px;height:${size}px;">
|
||||||
|
<span>${count}</span>
|
||||||
|
</div>`,
|
||||||
|
className: 'marker-cluster-wrapper',
|
||||||
|
iconSize: L.point(size, size),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{places.map((place) => {
|
||||||
|
const isSelected = place.id === selectedPlaceId
|
||||||
|
const cacheKey = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||||
|
const resolvedPhotoUrl = place.image_url || (cacheKey && photoUrls[cacheKey]) || null
|
||||||
|
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||||
|
const icon = createPlaceIcon({ ...place, image_url: resolvedPhotoUrl }, orderNumbers, isSelected)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Marker
|
||||||
|
key={place.id}
|
||||||
|
position={[place.lat, place.lng]}
|
||||||
|
icon={icon}
|
||||||
|
eventHandlers={{
|
||||||
|
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||||
|
}}
|
||||||
|
zIndexOffset={isSelected ? 1000 : 0}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
direction="right"
|
||||||
|
offset={[0, 0]}
|
||||||
|
opacity={1}
|
||||||
|
className="map-tooltip"
|
||||||
|
>
|
||||||
|
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||||
|
{place.name}
|
||||||
|
</div>
|
||||||
|
{place.category_name && (() => {
|
||||||
|
const CatIcon = getCategoryIcon(place.category_icon)
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||||
|
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||||
|
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{place.address && (
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{place.address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</MarkerClusterGroup>
|
||||||
|
|
||||||
|
{route && route.length > 1 && (
|
||||||
|
<>
|
||||||
|
<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`
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Camera, Plus, Share2, EyeOff, Eye, X, Check, Search, ArrowUpDown, MapPin, Filter } from 'lucide-react'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
|
// ── Types ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TripPhoto {
|
||||||
|
immich_asset_id: string
|
||||||
|
user_id: number
|
||||||
|
username: string
|
||||||
|
shared: number
|
||||||
|
added_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImmichAsset {
|
||||||
|
id: string
|
||||||
|
takenAt: string
|
||||||
|
city: string | null
|
||||||
|
country: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MemoriesPanelProps {
|
||||||
|
tripId: number
|
||||||
|
startDate: string | null
|
||||||
|
endDate: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const [connected, setConnected] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
// Trip photos (saved selections)
|
||||||
|
const [tripPhotos, setTripPhotos] = useState<TripPhoto[]>([])
|
||||||
|
|
||||||
|
// Photo picker
|
||||||
|
const [showPicker, setShowPicker] = useState(false)
|
||||||
|
const [pickerPhotos, setPickerPhotos] = useState<ImmichAsset[]>([])
|
||||||
|
const [pickerLoading, setPickerLoading] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Confirm share popup
|
||||||
|
const [showConfirmShare, setShowConfirmShare] = useState(false)
|
||||||
|
|
||||||
|
// Filters & sort
|
||||||
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
|
const [locationFilter, setLocationFilter] = useState('')
|
||||||
|
|
||||||
|
// Lightbox
|
||||||
|
const [lightboxId, setLightboxId] = useState<string | null>(null)
|
||||||
|
const [lightboxUserId, setLightboxUserId] = useState<number | null>(null)
|
||||||
|
const [lightboxInfo, setLightboxInfo] = useState<any>(null)
|
||||||
|
const [lightboxInfoLoading, setLightboxInfoLoading] = useState(false)
|
||||||
|
|
||||||
|
// ── Init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadInitial()
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
// WebSocket: reload photos when another user adds/removes/shares
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = () => loadPhotos()
|
||||||
|
window.addEventListener('memories:updated', handler)
|
||||||
|
return () => window.removeEventListener('memories:updated', handler)
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const loadPhotos = async () => {
|
||||||
|
try {
|
||||||
|
const photosRes = await apiClient.get(`/integrations/immich/trips/${tripId}/photos`)
|
||||||
|
setTripPhotos(photosRes.data.photos || [])
|
||||||
|
} catch {
|
||||||
|
setTripPhotos([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadInitial = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const statusRes = await apiClient.get('/integrations/immich/status')
|
||||||
|
setConnected(statusRes.data.connected)
|
||||||
|
} catch {
|
||||||
|
setConnected(false)
|
||||||
|
}
|
||||||
|
await loadPhotos()
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const [pickerDateFilter, setPickerDateFilter] = useState(true)
|
||||||
|
|
||||||
|
const openPicker = async () => {
|
||||||
|
setShowPicker(true)
|
||||||
|
setPickerLoading(true)
|
||||||
|
setSelectedIds(new Set())
|
||||||
|
setPickerDateFilter(!!(startDate && endDate))
|
||||||
|
await loadPickerPhotos(!!(startDate && endDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadPickerPhotos = async (useDate: boolean) => {
|
||||||
|
setPickerLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await apiClient.post('/integrations/immich/search', {
|
||||||
|
from: useDate && startDate ? startDate : undefined,
|
||||||
|
to: useDate && endDate ? endDate : undefined,
|
||||||
|
})
|
||||||
|
setPickerPhotos(res.data.assets || [])
|
||||||
|
} catch {
|
||||||
|
setPickerPhotos([])
|
||||||
|
} finally {
|
||||||
|
setPickerLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const togglePickerSelect = (id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id)
|
||||||
|
else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmSelection = () => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
setShowConfirmShare(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const executeAddPhotos = async () => {
|
||||||
|
setShowConfirmShare(false)
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/integrations/immich/trips/${tripId}/photos`, {
|
||||||
|
asset_ids: [...selectedIds],
|
||||||
|
shared: true,
|
||||||
|
})
|
||||||
|
setShowPicker(false)
|
||||||
|
loadInitial()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const removePhoto = async (assetId: string) => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||||
|
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const toggleSharing = async (assetId: string, shared: boolean) => {
|
||||||
|
try {
|
||||||
|
await apiClient.put(`/integrations/immich/trips/${tripId}/photos/${assetId}/sharing`, { shared })
|
||||||
|
setTripPhotos(prev => prev.map(p =>
|
||||||
|
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||||
|
))
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const token = useAuthStore(s => s.token)
|
||||||
|
|
||||||
|
const thumbnailUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/thumbnail?userId=${userId}&token=${token}`
|
||||||
|
|
||||||
|
const originalUrl = (assetId: string, userId: number) =>
|
||||||
|
`/api/integrations/immich/assets/${assetId}/original?userId=${userId}&token=${token}`
|
||||||
|
|
||||||
|
const ownPhotos = tripPhotos.filter(p => p.user_id === currentUser?.id)
|
||||||
|
const othersPhotos = tripPhotos.filter(p => p.user_id !== currentUser?.id && p.shared)
|
||||||
|
const allVisibleRaw = [...ownPhotos, ...othersPhotos]
|
||||||
|
|
||||||
|
// Unique locations for filter
|
||||||
|
const locations = [...new Set(allVisibleRaw.map(p => p.city).filter(Boolean) as string[])].sort()
|
||||||
|
|
||||||
|
// Apply filter + sort
|
||||||
|
const allVisible = allVisibleRaw
|
||||||
|
.filter(p => !locationFilter || p.city === locationFilter)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = new Date(a.added_at || 0).getTime()
|
||||||
|
const db = new Date(b.added_at || 0).getTime()
|
||||||
|
return sortAsc ? da - db : db - da
|
||||||
|
})
|
||||||
|
|
||||||
|
const font: React.CSSProperties = {
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', ...font }}>
|
||||||
|
<div className="w-8 h-8 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Not connected ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!connected && allVisible.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', padding: 40, textAlign: 'center', ...font }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.notConnected')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)', maxWidth: 300 }}>
|
||||||
|
{t('memories.notConnectedHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Photo Picker Modal ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (showPicker) {
|
||||||
|
const alreadyAdded = new Set(tripPhotos.filter(p => p.user_id === currentUser?.id).map(p => p.immich_asset_id))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
{/* Picker header */}
|
||||||
|
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.selectPhotos')}
|
||||||
|
</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<button onClick={() => setShowPicker(false)}
|
||||||
|
style={{ padding: '7px 14px', 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 onClick={confirmSelection} disabled={selectedIds.size === 0}
|
||||||
|
style={{
|
||||||
|
padding: '7px 14px', borderRadius: 10, border: 'none', fontSize: 12, fontWeight: 600,
|
||||||
|
cursor: selectedIds.size > 0 ? 'pointer' : 'default', fontFamily: 'inherit',
|
||||||
|
background: selectedIds.size > 0 ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: selectedIds.size > 0 ? 'var(--bg-primary)' : 'var(--text-faint)',
|
||||||
|
}}>
|
||||||
|
{selectedIds.size > 0 ? t('memories.addSelected', { count: selectedIds.size }) : t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
|
{startDate && endDate && (
|
||||||
|
<button onClick={() => { if (!pickerDateFilter) { setPickerDateFilter(true); loadPickerPhotos(true) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.tripDates')} ({startDate ? new Date(startDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) : ''} — {endDate ? new Date(endDate + 'T12:00:00').toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) : ''})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => { if (pickerDateFilter || !startDate) { setPickerDateFilter(false); loadPickerPhotos(false) } }}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px', borderRadius: 99, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
border: '1px solid', transition: 'all 0.15s',
|
||||||
|
background: !pickerDateFilter ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: !pickerDateFilter ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: !pickerDateFilter ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{t('memories.allPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{selectedIds.size > 0 && (
|
||||||
|
<p style={{ margin: '8px 0 0', fontSize: 12, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{selectedIds.size} {t('memories.selected')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Picker grid */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{pickerLoading ? (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 60 }}>
|
||||||
|
<div className="w-7 h-7 border-2 rounded-full animate-spin"
|
||||||
|
style={{ borderColor: 'var(--border-primary)', borderTopColor: 'var(--text-primary)' }} />
|
||||||
|
</div>
|
||||||
|
) : pickerPhotos.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={36} style={{ color: 'var(--text-faint)', margin: '0 auto 10px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 13, color: 'var(--text-muted)', margin: 0 }}>{t('memories.noPhotos')}</p>
|
||||||
|
</div>
|
||||||
|
) : (() => {
|
||||||
|
// Group photos by month
|
||||||
|
const byMonth: Record<string, ImmichAsset[]> = {}
|
||||||
|
for (const asset of pickerPhotos) {
|
||||||
|
const d = asset.takenAt ? new Date(asset.takenAt) : null
|
||||||
|
const key = d ? `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` : 'unknown'
|
||||||
|
if (!byMonth[key]) byMonth[key] = []
|
||||||
|
byMonth[key].push(asset)
|
||||||
|
}
|
||||||
|
const sortedMonths = Object.keys(byMonth).sort().reverse()
|
||||||
|
|
||||||
|
return sortedMonths.map(month => (
|
||||||
|
<div key={month} style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>
|
||||||
|
{month !== 'unknown'
|
||||||
|
? new Date(month + '-15').toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
|
||||||
|
: '—'}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(100px, 1fr))', gap: 4 }}>
|
||||||
|
{byMonth[month].map(asset => {
|
||||||
|
const isSelected = selectedIds.has(asset.id)
|
||||||
|
const isAlready = alreadyAdded.has(asset.id)
|
||||||
|
return (
|
||||||
|
<div key={asset.id}
|
||||||
|
onClick={() => !isAlready && togglePickerSelect(asset.id)}
|
||||||
|
style={{
|
||||||
|
position: 'relative', aspectRatio: '1', borderRadius: 8, overflow: 'hidden',
|
||||||
|
cursor: isAlready ? 'default' : 'pointer',
|
||||||
|
opacity: isAlready ? 0.3 : 1,
|
||||||
|
outline: isSelected ? '3px solid var(--text-primary)' : 'none',
|
||||||
|
outlineOffset: -3,
|
||||||
|
}}>
|
||||||
|
<img src={thumbnailUrl(asset.id, currentUser!.id)} alt="" loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
{isSelected && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: 4, right: 4, width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: 'var(--text-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<Check size={13} color="var(--bg-primary)" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isAlready && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', fontSize: 10, color: 'white', fontWeight: 600,
|
||||||
|
}}>
|
||||||
|
{t('memories.alreadyAdded')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm share popup (inside picker) */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main Gallery ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.title')}
|
||||||
|
</h2>
|
||||||
|
<p style={{ margin: '2px 0 0', fontSize: 12, color: 'var(--text-faint)' }}>
|
||||||
|
{allVisible.length} {t('memories.photosFound')}
|
||||||
|
{othersPhotos.length > 0 && ` · ${othersPhotos.length} ${t('memories.fromOthers')}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{connected && (
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '7px 14px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={14} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter & Sort bar */}
|
||||||
|
{allVisibleRaw.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', gap: 6, padding: '8px 20px', borderBottom: '1px solid var(--border-secondary)', flexShrink: 0, flexWrap: 'wrap' }}>
|
||||||
|
<button onClick={() => setSortAsc(v => !v)}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px', borderRadius: 8,
|
||||||
|
border: '1px solid var(--border-primary)', background: 'var(--bg-card)',
|
||||||
|
fontSize: 11, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
<ArrowUpDown size={11} /> {sortAsc ? t('memories.oldest') : t('memories.newest')}
|
||||||
|
</button>
|
||||||
|
{locations.length > 1 && (
|
||||||
|
<select value={locationFilter} onChange={e => setLocationFilter(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
|
background: 'var(--bg-card)', fontSize: 11, fontFamily: 'inherit', color: 'var(--text-muted)',
|
||||||
|
cursor: 'pointer', outline: 'none',
|
||||||
|
}}>
|
||||||
|
<option value="">{t('memories.allLocations')}</option>
|
||||||
|
{locations.map(loc => <option key={loc} value={loc}>{loc}</option>)}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Gallery */}
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: 12 }}>
|
||||||
|
{allVisible.length === 0 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
||||||
|
<Camera size={40} style={{ color: 'var(--text-faint)', margin: '0 auto 12px', display: 'block' }} />
|
||||||
|
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>
|
||||||
|
{t('memories.noPhotos')}
|
||||||
|
</p>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: '0 0 16px' }}>
|
||||||
|
{t('memories.noPhotosHint')}
|
||||||
|
</p>
|
||||||
|
<button onClick={openPicker}
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5, padding: '9px 18px', borderRadius: 10,
|
||||||
|
border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Plus size={15} /> {t('memories.addPhotos')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(130px, 1fr))', gap: 6 }}>
|
||||||
|
{allVisible.map(photo => {
|
||||||
|
const isOwn = photo.user_id === currentUser?.id
|
||||||
|
return (
|
||||||
|
<div key={photo.immich_asset_id} className="group"
|
||||||
|
style={{ position: 'relative', aspectRatio: '1', borderRadius: 10, overflow: 'visible', cursor: 'pointer' }}
|
||||||
|
onClick={() => {
|
||||||
|
setLightboxId(photo.immich_asset_id); setLightboxUserId(photo.user_id); setLightboxInfo(null)
|
||||||
|
setLightboxInfoLoading(true)
|
||||||
|
apiClient.get(`/integrations/immich/assets/${photo.immich_asset_id}/info?userId=${photo.user_id}`)
|
||||||
|
.then(r => setLightboxInfo(r.data)).catch(() => {}).finally(() => setLightboxInfoLoading(false))
|
||||||
|
}}>
|
||||||
|
|
||||||
|
<img src={thumbnailUrl(photo.immich_asset_id, photo.user_id)} alt="" loading="lazy"
|
||||||
|
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 10 }} />
|
||||||
|
|
||||||
|
{/* Other user's avatar */}
|
||||||
|
{!isOwn && (
|
||||||
|
<div className="memories-avatar" style={{ position: 'absolute', bottom: 6, left: 6, zIndex: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 22, height: 22, borderRadius: '50%',
|
||||||
|
background: `hsl(${photo.username.charCodeAt(0) * 37 % 360}, 55%, 55%)`,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
fontSize: 10, fontWeight: 700, color: 'white', textTransform: 'uppercase',
|
||||||
|
border: '2px solid white', boxShadow: '0 1px 4px rgba(0,0,0,0.3)',
|
||||||
|
}}>
|
||||||
|
{photo.username[0]}
|
||||||
|
</div>
|
||||||
|
<div className="memories-avatar-tooltip" style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: '50%', transform: 'translateX(-50%)',
|
||||||
|
marginBottom: 6, padding: '3px 8px', borderRadius: 6,
|
||||||
|
background: 'var(--text-primary)', color: 'var(--bg-primary)',
|
||||||
|
fontSize: 10, fontWeight: 600, whiteSpace: 'nowrap',
|
||||||
|
pointerEvents: 'none', opacity: 0, transition: 'opacity 0.15s',
|
||||||
|
}}>
|
||||||
|
{photo.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Own photo actions (hover) */}
|
||||||
|
{isOwn && (
|
||||||
|
<div className="opacity-0 group-hover:opacity-100"
|
||||||
|
style={{ position: 'absolute', top: 4, right: 4, display: 'flex', gap: 3, transition: 'opacity 0.15s' }}>
|
||||||
|
<button onClick={e => { e.stopPropagation(); toggleSharing(photo.immich_asset_id, !photo.shared) }}
|
||||||
|
title={photo.shared ? t('memories.stopSharing') : t('memories.sharePhotos')}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{photo.shared ? <Eye size={12} color="white" /> : <EyeOff size={12} color="white" />}
|
||||||
|
</button>
|
||||||
|
<button onClick={e => { e.stopPropagation(); removePhoto(photo.immich_asset_id) }}
|
||||||
|
style={{
|
||||||
|
width: 26, height: 26, borderRadius: '50%', border: 'none', cursor: 'pointer',
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={12} color="white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not shared indicator */}
|
||||||
|
{isOwn && !photo.shared && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 6, right: 6, padding: '2px 6px', borderRadius: 6,
|
||||||
|
background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)',
|
||||||
|
fontSize: 9, color: 'rgba(255,255,255,0.7)', fontWeight: 500,
|
||||||
|
}}>
|
||||||
|
<EyeOff size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||||
|
{t('memories.private')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.memories-avatar:hover .memories-avatar-tooltip { opacity: 1 !important; }
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Confirm share popup */}
|
||||||
|
{showConfirmShare && (
|
||||||
|
<div onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
|
<div onClick={e => e.stopPropagation()}
|
||||||
|
style={{ background: 'var(--bg-card)', borderRadius: 16, padding: 24, maxWidth: 360, width: '100%', boxShadow: '0 16px 48px rgba(0,0,0,0.2)', textAlign: 'center' }}>
|
||||||
|
<Share2 size={28} style={{ color: 'var(--text-primary)', marginBottom: 12 }} />
|
||||||
|
<h3 style={{ margin: '0 0 8px', fontSize: 16, fontWeight: 700, color: 'var(--text-primary)' }}>
|
||||||
|
{t('memories.confirmShareTitle')}
|
||||||
|
</h3>
|
||||||
|
<p style={{ margin: '0 0 20px', fontSize: 13, color: 'var(--text-muted)', lineHeight: 1.5 }}>
|
||||||
|
{t('memories.confirmShareHint', { count: selectedIds.size })}
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'center' }}>
|
||||||
|
<button onClick={() => setShowConfirmShare(false)}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={executeAddPhotos}
|
||||||
|
style={{ padding: '8px 20px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', background: 'var(--text-primary)', color: 'var(--bg-primary)' }}>
|
||||||
|
{t('memories.confirmShareButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Lightbox */}
|
||||||
|
{lightboxId && lightboxUserId && (
|
||||||
|
<div onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', inset: 0, zIndex: 100,
|
||||||
|
background: 'rgba(0,0,0,0.92)', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<button onClick={() => { setLightboxId(null); setLightboxUserId(null) }}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 16, right: 16, width: 40, height: 40, borderRadius: '50%',
|
||||||
|
background: 'rgba(255,255,255,0.1)', border: 'none', cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
<X size={20} color="white" />
|
||||||
|
</button>
|
||||||
|
<div onClick={e => e.stopPropagation()} style={{ display: 'flex', gap: 16, alignItems: 'flex-start', justifyContent: 'center', padding: 20, width: '100%', height: '100%' }}>
|
||||||
|
<img
|
||||||
|
src={originalUrl(lightboxId, lightboxUserId)}
|
||||||
|
alt=""
|
||||||
|
style={{ maxWidth: lightboxInfo ? 'calc(100% - 280px)' : '100%', maxHeight: '100%', objectFit: 'contain', borderRadius: 10, cursor: 'default' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Info panel — liquid glass */}
|
||||||
|
{lightboxInfo && (
|
||||||
|
<div style={{
|
||||||
|
width: 240, flexShrink: 0, borderRadius: 16, padding: 18,
|
||||||
|
background: 'rgba(255,255,255,0.08)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.12)', color: 'white',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 14, maxHeight: '100%', overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{/* Date */}
|
||||||
|
{lightboxInfo.takenAt && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Date</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{new Date(lightboxInfo.takenAt).toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' })}</div>
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>{new Date(lightboxInfo.takenAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
{(lightboxInfo.city || lightboxInfo.country) && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>
|
||||||
|
<MapPin size={9} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />Location
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>
|
||||||
|
{[lightboxInfo.city, lightboxInfo.state, lightboxInfo.country].filter(Boolean).join(', ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Camera */}
|
||||||
|
{lightboxInfo.camera && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>Camera</div>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 500 }}>{lightboxInfo.camera}</div>
|
||||||
|
{lightboxInfo.lens && <div style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)', marginTop: 2 }}>{lightboxInfo.lens}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Settings */}
|
||||||
|
{(lightboxInfo.focalLength || lightboxInfo.aperture || lightboxInfo.iso) && (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||||
|
{lightboxInfo.focalLength && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Focal</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.focalLength}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.aperture && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Aperture</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.aperture}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.shutter && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Shutter</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.shutter}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.iso && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, color: 'rgba(255,255,255,0.4)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>ISO</div>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 700 }}>{lightboxInfo.iso}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Resolution & File */}
|
||||||
|
{(lightboxInfo.width || lightboxInfo.fileName) && (
|
||||||
|
<div style={{ borderTop: '1px solid rgba(255,255,255,0.08)', paddingTop: 10 }}>
|
||||||
|
{lightboxInfo.width && lightboxInfo.height && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)', marginBottom: 3 }}>{lightboxInfo.width} × {lightboxInfo.height}</div>
|
||||||
|
)}
|
||||||
|
{lightboxInfo.fileSize && (
|
||||||
|
<div style={{ fontSize: 11, color: 'rgba(255,255,255,0.4)' }}>{(lightboxInfo.fileSize / 1024 / 1024).toFixed(1)} MB</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{lightboxInfoLoading && (
|
||||||
|
<div style={{ width: 240, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<div className="w-6 h-6 border-2 rounded-full animate-spin" style={{ borderColor: 'rgba(255,255,255,0.2)', borderTopColor: 'white' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,23 @@
|
|||||||
// Trip PDF via browser print window
|
// Trip PDF via browser print window
|
||||||
import { createElement } from 'react'
|
import { createElement } from 'react'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
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 { 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) {
|
||||||
|
if (!_renderToStaticMarkup) return ''
|
||||||
|
const Icon = NOTE_ICON_MAP[iconId] || FileText
|
||||||
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#94a3b8' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSPORT_ICON_MAP = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
|
function transportIconSvg(type) {
|
||||||
|
if (!_renderToStaticMarkup) return ''
|
||||||
|
const Icon = TRANSPORT_ICON_MAP[type] || Ticket
|
||||||
|
return _renderToStaticMarkup(createElement(Icon, { size: 14, strokeWidth: 1.8, color: '#3b82f6' }))
|
||||||
|
}
|
||||||
|
|
||||||
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
// ── SVG inline icons (for chips) ─────────────────────────────────────────────
|
||||||
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
const svgPin = `<svg width="11" height="11" viewBox="0 0 24 24" fill="#94a3b8" style="flex-shrink:0;margin-top:1px"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7z"/><circle cx="12" cy="9" r="2.5" fill="white"/></svg>`
|
||||||
@@ -80,9 +96,21 @@ async function fetchPlacePhotos(assignments) {
|
|||||||
return photoMap
|
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
|
||||||
|
reservations?: any[]
|
||||||
|
t: (key: string, params?: Record<string, string | number>) => string
|
||||||
|
locale: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadTripPDF({ trip, days, places, assignments, categories, dayNotes, reservations = [], t: _t, locale: _locale }: downloadTripPDFProps) {
|
||||||
await ensureRenderer()
|
await ensureRenderer()
|
||||||
const loc = _locale || 'de-DE'
|
const loc = _locale || undefined
|
||||||
const tr = _t || (k => k)
|
const tr = _t || (k => k)
|
||||||
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
const sorted = [...(days || [])].sort((a, b) => a.day_number - b.day_number)
|
||||||
const range = longDateRange(sorted, loc)
|
const range = longDateRange(sorted, loc)
|
||||||
@@ -103,26 +131,52 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
const notes = (dayNotes || []).filter(n => n.day_id === day.id)
|
||||||
const cost = dayCost(assignments, day.id, loc)
|
const cost = dayCost(assignments, day.id, loc)
|
||||||
|
|
||||||
|
// Transport bookings for this day
|
||||||
|
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||||
|
const dayTransport = (reservations || []).filter(r => {
|
||||||
|
if (!r.reservation_time || !TRANSPORT_TYPES.has(r.type)) return false
|
||||||
|
return day.date && r.reservation_time.split('T')[0] === day.date
|
||||||
|
})
|
||||||
|
|
||||||
const merged = []
|
const merged = []
|
||||||
assigned.forEach(a => merged.push({ type: 'place', k: a.sort_order ?? 0, data: a }))
|
assigned.forEach(a => merged.push({ type: 'place', k: a.order_index ?? a.sort_order ?? 0, data: a }))
|
||||||
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
notes.forEach(n => merged.push({ type: 'note', k: n.sort_order ?? 0, data: n }))
|
||||||
|
dayTransport.forEach(r => {
|
||||||
|
const pos = r.day_plan_position ?? (merged.length > 0 ? Math.max(...merged.map(m => m.k)) + 0.5 : 0.5)
|
||||||
|
merged.push({ type: 'transport', k: pos, data: r })
|
||||||
|
})
|
||||||
merged.sort((a, b) => a.k - b.k)
|
merged.sort((a, b) => a.k - b.k)
|
||||||
|
|
||||||
let pi = 0
|
let pi = 0
|
||||||
const itemsHtml = merged.length === 0
|
const itemsHtml = merged.length === 0
|
||||||
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
? `<div class="empty-day">${escHtml(tr('dayplan.emptyDay'))}</div>`
|
||||||
: merged.map(item => {
|
: merged.map(item => {
|
||||||
|
if (item.type === 'transport') {
|
||||||
|
const r = item.data
|
||||||
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
|
const icon = transportIconSvg(r.type)
|
||||||
|
let subtitle = ''
|
||||||
|
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
|
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
|
const time = r.reservation_time?.includes('T') ? r.reservation_time.split('T')[1]?.substring(0, 5) : ''
|
||||||
|
return `
|
||||||
|
<div class="note-card" style="border-left: 3px solid #3b82f6;">
|
||||||
|
<div class="note-line" style="background: #3b82f6;"></div>
|
||||||
|
<span class="note-icon">${icon}</span>
|
||||||
|
<div class="note-body">
|
||||||
|
<div class="note-text" style="font-weight: 600;">${escHtml(r.title)}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
if (item.type === 'note') {
|
if (item.type === 'note') {
|
||||||
const note = item.data
|
const note = item.data
|
||||||
return `
|
return `
|
||||||
<div class="note-card">
|
<div class="note-card">
|
||||||
<div class="note-line"></div>
|
<div class="note-line"></div>
|
||||||
<svg class="note-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="1.8" stroke-linecap="round">
|
<span class="note-icon">${noteIconSvg(note.icon)}</span>
|
||||||
<rect x="4" y="3" width="16" height="18" rx="2"/>
|
|
||||||
<line x1="8" y1="8" x2="16" y2="8"/>
|
|
||||||
<line x1="8" y1="12" x2="16" y2="12"/>
|
|
||||||
<line x1="8" y1="16" x2="13" y2="16"/>
|
|
||||||
</svg>
|
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text">${escHtml(note.text)}</div>
|
<div class="note-text">${escHtml(note.text)}</div>
|
||||||
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
${note.time ? `<div class="note-time">${escHtml(note.time)}</div>` : ''}
|
||||||
@@ -141,9 +195,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const googleImg = photoMap[place.id] || null
|
const googleImg = photoMap[place.id] || null
|
||||||
const img = directImg || googleImg
|
const img = directImg || googleImg
|
||||||
|
|
||||||
const confirmed = place.reservation_status === 'confirmed'
|
|
||||||
const pending = place.reservation_status === 'pending'
|
|
||||||
|
|
||||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||||
const thumbHtml = img
|
const thumbHtml = img
|
||||||
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
? `<img class="place-thumb" src="${escHtml(img)}" />`
|
||||||
@@ -153,9 +204,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
place.place_time ? `<span class="chip">${svgClock}${escHtml(place.place_time)}</span>` : '',
|
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>` : '',
|
place.price && parseFloat(place.price) > 0 ? `<span class="chip chip-green">${svgEuro}${Number(place.price).toLocaleString(loc)} 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('')
|
].filter(Boolean).join('')
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -180,7 +229,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
<div class="day-section${di > 0 ? ' page-break' : ''}">
|
||||||
<div class="day-header">
|
<div class="day-header">
|
||||||
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
<span class="day-tag">${escHtml(tr('dayplan.dayN', { n: day.day_number })).toUpperCase()}</span>
|
||||||
<span class="day-title">${escHtml(day.title || `Tag ${day.day_number}`)}</span>
|
<span class="day-title">${escHtml(day.title || tr('dayplan.dayN', { n: day.day_number }))}</span>
|
||||||
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
${day.date ? `<span class="day-date">${shortDate(day.date, loc)}</span>` : ''}
|
||||||
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
${cost ? `<span class="day-cost">${cost}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +238,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
}).join('')
|
}).join('')
|
||||||
|
|
||||||
const html = `<!DOCTYPE html>
|
const html = `<!DOCTYPE html>
|
||||||
<html lang="de">
|
<html lang="${loc.split('-')[0]}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<base href="${window.location.origin}/">
|
<base href="${window.location.origin}/">
|
||||||
@@ -200,6 +249,24 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
body { font-family: 'Poppins', sans-serif; background: #fff; color: #1e293b; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
svg { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||||
|
|
||||||
|
/* Footer on every printed page */
|
||||||
|
.pdf-footer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
.pdf-footer span {
|
||||||
|
font-size: 7px;
|
||||||
|
color: #64748b;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Cover ─────────────────────────────────────── */
|
/* ── Cover ─────────────────────────────────────── */
|
||||||
.cover {
|
.cover {
|
||||||
width: 100%; min-height: 100vh;
|
width: 100%; min-height: 100vh;
|
||||||
@@ -215,8 +282,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
.cover-dim { position: absolute; inset: 0; background: rgba(8,12,28,0.55); }
|
||||||
.cover-brand {
|
.cover-brand {
|
||||||
position: absolute; top: 36px; right: 52px;
|
position: absolute; top: 36px; right: 52px;
|
||||||
font-size: 9px; font-weight: 600; letter-spacing: 2.5px;
|
z-index: 2;
|
||||||
color: rgba(255,255,255,0.3); text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
.cover-body { position: relative; z-index: 1; }
|
.cover-body { position: relative; z-index: 1; }
|
||||||
.cover-circle {
|
.cover-circle {
|
||||||
@@ -316,17 +382,23 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<!-- Footer on every page -->
|
||||||
|
<div class="pdf-footer">
|
||||||
|
<span>made with</span>
|
||||||
|
<img src="${absUrl('/logo-dark.svg')}" style="height:10px;opacity:0.6;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Cover -->
|
<!-- Cover -->
|
||||||
<div class="cover">
|
<div class="cover">
|
||||||
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
${coverImg ? `<div class="cover-bg" style="background-image:url('${escHtml(coverImg)}')"></div>` : ''}
|
||||||
<div class="cover-dim"></div>
|
<div class="cover-dim"></div>
|
||||||
<div class="cover-brand">NOMAD</div>
|
<div class="cover-brand"><img src="${absUrl('/logo-light.svg')}" style="height:28px;opacity:0.5;" /></div>
|
||||||
<div class="cover-body">
|
<div class="cover-body">
|
||||||
${coverImg
|
${coverImg
|
||||||
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
? `<div class="cover-circle"><img src="${escHtml(coverImg)}" /></div>`
|
||||||
: `<div class="cover-circle-ph"></div>`}
|
: `<div class="cover-circle-ph"></div>`}
|
||||||
<div class="cover-label">${escHtml(tr('pdf.travelPlan'))}</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>` : ''}
|
${trip?.description ? `<div class="cover-desc">${escHtml(trip.description)}</div>` : ''}
|
||||||
${range ? `<div class="cover-dates">${range}</div>` : ''}
|
${range ? `<div class="cover-dates">${range}</div>` : ''}
|
||||||
<div class="cover-line"></div>
|
<div class="cover-line"></div>
|
||||||
@@ -344,7 +416,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
<div class="cover-stat-lbl">${escHtml(tr('pdf.planned'))}</div>
|
||||||
</div>
|
</div>
|
||||||
${totalCost > 0 ? `<div>
|
${totalCost > 0 ? `<div>
|
||||||
<div class="cover-stat-num">${totalCost.toLocaleString('de-DE')}</div>
|
<div class="cover-stat-num">${totalCost.toLocaleString(loc)}</div>
|
||||||
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
<div class="cover-stat-lbl">${escHtml(tr('pdf.costLabel'))}</div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import React, { useState, useMemo, useRef } from 'react'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import {
|
|
||||||
CheckSquare, Square, Trash2, Plus, ChevronDown, ChevronRight,
|
|
||||||
Sparkles, X, Pencil, Check, MoreHorizontal, CheckCheck, RotateCcw, Luggage,
|
|
||||||
} from 'lucide-react'
|
|
||||||
|
|
||||||
const VORSCHLAEGE = [
|
|
||||||
{ name: 'Reisepass', kategorie: 'Dokumente' },
|
|
||||||
{ name: 'Reiseversicherung', kategorie: 'Dokumente' },
|
|
||||||
{ name: 'Visum-Unterlagen', kategorie: 'Dokumente' },
|
|
||||||
{ name: 'Flugtickets', kategorie: 'Dokumente' },
|
|
||||||
{ name: 'Hotelbuchungen', kategorie: 'Dokumente' },
|
|
||||||
{ name: 'Impfpass', kategorie: 'Dokumente' },
|
|
||||||
{ name: 'T-Shirts (5×)', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Hosen (2×)', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Unterwäsche (7×)', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Socken (7×)', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Jacke', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Badeanzug / Badehose', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Sportschuhe', kategorie: 'Kleidung' },
|
|
||||||
{ name: 'Zahnbürste', kategorie: 'Körperpflege' },
|
|
||||||
{ name: 'Zahnpasta', kategorie: 'Körperpflege' },
|
|
||||||
{ name: 'Shampoo', kategorie: 'Körperpflege' },
|
|
||||||
{ name: 'Sonnencreme', kategorie: 'Körperpflege' },
|
|
||||||
{ name: 'Deo', kategorie: 'Körperpflege' },
|
|
||||||
{ name: 'Rasierer', kategorie: 'Körperpflege' },
|
|
||||||
{ name: 'Ladekabel Handy', kategorie: 'Elektronik' },
|
|
||||||
{ name: 'Reiseadapter', kategorie: 'Elektronik' },
|
|
||||||
{ name: 'Kopfhörer', kategorie: 'Elektronik' },
|
|
||||||
{ name: 'Kamera', kategorie: 'Elektronik' },
|
|
||||||
{ name: 'Powerbank', kategorie: 'Elektronik' },
|
|
||||||
{ name: 'Erste-Hilfe-Set', kategorie: 'Gesundheit' },
|
|
||||||
{ name: 'Verschreibungspflichtige Medikamente', kategorie: 'Gesundheit' },
|
|
||||||
{ name: 'Schmerzmittel', kategorie: 'Gesundheit' },
|
|
||||||
{ name: 'Mückenschutz', kategorie: 'Gesundheit' },
|
|
||||||
{ name: 'Bargeld', kategorie: 'Finanzen' },
|
|
||||||
{ name: 'Kreditkarte', kategorie: 'Finanzen' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Cycling color palette — works in light & dark mode
|
|
||||||
const KAT_COLORS = [
|
|
||||||
'#3b82f6', // blue
|
|
||||||
'#a855f7', // purple
|
|
||||||
'#ec4899', // pink
|
|
||||||
'#22c55e', // green
|
|
||||||
'#f97316', // orange
|
|
||||||
'#06b6d4', // cyan
|
|
||||||
'#ef4444', // red
|
|
||||||
'#eab308', // yellow
|
|
||||||
'#8b5cf6', // violet
|
|
||||||
'#14b8a6', // teal
|
|
||||||
]
|
|
||||||
// Stable color assignment: category name → index via simple hash
|
|
||||||
function katColor(kat, allCategories) {
|
|
||||||
const idx = allCategories ? allCategories.indexOf(kat) : -1
|
|
||||||
if (idx >= 0) return KAT_COLORS[idx % KAT_COLORS.length]
|
|
||||||
// Fallback: hash-based
|
|
||||||
let h = 0
|
|
||||||
for (let i = 0; i < kat.length; i++) h = ((h << 5) - h + kat.charCodeAt(i)) | 0
|
|
||||||
return KAT_COLORS[Math.abs(h) % KAT_COLORS.length]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Artikel-Zeile ──────────────────────────────────────────────────────────
|
|
||||||
function ArtikelZeile({ item, tripId, categories, onCategoryChange }) {
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [editName, setEditName] = useState(item.name)
|
|
||||||
const [hovered, setHovered] = useState(false)
|
|
||||||
const [showCatPicker, setShowCatPicker] = useState(false)
|
|
||||||
const { togglePackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
|
||||||
const toast = useToast()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const handleToggle = () => togglePackingItem(tripId, item.id, !item.checked)
|
|
||||||
|
|
||||||
const handleSaveName = async () => {
|
|
||||||
if (!editName.trim()) { setEditing(false); setEditName(item.name); return }
|
|
||||||
try { await updatePackingItem(tripId, item.id, { name: editName.trim() }); setEditing(false) }
|
|
||||||
catch { toast.error(t('packing.toast.saveError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
try { await deletePackingItem(tripId, item.id) }
|
|
||||||
catch { toast.error(t('packing.toast.deleteError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCatChange = async (cat) => {
|
|
||||||
setShowCatPicker(false)
|
|
||||||
if (cat === item.category) return
|
|
||||||
try { await updatePackingItem(tripId, item.id, { category: cat }) }
|
|
||||||
catch { toast.error(t('common.error')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => { setHovered(false); setShowCatPicker(false) }}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '6px 10px', borderRadius: 10, position: 'relative',
|
|
||||||
background: hovered ? 'var(--bg-secondary)' : 'transparent',
|
|
||||||
transition: 'background 0.1s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button onClick={handleToggle} style={{
|
|
||||||
flexShrink: 0, background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex',
|
|
||||||
color: item.checked ? '#10b981' : 'var(--text-faint)', transition: 'color 0.15s',
|
|
||||||
}}>
|
|
||||||
{item.checked ? <CheckSquare size={18} /> : <Square size={18} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{editing ? (
|
|
||||||
<input
|
|
||||||
type="text" value={editName} autoFocus
|
|
||||||
onChange={e => setEditName(e.target.value)}
|
|
||||||
onBlur={handleSaveName}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveName(); if (e.key === 'Escape') { setEditing(false); setEditName(item.name) } }}
|
|
||||||
style={{ flex: 1, fontSize: 13.5, padding: '2px 8px', borderRadius: 6, border: '1px solid var(--border-primary)', outline: 'none', fontFamily: 'inherit' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span
|
|
||||||
onClick={() => !item.checked && setEditing(true)}
|
|
||||||
style={{
|
|
||||||
flex: 1, fontSize: 13.5,
|
|
||||||
cursor: item.checked ? 'default' : 'text',
|
|
||||||
color: item.checked ? 'var(--text-faint)' : 'var(--text-primary)',
|
|
||||||
textDecoration: item.checked ? 'line-through' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 2, alignItems: 'center', opacity: hovered ? 1 : 0, transition: 'opacity 0.12s', flexShrink: 0 }}>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCatPicker(p => !p)}
|
|
||||||
title={t('packing.changeCategory')}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 5px', borderRadius: 6, display: 'flex', alignItems: 'center', color: 'var(--text-faint)', fontSize: 10, gap: 2 }}
|
|
||||||
>
|
|
||||||
<span style={{ width: 7, height: 7, borderRadius: '50%', background: katColor(item.category || t('packing.defaultCategory'), categories), display: 'inline-block' }} />
|
|
||||||
</button>
|
|
||||||
{showCatPicker && (
|
|
||||||
<div style={{
|
|
||||||
position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)',
|
|
||||||
border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)',
|
|
||||||
padding: 4, minWidth: 140,
|
|
||||||
}}>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<button key={cat} onClick={() => handleCatChange(cat)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 7, width: '100%',
|
|
||||||
padding: '6px 10px', background: cat === (item.category || t('packing.defaultCategory')) ? 'var(--bg-tertiary)' : 'none',
|
|
||||||
border: 'none', cursor: 'pointer', fontSize: 12.5, fontFamily: 'inherit',
|
|
||||||
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
|
||||||
}}>
|
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, categories), flexShrink: 0 }} />
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={() => setEditing(true)} title={t('common.rename')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<Pencil size={13} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button onClick={handleDelete} title={t('common.delete')} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '3px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = '#ef4444'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<Trash2 size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Kategorie-Gruppe ───────────────────────────────────────────────────────
|
|
||||||
function KategorieGruppe({ kategorie, items, tripId, allCategories, onRename, onDeleteAll }) {
|
|
||||||
const [offen, setOffen] = useState(true)
|
|
||||||
const [editingName, setEditingName] = useState(false)
|
|
||||||
const [editKatName, setEditKatName] = useState(kategorie)
|
|
||||||
const [showMenu, setShowMenu] = useState(false)
|
|
||||||
const { togglePackingItem } = useTripStore()
|
|
||||||
const toast = useToast()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const abgehakt = items.filter(i => i.checked).length
|
|
||||||
const alleAbgehakt = abgehakt === items.length
|
|
||||||
const dot = katColor(kategorie, allCategories)
|
|
||||||
|
|
||||||
const handleSaveKatName = async () => {
|
|
||||||
const neu = editKatName.trim()
|
|
||||||
if (!neu || neu === kategorie) { setEditingName(false); setEditKatName(kategorie); return }
|
|
||||||
try { await onRename(kategorie, neu); setEditingName(false) }
|
|
||||||
catch { toast.error(t('packing.toast.renameError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCheckAll = async () => {
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.checked) await togglePackingItem(tripId, item.id, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const handleUncheckAll = async () => {
|
|
||||||
for (const item of items) {
|
|
||||||
if (item.checked) await togglePackingItem(tripId, item.id, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const handleDeleteAll = async () => {
|
|
||||||
await onDeleteAll(items)
|
|
||||||
setShowMenu(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 6, background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-secondary)', overflow: 'visible' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px', borderBottom: offen ? '1px solid var(--border-secondary)' : 'none' }}>
|
|
||||||
<button onClick={() => setOffen(o => !o)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, display: 'flex', color: 'var(--text-faint)', flexShrink: 0 }}>
|
|
||||||
{offen ? <ChevronDown size={15} /> : <ChevronRight size={15} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: dot, flexShrink: 0 }} />
|
|
||||||
|
|
||||||
{editingName ? (
|
|
||||||
<input
|
|
||||||
autoFocus value={editKatName}
|
|
||||||
onChange={e => setEditKatName(e.target.value)}
|
|
||||||
onBlur={handleSaveKatName}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') handleSaveKatName(); if (e.key === 'Escape') { setEditingName(false); setEditKatName(kategorie) } }}
|
|
||||||
style={{ flex: 1, fontSize: 12.5, fontWeight: 600, border: 'none', borderBottom: '2px solid var(--text-primary)', outline: 'none', background: 'transparent', fontFamily: 'inherit', color: 'var(--text-primary)', padding: '0 2px' }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 700, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', flex: 1 }}>
|
|
||||||
{kategorie}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span style={{
|
|
||||||
fontSize: 11, fontWeight: 600, padding: '1px 8px', borderRadius: 99,
|
|
||||||
background: alleAbgehakt ? 'rgba(22,163,74,0.12)' : 'var(--bg-tertiary)',
|
|
||||||
color: alleAbgehakt ? '#16a34a' : 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
{abgehakt}/{items.length}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<button onClick={() => setShowMenu(m => !m)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', borderRadius: 6, display: 'flex', color: 'var(--text-faint)' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-secondary)'} onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}>
|
|
||||||
<MoreHorizontal size={15} />
|
|
||||||
</button>
|
|
||||||
{showMenu && (
|
|
||||||
<div style={{ position: 'absolute', right: 0, top: '100%', zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', padding: 4, minWidth: 170 }}
|
|
||||||
onMouseLeave={() => setShowMenu(false)}>
|
|
||||||
<MenuItem icon={<Pencil size={13} />} label={t('packing.menuRename')} onClick={() => { setEditingName(true); setShowMenu(false) }} />
|
|
||||||
<MenuItem icon={<CheckCheck size={13} />} label={t('packing.menuCheckAll')} onClick={() => { handleCheckAll(); setShowMenu(false) }} />
|
|
||||||
<MenuItem icon={<RotateCcw size={13} />} label={t('packing.menuUncheckAll')} onClick={() => { handleUncheckAll(); setShowMenu(false) }} />
|
|
||||||
<div style={{ height: 1, background: 'var(--bg-tertiary)', margin: '4px 0' }} />
|
|
||||||
<MenuItem icon={<Trash2 size={13} />} label={t('packing.menuDeleteCat')} danger onClick={handleDeleteAll} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{offen && (
|
|
||||||
<div style={{ padding: '4px 4px 6px' }}>
|
|
||||||
{items.map(item => (
|
|
||||||
<ArtikelZeile key={item.id} item={item} tripId={tripId} categories={allCategories} onCategoryChange={() => {}} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function MenuItem({ icon, label, onClick, danger }) {
|
|
||||||
return (
|
|
||||||
<button onClick={onClick} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
|
||||||
padding: '7px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 12.5, fontFamily: 'inherit', borderRadius: 7, textAlign: 'left',
|
|
||||||
color: danger ? '#ef4444' : 'var(--text-secondary)',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = danger ? '#fef2f2' : 'var(--bg-tertiary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
|
||||||
>
|
|
||||||
{icon}{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Haupt-Panel ────────────────────────────────────────────────────────────
|
|
||||||
export default function PackingListPanel({ tripId, items }) {
|
|
||||||
const [neuerName, setNeuerName] = useState('')
|
|
||||||
const [neueKategorie, setNeueKategorie] = useState('')
|
|
||||||
const [zeigeVorschlaege, setZeigeVorschlaege] = useState(false)
|
|
||||||
const [filter, setFilter] = useState('alle') // 'alle' | 'offen' | 'erledigt'
|
|
||||||
const [showKatDropdown, setShowKatDropdown] = useState(false)
|
|
||||||
const katInputRef = useRef(null)
|
|
||||||
const { addPackingItem, updatePackingItem, deletePackingItem } = useTripStore()
|
|
||||||
const toast = useToast()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const allCategories = useMemo(() => {
|
|
||||||
const cats = new Set(items.map(i => i.category || t('packing.defaultCategory')))
|
|
||||||
return Array.from(cats).sort()
|
|
||||||
}, [items, t])
|
|
||||||
|
|
||||||
const gruppiert = useMemo(() => {
|
|
||||||
const filtered = items.filter(i => {
|
|
||||||
if (filter === 'offen') return !i.checked
|
|
||||||
if (filter === 'erledigt') return i.checked
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
const groups = {}
|
|
||||||
for (const item of filtered) {
|
|
||||||
const kat = item.category || t('packing.defaultCategory')
|
|
||||||
if (!groups[kat]) groups[kat] = []
|
|
||||||
groups[kat].push(item)
|
|
||||||
}
|
|
||||||
return groups
|
|
||||||
}, [items, filter, t])
|
|
||||||
|
|
||||||
const abgehakt = items.filter(i => i.checked).length
|
|
||||||
const fortschritt = items.length > 0 ? Math.round((abgehakt / items.length) * 100) : 0
|
|
||||||
|
|
||||||
const handleAdd = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!neuerName.trim()) return
|
|
||||||
const kat = neueKategorie.trim() || (allCategories[0] || t('packing.defaultCategory'))
|
|
||||||
try {
|
|
||||||
await addPackingItem(tripId, { name: neuerName.trim(), category: kat })
|
|
||||||
setNeuerName('')
|
|
||||||
} catch { toast.error(t('packing.toast.addError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const vorschlaege = t('packing.suggestions.items') || VORSCHLAEGE
|
|
||||||
|
|
||||||
const handleVorschlag = async (v) => {
|
|
||||||
try { await addPackingItem(tripId, { name: v.name, category: v.category || v.kategorie }) }
|
|
||||||
catch { toast.error(t('packing.toast.addError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRenameCategory = async (oldName, newName) => {
|
|
||||||
const toUpdate = items.filter(i => (i.category || t('packing.defaultCategory')) === oldName)
|
|
||||||
for (const item of toUpdate) {
|
|
||||||
await updatePackingItem(tripId, item.id, { category: newName })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDeleteCategory = async (catItems) => {
|
|
||||||
for (const item of catItems) {
|
|
||||||
try { await deletePackingItem(tripId, item.id) } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClearChecked = async () => {
|
|
||||||
if (!confirm(t('packing.confirm.clearChecked', { count: abgehakt }))) return
|
|
||||||
for (const item of items.filter(i => i.checked)) {
|
|
||||||
try { await deletePackingItem(tripId, item.id) } catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vorhandeneNamen = new Set(items.map(i => i.name.toLowerCase()))
|
|
||||||
const verfuegbareVorschlaege = vorschlaege.filter(v => !vorhandeneNamen.has(v.name.toLowerCase()))
|
|
||||||
|
|
||||||
const font = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', ...font }}>
|
|
||||||
|
|
||||||
{/* ── Header ── */}
|
|
||||||
<div style={{ padding: '20px 24px 16px', borderBottom: '1px solid rgba(0,0,0,0.06)', flexShrink: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', marginBottom: 14 }}>
|
|
||||||
<div>
|
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: 'var(--text-primary)' }}>{t('packing.title')}</h2>
|
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: 'var(--text-faint)' }}>
|
|
||||||
{items.length === 0 ? t('packing.empty') : t('packing.progress', { packed: abgehakt, total: items.length, percent: fortschritt })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
{abgehakt > 0 && (
|
|
||||||
<button onClick={handleClearChecked} style={{
|
|
||||||
fontSize: 11.5, padding: '5px 10px', borderRadius: 99, border: '1px solid rgba(239,68,68,0.3)',
|
|
||||||
background: 'rgba(239,68,68,0.1)', color: '#ef4444', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
<span className="hidden sm:inline">{t('packing.clearChecked', { count: abgehakt })}</span>
|
|
||||||
<span className="sm:hidden">{t('packing.clearCheckedShort', { count: abgehakt })}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={() => setZeigeVorschlaege(v => !v)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
|
||||||
border: '1px solid', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
background: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--bg-card)',
|
|
||||||
borderColor: zeigeVorschlaege ? 'var(--text-primary)' : 'var(--border-primary)',
|
|
||||||
color: zeigeVorschlaege ? 'var(--bg-primary)' : 'var(--text-muted)',
|
|
||||||
}}>
|
|
||||||
<Sparkles size={12} /> {t('packing.suggestions')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{items.length > 0 && (
|
|
||||||
<div style={{ marginBottom: 14 }}>
|
|
||||||
<div style={{ height: 5, background: 'var(--bg-tertiary)', borderRadius: 99, overflow: 'hidden' }}>
|
|
||||||
<div style={{
|
|
||||||
height: '100%', borderRadius: 99, transition: 'width 0.4s ease',
|
|
||||||
background: fortschritt === 100 ? '#10b981' : 'linear-gradient(90deg, var(--text-primary) 0%, var(--text-muted) 100%)',
|
|
||||||
width: `${fortschritt}%`,
|
|
||||||
}} />
|
|
||||||
</div>
|
|
||||||
{fortschritt === 100 && (
|
|
||||||
<p style={{ fontSize: 11.5, color: '#10b981', marginTop: 4, fontWeight: 600, margin: '4px 0 0' }}>{t('packing.allPacked')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleAdd} style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<input
|
|
||||||
type="text" value={neuerName} onChange={e => setNeuerName(e.target.value)}
|
|
||||||
placeholder={t('packing.addPlaceholder')}
|
|
||||||
style={{ flex: 1, padding: '8px 12px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13.5, fontFamily: 'inherit', outline: 'none', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<div style={{ position: 'relative' }}>
|
|
||||||
<input
|
|
||||||
ref={katInputRef}
|
|
||||||
type="text" value={neueKategorie}
|
|
||||||
onChange={e => { setNeueKategorie(e.target.value); setShowKatDropdown(true) }}
|
|
||||||
onFocus={() => setShowKatDropdown(true)}
|
|
||||||
onBlur={() => setTimeout(() => setShowKatDropdown(false), 150)}
|
|
||||||
placeholder={allCategories[0] || t('packing.categoryPlaceholder')}
|
|
||||||
style={{ width: 120, padding: '8px 10px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', color: 'var(--text-secondary)' }}
|
|
||||||
/>
|
|
||||||
{showKatDropdown && allCategories.length > 0 && (
|
|
||||||
<div style={{ position: 'absolute', top: '100%', left: 0, right: 0, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10, boxShadow: '0 4px 16px rgba(0,0,0,0.1)', zIndex: 50, padding: 4, marginTop: 2 }}>
|
|
||||||
{allCategories.filter(c => !neueKategorie || c.toLowerCase().includes(neueKategorie.toLowerCase())).map(cat => (
|
|
||||||
<button key={cat} type="button" onMouseDown={() => setNeueKategorie(cat)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%',
|
|
||||||
padding: '6px 10px', background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 12.5, fontFamily: 'inherit', color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'none'}
|
|
||||||
>
|
|
||||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(cat, allCategories), flexShrink: 0 }} />
|
|
||||||
{cat}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button type="submit" style={{ padding: '8px 12px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', cursor: 'pointer', display: 'flex', alignItems: 'center' }}>
|
|
||||||
<Plus size={16} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Vorschläge ── */}
|
|
||||||
{zeigeVorschlaege && (
|
|
||||||
<div style={{ borderBottom: '1px solid rgba(0,0,0,0.06)', background: 'var(--bg-secondary)', padding: '10px 20px', flexShrink: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)' }}>{t('packing.suggestionsTitle')}</span>
|
|
||||||
<button onClick={() => setZeigeVorschlaege(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 2, display: 'flex' }}>
|
|
||||||
<X size={14} style={{ color: 'var(--text-faint)' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, maxHeight: 110, overflowY: 'auto' }}>
|
|
||||||
{verfuegbareVorschlaege.map((v, i) => (
|
|
||||||
<button key={i} onClick={() => handleVorschlag(v)} style={{
|
|
||||||
fontSize: 12, padding: '4px 10px', borderRadius: 99, border: '1px solid var(--border-primary)',
|
|
||||||
background: 'var(--bg-card)', cursor: 'pointer', color: 'var(--text-secondary)', fontFamily: 'inherit', transition: 'all 0.1s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--text-primary)'; e.currentTarget.style.color = 'white'; e.currentTarget.style.borderColor = 'var(--text-primary)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-card)'; e.currentTarget.style.color = 'var(--text-secondary)'; e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
|
||||||
>
|
|
||||||
+ {v.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
{verfuegbareVorschlaege.length === 0 && <p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0 }}>{t('packing.allSuggested')}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Filter-Tabs ── */}
|
|
||||||
{items.length > 0 && (
|
|
||||||
<div style={{ display: 'flex', gap: 4, padding: '10px 16px 0', flexShrink: 0 }}>
|
|
||||||
{[['alle', t('packing.filterAll')], ['offen', t('packing.filterOpen')], ['erledigt', t('packing.filterDone')]].map(([id, label]) => (
|
|
||||||
<button key={id} onClick={() => setFilter(id)} style={{
|
|
||||||
padding: '4px 12px', borderRadius: 99, border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 12, fontFamily: 'inherit', fontWeight: filter === id ? 600 : 400,
|
|
||||||
background: filter === id ? 'var(--text-primary)' : 'transparent',
|
|
||||||
color: filter === id ? 'var(--bg-primary)' : 'var(--text-muted)',
|
|
||||||
}}>{label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Liste ── */}
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '10px 12px 16px' }}>
|
|
||||||
{items.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px' }}>
|
|
||||||
<Luggage size={40} style={{ color: 'var(--text-faint)', display: 'block', margin: '0 auto 10px' }} />
|
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-secondary)', margin: '0 0 4px' }}>{t('packing.emptyTitle')}</p>
|
|
||||||
<p style={{ fontSize: 13, color: 'var(--text-faint)', margin: 0 }}>{t('packing.emptyHint')}</p>
|
|
||||||
</div>
|
|
||||||
) : Object.keys(gruppiert).length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '40px 20px', color: 'var(--text-faint)' }}>
|
|
||||||
<p style={{ fontSize: 13, margin: 0 }}>{t('packing.emptyFiltered')}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{Object.entries(gruppiert).map(([kat, katItems]) => (
|
|
||||||
<KategorieGruppe
|
|
||||||
key={kat}
|
|
||||||
kategorie={kat}
|
|
||||||
items={katItems}
|
|
||||||
tripId={tripId}
|
|
||||||
allCategories={allCategories}
|
|
||||||
onRename={handleRenameCategory}
|
|
||||||
onDeleteAll={handleDeleteCategory}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,23 @@
|
|||||||
import React, { useState, useMemo } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import { PhotoLightbox } from './PhotoLightbox'
|
import { PhotoLightbox } from './PhotoLightbox'
|
||||||
import { PhotoUpload } from './PhotoUpload'
|
import { PhotoUpload } from './PhotoUpload'
|
||||||
import { Upload, Camera } from 'lucide-react'
|
import { Upload, Camera } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
|
import { getLocaleForLanguage, 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, language } = useTranslation()
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(null)
|
const [lightboxIndex, setLightboxIndex] = useState(null)
|
||||||
const [showUpload, setShowUpload] = useState(false)
|
const [showUpload, setShowUpload] = useState(false)
|
||||||
const [filterDayId, setFilterDayId] = useState('')
|
const [filterDayId, setFilterDayId] = useState('')
|
||||||
@@ -40,7 +53,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<div style={{ marginRight: 'auto' }}>
|
<div style={{ marginRight: 'auto' }}>
|
||||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 700, color: '#111827' }}>Fotos</h2>
|
||||||
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
<p style={{ margin: '2px 0 0', fontSize: 12.5, color: '#9ca3af' }}>
|
||||||
{photos.length} Foto{photos.length !== 1 ? 's' : ''}
|
{photos.length} {photos.length !== 1 ? 'Fotos' : 'Foto'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,10 +62,10 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
onChange={e => setFilterDayId(e.target.value)}
|
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"
|
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 => (
|
{(days || []).map(day => (
|
||||||
<option key={day.id} value={day.id}>
|
<option key={day.id} value={day.id}>
|
||||||
Tag {day.day_number}{day.date ? ` · ${formatDate(day.date)}` : ''}
|
{t('planner.dayN', { n: day.day_number })}{day.date ? ` · ${formatDate(day.date, getLocaleForLanguage(language))}` : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -62,7 +75,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
onClick={() => setFilterDayId('')}
|
onClick={() => setFilterDayId('')}
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
className="text-xs text-gray-500 hover:text-gray-700 underline"
|
||||||
>
|
>
|
||||||
Zurücksetzen
|
{t('common.reset')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -71,7 +84,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
|
className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-lg hover:bg-slate-700 text-sm font-medium whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Fotos hochladen
|
{t('common.upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -80,15 +93,15 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
{filteredPhotos.length === 0 ? (
|
{filteredPhotos.length === 0 ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
|
<div style={{ textAlign: 'center', padding: '60px 20px', color: '#9ca3af' }}>
|
||||||
<Camera size={40} style={{ color: '#d1d5db', display: 'block', margin: '0 auto 12px' }} />
|
<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: 14, fontWeight: 600, color: '#374151', margin: '0 0 4px' }}>{t('photos.noPhotos')}</p>
|
||||||
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>Lade deine Reisefotos hoch</p>
|
<p style={{ fontSize: 13, color: '#9ca3af', margin: '0 0 20px' }}>{t('photos.uploadHint')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowUpload(true)}
|
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"
|
className="flex items-center gap-2 bg-slate-900 text-white px-6 py-3 rounded-xl hover:bg-slate-700 font-medium"
|
||||||
style={{ display: 'inline-flex', margin: '0 auto' }}
|
style={{ display: 'inline-flex', margin: '0 auto' }}
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4" />
|
<Upload className="w-4 h-4" />
|
||||||
Fotos hochladen
|
{t('common.upload')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -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"
|
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" />
|
<Upload className="w-6 h-6" />
|
||||||
<span className="text-xs">Hinzufügen</span>
|
<span className="text-xs">{t('common.add')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -133,7 +146,7 @@ export default function PhotoGallery({ photos, onUpload, onDelete, onUpdate, pla
|
|||||||
<Modal
|
<Modal
|
||||||
isOpen={showUpload}
|
isOpen={showUpload}
|
||||||
onClose={() => setShowUpload(false)}
|
onClose={() => setShowUpload(false)}
|
||||||
title="Fotos hochladen"
|
title={t('common.upload')}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<PhotoUpload
|
<PhotoUpload
|
||||||
@@ -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 day = days?.find(d => d.id === photo.day_id)
|
||||||
const place = places?.find(p => p.id === photo.place_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"
|
className="w-full h-full object-cover transition-transform duration-200 group-hover:scale-105"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
onError={e => {
|
onError={e => {
|
||||||
e.target.style.display = 'none'
|
(e.target as HTMLImageElement).style.display = 'none'
|
||||||
e.target.nextSibling && (e.target.nextSibling.style.display = 'flex')
|
const next = (e.target as HTMLImageElement).nextSibling as HTMLElement; if (next) next.style.display = 'flex'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -191,7 +211,7 @@ function PhotoThumbnail({ photo, days, places, onClick }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr, locale) {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })
|
||||||
}
|
}
|
||||||
@@ -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 { 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 [index, setIndex] = useState(initialIndex || 0)
|
||||||
const [editCaption, setEditCaption] = useState(false)
|
const [editCaption, setEditCaption] = useState(false)
|
||||||
const [caption, setCaption] = useState('')
|
const [caption, setCaption] = useState('')
|
||||||
@@ -81,7 +95,7 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
|||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="p-2 text-white/60 hover:text-red-400 hover:bg-white/10 rounded-lg transition-colors"
|
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" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -213,10 +227,10 @@ export function PhotoLightbox({ photos, initialIndex, onClose, onUpdate, onDelet
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
function formatDate(dateStr, locale = 'en-US') {
|
||||||
if (!dateStr) return ''
|
if (!dateStr) return ''
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleDateString('de-DE', { day: 'numeric', month: 'long', year: 'numeric' })
|
return new Date(dateStr).toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
|
||||||
} catch { return '' }
|
} catch { return '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
import React, { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
import { useDropzone } from 'react-dropzone'
|
import { useDropzone } from 'react-dropzone'
|
||||||
import { Upload, X, Image } from 'lucide-react'
|
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 [files, setFiles] = useState([])
|
||||||
const [dayId, setDayId] = useState('')
|
const [dayId, setDayId] = useState('')
|
||||||
const [placeId, setPlaceId] = useState('')
|
const [placeId, setPlaceId] = useState('')
|
||||||
@@ -46,7 +57,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
|||||||
await onUpload(formData)
|
await onUpload(formData)
|
||||||
files.forEach(f => URL.revokeObjectURL(f.preview))
|
files.forEach(f => URL.revokeObjectURL(f.preview))
|
||||||
setFiles([])
|
setFiles([])
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
console.error('Upload failed:', err)
|
console.error('Upload failed:', err)
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false)
|
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-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>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<select
|
||||||
value={placeId}
|
value={placeId}
|
||||||
onChange={e => setPlaceId(e.target.value)}
|
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"
|
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 => (
|
{(places || []).map(place => (
|
||||||
<option key={place.id} value={place.id}>{place.name}</option>
|
<option key={place.id} value={place.id}>{place.name}</option>
|
||||||
))}
|
))}
|
||||||
@@ -175,7 +186,7 @@ export function PhotoUpload({ tripId, days, places, onUpload, onClose }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-sm text-gray-600 border border-gray-200 rounded-lg hover:bg-gray-50"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={handleUpload}
|
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"
|
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" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,504 +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 { Search, Plus, MapPin, Loader } from 'lucide-react'
|
|
||||||
|
|
||||||
const STATUSES = [
|
|
||||||
{ value: 'none', label: 'None' },
|
|
||||||
{ value: 'pending', label: 'Pending' },
|
|
||||||
{ value: 'confirmed', label: 'Confirmed' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function PlaceFormModal({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
place,
|
|
||||||
tripId,
|
|
||||||
categories: initialCategories = [],
|
|
||||||
tags: initialTags = [],
|
|
||||||
onCategoryCreated,
|
|
||||||
onTagCreated,
|
|
||||||
}) {
|
|
||||||
const isEditing = !!place
|
|
||||||
const { user } = useAuthStore()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const [categories, setCategories] = useState(initialCategories)
|
|
||||||
const [tags, setTags] = useState(initialTags)
|
|
||||||
|
|
||||||
useEffect(() => { setCategories(initialCategories) }, [initialCategories])
|
|
||||||
useEffect(() => { setTags(initialTags) }, [initialTags])
|
|
||||||
|
|
||||||
const emptyForm = {
|
|
||||||
name: '',
|
|
||||||
description: '',
|
|
||||||
address: '',
|
|
||||||
lat: '',
|
|
||||||
lng: '',
|
|
||||||
category_id: '',
|
|
||||||
place_time: '',
|
|
||||||
reservation_status: 'none',
|
|
||||||
reservation_notes: '',
|
|
||||||
reservation_datetime: '',
|
|
||||||
google_place_id: '',
|
|
||||||
website: '',
|
|
||||||
tags: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState(emptyForm)
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
// Maps search state
|
|
||||||
const [mapQuery, setMapQuery] = useState('')
|
|
||||||
const [mapResults, setMapResults] = useState([])
|
|
||||||
const [mapSearching, setMapSearching] = useState(false)
|
|
||||||
|
|
||||||
// New category/tag
|
|
||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
|
||||||
const [newCategoryColor, setNewCategoryColor] = useState('#374151')
|
|
||||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
|
||||||
const [newTagName, setNewTagName] = useState('')
|
|
||||||
const [newTagColor, setNewTagColor] = useState('#374151')
|
|
||||||
const [showNewTag, setShowNewTag] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (place && isOpen) {
|
|
||||||
setFormData({
|
|
||||||
name: place.name || '',
|
|
||||||
description: place.description || '',
|
|
||||||
address: place.address || '',
|
|
||||||
lat: place.lat ?? '',
|
|
||||||
lng: place.lng ?? '',
|
|
||||||
category_id: place.category_id || '',
|
|
||||||
place_time: place.place_time || '',
|
|
||||||
reservation_status: place.reservation_status || 'none',
|
|
||||||
reservation_notes: place.reservation_notes || '',
|
|
||||||
reservation_datetime: place.reservation_datetime || '',
|
|
||||||
google_place_id: place.google_place_id || '',
|
|
||||||
website: place.website || '',
|
|
||||||
tags: (place.tags || []).map(t => t.id),
|
|
||||||
})
|
|
||||||
} else if (!place && isOpen) {
|
|
||||||
setFormData(emptyForm)
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
setMapResults([])
|
|
||||||
setMapQuery('')
|
|
||||||
}, [place, isOpen])
|
|
||||||
|
|
||||||
const update = (field, value) => setFormData(prev => ({ ...prev, [field]: value }))
|
|
||||||
|
|
||||||
const toggleTag = (tagId) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags: prev.tags.includes(tagId)
|
|
||||||
? prev.tags.filter(id => id !== tagId)
|
|
||||||
: [...prev.tags, tagId]
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!formData.name.trim()) {
|
|
||||||
setError('Place name is required')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsLoading(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
await onSave({
|
|
||||||
...formData,
|
|
||||||
lat: formData.lat !== '' ? parseFloat(formData.lat) : null,
|
|
||||||
lng: formData.lng !== '' ? parseFloat(formData.lng) : null,
|
|
||||||
category_id: formData.category_id || null,
|
|
||||||
})
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || 'Failed to save place')
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMapSearch = async () => {
|
|
||||||
if (!mapQuery.trim()) return
|
|
||||||
setMapSearching(true)
|
|
||||||
try {
|
|
||||||
const data = await mapsApi.search(mapQuery)
|
|
||||||
setMapResults(data.places || [])
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err.response?.data?.error || 'Maps search failed')
|
|
||||||
} finally {
|
|
||||||
setMapSearching(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectMapPlace = (p) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
name: p.name || prev.name,
|
|
||||||
address: p.address || prev.address,
|
|
||||||
lat: p.lat ?? prev.lat,
|
|
||||||
lng: p.lng ?? prev.lng,
|
|
||||||
google_place_id: p.google_place_id || prev.google_place_id,
|
|
||||||
website: p.website || prev.website,
|
|
||||||
}))
|
|
||||||
setMapResults([])
|
|
||||||
setMapQuery('')
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateCategory = async () => {
|
|
||||||
if (!newCategoryName.trim()) return
|
|
||||||
try {
|
|
||||||
const data = await categoriesApi.create({ name: newCategoryName, color: newCategoryColor, icon: 'MapPin' })
|
|
||||||
setCategories(prev => [...prev, data.category])
|
|
||||||
if (onCategoryCreated) onCategoryCreated(data.category)
|
|
||||||
setFormData(prev => ({ ...prev, category_id: data.category.id }))
|
|
||||||
setNewCategoryName('')
|
|
||||||
setShowNewCategory(false)
|
|
||||||
toast.success('Category created')
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Failed to create category')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCreateTag = async () => {
|
|
||||||
if (!newTagName.trim()) return
|
|
||||||
try {
|
|
||||||
const data = await tagsApi.create({ name: newTagName, color: newTagColor })
|
|
||||||
setTags(prev => [...prev, data.tag])
|
|
||||||
if (onTagCreated) onTagCreated(data.tag)
|
|
||||||
setFormData(prev => ({ ...prev, tags: [...prev.tags, data.tag.id] }))
|
|
||||||
setNewTagName('')
|
|
||||||
setShowNewTag(false)
|
|
||||||
toast.success('Tag created')
|
|
||||||
} catch (err) {
|
|
||||||
toast.error('Failed to create tag')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={isEditing ? 'Edit Place' : 'Add Place'}
|
|
||||||
size="xl"
|
|
||||||
footer={
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-slate-600 border border-slate-200 rounded-lg hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isLoading}
|
|
||||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{isLoading ? (
|
|
||||||
<>
|
|
||||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
|
||||||
Saving...
|
|
||||||
</>
|
|
||||||
) : isEditing ? 'Save Changes' : 'Add Place'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-5">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Google Maps search — always visible when API key is set */}
|
|
||||||
{user?.maps_api_key && (
|
|
||||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={mapQuery}
|
|
||||||
onChange={e => setMapQuery(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && handleMapSearch()}
|
|
||||||
placeholder="Google Maps suchen..."
|
|
||||||
className="w-full pl-8 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleMapSearch}
|
|
||||||
disabled={mapSearching}
|
|
||||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{mapSearching ? <Loader className="w-4 h-4 animate-spin" /> : 'Suchen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mapResults.length > 0 && (
|
|
||||||
<div className="bg-white rounded-lg border border-slate-200 max-h-48 overflow-y-auto mt-2">
|
|
||||||
{mapResults.map((p, i) => (
|
|
||||||
<button
|
|
||||||
key={p.google_place_id || i}
|
|
||||||
onClick={() => selectMapPlace(p)}
|
|
||||||
className="w-full text-left px-3 py-2.5 hover:bg-slate-50 transition-colors border-b border-slate-100 last:border-0"
|
|
||||||
>
|
|
||||||
<p className="text-sm font-medium text-slate-900">{p.name}</p>
|
|
||||||
<p className="text-xs text-slate-500 truncate flex items-center gap-1 mt-0.5">
|
|
||||||
<MapPin className="w-3 h-3" />
|
|
||||||
{p.address}
|
|
||||||
</p>
|
|
||||||
{p.rating && (
|
|
||||||
<p className="text-xs text-amber-600 mt-0.5">★ {p.rating}</p>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
Name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={e => update('name', e.target.value)}
|
|
||||||
required
|
|
||||||
placeholder="e.g. Eiffel Tower"
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Description</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.description}
|
|
||||||
onChange={e => update('description', e.target.value)}
|
|
||||||
placeholder="Notes about this place..."
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Address */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={e => update('address', e.target.value)}
|
|
||||||
placeholder="Street address"
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lat / Lng */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Latitude</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
value={formData.lat}
|
|
||||||
onChange={e => update('lat', e.target.value)}
|
|
||||||
placeholder="e.g. 48.8584"
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Longitude</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
value={formData.lng}
|
|
||||||
onChange={e => update('lng', e.target.value)}
|
|
||||||
placeholder="e.g. 2.2945"
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Category */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Category</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<select
|
|
||||||
value={formData.category_id}
|
|
||||||
onChange={e => update('category_id', e.target.value)}
|
|
||||||
className="flex-1 px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
|
||||||
>
|
|
||||||
<option value="">No category</option>
|
|
||||||
{categories.map(cat => (
|
|
||||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewCategory(!showNewCategory)}
|
|
||||||
className="px-3 py-2.5 border border-slate-300 rounded-lg text-slate-500 hover:text-slate-700 hover:border-slate-400 transition-colors"
|
|
||||||
title="Create new category"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showNewCategory && (
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newCategoryName}
|
|
||||||
onChange={e => setNewCategoryName(e.target.value)}
|
|
||||||
placeholder="Category name"
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={newCategoryColor}
|
|
||||||
onChange={e => setNewCategoryColor(e.target.value)}
|
|
||||||
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
|
|
||||||
title="Category color"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreateCategory}
|
|
||||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tags */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Tags</label>
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
||||||
{tags.map(tag => (
|
|
||||||
<button
|
|
||||||
key={tag.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleTag(tag.id)}
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-full font-medium transition-all ${
|
|
||||||
formData.tags.includes(tag.id)
|
|
||||||
? 'text-white shadow-sm ring-2 ring-offset-1'
|
|
||||||
: 'text-white opacity-50 hover:opacity-80'
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: tag.color || '#374151',
|
|
||||||
ringColor: formData.tags.includes(tag.id) ? tag.color : 'transparent'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tag.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowNewTag(!showNewTag)}
|
|
||||||
className="text-xs px-2.5 py-1 border border-dashed border-slate-300 rounded-full text-slate-500 hover:border-slate-400 hover:text-slate-700 transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="inline w-3 h-3 mr-0.5" />
|
|
||||||
New tag
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showNewTag && (
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newTagName}
|
|
||||||
onChange={e => setNewTagName(e.target.value)}
|
|
||||||
placeholder="Tag name"
|
|
||||||
className="flex-1 px-3 py-2 text-sm border border-slate-300 rounded-lg focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={newTagColor}
|
|
||||||
onChange={e => setNewTagColor(e.target.value)}
|
|
||||||
className="w-10 h-10 border border-slate-300 rounded-lg cursor-pointer p-1"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCreateTag}
|
|
||||||
className="px-3 py-2 bg-slate-900 text-white text-sm rounded-lg hover:bg-slate-700"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time & Reservation */}
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Visit Time</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={formData.place_time}
|
|
||||||
onChange={e => update('place_time', e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation</label>
|
|
||||||
<select
|
|
||||||
value={formData.reservation_status}
|
|
||||||
onChange={e => update('reservation_status', e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
|
||||||
>
|
|
||||||
{STATUSES.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reservation details */}
|
|
||||||
{formData.reservation_status !== 'none' && (
|
|
||||||
<div className="space-y-3 p-3 bg-amber-50 rounded-lg border border-amber-200">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Date & Time</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={formData.reservation_datetime}
|
|
||||||
onChange={e => update('reservation_datetime', e.target.value)}
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Reservation Notes</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.reservation_notes}
|
|
||||||
onChange={e => update('reservation_notes', e.target.value)}
|
|
||||||
placeholder="Confirmation number, special requests..."
|
|
||||||
rows={2}
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent resize-none bg-white"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Website */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">Website</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={formData.website}
|
|
||||||
onChange={e => update('website', e.target.value)}
|
|
||||||
placeholder="https://..."
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 placeholder-slate-400 focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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,652 @@
|
|||||||
|
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 { getLocaleForLanguage, 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
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DayDetailPanel({ day, days, places, categories = [], tripId, assignments, reservations = [], lat, lng, onClose, onAccommodationChange, leftWidth = 0, rightWidth = 0 }: DayDetailPanelProps) {
|
||||||
|
const { t, language, locale } = useTranslation()
|
||||||
|
const isFahrenheit = useSettingsStore(s => s.settings.temperature_unit) === 'fahrenheit'
|
||||||
|
const is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
|
const blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
|
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 [dayAccommodations, setDayAccommodations] = useState<any[]>([])
|
||||||
|
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 allForDay = (data.accommodations || []).filter(a =>
|
||||||
|
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
|
||||||
|
)
|
||||||
|
setDayAccommodations(allForDay)
|
||||||
|
setAccommodation(allForDay[0] || 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(
|
||||||
|
getLocaleForLanguage(language),
|
||||||
|
{ 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: `calc(${leftWidth}px + (100vw - ${leftWidth}px - ${rightWidth}px) / 2)`, transform: 'translateX(-50%)', width: `min(800px, calc(100vw - ${leftWidth}px - ${rightWidth}px - 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(locale, { 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>
|
||||||
|
|
||||||
|
{dayAccommodations.length > 0 ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{dayAccommodations.map(acc => {
|
||||||
|
const isCheckInDay = acc.start_day_id === day.id
|
||||||
|
const isCheckOutDay = acc.end_day_id === day.id
|
||||||
|
const isMiddleDay = !isCheckInDay && !isCheckOutDay
|
||||||
|
const dayLabel = isCheckInDay && isCheckOutDay ? t('day.checkIn') + ' & ' + t('day.checkOut')
|
||||||
|
: isCheckInDay ? t('day.checkIn')
|
||||||
|
: isCheckOutDay ? t('day.checkOut')
|
||||||
|
: null
|
||||||
|
const linked = reservations.find(r => r.accommodation_id === acc.id)
|
||||||
|
const confirmed = linked?.status === 'confirmed'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={acc.id} style={{ borderRadius: 12, background: 'var(--bg-secondary)', overflow: 'hidden' }}>
|
||||||
|
{/* Day label */}
|
||||||
|
{dayLabel && (
|
||||||
|
<div style={{ padding: '4px 12px 0', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
|
{isCheckInDay && <LogIn size={9} style={{ color: '#22c55e' }} />}
|
||||||
|
{isCheckOutDay && !isCheckInDay && <LogOut size={9} style={{ color: '#ef4444' }} />}
|
||||||
|
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.05em', color: isCheckOutDay && !isCheckInDay ? '#ef4444' : '#22c55e' }}>{dayLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Hotel header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px' }}>
|
||||||
|
<div style={{ width: 36, height: 36, borderRadius: 10, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
|
{acc.place_image ? (
|
||||||
|
<img src={acc.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' }}>{acc.place_name}</div>
|
||||||
|
{acc.place_address && <div style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{acc.place_address}</div>}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { setAccommodation(acc); setHotelForm({ check_in: acc.check_in || '', check_out: acc.check_out || '', confirmation: acc.confirmation || '', place_id: acc.place_id }); setHotelDayRange({ start: acc.start_day_id, end: acc.end_day_id }); setShowHotelPicker('edit') }}
|
||||||
|
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||||
|
<Pencil size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setAccommodation(acc); handleRemoveAccommodation() }} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 3, flexShrink: 0 }}>
|
||||||
|
<X size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Details grid */}
|
||||||
|
<div style={{ display: 'flex', gap: 0, margin: '0 12px 8px', borderRadius: 10, overflow: 'hidden', border: '1px solid var(--border-faint)' }}>
|
||||||
|
{acc.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(acc.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>
|
||||||
|
)}
|
||||||
|
{acc.check_out && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', borderRight: acc.confirmation ? '1px solid var(--border-faint)' : 'none', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{fmtTime(acc.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>
|
||||||
|
)}
|
||||||
|
{acc.confirmation && (
|
||||||
|
<div style={{ flex: 1, padding: '8px 10px', textAlign: 'center' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', lineHeight: 1.2 }}>{acc.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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Linked booking */}
|
||||||
|
{linked && (
|
||||||
|
<div style={{ margin: '0 12px 8px', padding: '6px 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)'}`, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ width: 6, height: 6, borderRadius: '50%', background: confirmed ? '#16a34a' : '#d97706', flexShrink: 0 }} />
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.title}</div>
|
||||||
|
<div style={{ fontSize: 9, color: 'var(--text-faint)', display: 'flex', gap: 6, marginTop: 1 }}>
|
||||||
|
<span>{confirmed ? t('reservations.confirmed') : t('reservations.pending')}</span>
|
||||||
|
{linked.confirmation_number && <span
|
||||||
|
onMouseEnter={e => { if (blurCodes) e.currentTarget.style.filter = 'none' }}
|
||||||
|
onMouseLeave={e => { if (blurCodes) e.currentTarget.style.filter = 'blur(4px)' }}
|
||||||
|
onClick={e => { if (blurCodes) { const el = e.currentTarget; el.style.filter = el.style.filter === 'none' ? 'blur(4px)' : 'none' } }}
|
||||||
|
style={{ filter: blurCodes ? 'blur(4px)' : 'none', transition: 'filter 0.2s', cursor: blurCodes ? 'pointer' : 'default' }}
|
||||||
|
>#{linked.confirmation_number}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{/* Add another hotel */}
|
||||||
|
<button onClick={() => setShowHotelPicker(true)} style={{
|
||||||
|
width: '100%', padding: 8, border: '1.5px dashed var(--border-primary)', borderRadius: 10,
|
||||||
|
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
fontSize: 10, color: 'var(--text-faint)', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Hotel size={10} /> {t('day.addAccommodation')}
|
||||||
|
</button>
|
||||||
|
</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(locale, { 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(locale, { 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,877 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLink, Clock, AlertCircle, CheckCircle2, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown } from 'lucide-react'
|
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
|
||||||
import { calculateRoute, generateGoogleMapsUrl, optimizeRoute } from '../Map/RouteCalculator'
|
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
|
||||||
import WeatherWidget from '../Weather/WeatherWidget'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
|
|
||||||
function formatDate(dateStr, locale) {
|
|
||||||
if (!dateStr) return null
|
|
||||||
return new Date(dateStr + 'T00:00:00').toLocaleDateString(locale, {
|
|
||||||
weekday: 'short', day: 'numeric', month: 'short',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(timeStr, locale, timeFormat) {
|
|
||||||
if (!timeStr) return ''
|
|
||||||
try {
|
|
||||||
const [h, m] = timeStr.split(':').map(Number)
|
|
||||||
if (timeFormat === '12h') {
|
|
||||||
const period = h >= 12 ? 'PM' : 'AM'
|
|
||||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
||||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
|
||||||
}
|
|
||||||
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
||||||
return locale?.startsWith('de') ? `${str} Uhr` : str
|
|
||||||
} catch { return timeStr }
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayTotalCost(dayId, assignments, currency) {
|
|
||||||
const da = assignments[String(dayId)] || []
|
|
||||||
const total = da.reduce((s, a) => s + (parseFloat(a.place?.price) || 0), 0)
|
|
||||||
return total > 0 ? `${total.toFixed(0)} ${currency}` : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const NOTE_ICONS = [
|
|
||||||
{ id: 'FileText', Icon: FileText },
|
|
||||||
{ id: 'Info', Icon: Info },
|
|
||||||
{ id: 'Clock', Icon: Clock },
|
|
||||||
{ id: 'MapPin', Icon: MapPin },
|
|
||||||
{ id: 'Navigation', Icon: Navigation },
|
|
||||||
{ id: 'Train', Icon: Train },
|
|
||||||
{ id: 'Plane', Icon: Plane },
|
|
||||||
{ id: 'Bus', Icon: Bus },
|
|
||||||
{ id: 'Car', Icon: Car },
|
|
||||||
{ id: 'Ship', Icon: Ship },
|
|
||||||
{ id: 'Coffee', Icon: Coffee },
|
|
||||||
{ id: 'Ticket', Icon: Ticket },
|
|
||||||
{ id: 'Star', Icon: Star },
|
|
||||||
{ id: 'Heart', Icon: Heart },
|
|
||||||
{ id: 'Camera', Icon: Camera },
|
|
||||||
{ id: 'Flag', Icon: Flag },
|
|
||||||
{ id: 'Lightbulb', Icon: Lightbulb },
|
|
||||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
|
||||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
|
||||||
{ id: 'Bookmark', Icon: Bookmark },
|
|
||||||
]
|
|
||||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
|
||||||
function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
|
||||||
|
|
||||||
const TYPE_ICONS = {
|
|
||||||
flight: '✈️', hotel: '🏨', restaurant: '🍽️', train: '🚆',
|
|
||||||
car: '🚗', cruise: '🚢', event: '🎫', other: '📋',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DayPlanSidebar({
|
|
||||||
tripId,
|
|
||||||
trip, days, places, categories, assignments,
|
|
||||||
selectedDayId, selectedPlaceId,
|
|
||||||
onSelectDay, onPlaceClick,
|
|
||||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
|
||||||
onAssignToDay,
|
|
||||||
reservations = [],
|
|
||||||
onAddReservation,
|
|
||||||
}) {
|
|
||||||
const toast = useToast()
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
|
||||||
const tripStore = useTripStore()
|
|
||||||
|
|
||||||
const TRANSPORT_MODES = [
|
|
||||||
{ value: 'driving', label: t('dayplan.transport.car') },
|
|
||||||
{ value: 'walking', label: t('dayplan.transport.walk') },
|
|
||||||
{ value: 'cycling', label: t('dayplan.transport.bike') },
|
|
||||||
]
|
|
||||||
const dayNotes = tripStore.dayNotes || {}
|
|
||||||
|
|
||||||
const [expandedDays, setExpandedDays] = useState(() => new Set(days.map(d => d.id)))
|
|
||||||
const [editingDayId, setEditingDayId] = useState(null)
|
|
||||||
const [editTitle, setEditTitle] = useState('')
|
|
||||||
const [transportMode, setTransportMode] = useState('driving')
|
|
||||||
const [isCalculating, setIsCalculating] = useState(false)
|
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
|
||||||
const [dropTargetKey, setDropTargetKey] = useState(null)
|
|
||||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
|
||||||
const [hoveredId, setHoveredId] = useState(null)
|
|
||||||
const [noteUi, setNoteUi] = useState({}) // { [dayId]: { mode, text, time, noteId?, sortOrder? } }
|
|
||||||
const inputRef = useRef(null)
|
|
||||||
const noteInputRef = useRef(null)
|
|
||||||
const dragDataRef = useRef(null) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
|
|
||||||
|
|
||||||
const currency = trip?.currency || 'EUR'
|
|
||||||
|
|
||||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
|
||||||
const getDragData = (e) => {
|
|
||||||
const dt = e?.dataTransfer
|
|
||||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
|
|
||||||
if (dragDataRef.current) {
|
|
||||||
return {
|
|
||||||
placeId: '',
|
|
||||||
assignmentId: dragDataRef.current.assignmentId || '',
|
|
||||||
noteId: dragDataRef.current.noteId || '',
|
|
||||||
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Externer Drag (aus PlacesSidebar)
|
|
||||||
const ext = window.__dragData || {}
|
|
||||||
const placeId = dt?.getData('placeId') || ext.placeId || ''
|
|
||||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setExpandedDays(prev => new Set([...prev, ...days.map(d => d.id)]))
|
|
||||||
}, [days.length])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (editingDayId && inputRef.current) inputRef.current.focus()
|
|
||||||
}, [editingDayId])
|
|
||||||
|
|
||||||
// Globaler Aufräum-Listener: wenn ein Drag endet ohne Drop, alles zurücksetzen
|
|
||||||
useEffect(() => {
|
|
||||||
const cleanup = () => {
|
|
||||||
setDraggingId(null)
|
|
||||||
setDropTargetKey(null)
|
|
||||||
setDragOverDayId(null)
|
|
||||||
dragDataRef.current = null
|
|
||||||
window.__dragData = null
|
|
||||||
}
|
|
||||||
document.addEventListener('dragend', cleanup)
|
|
||||||
return () => document.removeEventListener('dragend', cleanup)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleDay = (dayId, e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setExpandedDays(prev => {
|
|
||||||
const n = new Set(prev)
|
|
||||||
n.has(dayId) ? n.delete(dayId) : n.add(dayId)
|
|
||||||
return n
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const getDayAssignments = (dayId) =>
|
|
||||||
(assignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
|
||||||
|
|
||||||
const getMergedItems = (dayId) => {
|
|
||||||
const da = getDayAssignments(dayId)
|
|
||||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
|
||||||
return [
|
|
||||||
...da.map(a => ({ type: 'place', sortKey: a.order_index, data: a })),
|
|
||||||
...dn.map(n => ({ type: 'note', sortKey: n.sort_order, data: n })),
|
|
||||||
].sort((a, b) => a.sortKey - b.sortKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
|
||||||
e?.stopPropagation()
|
|
||||||
const merged = getMergedItems(dayId)
|
|
||||||
const maxKey = merged.length > 0 ? Math.max(...merged.map(i => i.sortKey)) : -1
|
|
||||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'add', text: '', time: '', icon: 'FileText', sortOrder: maxKey + 1 } }))
|
|
||||||
if (!expandedDays.has(dayId)) setExpandedDays(prev => new Set([...prev, dayId]))
|
|
||||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openEditNote = (dayId, note, e) => {
|
|
||||||
e?.stopPropagation()
|
|
||||||
setNoteUi(prev => ({ ...prev, [dayId]: { mode: 'edit', noteId: note.id, text: note.text, time: note.time || '', icon: note.icon || 'FileText' } }))
|
|
||||||
setTimeout(() => noteInputRef.current?.focus(), 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelNote = (dayId) => {
|
|
||||||
setNoteUi(prev => { const n = { ...prev }; delete n[dayId]; return n })
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveNote = async (dayId) => {
|
|
||||||
const ui = noteUi[dayId]
|
|
||||||
if (!ui?.text?.trim()) return
|
|
||||||
try {
|
|
||||||
if (ui.mode === 'add') {
|
|
||||||
await tripStore.addDayNote(tripId, dayId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText', sort_order: ui.sortOrder })
|
|
||||||
} else {
|
|
||||||
await tripStore.updateDayNote(tripId, dayId, ui.noteId, { text: ui.text.trim(), time: ui.time || null, icon: ui.icon || 'FileText' })
|
|
||||||
}
|
|
||||||
cancelNote(dayId)
|
|
||||||
} catch (err) { toast.error(err.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteNote = async (dayId, noteId, e) => {
|
|
||||||
e?.stopPropagation()
|
|
||||||
try { await tripStore.deleteDayNote(tripId, dayId, noteId) }
|
|
||||||
catch (err) { toast.error(err.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId) => {
|
|
||||||
const m = getMergedItems(dayId)
|
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
|
||||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
|
||||||
|
|
||||||
// Neue Reihenfolge erstellen — VOR dem Ziel einfügen (Standardkonvention)
|
|
||||||
const newOrder = [...m]
|
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
|
||||||
const adjustedTo = fromIdx < toIdx ? toIdx - 1 : toIdx
|
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
|
||||||
|
|
||||||
// Orte: neuer order_index über onReorder
|
|
||||||
const assignmentIds = newOrder.filter(i => i.type === 'place').map(i => i.data.id)
|
|
||||||
|
|
||||||
// Notizen: sort_order muss ZWISCHEN den umgebenden order_indices der Orte liegen, niemals gleich sein.
|
|
||||||
// Formel: Notiz zwischen placesBefore-1 und placesBefore ergibt (placesBefore - 1) + rank/(count+1)
|
|
||||||
// z.B. einzelne Notiz nach 2 Orten → (2-1) + 0.5 = 1.5 (zwischen order_index 1 und 2)
|
|
||||||
const groups = {}
|
|
||||||
let pc = 0
|
|
||||||
newOrder.forEach(item => {
|
|
||||||
if (item.type === 'place') { pc++ }
|
|
||||||
else { if (!groups[pc]) groups[pc] = []; groups[pc].push(item.data.id) }
|
|
||||||
})
|
|
||||||
const noteChanges = []
|
|
||||||
Object.entries(groups).forEach(([pb, ids]) => {
|
|
||||||
ids.forEach((id, i) => {
|
|
||||||
noteChanges.push({ id, sort_order: (Number(pb) - 1) + (i + 1) / (ids.length + 1) })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
|
||||||
for (const n of noteChanges) {
|
|
||||||
await tripStore.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
|
||||||
}
|
|
||||||
} catch (err) { toast.error(err.message) }
|
|
||||||
setDraggingId(null)
|
|
||||||
setDropTargetKey(null)
|
|
||||||
dragDataRef.current = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveNote = async (dayId, noteId, direction) => {
|
|
||||||
const merged = getMergedItems(dayId)
|
|
||||||
const idx = merged.findIndex(i => i.type === 'note' && i.data.id === noteId)
|
|
||||||
if (idx === -1) return
|
|
||||||
let newSortOrder
|
|
||||||
if (direction === 'up') {
|
|
||||||
if (idx === 0) return
|
|
||||||
newSortOrder = idx >= 2 ? (merged[idx - 2].sortKey + merged[idx - 1].sortKey) / 2 : merged[idx - 1].sortKey - 1
|
|
||||||
} else {
|
|
||||||
if (idx >= merged.length - 1) return
|
|
||||||
newSortOrder = idx < merged.length - 2 ? (merged[idx + 1].sortKey + merged[idx + 2].sortKey) / 2 : merged[idx + 1].sortKey + 1
|
|
||||||
}
|
|
||||||
try { await tripStore.updateDayNote(tripId, dayId, noteId, { sort_order: newSortOrder }) }
|
|
||||||
catch (err) { toast.error(err.message) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const startEditTitle = (day, e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setEditTitle(day.title || '')
|
|
||||||
setEditingDayId(day.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTitle = async (dayId) => {
|
|
||||||
setEditingDayId(null)
|
|
||||||
await onUpdateDayTitle?.(dayId, editTitle.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCalculateRoute = async () => {
|
|
||||||
if (!selectedDayId) return
|
|
||||||
const da = getDayAssignments(selectedDayId)
|
|
||||||
const waypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng).map(p => ({ lat: p.lat, lng: p.lng }))
|
|
||||||
if (waypoints.length < 2) { toast.error(t('dayplan.toast.needTwoPlaces')); return }
|
|
||||||
setIsCalculating(true)
|
|
||||||
try {
|
|
||||||
const result = await calculateRoute(waypoints, transportMode)
|
|
||||||
// Luftlinien zwischen Wegpunkten anzeigen
|
|
||||||
const lineCoords = waypoints.map(p => [p.lat, p.lng])
|
|
||||||
setRouteInfo({ distance: result.distanceText, duration: result.durationText })
|
|
||||||
onRouteCalculated?.({ ...result, coordinates: lineCoords })
|
|
||||||
} catch { toast.error(t('dayplan.toast.routeError')) }
|
|
||||||
finally { setIsCalculating(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleOptimize = async () => {
|
|
||||||
if (!selectedDayId) return
|
|
||||||
const da = getDayAssignments(selectedDayId)
|
|
||||||
if (da.length < 3) return
|
|
||||||
const withCoords = da.map(a => a.place).filter(p => p?.lat && p?.lng)
|
|
||||||
const optimized = optimizeRoute(withCoords)
|
|
||||||
const reorderedIds = optimized.map(p => da.find(a => a.place?.id === p.id)?.id).filter(Boolean)
|
|
||||||
for (const a of da) { if (!reorderedIds.includes(a.id)) reorderedIds.push(a.id) }
|
|
||||||
await onReorder(selectedDayId, reorderedIds)
|
|
||||||
toast.success(t('dayplan.toast.routeOptimized'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleGoogleMaps = () => {
|
|
||||||
if (!selectedDayId) return
|
|
||||||
const da = getDayAssignments(selectedDayId)
|
|
||||||
const url = generateGoogleMapsUrl(da.map(a => a.place).filter(p => p?.lat && p?.lng))
|
|
||||||
if (url) window.open(url, '_blank')
|
|
||||||
else toast.error(t('dayplan.toast.noGeoPlaces'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDropOnDay = (e, dayId) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setDragOverDayId(null)
|
|
||||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
|
||||||
if (placeId) {
|
|
||||||
onAssignToDay?.(parseInt(placeId), dayId)
|
|
||||||
} else if (assignmentId && fromDayId !== dayId) {
|
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, dayId).catch(err => toast.error(err.message))
|
|
||||||
} else if (noteId && fromDayId !== dayId) {
|
|
||||||
tripStore.moveDayNote(tripId, fromDayId, dayId, Number(noteId)).catch(err => toast.error(err.message))
|
|
||||||
}
|
|
||||||
setDraggingId(null)
|
|
||||||
setDropTargetKey(null)
|
|
||||||
dragDataRef.current = null
|
|
||||||
window.__dragData = null
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleDropOnRow = (e, dayId, toIdx) => {
|
|
||||||
e.preventDefault()
|
|
||||||
e.stopPropagation()
|
|
||||||
setDragOverDayId(null)
|
|
||||||
const placeId = e.dataTransfer.getData('placeId')
|
|
||||||
const fromAssignmentId = e.dataTransfer.getData('assignmentId')
|
|
||||||
|
|
||||||
if (placeId) {
|
|
||||||
onAssignToDay?.(parseInt(placeId), dayId)
|
|
||||||
} else if (fromAssignmentId) {
|
|
||||||
const da = getDayAssignments(dayId)
|
|
||||||
const fromIdx = da.findIndex(a => String(a.id) === fromAssignmentId)
|
|
||||||
if (fromIdx === -1 || fromIdx === toIdx) { setDraggingId(null); dragDataRef.current = null; return }
|
|
||||||
const ids = da.map(a => a.id)
|
|
||||||
const [removed] = ids.splice(fromIdx, 1)
|
|
||||||
ids.splice(toIdx, 0, removed)
|
|
||||||
onReorder(dayId, ids)
|
|
||||||
}
|
|
||||||
setDraggingId(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCost = days.reduce((s, d) => {
|
|
||||||
const da = assignments[String(d.id)] || []
|
|
||||||
return s + da.reduce((s2, a) => s2 + (parseFloat(a.place?.price) || 0), 0)
|
|
||||||
}, 0)
|
|
||||||
|
|
||||||
// Bester verfügbarer Standort für Wetter: zugewiesene Orte zuerst, dann beliebiger Reiseort
|
|
||||||
const anyGeoAssignment = Object.values(assignments).flatMap(da => da).find(a => a.place?.lat && a.place?.lng)
|
|
||||||
const anyGeoPlace = anyGeoAssignment || (places || []).find(p => p.lat && p.lng)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', position: 'relative', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
|
||||||
{/* Reise-Titel */}
|
|
||||||
<div style={{ padding: '16px 16px 12px', borderBottom: '1px solid var(--border-faint)', flexShrink: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 8 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)', lineHeight: '1.3' }}>{trip?.title}</div>
|
|
||||||
{(trip?.start_date || trip?.end_date) && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 3 }}>
|
|
||||||
{[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00').toLocaleDateString(locale, { day: 'numeric', month: 'short' })).join(' – ')}
|
|
||||||
{days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const flatNotes = Object.entries(dayNotes).flatMap(([dayId, notes]) =>
|
|
||||||
notes.map(n => ({ ...n, day_id: Number(dayId) }))
|
|
||||||
)
|
|
||||||
try {
|
|
||||||
await downloadTripPDF({ trip, days, places, assignments, categories, dayNotes: flatNotes, t, locale })
|
|
||||||
} catch (e) {
|
|
||||||
console.error('PDF error:', e)
|
|
||||||
toast.error(t('dayplan.pdfError') + ': ' + (e?.message || String(e)))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={t('dayplan.pdfTooltip')}
|
|
||||||
style={{
|
|
||||||
flexShrink: 0, display: 'flex', alignItems: 'center', gap: 5,
|
|
||||||
padding: '5px 10px', borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', fontSize: 11, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileDown size={13} strokeWidth={2} />
|
|
||||||
{t('dayplan.pdf')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tagesliste */}
|
|
||||||
<div className="scroll-container" style={{ flex: 1, overflowY: 'auto', minHeight: 0, scrollbarWidth: 'thin', scrollbarColor: 'var(--scrollbar-thumb) transparent' }}>
|
|
||||||
{days.map((day, index) => {
|
|
||||||
const isSelected = selectedDayId === day.id
|
|
||||||
const isExpanded = expandedDays.has(day.id)
|
|
||||||
const da = getDayAssignments(day.id)
|
|
||||||
const cost = dayTotalCost(day.id, assignments, currency)
|
|
||||||
const formattedDate = formatDate(day.date, locale)
|
|
||||||
const loc = da.find(a => a.place?.lat && a.place?.lng)
|
|
||||||
const isDragTarget = dragOverDayId === day.id
|
|
||||||
const merged = getMergedItems(day.id)
|
|
||||||
const dayNoteUi = noteUi[day.id]
|
|
||||||
const placeItems = merged.filter(i => i.type === 'place')
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={day.id} style={{ borderBottom: '1px solid var(--border-faint)' }}>
|
|
||||||
{/* Tages-Header — akzeptiert Drops aus der PlacesSidebar */}
|
|
||||||
<div
|
|
||||||
onClick={() => onSelectDay(isSelected ? null : day.id)}
|
|
||||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
|
||||||
onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget)) setDragOverDayId(null) }}
|
|
||||||
onDrop={e => handleDropOnDay(e, day.id)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
|
||||||
padding: '11px 14px 11px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isDragTarget ? 'rgba(17,24,39,0.07)' : (isSelected ? 'var(--bg-hover)' : 'transparent'),
|
|
||||||
transition: 'background 0.12s',
|
|
||||||
userSelect: 'none',
|
|
||||||
outline: isDragTarget ? '2px dashed rgba(17,24,39,0.25)' : 'none',
|
|
||||||
outlineOffset: -2,
|
|
||||||
borderRadius: isDragTarget ? 8 : 0,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!isSelected && !isDragTarget) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = isDragTarget ? 'rgba(17,24,39,0.07)' : 'transparent' }}
|
|
||||||
>
|
|
||||||
{/* Tages-Badge */}
|
|
||||||
<div style={{
|
|
||||||
width: 26, height: 26, borderRadius: '50%', flexShrink: 0,
|
|
||||||
background: isSelected ? 'var(--accent)' : 'var(--bg-hover)',
|
|
||||||
color: isSelected ? 'var(--accent-text)' : 'var(--text-muted)',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
fontSize: 11, fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
{index + 1}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
{editingDayId === day.id ? (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
value={editTitle}
|
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
|
||||||
onBlur={() => saveTitle(day.id)}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') saveTitle(day.id); if (e.key === 'Escape') setEditingDayId(null) }}
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
style={{
|
|
||||||
width: '100%', border: 'none', outline: 'none',
|
|
||||||
fontSize: 13, fontWeight: 600, color: 'var(--text-primary)',
|
|
||||||
background: 'transparent', padding: 0, fontFamily: 'inherit',
|
|
||||||
borderBottom: '1.5px solid var(--text-primary)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{day.title || t('dayplan.dayN', { n: index + 1 })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={e => startEditTitle(day, e)}
|
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: '2px', cursor: 'pointer', opacity: 0.35, display: 'flex', alignItems: 'center' }}
|
|
||||||
>
|
|
||||||
<Pencil size={10} strokeWidth={1.8} color="var(--text-secondary)" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 2, flexWrap: 'wrap' }}>
|
|
||||||
{formattedDate && <span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{formattedDate}</span>}
|
|
||||||
{cost && <span style={{ fontSize: 11, color: '#059669' }}>{cost}</span>}
|
|
||||||
{day.date && anyGeoPlace && (() => {
|
|
||||||
const wLat = loc?.place.lat ?? anyGeoPlace?.place?.lat ?? anyGeoPlace?.lat
|
|
||||||
const wLng = loc?.place.lng ?? anyGeoPlace?.place?.lng ?? anyGeoPlace?.lng
|
|
||||||
return <WeatherWidget lat={wLat} lng={wLng} date={day.date} compact />
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={e => openAddNote(day.id, e)}
|
|
||||||
title={t('dayplan.addNote')}
|
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
|
||||||
>
|
|
||||||
<FileText size={13} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={e => toggleDay(day.id, e)}
|
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
|
||||||
>
|
|
||||||
{isExpanded ? <ChevronDown size={15} strokeWidth={2} /> : <ChevronRight size={15} strokeWidth={2} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Aufgeklappte Orte + Notizen */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
style={{ background: 'var(--bg-hover)' }}
|
|
||||||
onDragOver={e => { e.preventDefault(); if (draggingId) setDropTargetKey(`end-${day.id}`) }}
|
|
||||||
onDrop={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
const { assignmentId, noteId, fromDayId } = getDragData(e)
|
|
||||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
|
||||||
if (assignmentId && fromDayId !== day.id) {
|
|
||||||
tripStore.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch(err => toast.error(err.message))
|
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
|
||||||
}
|
|
||||||
if (noteId && fromDayId !== day.id) {
|
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch(err => toast.error(err.message))
|
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
|
||||||
}
|
|
||||||
const m = getMergedItems(day.id)
|
|
||||||
if (m.length === 0) return
|
|
||||||
const lastItem = m[m.length - 1]
|
|
||||||
if (assignmentId && String(lastItem?.data?.id) !== assignmentId)
|
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), lastItem.type, lastItem.data.id)
|
|
||||||
else if (noteId && String(lastItem?.data?.id) !== noteId)
|
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
|
||||||
<div
|
|
||||||
onDragOver={e => { e.preventDefault(); setDragOverDayId(day.id) }}
|
|
||||||
onDrop={e => handleDropOnDay(e, day.id)}
|
|
||||||
style={{ padding: '16px', textAlign: 'center', borderRadius: 8,
|
|
||||||
background: dragOverDayId === day.id ? 'rgba(17,24,39,0.05)' : 'transparent',
|
|
||||||
border: dragOverDayId === day.id ? '2px dashed rgba(17,24,39,0.2)' : '2px dashed transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-faint)' }}>{t('dayplan.emptyDay')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
merged.map((item, idx) => {
|
|
||||||
const itemKey = item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`
|
|
||||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
|
||||||
|
|
||||||
if (item.type === 'place') {
|
|
||||||
const assignment = item.data
|
|
||||||
const place = assignment.place
|
|
||||||
if (!place) return null
|
|
||||||
const cat = categories.find(c => c.id === place.category_id)
|
|
||||||
const isPlaceSelected = place.id === selectedPlaceId
|
|
||||||
const hasReservation = place.reservation_status && place.reservation_status !== 'none'
|
|
||||||
const isConfirmed = place.reservation_status === 'confirmed'
|
|
||||||
const isDraggingThis = draggingId === assignment.id
|
|
||||||
const isHovered = hoveredId === assignment.id
|
|
||||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
|
||||||
|
|
||||||
const moveUp = (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (placeIdx === 0) return
|
|
||||||
const ids = placeItems.map(i => i.data.id)
|
|
||||||
;[ids[placeIdx - 1], ids[placeIdx]] = [ids[placeIdx], ids[placeIdx - 1]]
|
|
||||||
onReorder(day.id, ids)
|
|
||||||
}
|
|
||||||
const moveDown = (e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (placeIdx === placeItems.length - 1) return
|
|
||||||
const ids = placeItems.map(i => i.data.id)
|
|
||||||
;[ids[placeIdx], ids[placeIdx + 1]] = [ids[placeIdx + 1], ids[placeIdx]]
|
|
||||||
onReorder(day.id, ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={`place-${assignment.id}`}>
|
|
||||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
onDragStart={e => {
|
|
||||||
e.dataTransfer.setData('assignmentId', String(assignment.id))
|
|
||||||
e.dataTransfer.setData('fromDayId', String(day.id))
|
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
|
||||||
dragDataRef.current = { assignmentId: String(assignment.id), fromDayId: String(day.id) }
|
|
||||||
setDraggingId(assignment.id)
|
|
||||||
}}
|
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); setDropTargetKey(`place-${assignment.id}`) }}
|
|
||||||
onDrop={e => {
|
|
||||||
e.preventDefault(); e.stopPropagation()
|
|
||||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
|
||||||
if (placeId) {
|
|
||||||
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
|
|
||||||
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
|
|
||||||
setDropTargetKey(null); window.__dragData = null
|
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
|
||||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
|
||||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
|
||||||
} else if (fromAssignmentId) {
|
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'place', assignment.id)
|
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
|
||||||
const tm = getMergedItems(day.id)
|
|
||||||
const toIdx = tm.findIndex(i => i.type === 'place' && i.data.id === assignment.id)
|
|
||||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(noteId), so).catch(err => toast.error(err.message))
|
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
|
||||||
} else if (noteId) {
|
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'place', assignment.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
|
||||||
onClick={() => { onPlaceClick(isPlaceSelected ? null : place.id); if (!isPlaceSelected) onSelectDay(day.id) }}
|
|
||||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '7px 8px 7px 10px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
background: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
|
||||||
borderLeft: hasReservation
|
|
||||||
? `3px solid ${isConfirmed ? '#10b981' : '#f59e0b'}`
|
|
||||||
: '3px solid transparent',
|
|
||||||
transition: 'background 0.1s',
|
|
||||||
opacity: isDraggingThis ? 0.4 : 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
|
||||||
</div>
|
|
||||||
<PlaceAvatar place={place} category={cat} size={28} />
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
|
||||||
{cat && (() => {
|
|
||||||
const CatIcon = getCategoryIcon(cat.icon)
|
|
||||||
return <CatIcon size={10} strokeWidth={2} color={cat.color || 'var(--text-muted)'} title={cat.name} style={{ flexShrink: 0 }} />
|
|
||||||
})()}
|
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
|
||||||
{place.name}
|
|
||||||
</span>
|
|
||||||
{place.place_time && (
|
|
||||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 3, flexShrink: 0, fontSize: 10, color: 'var(--text-faint)', fontWeight: 400, marginLeft: 6 }}>
|
|
||||||
<Clock size={9} strokeWidth={2} />
|
|
||||||
{formatTime(place.place_time, locale, timeFormat)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{(place.description || place.address || cat?.name) && !hasReservation && (
|
|
||||||
<div style={{ marginTop: 2 }}>
|
|
||||||
<span style={{ fontSize: 10, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
|
||||||
{place.description || place.address || cat?.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasReservation && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2, marginTop: 2 }}>
|
|
||||||
<span style={{ fontSize: 10, color: isConfirmed ? '#059669' : '#d97706', display: 'flex', alignItems: 'center', gap: 2, fontWeight: 600 }}>
|
|
||||||
{isConfirmed ? <><CheckCircle2 size={10} />
|
|
||||||
{place.reservation_datetime
|
|
||||||
? `Res. ${formatTime(new Date(place.reservation_datetime).toTimeString().slice(0,5), locale, timeFormat)}`
|
|
||||||
: place.place_time ? `Res. ${formatTime(place.place_time, locale, timeFormat)}` : t('dayplan.confirmed')}
|
|
||||||
</> : <><AlertCircle size={10} />{t('dayplan.pendingRes')}</>}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
|
||||||
<button onClick={moveUp} disabled={placeIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === 0 ? 'default' : 'pointer', color: placeIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
|
||||||
<ChevronUp size={12} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<button onClick={moveDown} disabled={placeIdx === placeItems.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: placeIdx === placeItems.length - 1 ? 'default' : 'pointer', color: placeIdx === placeItems.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
|
||||||
<ChevronDown size={12} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Notizkarte
|
|
||||||
const note = item.data
|
|
||||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
|
||||||
const NoteIcon = getNoteIcon(note.icon)
|
|
||||||
const noteIdx = idx
|
|
||||||
return (
|
|
||||||
<React.Fragment key={`note-${note.id}`}>
|
|
||||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
|
||||||
<div
|
|
||||||
draggable
|
|
||||||
onDragStart={e => { e.dataTransfer.setData('noteId', String(note.id)); e.dataTransfer.setData('fromDayId', String(day.id)); e.dataTransfer.effectAllowed = 'move'; dragDataRef.current = { noteId: String(note.id), fromDayId: String(day.id) }; setDraggingId(`note-${note.id}`) }}
|
|
||||||
onDragEnd={() => { setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
|
||||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDropTargetKey(`note-${note.id}`) }}
|
|
||||||
onDrop={e => {
|
|
||||||
e.preventDefault(); e.stopPropagation()
|
|
||||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
|
||||||
if (fromNoteId && fromDayId !== day.id) {
|
|
||||||
const tm = getMergedItems(day.id)
|
|
||||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
|
||||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
|
||||||
tripStore.moveDayNote(tripId, fromDayId, day.id, Number(fromNoteId), so).catch(err => toast.error(err.message))
|
|
||||||
setDraggingId(null); setDropTargetKey(null)
|
|
||||||
} else if (fromNoteId && fromNoteId !== String(note.id)) {
|
|
||||||
handleMergedDrop(day.id, 'note', Number(fromNoteId), 'note', note.id)
|
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
|
||||||
const tm = getMergedItems(day.id)
|
|
||||||
const noteIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
|
||||||
const toIdx = tm.slice(0, noteIdx).filter(i => i.type === 'place').length
|
|
||||||
tripStore.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch(err => toast.error(err.message))
|
|
||||||
setDraggingId(null); setDropTargetKey(null)
|
|
||||||
} else if (fromAssignmentId) {
|
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'note', note.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
|
||||||
onMouseLeave={() => setHoveredId(null)}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '7px 8px 7px 2px',
|
|
||||||
margin: '1px 8px',
|
|
||||||
borderRadius: 6,
|
|
||||||
border: '1px solid var(--border-faint)',
|
|
||||||
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
|
|
||||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
|
||||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
|
||||||
</div>
|
|
||||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
|
||||||
<NoteIcon size={13} strokeWidth={1.8} color="var(--text-muted)" />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<span style={{ fontSize: 12.5, fontWeight: 500, color: 'var(--text-primary)',
|
|
||||||
display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
|
||||||
{note.text}
|
|
||||||
</span>
|
|
||||||
{note.time && (
|
|
||||||
<div style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.2', marginTop: 2 }}>{note.time}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
|
||||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
|
||||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
|
||||||
</div>
|
|
||||||
<div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
|
||||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
|
||||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</React.Fragment>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
{/* Drop-Indikator am Listenende */}
|
|
||||||
{!!draggingId && dropTargetKey === `end-${day.id}` && (
|
|
||||||
<div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
|
||||||
{isSelected && getDayAssignments(day.id).length >= 2 && (
|
|
||||||
<div style={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
|
||||||
<div style={{ display: 'flex', background: 'var(--bg-hover)', borderRadius: 8, padding: 2, gap: 2 }}>
|
|
||||||
{TRANSPORT_MODES.map(m => (
|
|
||||||
<button key={m.value} onClick={() => setTransportMode(m.value)} style={{
|
|
||||||
flex: 1, padding: '4px 0', fontSize: 11, fontWeight: transportMode === m.value ? 600 : 400,
|
|
||||||
background: transportMode === m.value ? 'var(--bg-card)' : 'transparent',
|
|
||||||
border: 'none', borderRadius: 6, cursor: 'pointer', color: transportMode === m.value ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
boxShadow: transportMode === m.value ? '0 1px 3px rgba(0,0,0,0.1)' : 'none',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}>{m.label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{routeInfo && (
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, color: 'var(--text-secondary)', background: 'var(--bg-hover)', borderRadius: 8, padding: '5px 10px' }}>
|
|
||||||
<span>{routeInfo.distance}</span>
|
|
||||||
<span style={{ color: 'var(--text-faint)' }}>·</span>
|
|
||||||
<span>{routeInfo.duration}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
<button onClick={handleCalculateRoute} disabled={isCalculating} style={{
|
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--accent)', color: 'var(--accent-text)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
opacity: isCalculating ? 0.6 : 1,
|
|
||||||
}}>
|
|
||||||
<Navigation size={12} strokeWidth={2} />
|
|
||||||
{isCalculating ? t('dayplan.calculating') : t('dayplan.route')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleOptimize} style={{
|
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
|
||||||
background: 'var(--bg-hover)', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
<RotateCcw size={12} strokeWidth={2} />
|
|
||||||
{t('dayplan.optimize')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleGoogleMaps} style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
padding: '6px 10px', fontSize: 11, fontWeight: 500, borderRadius: 8,
|
|
||||||
border: '1px solid var(--border-faint)', background: 'transparent', color: 'var(--text-secondary)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}>
|
|
||||||
<ExternalLink size={12} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notiz-Popup-Modal — über Portal gerendert, um den backdropFilter-Stapelkontext zu umgehen */}
|
|
||||||
{Object.entries(noteUi).map(([dayId, ui]) => ui && ReactDOM.createPortal(
|
|
||||||
<div key={dayId} style={{
|
|
||||||
position: 'fixed', inset: 0, zIndex: 1000,
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
|
||||||
}} onClick={() => cancelNote(Number(dayId))}>
|
|
||||||
<div style={{
|
|
||||||
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
|
||||||
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
|
||||||
display: 'flex', flexDirection: 'column', gap: 12,
|
|
||||||
}} onClick={e => e.stopPropagation()}>
|
|
||||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
|
||||||
{ui.mode === 'add' ? t('dayplan.noteAdd') : t('dayplan.noteEdit')}
|
|
||||||
</div>
|
|
||||||
{/* Icon-Auswahl */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
|
||||||
{NOTE_ICONS.map(({ id, Icon }) => (
|
|
||||||
<button key={id} onClick={() => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], icon: id } }))}
|
|
||||||
title={id}
|
|
||||||
style={{ width: 34, height: 34, borderRadius: 8, border: ui.icon === id ? '2px solid var(--text-primary)' : '2px solid var(--border-faint)', background: ui.icon === id ? 'var(--bg-hover)' : 'transparent', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0 }}>
|
|
||||||
<Icon size={15} strokeWidth={1.8} color={ui.icon === id ? 'var(--text-primary)' : 'var(--text-muted)'} />
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={noteInputRef}
|
|
||||||
type="text"
|
|
||||||
value={ui.text}
|
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], text: e.target.value } }))}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
|
||||||
placeholder={t('dayplan.noteTitle')}
|
|
||||||
style={{ fontSize: 13, fontWeight: 500, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '8px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={ui.time}
|
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
|
||||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); saveNote(Number(dayId)) } if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
|
||||||
placeholder={t('dayplan.noteSubtitle')}
|
|
||||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', color: 'var(--text-primary)' }}
|
|
||||||
/>
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
||||||
<button onClick={() => cancelNote(Number(dayId))} style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
|
||||||
<button onClick={() => saveNote(Number(dayId))} style={{ fontSize: 12, background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit' }}>
|
|
||||||
{ui.mode === 'add' ? t('common.add') : t('common.save')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Budget-Fußzeile */}
|
|
||||||
{totalCost > 0 && (
|
|
||||||
<div style={{ flexShrink: 0, padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{t('dayplan.totalCost')}</span>
|
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{totalCost.toFixed(2)} {currency}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { mapsApi } from '../../api/client'
|
import { mapsApi } from '../../api/client'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Search } from 'lucide-react'
|
import { Search, Paperclip, X, AlertTriangle } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
import type { Place, Category, Assignment } from '../../types'
|
||||||
|
|
||||||
const TRANSPORT_MODES = [
|
interface PlaceFormData {
|
||||||
{ value: 'walking', labelKey: 'places.transport.walking' },
|
name: string
|
||||||
{ value: 'driving', labelKey: 'places.transport.driving' },
|
description: string
|
||||||
{ value: 'cycling', labelKey: 'places.transport.cycling' },
|
address: string
|
||||||
{ value: 'transit', labelKey: 'places.transport.transit' },
|
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: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -23,18 +31,29 @@ const DEFAULT_FORM = {
|
|||||||
lng: '',
|
lng: '',
|
||||||
category_id: '',
|
category_id: '',
|
||||||
place_time: '',
|
place_time: '',
|
||||||
|
end_time: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
transport_mode: 'walking',
|
transport_mode: 'walking',
|
||||||
reservation_status: 'none',
|
|
||||||
reservation_notes: '',
|
|
||||||
reservation_datetime: '',
|
|
||||||
website: '',
|
website: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlaceFormModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (data: PlaceFormData, files?: File[]) => Promise<void> | void
|
||||||
|
place: Place | null
|
||||||
|
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
|
||||||
|
tripId: number
|
||||||
|
categories: Category[]
|
||||||
|
onCategoryCreated: (category: Category) => void
|
||||||
|
assignmentId: number | null
|
||||||
|
dayAssignments?: Assignment[]
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlaceFormModal({
|
export default function PlaceFormModal({
|
||||||
isOpen, onClose, onSave, place, tripId, categories,
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||||
onCategoryCreated,
|
onCategoryCreated, assignmentId, dayAssignments = [],
|
||||||
}) {
|
}: PlaceFormModalProps) {
|
||||||
const [form, setForm] = useState(DEFAULT_FORM)
|
const [form, setForm] = useState(DEFAULT_FORM)
|
||||||
const [mapsSearch, setMapsSearch] = useState('')
|
const [mapsSearch, setMapsSearch] = useState('')
|
||||||
const [mapsResults, setMapsResults] = useState([])
|
const [mapsResults, setMapsResults] = useState([])
|
||||||
@@ -42,8 +61,11 @@ export default function PlaceFormModal({
|
|||||||
const [newCategoryName, setNewCategoryName] = useState('')
|
const [newCategoryName, setNewCategoryName] = useState('')
|
||||||
const [showNewCategory, setShowNewCategory] = useState(false)
|
const [showNewCategory, setShowNewCategory] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
const fileRef = useRef(null)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
|
const { hasMapsKey } = useAuthStore()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
@@ -55,17 +77,24 @@ export default function PlaceFormModal({
|
|||||||
lng: place.lng || '',
|
lng: place.lng || '',
|
||||||
category_id: place.category_id || '',
|
category_id: place.category_id || '',
|
||||||
place_time: place.place_time || '',
|
place_time: place.place_time || '',
|
||||||
|
end_time: place.end_time || '',
|
||||||
notes: place.notes || '',
|
notes: place.notes || '',
|
||||||
transport_mode: place.transport_mode || 'walking',
|
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 || '',
|
website: place.website || '',
|
||||||
})
|
})
|
||||||
|
} else if (prefillCoords) {
|
||||||
|
setForm({
|
||||||
|
...DEFAULT_FORM,
|
||||||
|
lat: String(prefillCoords.lat),
|
||||||
|
lng: String(prefillCoords.lng),
|
||||||
|
name: prefillCoords.name || '',
|
||||||
|
address: prefillCoords.address || '',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
}, [place, isOpen])
|
setPendingFiles([])
|
||||||
|
}, [place, prefillCoords, isOpen])
|
||||||
|
|
||||||
const handleChange = (field, value) => {
|
const handleChange = (field, value) => {
|
||||||
setForm(prev => ({ ...prev, [field]: value }))
|
setForm(prev => ({ ...prev, [field]: value }))
|
||||||
@@ -75,9 +104,27 @@ export default function PlaceFormModal({
|
|||||||
if (!mapsSearch.trim()) return
|
if (!mapsSearch.trim()) return
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
try {
|
||||||
|
// Detect Google Maps URLs and resolve them directly
|
||||||
|
const trimmed = mapsSearch.trim()
|
||||||
|
if (trimmed.match(/^https?:\/\/(www\.)?(google\.[a-z.]+\/maps|maps\.google\.[a-z.]+|maps\.app\.goo\.gl|goo\.gl)/i)) {
|
||||||
|
const resolved = await mapsApi.resolveUrl(trimmed)
|
||||||
|
if (resolved.lat && resolved.lng) {
|
||||||
|
setForm(prev => ({
|
||||||
|
...prev,
|
||||||
|
name: resolved.name || prev.name,
|
||||||
|
address: resolved.address || prev.address,
|
||||||
|
lat: String(resolved.lat),
|
||||||
|
lng: String(resolved.lng),
|
||||||
|
}))
|
||||||
|
setMapsResults([])
|
||||||
|
setMapsSearch('')
|
||||||
|
toast.success(t('places.urlResolved'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
const result = await mapsApi.search(mapsSearch, language)
|
const result = await mapsApi.search(mapsSearch, language)
|
||||||
setMapsResults(result.places || [])
|
setMapsResults(result.places || [])
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(t('places.mapsSearchError'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSearchingMaps(false)
|
setIsSearchingMaps(false)
|
||||||
@@ -92,6 +139,9 @@ export default function PlaceFormModal({
|
|||||||
lat: result.lat || prev.lat,
|
lat: result.lat || prev.lat,
|
||||||
lng: result.lng || prev.lng,
|
lng: result.lng || prev.lng,
|
||||||
google_place_id: result.google_place_id || prev.google_place_id,
|
google_place_id: result.google_place_id || prev.google_place_id,
|
||||||
|
osm_id: result.osm_id || prev.osm_id,
|
||||||
|
website: result.website || prev.website,
|
||||||
|
phone: result.phone || prev.phone,
|
||||||
}))
|
}))
|
||||||
setMapsResults([])
|
setMapsResults([])
|
||||||
setMapsSearch('')
|
setMapsSearch('')
|
||||||
@@ -104,11 +154,37 @@ export default function PlaceFormModal({
|
|||||||
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
|
if (cat) setForm(prev => ({ ...prev, category_id: cat.id }))
|
||||||
setNewCategoryName('')
|
setNewCategoryName('')
|
||||||
setShowNewCategory(false)
|
setShowNewCategory(false)
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(t('places.categoryCreateError'))
|
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) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.name.trim()) {
|
if (!form.name.trim()) {
|
||||||
@@ -122,10 +198,11 @@ export default function PlaceFormModal({
|
|||||||
lat: form.lat ? parseFloat(form.lat) : null,
|
lat: form.lat ? parseFloat(form.lat) : null,
|
||||||
lng: form.lng ? parseFloat(form.lng) : null,
|
lng: form.lng ? parseFloat(form.lng) : null,
|
||||||
category_id: form.category_id || null,
|
category_id: form.category_id || null,
|
||||||
|
_pendingFiles: pendingFiles.length > 0 ? pendingFiles : undefined,
|
||||||
})
|
})
|
||||||
onClose()
|
onClose()
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.message || t('places.saveError'))
|
toast.error(err instanceof Error ? err.message : t('places.saveError'))
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
@@ -138,9 +215,14 @@ export default function PlaceFormModal({
|
|||||||
title={place ? t('places.editPlace') : t('places.addPlace')}
|
title={place ? t('places.editPlace') : t('places.addPlace')}
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4" onPaste={handlePaste}>
|
||||||
{/* Google Maps Search */}
|
{/* Place Search */}
|
||||||
<div className="bg-slate-50 rounded-xl p-3 border border-slate-200">
|
<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="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -217,6 +299,15 @@ export default function PlaceFormModal({
|
|||||||
step="any"
|
step="any"
|
||||||
value={form.lat}
|
value={form.lat}
|
||||||
onChange={e => handleChange('lat', e.target.value)}
|
onChange={e => handleChange('lat', e.target.value)}
|
||||||
|
onPaste={e => {
|
||||||
|
const text = e.clipboardData.getData('text').trim()
|
||||||
|
const match = text.match(/^(-?\d+\.?\d*)\s*[,;\s]\s*(-?\d+\.?\d*)$/)
|
||||||
|
if (match) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleChange('lat', match[1])
|
||||||
|
handleChange('lng', match[2])
|
||||||
|
}
|
||||||
|
}}
|
||||||
placeholder={t('places.formLat')}
|
placeholder={t('places.formLat')}
|
||||||
className="form-input"
|
className="form-input"
|
||||||
/>
|
/>
|
||||||
@@ -270,14 +361,17 @@ export default function PlaceFormModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time */}
|
{/* Time — only shown when editing, not when creating */}
|
||||||
<div>
|
{place && (
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">{t('places.formTime')}</label>
|
<TimeSection
|
||||||
<CustomTimePicker
|
form={form}
|
||||||
value={form.place_time}
|
handleChange={handleChange}
|
||||||
onChange={v => handleChange('place_time', v)}
|
assignmentId={assignmentId}
|
||||||
|
dayAssignments={dayAssignments}
|
||||||
|
hasTimeError={hasTimeError}
|
||||||
|
t={t}
|
||||||
/>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Website */}
|
{/* Website */}
|
||||||
<div>
|
<div>
|
||||||
@@ -291,45 +385,35 @@ export default function PlaceFormModal({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reservation */}
|
{/* File Attachments */}
|
||||||
<div className="border border-gray-200 rounded-xl p-3 space-y-3">
|
{true && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="border border-gray-200 rounded-xl p-3 space-y-2">
|
||||||
<label className="block text-sm font-medium text-gray-700">{t('places.formReservation')}</label>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<label className="block text-sm font-medium text-gray-700">{t('files.title')}</label>
|
||||||
{['none', 'pending', 'confirmed'].map(status => (
|
<button type="button" onClick={() => fileRef.current?.click()}
|
||||||
<button
|
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-700 transition-colors">
|
||||||
key={status}
|
<Paperclip size={12} /> {t('files.attach')}
|
||||||
type="button"
|
</button>
|
||||||
onClick={() => handleChange('reservation_status', status)}
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-full transition-colors ${
|
|
||||||
form.reservation_status === status
|
|
||||||
? status === 'confirmed' ? 'bg-emerald-600 text-white'
|
|
||||||
: status === 'pending' ? 'bg-yellow-500 text-white'
|
|
||||||
: 'bg-gray-600 text-white'
|
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{status === 'none' ? t('common.none') : status === 'pending' ? t('reservations.pending') : t('reservations.confirmed')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<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>
|
</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 */}
|
{/* Actions */}
|
||||||
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
<div className="flex justify-end gap-3 pt-2 border-t border-gray-100">
|
||||||
@@ -342,7 +426,7 @@ export default function PlaceFormModal({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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')}
|
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||||
@@ -352,3 +436,71 @@ export default function PlaceFormModal({
|
|||||||
</Modal>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,472 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
||||||
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, CheckCircle2, AlertCircle, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation } from 'lucide-react'
|
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
|
||||||
import { mapsApi } from '../../api/client'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
|
|
||||||
const detailsCache = new Map()
|
|
||||||
|
|
||||||
function getSessionCache(key) {
|
|
||||||
try {
|
|
||||||
const raw = sessionStorage.getItem(key)
|
|
||||||
return raw ? JSON.parse(raw) : undefined
|
|
||||||
} catch { return undefined }
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSessionCache(key, value) {
|
|
||||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function useGoogleDetails(googlePlaceId, language) {
|
|
||||||
const [details, setDetails] = useState(null)
|
|
||||||
const cacheKey = `gdetails_${googlePlaceId}_${language}`
|
|
||||||
useEffect(() => {
|
|
||||||
if (!googlePlaceId) { setDetails(null); return }
|
|
||||||
// In-memory cache (fastest)
|
|
||||||
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
|
|
||||||
// sessionStorage cache (survives reload)
|
|
||||||
const cached = getSessionCache(cacheKey)
|
|
||||||
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
|
|
||||||
// Fetch from API
|
|
||||||
mapsApi.details(googlePlaceId, language).then(data => {
|
|
||||||
detailsCache.set(cacheKey, data.place)
|
|
||||||
setSessionCache(cacheKey, data.place)
|
|
||||||
setDetails(data.place)
|
|
||||||
}).catch(() => {})
|
|
||||||
}, [googlePlaceId, language])
|
|
||||||
return details
|
|
||||||
}
|
|
||||||
|
|
||||||
function getWeekdayIndex(dateStr) {
|
|
||||||
// weekdayDescriptions[0] = Monday … [6] = Sunday
|
|
||||||
const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date()
|
|
||||||
const jsDay = d.getDay()
|
|
||||||
return jsDay === 0 ? 6 : jsDay - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
function convertHoursLine(line, timeFormat) {
|
|
||||||
if (!line) return ''
|
|
||||||
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
|
|
||||||
|
|
||||||
if (timeFormat === '12h' && !hasAmPm) {
|
|
||||||
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
|
|
||||||
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
|
|
||||||
const hour = parseInt(h)
|
|
||||||
if (isNaN(hour)) return match
|
|
||||||
const period = hour >= 12 ? 'PM' : 'AM'
|
|
||||||
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
|
|
||||||
return `${h12}:${m} ${period}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (timeFormat !== '12h' && hasAmPm) {
|
|
||||||
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
|
|
||||||
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
|
|
||||||
let hour = parseInt(h)
|
|
||||||
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
|
|
||||||
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
|
|
||||||
return `${String(hour).padStart(2, '0')}:${m}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(timeStr, locale, timeFormat) {
|
|
||||||
if (!timeStr) return ''
|
|
||||||
try {
|
|
||||||
const [h, m] = timeStr.split(':').map(Number)
|
|
||||||
if (timeFormat === '12h') {
|
|
||||||
const period = h >= 12 ? 'PM' : 'AM'
|
|
||||||
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
||||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
|
||||||
}
|
|
||||||
const str = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
|
|
||||||
return locale?.startsWith('de') ? `${str} Uhr` : str
|
|
||||||
} catch { return timeStr }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReservationDatetime(dt, locale, timeFormat) {
|
|
||||||
if (!dt) return null
|
|
||||||
try {
|
|
||||||
const d = new Date(dt)
|
|
||||||
if (isNaN(d)) return dt
|
|
||||||
const datePart = d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
|
||||||
const timePart = formatTime(`${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`, locale, timeFormat)
|
|
||||||
return `${datePart}, ${timePart}`
|
|
||||||
} catch { return dt }
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (!bytes) return ''
|
|
||||||
if (bytes < 1024) return `${bytes} B`
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
||||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PlaceInspector({
|
|
||||||
place, categories, days, selectedDayId, assignments,
|
|
||||||
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
|
||||||
files, onFileUpload,
|
|
||||||
}) {
|
|
||||||
const { t, locale, language } = useTranslation()
|
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
|
||||||
const [hoursExpanded, setHoursExpanded] = useState(false)
|
|
||||||
const [filesExpanded, setFilesExpanded] = useState(false)
|
|
||||||
const [isUploading, setIsUploading] = useState(false)
|
|
||||||
const fileInputRef = useRef(null)
|
|
||||||
const googleDetails = useGoogleDetails(place?.google_place_id, language)
|
|
||||||
|
|
||||||
if (!place) return null
|
|
||||||
|
|
||||||
const category = categories?.find(c => c.id === place.category_id)
|
|
||||||
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
|
||||||
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
|
||||||
|
|
||||||
const openingHours = googleDetails?.opening_hours || null
|
|
||||||
const openNow = googleDetails?.open_now ?? null
|
|
||||||
const selectedDay = days?.find(d => d.id === selectedDayId)
|
|
||||||
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
|
||||||
|
|
||||||
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id))
|
|
||||||
|
|
||||||
const handleFileUpload = useCallback(async (e) => {
|
|
||||||
const selectedFiles = Array.from(e.target.files || [])
|
|
||||||
if (!selectedFiles.length || !onFileUpload) return
|
|
||||||
setIsUploading(true)
|
|
||||||
try {
|
|
||||||
for (const file of selectedFiles) {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
fd.append('place_id', place.id)
|
|
||||||
await onFileUpload(fd)
|
|
||||||
}
|
|
||||||
setFilesExpanded(true)
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Upload failed', err)
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false)
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
|
||||||
}
|
|
||||||
}, [onFileUpload, place.id])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 20,
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: 'min(800px, calc(100vw - 32px))',
|
|
||||||
zIndex: 50,
|
|
||||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{
|
|
||||||
background: 'var(--bg-elevated)',
|
|
||||||
backdropFilter: 'blur(40px) saturate(180%)',
|
|
||||||
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
|
||||||
borderRadius: 20,
|
|
||||||
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
|
|
||||||
overflow: 'hidden',
|
|
||||||
maxHeight: '60vh',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
}}>
|
|
||||||
{/* Header */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
|
||||||
{/* Avatar with open/closed ring + tag */}
|
|
||||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
|
||||||
<div style={{
|
|
||||||
borderRadius: '50%', padding: 2.5,
|
|
||||||
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
|
|
||||||
}}>
|
|
||||||
<PlaceAvatar place={place} category={category} size={52} />
|
|
||||||
</div>
|
|
||||||
{openNow !== null && (
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
|
|
||||||
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
|
|
||||||
color: 'white',
|
|
||||||
background: openNow ? '#16a34a' : '#dc2626',
|
|
||||||
padding: '1.5px 7px', borderRadius: 99,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
|
||||||
}}>
|
|
||||||
{openNow ? t('inspector.opened') : t('inspector.closed')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
|
||||||
<span style={{ fontWeight: 600, fontSize: 15, color: 'var(--text-primary)', lineHeight: '1.3' }}>{place.name}</span>
|
|
||||||
{category && (() => {
|
|
||||||
const CatIcon = getCategoryIcon(category.icon)
|
|
||||||
return (
|
|
||||||
<span style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 4,
|
|
||||||
fontSize: 11, fontWeight: 500,
|
|
||||||
color: category.color || '#6b7280',
|
|
||||||
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
|
|
||||||
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
|
|
||||||
padding: '2px 8px', borderRadius: 99,
|
|
||||||
}}>
|
|
||||||
<CatIcon size={10} />
|
|
||||||
{category.name}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
{place.address && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
|
|
||||||
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4' }}>{place.address}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{place.place_time && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginTop: 3 }}>
|
|
||||||
<Clock size={10} color="var(--text-faint)" style={{ flexShrink: 0 }} />
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{formatTime(place.place_time, locale, timeFormat)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{place.lat && place.lng && (
|
|
||||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 4, fontVariantNumeric: 'tabular-nums' }}>
|
|
||||||
{Number(place.lat).toFixed(6)}, {Number(place.lng).toFixed(6)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
|
||||||
>
|
|
||||||
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content — scrollable */}
|
|
||||||
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
|
||||||
|
|
||||||
{/* Info-Chips */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
|
||||||
{googleDetails?.rating && (() => {
|
|
||||||
const shortReview = (googleDetails.reviews || []).find(r => r.text && r.text.length > 5)
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
|
||||||
text={<>
|
|
||||||
{googleDetails.rating.toFixed(1)}
|
|
||||||
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString('de-DE')})</span> : ''}
|
|
||||||
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
|
||||||
</>}
|
|
||||||
color="var(--text-secondary)" bg="var(--bg-hover)"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{place.price > 0 && (
|
|
||||||
<Chip icon={<Euro size={12} />} text={`${place.price} ${place.currency || '€'}`} color="#059669" bg="#ecfdf5" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Telefon */}
|
|
||||||
{place.phone && (
|
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
|
||||||
<a href={`tel:${place.phone}`}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
|
||||||
<Phone size={12} /> {place.phone}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Description + Reservation in one box */}
|
|
||||||
{(place.description || place.notes || (place.reservation_status && place.reservation_status !== 'none')) && (
|
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
|
||||||
{(place.description || place.notes) && (
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px',
|
|
||||||
borderBottom: (place.reservation_status && place.reservation_status !== 'none') ? '1px solid var(--border-faint)' : 'none'
|
|
||||||
}}>
|
|
||||||
{place.description || place.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{place.reservation_status && place.reservation_status !== 'none' && (
|
|
||||||
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 3 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flexWrap: 'wrap' }}>
|
|
||||||
{place.reservation_status === 'confirmed'
|
|
||||||
? <CheckCircle2 size={12} color="#059669" />
|
|
||||||
: <AlertCircle size={12} color="#d97706" />
|
|
||||||
}
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: place.reservation_status === 'confirmed' ? '#059669' : '#d97706' }}>
|
|
||||||
{place.reservation_status === 'confirmed' ? t('inspector.confirmedRes') : t('inspector.pendingRes')}
|
|
||||||
</span>
|
|
||||||
{(place.reservation_datetime || place.place_time) && (
|
|
||||||
<>
|
|
||||||
<span style={{ fontSize: 11, color: '#d1d5db' }}>·</span>
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>
|
|
||||||
{place.reservation_datetime
|
|
||||||
? formatReservationDatetime(place.reservation_datetime, locale, timeFormat)
|
|
||||||
: formatTime(place.place_time, locale, timeFormat)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{place.reservation_notes && (
|
|
||||||
<p style={{ fontSize: 12, color: 'var(--text-faint)', margin: 0, lineHeight: '1.5', paddingLeft: 17 }}>{place.reservation_notes}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Opening hours */}
|
|
||||||
{openingHours && openingHours.length > 0 && (
|
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setHoursExpanded(h => !h)}
|
|
||||||
style={{
|
|
||||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
||||||
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<Clock size={13} color="#9ca3af" />
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
|
||||||
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
|
|
||||||
</button>
|
|
||||||
{hoursExpanded && (
|
|
||||||
<div style={{ padding: '0 12px 10px' }}>
|
|
||||||
{openingHours.map((line, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
|
|
||||||
fontWeight: i === weekdayIndex ? 600 : 400,
|
|
||||||
padding: '2px 0',
|
|
||||||
}}>{convertHoursLine(line, timeFormat)}</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
{/* Files section */}
|
|
||||||
{(placeFiles.length > 0 || onFileUpload) && (
|
|
||||||
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
|
|
||||||
<button
|
|
||||||
onClick={() => setFilesExpanded(f => !f)}
|
|
||||||
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
|
|
||||||
>
|
|
||||||
<FileText size={13} color="#9ca3af" />
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
|
||||||
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
|
|
||||||
</span>
|
|
||||||
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
|
|
||||||
</button>
|
|
||||||
{onFileUpload && (
|
|
||||||
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
|
||||||
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
|
|
||||||
{isUploading ? (
|
|
||||||
<span style={{ fontSize: 11 }}>…</span>
|
|
||||||
) : (
|
|
||||||
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{filesExpanded && placeFiles.length > 0 && (
|
|
||||||
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{placeFiles.map(f => (
|
|
||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
{(f.mime_type || '').startsWith('image/') ? <FileImage size={12} color="#6b7280" /> : <File size={12} color="#6b7280" />}
|
|
||||||
<span style={{ fontSize: 12, color: 'var(--text-secondary)', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
|
||||||
{f.file_size && <span style={{ fontSize: 11, color: 'var(--text-faint)', flexShrink: 0 }}>{formatFileSize(f.file_size)}</span>}
|
|
||||||
<a
|
|
||||||
href={`/uploads/files/${f.filename}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex' }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={11} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer actions */}
|
|
||||||
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
|
||||||
{selectedDayId && (
|
|
||||||
assignmentInDay ? (
|
|
||||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
|
||||||
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} />
|
|
||||||
) : (
|
|
||||||
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
{googleDetails?.google_maps_url && (
|
|
||||||
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
|
||||||
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
|
||||||
)}
|
|
||||||
{place.website && (
|
|
||||||
<ActionButton onClick={() => window.open(place.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
|
||||||
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
|
||||||
)}
|
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
<ActionButton onClick={onEdit} variant="ghost" icon={<Edit2 size={13} />} label={<span className="hidden sm:inline">{t('common.edit')}</span>} />
|
|
||||||
<ActionButton onClick={onDelete} variant="danger" icon={<Trash2 size={13} />} label={<span className="hidden sm:inline">{t('common.delete')}</span>} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Chip({ icon, text, color = 'var(--text-secondary)', bg = 'var(--bg-hover)' }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '3px 9px', borderRadius: 99, background: bg, color, fontSize: 12, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
|
|
||||||
<span style={{ flexShrink: 0, display: 'flex' }}>{icon}</span>
|
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ icon, children }) {
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<div style={{ flexShrink: 0 }}>{icon}</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActionButton({ onClick, variant, icon, label }) {
|
|
||||||
const base = {
|
|
||||||
primary: { background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', hoverBg: 'var(--text-secondary)' },
|
|
||||||
ghost: { background: 'var(--bg-hover)', color: 'var(--text-secondary)', border: 'none', hoverBg: 'var(--bg-tertiary)' },
|
|
||||||
danger: { background: 'rgba(239,68,68,0.08)', color: '#dc2626', border: 'none', hoverBg: 'rgba(239,68,68,0.16)' },
|
|
||||||
}
|
|
||||||
const s = base[variant] || base.ghost
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 5,
|
|
||||||
padding: '6px 12px', borderRadius: 10, minHeight: 30,
|
|
||||||
fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', transition: 'background 0.15s, opacity 0.15s',
|
|
||||||
background: s.background, color: s.color, border: s.border,
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.background = s.hoverBg}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.background = s.background}
|
|
||||||
>
|
|
||||||
{icon}{label}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,720 @@
|
|||||||
|
import React, { useState, useEffect, useRef, useCallback } from '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()
|
||||||
|
|
||||||
|
function getSessionCache(key) {
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(key)
|
||||||
|
return raw ? JSON.parse(raw) : undefined
|
||||||
|
} catch { return undefined }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionCache(key, value) {
|
||||||
|
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function usePlaceDetails(googlePlaceId, osmId, language) {
|
||||||
|
const [details, setDetails] = useState(null)
|
||||||
|
const detailId = googlePlaceId || osmId
|
||||||
|
const cacheKey = `gdetails_${detailId}_${language}`
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailId) { setDetails(null); return }
|
||||||
|
if (detailsCache.has(cacheKey)) { setDetails(detailsCache.get(cacheKey)); return }
|
||||||
|
const cached = getSessionCache(cacheKey)
|
||||||
|
if (cached) { detailsCache.set(cacheKey, cached); setDetails(cached); return }
|
||||||
|
mapsApi.details(detailId, language).then(data => {
|
||||||
|
detailsCache.set(cacheKey, data.place)
|
||||||
|
setSessionCache(cacheKey, data.place)
|
||||||
|
setDetails(data.place)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [detailId, language])
|
||||||
|
return details
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekdayIndex(dateStr) {
|
||||||
|
// weekdayDescriptions[0] = Monday … [6] = Sunday
|
||||||
|
const d = dateStr ? new Date(dateStr + 'T12:00:00') : new Date()
|
||||||
|
const jsDay = d.getDay()
|
||||||
|
return jsDay === 0 ? 6 : jsDay - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertHoursLine(line, timeFormat) {
|
||||||
|
if (!line) return ''
|
||||||
|
const hasAmPm = /\d{1,2}:\d{2}\s*(AM|PM)/i.test(line)
|
||||||
|
|
||||||
|
if (timeFormat === '12h' && !hasAmPm) {
|
||||||
|
// 24h → 12h: "10:00" → "10:00 AM", "21:00" → "9:00 PM", "Uhr" entfernen
|
||||||
|
return line.replace(/\s*Uhr/g, '').replace(/(\d{1,2}):(\d{2})/g, (match, h, m) => {
|
||||||
|
const hour = parseInt(h)
|
||||||
|
if (isNaN(hour)) return match
|
||||||
|
const period = hour >= 12 ? 'PM' : 'AM'
|
||||||
|
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
|
||||||
|
return `${h12}:${m} ${period}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (timeFormat !== '12h' && hasAmPm) {
|
||||||
|
// 12h → 24h: "10:00 AM" → "10:00", "9:00 PM" → "21:00"
|
||||||
|
return line.replace(/(\d{1,2}):(\d{2})\s*(AM|PM)/gi, (_, h, m, p) => {
|
||||||
|
let hour = parseInt(h)
|
||||||
|
if (p.toUpperCase() === 'PM' && hour !== 12) hour += 12
|
||||||
|
if (p.toUpperCase() === 'AM' && hour === 12) hour = 0
|
||||||
|
return `${String(hour).padStart(2, '0')}:${m}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(timeStr, locale, timeFormat) {
|
||||||
|
if (!timeStr) return ''
|
||||||
|
try {
|
||||||
|
const 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
|
||||||
|
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 formatFileSize(bytes) {
|
||||||
|
if (!bytes) return ''
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
leftWidth?: number
|
||||||
|
rightWidth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlaceInspector({
|
||||||
|
place, categories, days, selectedDayId, selectedAssignmentId, assignments, reservations = [],
|
||||||
|
onClose, onEdit, onDelete, onAssignToDay, onRemoveAssignment,
|
||||||
|
files, onFileUpload, tripMembers = [], onSetParticipants, onUpdatePlace,
|
||||||
|
leftWidth = 0, rightWidth = 0,
|
||||||
|
}: 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 = usePlaceDetails(place?.google_place_id, place?.osm_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)
|
||||||
|
const dayAssignments = selectedDayId ? (assignments[String(selectedDayId)] || []) : []
|
||||||
|
const assignmentInDay = selectedDayId ? dayAssignments.find(a => a.place?.id === place.id) : null
|
||||||
|
|
||||||
|
const openingHours = googleDetails?.opening_hours || null
|
||||||
|
const openNow = googleDetails?.open_now ?? null
|
||||||
|
const selectedDay = days?.find(d => d.id === selectedDayId)
|
||||||
|
const weekdayIndex = getWeekdayIndex(selectedDay?.date)
|
||||||
|
|
||||||
|
const placeFiles = (files || []).filter(f => String(f.place_id) === String(place.id) || (f.linked_place_ids || []).includes(place.id))
|
||||||
|
|
||||||
|
const handleFileUpload = useCallback(async (e) => {
|
||||||
|
const selectedFiles = Array.from((e.target as HTMLInputElement).files || [])
|
||||||
|
if (!selectedFiles.length || !onFileUpload) return
|
||||||
|
setIsUploading(true)
|
||||||
|
try {
|
||||||
|
for (const file of selectedFiles) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('place_id', place.id)
|
||||||
|
await onFileUpload(fd)
|
||||||
|
}
|
||||||
|
setFilesExpanded(true)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error('Upload failed', err)
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false)
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||||
|
}
|
||||||
|
}, [onFileUpload, place.id])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 20,
|
||||||
|
left: `calc(${leftWidth}px + (100% - ${leftWidth}px - ${rightWidth}px) / 2)`,
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: `min(800px, calc(100% - ${leftWidth}px - ${rightWidth}px - 32px))`,
|
||||||
|
zIndex: 50,
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--bg-elevated)',
|
||||||
|
backdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
WebkitBackdropFilter: 'blur(40px) saturate(180%)',
|
||||||
|
borderRadius: 20,
|
||||||
|
boxShadow: '0 8px 40px rgba(0,0,0,0.14), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
maxHeight: '60vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
}}>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: openNow !== null ? 26 : 14, padding: openNow !== null ? '18px 16px 14px 28px' : '18px 16px 14px', borderBottom: '1px solid var(--border-faint)' }}>
|
||||||
|
{/* Avatar with open/closed ring + tag */}
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||||
|
<div style={{
|
||||||
|
borderRadius: '50%', padding: 2.5,
|
||||||
|
background: openNow === true ? '#22c55e' : openNow === false ? '#ef4444' : 'transparent',
|
||||||
|
}}>
|
||||||
|
<PlaceAvatar place={place} category={category} size={52} />
|
||||||
|
</div>
|
||||||
|
{openNow !== null && (
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', bottom: -7, left: '50%', transform: 'translateX(-50%)',
|
||||||
|
fontSize: 9, fontWeight: 500, letterSpacing: '0.02em',
|
||||||
|
color: 'white',
|
||||||
|
background: openNow ? '#16a34a' : '#dc2626',
|
||||||
|
padding: '1.5px 7px', borderRadius: 99,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: '0 1px 4px rgba(0,0,0,0.2)',
|
||||||
|
}}>
|
||||||
|
{openNow ? t('inspector.opened') : t('inspector.closed')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
{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 (
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 4,
|
||||||
|
fontSize: 11, fontWeight: 500,
|
||||||
|
color: category.color || '#6b7280',
|
||||||
|
background: category.color ? `${category.color}18` : 'rgba(0,0,0,0.06)',
|
||||||
|
border: `1px solid ${category.color ? `${category.color}30` : 'transparent'}`,
|
||||||
|
padding: '2px 8px', borderRadius: 99,
|
||||||
|
}}>
|
||||||
|
<CatIcon size={10} />
|
||||||
|
<span className="hidden sm:inline">{category.name}</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
{place.address && (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, marginTop: 6 }}>
|
||||||
|
<MapPin size={11} color="var(--text-faint)" style={{ flexShrink: 0, marginTop: 2 }} />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.4', 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)}{place.end_time ? ` – ${formatTime(place.end_time, locale, timeFormat)}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{place.lat && place.lng && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: '50%', background: 'var(--bg-hover)', border: 'none', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', flexShrink: 0, alignSelf: 'flex-start', transition: 'background 0.15s' }}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||||
|
>
|
||||||
|
<X size={14} strokeWidth={2} color="var(--text-secondary)" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content — scrollable */}
|
||||||
|
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
|
||||||
|
{/* Info-Chips — 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 (
|
||||||
|
<Chip
|
||||||
|
icon={<Star size={12} fill="#facc15" color="#facc15" />}
|
||||||
|
text={<>
|
||||||
|
{googleDetails.rating.toFixed(1)}
|
||||||
|
{googleDetails.rating_count ? <span style={{ opacity: 0.5 }}> ({googleDetails.rating_count.toLocaleString(locale)})</span> : ''}
|
||||||
|
{shortReview && <span className="hidden md:inline" style={{ opacity: 0.6, fontWeight: 400, fontStyle: 'italic', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}> · „{shortReview.text}"</span>}
|
||||||
|
</>}
|
||||||
|
color="var(--text-secondary)" bg="var(--bg-hover)"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{place.price > 0 && (
|
||||||
|
<Chip icon={<Euro size={12} />} text={`${place.price} ${place.currency || '€'}`} color="#059669" bg="#ecfdf5" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Telefon */}
|
||||||
|
{(place.phone || googleDetails?.phone) && (
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<a href={`tel:${place.phone || googleDetails.phone}`}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 12, color: 'var(--text-primary)', textDecoration: 'none' }}>
|
||||||
|
<Phone size={12} /> {place.phone || googleDetails.phone}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description / Summary */}
|
||||||
|
{(place.description || place.notes || googleDetails?.summary) && (
|
||||||
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text-muted)', margin: 0, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
|
{place.description || place.notes || googleDetails?.summary}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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>}
|
||||||
|
{(() => {
|
||||||
|
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||||
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
|
const parts: string[] = []
|
||||||
|
if (meta.airline && meta.flight_number) parts.push(`${meta.airline} ${meta.flight_number}`)
|
||||||
|
else if (meta.flight_number) parts.push(meta.flight_number)
|
||||||
|
if (meta.departure_airport && meta.arrival_airport) parts.push(`${meta.departure_airport} → ${meta.arrival_airport}`)
|
||||||
|
if (meta.train_number) parts.push(meta.train_number)
|
||||||
|
if (meta.platform) parts.push(`Gl. ${meta.platform}`)
|
||||||
|
if (meta.check_in_time) parts.push(`Check-in ${meta.check_in_time}`)
|
||||||
|
if (meta.check_out_time) parts.push(`Check-out ${meta.check_out_time}`)
|
||||||
|
if (parts.length === 0) return null
|
||||||
|
return <div style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 500 }}>{parts.join(' · ')}</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
|
||||||
|
onClick={() => setHoursExpanded(h => !h)}
|
||||||
|
style={{
|
||||||
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
|
padding: '8px 12px', background: 'none', border: 'none', cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<Clock size={13} color="#9ca3af" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
||||||
|
{hoursExpanded ? t('inspector.openingHours') : (convertHoursLine(openingHours[weekdayIndex] || '', timeFormat) || t('inspector.showHours'))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{hoursExpanded ? <ChevronUp size={13} color="#9ca3af" /> : <ChevronDown size={13} color="#9ca3af" />}
|
||||||
|
</button>
|
||||||
|
{hoursExpanded && (
|
||||||
|
<div style={{ padding: '0 12px 10px' }}>
|
||||||
|
{openingHours.map((line, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
fontSize: 12, color: i === weekdayIndex ? 'var(--text-primary)' : 'var(--text-muted)',
|
||||||
|
fontWeight: i === weekdayIndex ? 600 : 400,
|
||||||
|
padding: '2px 0',
|
||||||
|
}}>{convertHoursLine(line, timeFormat)}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
{/* Files section */}
|
||||||
|
{(placeFiles.length > 0 || onFileUpload) && (
|
||||||
|
<div style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', padding: '8px 12px', gap: 6 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilesExpanded(f => !f)}
|
||||||
|
style={{ flex: 1, display: 'flex', alignItems: 'center', gap: 6, background: 'none', border: 'none', cursor: 'pointer', padding: 0, fontFamily: 'inherit', textAlign: 'left' }}
|
||||||
|
>
|
||||||
|
<FileText size={13} color="#9ca3af" />
|
||||||
|
<span style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500 }}>
|
||||||
|
{placeFiles.length > 0 ? t('inspector.filesCount', { count: placeFiles.length }) : t('inspector.files')}
|
||||||
|
</span>
|
||||||
|
{filesExpanded ? <ChevronUp size={12} color="#9ca3af" /> : <ChevronDown size={12} color="#9ca3af" />}
|
||||||
|
</button>
|
||||||
|
{onFileUpload && (
|
||||||
|
<label style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 4, fontSize: 11, color: 'var(--text-muted)', padding: '2px 6px', borderRadius: 6, background: 'var(--bg-tertiary)' }}>
|
||||||
|
<input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={handleFileUpload} />
|
||||||
|
{isUploading ? (
|
||||||
|
<span style={{ fontSize: 11 }}>…</span>
|
||||||
|
) : (
|
||||||
|
<><Upload size={11} strokeWidth={2} /> {t('common.upload')}</>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{filesExpanded && placeFiles.length > 0 && (
|
||||||
|
<div style={{ padding: '0 12px 10px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{placeFiles.map(f => (
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer actions */}
|
||||||
|
<div style={{ padding: '10px 16px', borderTop: '1px solid var(--border-faint)', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
{selectedDayId && (
|
||||||
|
assignmentInDay ? (
|
||||||
|
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||||
|
label={<><span className="hidden sm:inline">{t('inspector.removeFromDay')}</span><span className="sm:hidden">Remove</span></>} />
|
||||||
|
) : (
|
||||||
|
<ActionButton onClick={() => onAssignToDay(place.id)} variant="primary" icon={<Plus size={13} />} label={t('inspector.addToDay')} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{googleDetails?.google_maps_url && (
|
||||||
|
<ActionButton onClick={() => window.open(googleDetails.google_maps_url, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||||
|
label={<span className="hidden sm:inline">{t('inspector.google')}</span>} />
|
||||||
|
)}
|
||||||
|
{!googleDetails?.google_maps_url && place.lat && place.lng && (
|
||||||
|
<ActionButton onClick={() => window.open(`https://www.google.com/maps/search/?api=1&query=${place.lat},${place.lng}`, '_blank')} variant="ghost" icon={<Navigation size={13} />}
|
||||||
|
label={<span className="hidden sm:inline">Google Maps</span>} />
|
||||||
|
)}
|
||||||
|
{(place.website || googleDetails?.website) && (
|
||||||
|
<ActionButton onClick={() => window.open(place.website || googleDetails?.website, '_blank')} variant="ghost" icon={<ExternalLink size={13} />}
|
||||||
|
label={<span className="hidden sm:inline">{t('inspector.website')}</span>} />
|
||||||
|
)}
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<ActionButton onClick={onEdit} variant="ghost" icon={<Edit2 size={13} />} label={<span className="hidden sm:inline">{t('common.edit')}</span>} />
|
||||||
|
<ActionButton onClick={onDelete} variant="danger" icon={<Trash2 size={13} />} label={<span className="hidden sm:inline">{t('common.delete')}</span>} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)' },
|
||||||
|
danger: { background: 'rgba(239,68,68,0.08)', color: '#dc2626', border: 'none', hoverBg: 'rgba(239,68,68,0.16)' },
|
||||||
|
}
|
||||||
|
const s = base[variant] || base.ghost
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '6px 12px', borderRadius: 10, minHeight: 30,
|
||||||
|
fontSize: 12, fontWeight: 500, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', transition: 'background 0.15s, opacity 0.15s',
|
||||||
|
background: s.background, color: s.color, border: s.border,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = s.hoverBg}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = s.background}
|
||||||
|
>
|
||||||
|
{icon}{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,85 @@
|
|||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { Search, Plus, X, CalendarDays } from 'lucide-react'
|
import { useState, useRef, useMemo, useCallback } from 'react'
|
||||||
|
import DOM from 'react-dom'
|
||||||
|
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check } from 'lucide-react'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import { getCategoryIcon } from '../shared/categoryIcons'
|
import { getCategoryIcon } from '../shared/categoryIcons'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
|
import { placesApi } from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
|
interface PlacesSidebarProps {
|
||||||
|
tripId: number
|
||||||
|
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
|
||||||
|
onCategoryFilterChange?: (categoryId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
export default function PlacesSidebar({
|
export default function PlacesSidebar({
|
||||||
places, categories, assignments, selectedDayId, selectedPlaceId,
|
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||||
onPlaceClick, onAddPlace, onAssignToDay, days, isMobile,
|
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange,
|
||||||
}) {
|
}: PlacesSidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const ctxMenu = useContextMenu()
|
||||||
|
const gpxInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const tripStore = useTripStore()
|
||||||
|
|
||||||
|
const handleGpxImport = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
e.target.value = ''
|
||||||
|
try {
|
||||||
|
const result = await placesApi.importGpx(tripId, file)
|
||||||
|
await tripStore.loadTrip(tripId)
|
||||||
|
toast.success(t('places.gpxImported', { count: result.count }))
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.response?.data?.error || t('places.gpxError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filter, setFilter] = useState('all')
|
const [filter, setFilter] = useState('all')
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const toggleCategoryFilter = (catId: string) => {
|
||||||
|
setCategoryFiltersLocal(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(catId)) next.delete(catId); else next.add(catId)
|
||||||
|
// Notify parent with first selected or empty
|
||||||
|
onCategoryFilterChange?.(next.size === 1 ? [...next][0] : '')
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
const [dayPickerPlace, setDayPickerPlace] = useState(null)
|
||||||
|
const [catDropOpen, setCatDropOpen] = useState(false)
|
||||||
|
|
||||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||||
const plannedIds = new Set(
|
const plannedIds = new Set(
|
||||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||||
)
|
)
|
||||||
|
|
||||||
const filtered = places.filter(p => {
|
const filtered = useMemo(() => places.filter(p => {
|
||||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||||
if (categoryFilter && String(p.category_id) !== String(categoryFilter)) return false
|
if (categoryFilters.size > 0 && !categoryFilters.has(String(p.category_id))) return false
|
||||||
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
return true
|
return true
|
||||||
})
|
}), [places, filter, categoryFilters, search, plannedIds.size])
|
||||||
|
|
||||||
const isAssignedToSelectedDay = (placeId) =>
|
const isAssignedToSelectedDay = (placeId) =>
|
||||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||||
@@ -47,6 +99,19 @@ export default function PlacesSidebar({
|
|||||||
>
|
>
|
||||||
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
<Plus size={14} strokeWidth={2} /> {t('places.addPlace')}
|
||||||
</button>
|
</button>
|
||||||
|
<input ref={gpxInputRef} type="file" accept=".gpx" style={{ display: 'none' }} onChange={handleGpxImport} />
|
||||||
|
<button
|
||||||
|
onClick={() => gpxInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
width: '100%', padding: '5px 12px', borderRadius: 8, marginBottom: 10,
|
||||||
|
border: '1px dashed var(--border-primary)', background: 'none',
|
||||||
|
color: 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={11} strokeWidth={2} /> {t('places.importGpx')}
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Filter-Tabs */}
|
{/* Filter-Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||||
@@ -81,21 +146,69 @@ export default function PlacesSidebar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Kategoriefilter */}
|
{/* Category multi-select dropdown */}
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (() => {
|
||||||
<div style={{ marginTop: 6 }}>
|
const label = categoryFilters.size === 0
|
||||||
<CustomSelect
|
? t('places.allCategories')
|
||||||
value={categoryFilter}
|
: categoryFilters.size === 1
|
||||||
onChange={setCategoryFilter}
|
? categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories')
|
||||||
placeholder={t('places.allCategories')}
|
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||||
size="sm"
|
return (
|
||||||
options={[
|
<div style={{ marginTop: 6, position: 'relative' }}>
|
||||||
{ value: '', label: t('places.allCategories') },
|
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||||
...categories.map(c => ({ value: String(c.id), label: c.name }))
|
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
]}
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
/>
|
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||||
</div>
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
)}
|
}}>
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
|
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
|
</button>
|
||||||
|
{catDropOpen && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
|
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{categories.map(c => {
|
||||||
|
const active = categoryFilters.has(String(c.id))
|
||||||
|
const CatIcon = getCategoryIcon(c.icon)
|
||||||
|
return (
|
||||||
|
<button key={c.id} onClick={() => toggleCategoryFilter(String(c.id))} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%',
|
||||||
|
padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: active ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
fontFamily: 'inherit', fontSize: 12, color: 'var(--text-primary)',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||||
|
border: active ? 'none' : '1.5px solid var(--border-primary)',
|
||||||
|
background: active ? (c.color || 'var(--accent)') : 'transparent',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
}}>
|
||||||
|
{active && <Check size={10} strokeWidth={3} color="white" />}
|
||||||
|
</div>
|
||||||
|
<CatIcon size={12} strokeWidth={2} color={c.color || 'var(--text-muted)'} />
|
||||||
|
<span style={{ flex: 1 }}>{c.name}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{categoryFilters.size > 0 && (
|
||||||
|
<button onClick={() => { setCategoryFiltersLocal(new Set()); onCategoryFilterChange?.('') }} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||||
|
width: '100%', padding: '6px 10px', borderRadius: 6, border: 'none', cursor: 'pointer',
|
||||||
|
background: 'transparent', fontFamily: 'inherit', fontSize: 11, color: 'var(--text-faint)',
|
||||||
|
marginTop: 2, borderTop: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
<X size={10} /> {t('places.clearFilter')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anzahl */}
|
{/* Anzahl */}
|
||||||
@@ -138,6 +251,14 @@ export default function PlacesSidebar({
|
|||||||
onPlaceClick(isSelected ? null : place.id)
|
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={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
display: 'flex', alignItems: 'center', gap: 10,
|
||||||
padding: '9px 14px 9px 16px',
|
padding: '9px 14px 9px 16px',
|
||||||
@@ -145,6 +266,8 @@ export default function PlacesSidebar({
|
|||||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||||
borderBottom: '1px solid var(--border-faint)',
|
borderBottom: '1px solid var(--border-faint)',
|
||||||
transition: 'background 0.1s',
|
transition: 'background 0.1s',
|
||||||
|
contentVisibility: 'auto',
|
||||||
|
containIntrinsicSize: '0 52px',
|
||||||
}}
|
}}
|
||||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||||
@@ -204,19 +327,17 @@ export default function PlacesSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 12px 16px' }}>
|
||||||
{days.map((day, i) => {
|
{days.map((day, i) => {
|
||||||
const alreadyAssigned = (assignments[String(day.id)] || []).some(a => a.place?.id === dayPickerPlace.id)
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={day.id}
|
key={day.id}
|
||||||
disabled={alreadyAssigned}
|
|
||||||
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
onClick={() => { onAssignToDay(dayPickerPlace.id, day.id); setDayPickerPlace(null) }}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10, width: '100%',
|
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',
|
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'}
|
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
|
||||||
>
|
>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -230,7 +351,7 @@ export default function PlacesSidebar({
|
|||||||
</div>
|
</div>
|
||||||
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
{day.date && <div style={{ fontSize: 11, color: 'var(--text-faint)' }}>{new Date(day.date + 'T00:00:00').toLocaleDateString()}</div>}
|
||||||
</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>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -239,6 +360,7 @@ export default function PlacesSidebar({
|
|||||||
</div>,
|
</div>,
|
||||||
document.body
|
document.body
|
||||||
)}
|
)}
|
||||||
|
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||||
</div>
|
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
|
||||||
import Modal from '../shared/Modal'
|
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
|
||||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink } from 'lucide-react'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { CustomDateTimePicker } from '../shared/CustomDateTimePicker'
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
|
||||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
|
||||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
|
||||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
|
||||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
|
||||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
|
||||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
|
||||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
|
||||||
]
|
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, selectedDayId, files = [], onFileUpload, onFileDelete }) {
|
|
||||||
const toast = useToast()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const fileInputRef = useRef(null)
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
title: '', type: 'other', status: 'pending',
|
|
||||||
reservation_time: '', location: '', confirmation_number: '',
|
|
||||||
notes: '', day_id: '', place_id: '',
|
|
||||||
})
|
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
|
||||||
const [pendingFiles, setPendingFiles] = useState([]) // for new reservations
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (reservation) {
|
|
||||||
setForm({
|
|
||||||
title: reservation.title || '',
|
|
||||||
type: reservation.type || 'other',
|
|
||||||
status: reservation.status || 'pending',
|
|
||||||
reservation_time: reservation.reservation_time ? reservation.reservation_time.slice(0, 16) : '',
|
|
||||||
location: reservation.location || '',
|
|
||||||
confirmation_number: reservation.confirmation_number || '',
|
|
||||||
notes: reservation.notes || '',
|
|
||||||
day_id: reservation.day_id || '',
|
|
||||||
place_id: reservation.place_id || '',
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
setForm({
|
|
||||||
title: '', type: 'other', status: 'pending',
|
|
||||||
reservation_time: '', location: '', confirmation_number: '',
|
|
||||||
notes: '', day_id: selectedDayId || '', place_id: '',
|
|
||||||
})
|
|
||||||
setPendingFiles([])
|
|
||||||
}
|
|
||||||
}, [reservation, isOpen, selectedDayId])
|
|
||||||
|
|
||||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!form.title.trim()) return
|
|
||||||
setIsSaving(true)
|
|
||||||
try {
|
|
||||||
const saved = await onSave({
|
|
||||||
...form,
|
|
||||||
day_id: form.day_id || null,
|
|
||||||
place_id: form.place_id || null,
|
|
||||||
})
|
|
||||||
// Upload pending files for newly created reservations
|
|
||||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
|
||||||
for (const file of pendingFiles) {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
fd.append('reservation_id', saved.id)
|
|
||||||
fd.append('description', form.title)
|
|
||||||
await onFileUpload(fd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file) return
|
|
||||||
if (reservation?.id) {
|
|
||||||
// Existing reservation — upload immediately
|
|
||||||
setUploadingFile(true)
|
|
||||||
try {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('file', file)
|
|
||||||
fd.append('reservation_id', reservation.id)
|
|
||||||
fd.append('description', reservation.title)
|
|
||||||
await onFileUpload(fd)
|
|
||||||
toast.success(t('reservations.toast.fileUploaded'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('reservations.toast.uploadError'))
|
|
||||||
} finally {
|
|
||||||
setUploadingFile(false)
|
|
||||||
e.target.value = ''
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// New reservation — stage locally
|
|
||||||
setPendingFiles(prev => [...prev, file])
|
|
||||||
e.target.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachedFiles = reservation?.id ? files.filter(f => f.reservation_id === reservation.id) : []
|
|
||||||
|
|
||||||
const inputStyle = {
|
|
||||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
|
||||||
padding: '8px 14px', fontSize: 13, fontFamily: 'inherit',
|
|
||||||
outline: 'none', boxSizing: 'border-box', color: 'var(--text-primary)', background: 'var(--bg-input)',
|
|
||||||
}
|
|
||||||
const labelStyle = { display: 'block', fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 5 }
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={reservation ? t('reservations.editTitle') : t('reservations.newTitle')} size="md">
|
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
||||||
|
|
||||||
{/* Type selector */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
||||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
|
||||||
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 5,
|
|
||||||
padding: '6px 11px', borderRadius: 99, border: '1px solid',
|
|
||||||
fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
|
||||||
background: form.type === value ? '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)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
|
|
||||||
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
|
||||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Date/Time + Status */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 10 }}>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.datetime')}</label>
|
|
||||||
<CustomDateTimePicker value={form.reservation_time} onChange={v => set('reservation_time', v)} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.status}
|
|
||||||
onChange={value => set('status', value)}
|
|
||||||
options={[
|
|
||||||
{ value: 'pending', label: t('reservations.pending') },
|
|
||||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
|
||||||
]}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
|
||||||
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
|
||||||
placeholder={t('reservations.locationPlaceholder')} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Confirmation number */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
|
||||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
|
||||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Linked day + place */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.day')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.day_id}
|
|
||||||
onChange={value => set('day_id', value)}
|
|
||||||
placeholder={t('reservations.noDay')}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.noDay') },
|
|
||||||
...(days || []).map(day => ({
|
|
||||||
value: day.id,
|
|
||||||
label: `${t('reservations.day')} ${day.day_number}${day.date ? ` · ${formatDate(day.date)}` : ''}`,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.place')}</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={form.place_id}
|
|
||||||
onChange={value => set('place_id', value)}
|
|
||||||
placeholder={t('reservations.noPlace')}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('reservations.noPlace') },
|
|
||||||
...(places || []).map(place => ({
|
|
||||||
value: place.id,
|
|
||||||
label: place.name,
|
|
||||||
})),
|
|
||||||
]}
|
|
||||||
searchable
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Notes */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
|
||||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={3}
|
|
||||||
placeholder={t('reservations.notesPlaceholder')}
|
|
||||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* File upload — always visible */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>{t('files.title')}</label>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{attachedFiles.map(f => (
|
|
||||||
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 10px', background: '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>
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
<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} />
|
|
||||||
</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} />
|
|
||||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid 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)' }}>
|
|
||||||
{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 }}>
|
|
||||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr) {
|
|
||||||
if (!dateStr) return ''
|
|
||||||
const d = new Date(dateStr + 'T00:00:00')
|
|
||||||
return d.toLocaleDateString('de-DE', { day: 'numeric', month: 'short' })
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||||
|
import { useParams } from 'react-router-dom'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import { useTripStore } from '../../store/tripStore'
|
||||||
|
import Modal from '../shared/Modal'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
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 { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||||
|
|
||||||
|
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 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>
|
||||||
|
accommodations?: Accommodation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||||
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
|
const toast = useToast()
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
title: '', type: 'other', status: 'pending',
|
||||||
|
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||||
|
notes: '', assignment_id: '', accommodation_id: '',
|
||||||
|
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||||
|
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||||
|
meta_check_in_time: '', meta_check_out_time: '',
|
||||||
|
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||||
|
})
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
|
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||||
|
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||||
|
|
||||||
|
const assignmentOptions = useMemo(
|
||||||
|
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||||
|
[days, assignments, t, locale]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reservation) {
|
||||||
|
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||||
|
setForm({
|
||||||
|
title: reservation.title || '',
|
||||||
|
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 || '',
|
||||||
|
assignment_id: reservation.assignment_id || '',
|
||||||
|
accommodation_id: reservation.accommodation_id || '',
|
||||||
|
meta_airline: meta.airline || '',
|
||||||
|
meta_flight_number: meta.flight_number || '',
|
||||||
|
meta_departure_airport: meta.departure_airport || '',
|
||||||
|
meta_arrival_airport: meta.arrival_airport || '',
|
||||||
|
meta_train_number: meta.train_number || '',
|
||||||
|
meta_platform: meta.platform || '',
|
||||||
|
meta_seat: meta.seat || '',
|
||||||
|
meta_check_in_time: meta.check_in_time || '',
|
||||||
|
meta_check_out_time: meta.check_out_time || '',
|
||||||
|
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||||
|
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||||
|
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setForm({
|
||||||
|
title: '', type: 'other', status: 'pending',
|
||||||
|
reservation_time: '', reservation_end_time: '', location: '', confirmation_number: '',
|
||||||
|
notes: '', assignment_id: '', accommodation_id: '',
|
||||||
|
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||||
|
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||||
|
meta_check_in_time: '', meta_check_out_time: '',
|
||||||
|
})
|
||||||
|
setPendingFiles([])
|
||||||
|
}
|
||||||
|
}, [reservation, isOpen, selectedDayId])
|
||||||
|
|
||||||
|
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!form.title.trim()) return
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const metadata: Record<string, string> = {}
|
||||||
|
if (form.type === 'flight') {
|
||||||
|
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||||
|
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||||
|
if (form.meta_departure_airport) metadata.departure_airport = form.meta_departure_airport
|
||||||
|
if (form.meta_arrival_airport) metadata.arrival_airport = form.meta_arrival_airport
|
||||||
|
} else if (form.type === 'hotel') {
|
||||||
|
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||||
|
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||||
|
} else if (form.type === 'train') {
|
||||||
|
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||||
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
|
}
|
||||||
|
const saveData: Record<string, any> = {
|
||||||
|
title: form.title, type: form.type, status: form.status,
|
||||||
|
reservation_time: form.reservation_time, reservation_end_time: form.reservation_end_time,
|
||||||
|
location: form.location, confirmation_number: form.confirmation_number,
|
||||||
|
notes: form.notes,
|
||||||
|
assignment_id: form.assignment_id || null,
|
||||||
|
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||||
|
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||||
|
}
|
||||||
|
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||||
|
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||||
|
saveData.create_accommodation = {
|
||||||
|
place_id: form.hotel_place_id,
|
||||||
|
start_day_id: form.hotel_start_day,
|
||||||
|
end_day_id: form.hotel_end_day,
|
||||||
|
check_in: form.meta_check_in_time || null,
|
||||||
|
check_out: form.meta_check_out_time || null,
|
||||||
|
confirmation: form.confirmation_number || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saved = await onSave(saveData)
|
||||||
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0) {
|
||||||
|
for (const file of pendingFiles) {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('reservation_id', saved.id)
|
||||||
|
fd.append('description', form.title)
|
||||||
|
await onFileUpload(fd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = async (e) => {
|
||||||
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
if (reservation?.id) {
|
||||||
|
setUploadingFile(true)
|
||||||
|
try {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('file', file)
|
||||||
|
fd.append('reservation_id', reservation.id)
|
||||||
|
fd.append('description', reservation.title)
|
||||||
|
await onFileUpload(fd)
|
||||||
|
toast.success(t('reservations.toast.fileUploaded'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('reservations.toast.uploadError'))
|
||||||
|
} finally {
|
||||||
|
setUploadingFile(false)
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPendingFiles(prev => [...prev, file])
|
||||||
|
e.target.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachedFiles = reservation?.id
|
||||||
|
? files.filter(f =>
|
||||||
|
f.reservation_id === reservation.id ||
|
||||||
|
linkedFileIds.includes(f.id) ||
|
||||||
|
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
|
const inputStyle = {
|
||||||
|
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||||
|
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: 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="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: 5 }}>
|
||||||
|
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||||
|
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
||||||
|
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={11} /> {t(labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
|
||||||
|
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
||||||
|
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignment Picker + Date (hidden for hotels) */}
|
||||||
|
{form.type !== 'hotel' && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Start Time + End Time + Status */}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
{form.type !== 'hotel' && (
|
||||||
|
<>
|
||||||
|
<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}
|
||||||
|
onChange={value => set('status', value)}
|
||||||
|
options={[
|
||||||
|
{ value: 'pending', label: t('reservations.pending') },
|
||||||
|
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||||
|
]}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location + Booking Code */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Type-specific fields */}
|
||||||
|
{form.type === 'flight' && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
|
||||||
|
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||||
|
placeholder="Lufthansa" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
|
||||||
|
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||||
|
placeholder="LH 123" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.from') || 'From'}</label>
|
||||||
|
<input type="text" value={form.meta_departure_airport} onChange={e => set('meta_departure_airport', e.target.value)}
|
||||||
|
placeholder="FRA" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.to') || 'To'}</label>
|
||||||
|
<input type="text" value={form.meta_arrival_airport} onChange={e => set('meta_arrival_airport', e.target.value)}
|
||||||
|
placeholder="NRT" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.type === 'hotel' && (
|
||||||
|
<>
|
||||||
|
{/* Hotel place + day range */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.hotel_place_id}
|
||||||
|
onChange={value => {
|
||||||
|
set('hotel_place_id', value)
|
||||||
|
const p = places.find(pl => pl.id === value)
|
||||||
|
if (p) {
|
||||||
|
if (!form.title) set('title', p.name)
|
||||||
|
if (!form.location && p.address) set('location', p.address)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t('reservations.meta.pickHotel')}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '—' },
|
||||||
|
...places.map(p => ({ value: p.id, label: p.name })),
|
||||||
|
]}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.hotel_start_day}
|
||||||
|
onChange={value => set('hotel_start_day', value)}
|
||||||
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
|
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.hotel_end_day}
|
||||||
|
onChange={value => set('hotel_end_day', value)}
|
||||||
|
placeholder={t('reservations.meta.selectDay')}
|
||||||
|
options={days.map(d => ({ value: d.id, label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale)}` : ''}` }))}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Check-in/out times */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||||
|
<CustomTimePicker value={form.meta_check_in_time} onChange={v => set('meta_check_in_time', v)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.checkOut')}</label>
|
||||||
|
<CustomTimePicker value={form.meta_check_out_time} onChange={v => set('meta_check_out_time', v)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.type === 'train' && (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
|
||||||
|
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||||
|
placeholder="ICE 123" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
|
||||||
|
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||||
|
placeholder="12" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
|
||||||
|
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||||
|
placeholder="42A" 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={2}
|
||||||
|
placeholder={t('reservations.notesPlaceholder')}
|
||||||
|
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<div>
|
||||||
|
<label style={labelStyle}>{t('files.title')}</label>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{attachedFiles.map(f => (
|
||||||
|
<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>
|
||||||
|
<button type="button" onClick={async () => {
|
||||||
|
// Always unlink, never delete the file
|
||||||
|
// Clear primary reservation_id if it points to this reservation
|
||||||
|
if (f.reservation_id === reservation?.id) {
|
||||||
|
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||||
|
}
|
||||||
|
// Remove from file_links if linked there
|
||||||
|
try {
|
||||||
|
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||||
|
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||||
|
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
|
||||||
|
} catch {}
|
||||||
|
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
}} 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: '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 }}>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||||
|
<button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
|
||||||
|
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>
|
||||||
|
{/* Link existing file picker */}
|
||||||
|
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||||
|
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: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={11} /> {t('reservations.linkExisting')}
|
||||||
|
</button>
|
||||||
|
{showFilePicker && (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
|
||||||
|
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: 220, maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
|
||||||
|
<button key={f.id} type="button" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
|
||||||
|
setLinkedFileIds(prev => [...prev, f.id])
|
||||||
|
setShowFilePicker(false)
|
||||||
|
if (tripId) loadFiles(tripId)
|
||||||
|
} catch {}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
|
||||||
|
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
|
||||||
|
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
|
||||||
|
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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: '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: 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>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr, locale) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
|
return d.toLocaleDateString(locale || undefined, { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
@@ -1,453 +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 }) {
|
|
||||||
const { updatePlace } = useTripStore()
|
|
||||||
const toast = useToast()
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format) || '24h'
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const confirmed = item.status === 'confirmed'
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!confirm(t('reservations.confirm.remove', { name: item.title }))) return
|
|
||||||
try {
|
|
||||||
await updatePlace(tripId, item.placeId, {
|
|
||||||
reservation_status: 'none',
|
|
||||||
reservation_datetime: null,
|
|
||||||
place_time: null,
|
|
||||||
reservation_notes: null,
|
|
||||||
})
|
|
||||||
toast.success(t('reservations.toast.removed'))
|
|
||||||
} catch { toast.error(t('reservations.toast.deleteError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{editing && <PlaceReservationEditModal item={item} tripId={tripId} onClose={() => setEditing(false)} />}
|
|
||||||
<div style={{ background: 'var(--bg-card)', borderRadius: 14, border: '1px solid var(--border-faint)', boxShadow: '0 1px 6px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'stretch' }}>
|
|
||||||
<div style={{
|
|
||||||
width: 44, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
background: confirmed ? '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>}
|
|
||||||
</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} />
|
|
||||||
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,389 @@
|
|||||||
|
import { 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 {
|
||||||
|
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 blurCodes = useSettingsStore(s => s.settings.blur_booking_codes)
|
||||||
|
const [codeRevealed, setCodeRevealed] = useState(false)
|
||||||
|
const typeInfo = getType(r.type)
|
||||||
|
const TypeIcon = typeInfo.Icon
|
||||||
|
const confirmed = r.status === 'confirmed'
|
||||||
|
const attachedFiles = files.filter(f => f.reservation_id === r.id || (f.linked_reservation_ids || []).includes(r.id))
|
||||||
|
const linked = r.assignment_id ? assignmentLookup[r.assignment_id] : null
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
try { await toggleReservationStatus(tripId, r.id) }
|
||||||
|
catch { toast.error(t('reservations.toast.updateError')) }
|
||||||
|
}
|
||||||
|
const handleDelete = async () => {
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
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={() => setShowDeleteConfirm(true)} 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 || r.metadata) && (
|
||||||
|
<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
|
||||||
|
onMouseEnter={() => blurCodes && setCodeRevealed(true)}
|
||||||
|
onMouseLeave={() => blurCodes && setCodeRevealed(false)}
|
||||||
|
onClick={() => blurCodes && setCodeRevealed(v => !v)}
|
||||||
|
style={{
|
||||||
|
fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1,
|
||||||
|
filter: blurCodes && !codeRevealed ? 'blur(5px)' : 'none',
|
||||||
|
cursor: blurCodes ? 'pointer' : 'default',
|
||||||
|
transition: 'filter 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{r.confirmation_number}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Row 1b: Type-specific metadata */}
|
||||||
|
{(() => {
|
||||||
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
|
if (!meta || Object.keys(meta).length === 0) return null
|
||||||
|
const cells: { label: string; value: string }[] = []
|
||||||
|
if (meta.airline) cells.push({ label: t('reservations.meta.airline'), value: meta.airline })
|
||||||
|
if (meta.flight_number) cells.push({ label: t('reservations.meta.flightNumber'), value: meta.flight_number })
|
||||||
|
if (meta.departure_airport) cells.push({ label: t('reservations.meta.from'), value: meta.departure_airport })
|
||||||
|
if (meta.arrival_airport) cells.push({ label: t('reservations.meta.to'), value: meta.arrival_airport })
|
||||||
|
if (meta.train_number) cells.push({ label: t('reservations.meta.trainNumber'), value: meta.train_number })
|
||||||
|
if (meta.platform) cells.push({ label: t('reservations.meta.platform'), value: meta.platform })
|
||||||
|
if (meta.seat) cells.push({ label: t('reservations.meta.seat'), value: meta.seat })
|
||||||
|
if (meta.check_in_time) cells.push({ label: t('reservations.meta.checkIn'), value: meta.check_in_time })
|
||||||
|
if (meta.check_out_time) cells.push({ label: t('reservations.meta.checkOut'), value: meta.check_out_time })
|
||||||
|
if (cells.length === 0) return null
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 0, borderRadius: 8, overflow: 'hidden', background: 'var(--bg-secondary)', boxShadow: '0 1px 6px rgba(0,0,0,0.08)' }}>
|
||||||
|
{cells.map((c, i) => (
|
||||||
|
<div key={i} style={{ flex: 1, padding: '5px 10px', textAlign: 'center', borderRight: i < cells.length - 1 ? '1px solid var(--border-faint)' : 'none' }}>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em' }}>{c.label}</div>
|
||||||
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-primary)', marginTop: 1 }}>{c.value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
{/* Row 2: Location + Assignment */}
|
||||||
|
{(r.location || linked || r.accommodation_name) && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
{r.accommodation_name && (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.meta.linkAccommodation')}</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 5, padding: '4px 8px', borderRadius: 7, background: 'var(--bg-secondary)', fontSize: 11, color: 'var(--text-muted)' }}>
|
||||||
|
<Hotel size={10} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.accommodation_name}</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>
|
||||||
|
)}
|
||||||
|
{/* Delete confirmation popup */}
|
||||||
|
{showDeleteConfirm && ReactDOM.createPortal(
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed', inset: 0, zIndex: 1000,
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(3px)',
|
||||||
|
}} onClick={() => setShowDeleteConfirm(false)}>
|
||||||
|
<div style={{
|
||||||
|
width: 340, background: 'var(--bg-card)', borderRadius: 16,
|
||||||
|
boxShadow: '0 16px 48px rgba(0,0,0,0.22)', padding: '22px 22px 18px',
|
||||||
|
display: 'flex', flexDirection: 'column', gap: 12,
|
||||||
|
}} onClick={e => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 36, height: 36, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
borderRadius: '50%', background: 'rgba(239,68,68,0.12)',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={18} strokeWidth={1.8} color="#ef4444" />
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
|
||||||
|
{t('reservations.confirm.deleteTitle')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12.5, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||||
|
{t('reservations.confirm.deleteBody', { name: r.title })}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||||
|
<button onClick={() => setShowDeleteConfirm(false)} 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={handleDelete} style={{
|
||||||
|
fontSize: 12, background: '#ef4444', color: 'white',
|
||||||
|
border: 'none', borderRadius: 8, padding: '6px 16px', cursor: 'pointer', fontWeight: 600, fontFamily: 'inherit',
|
||||||
|
}}>{t('common.confirm')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react'
|
|
||||||
import Modal from '../shared/Modal'
|
|
||||||
import { Calendar, Camera, X } from 'lucide-react'
|
|
||||||
import { tripsApi } from '../../api/client'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
|
||||||
|
|
||||||
export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUpdate }) {
|
|
||||||
const isEditing = !!trip
|
|
||||||
const fileRef = useRef(null)
|
|
||||||
const toast = useToast()
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
start_date: '',
|
|
||||||
end_date: '',
|
|
||||||
})
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
|
||||||
const [coverPreview, setCoverPreview] = useState(null)
|
|
||||||
const [uploadingCover, setUploadingCover] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (trip) {
|
|
||||||
setFormData({
|
|
||||||
title: trip.title || '',
|
|
||||||
description: trip.description || '',
|
|
||||||
start_date: trip.start_date || '',
|
|
||||||
end_date: trip.end_date || '',
|
|
||||||
})
|
|
||||||
setCoverPreview(trip.cover_image || null)
|
|
||||||
} else {
|
|
||||||
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
|
||||||
setCoverPreview(null)
|
|
||||||
}
|
|
||||||
setError('')
|
|
||||||
}, [trip, isOpen])
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setError('')
|
|
||||||
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
|
|
||||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
|
||||||
setError(t('dashboard.endDateError')); return
|
|
||||||
}
|
|
||||||
setIsLoading(true)
|
|
||||||
try {
|
|
||||||
await onSave({
|
|
||||||
title: formData.title.trim(),
|
|
||||||
description: formData.description.trim() || null,
|
|
||||||
start_date: formData.start_date || null,
|
|
||||||
end_date: formData.end_date || null,
|
|
||||||
})
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message || t('places.saveError'))
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleCoverChange = async (e) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (!file || !trip?.id) return
|
|
||||||
setUploadingCover(true)
|
|
||||||
try {
|
|
||||||
const fd = new FormData()
|
|
||||||
fd.append('cover', file)
|
|
||||||
const data = await tripsApi.uploadCover(trip.id, fd)
|
|
||||||
setCoverPreview(data.cover_image)
|
|
||||||
onCoverUpdate?.(trip.id, data.cover_image)
|
|
||||||
toast.success(t('dashboard.coverSaved'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('dashboard.coverUploadError'))
|
|
||||||
} finally {
|
|
||||||
setUploadingCover(false)
|
|
||||||
e.target.value = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveCover = async () => {
|
|
||||||
if (!trip?.id) return
|
|
||||||
try {
|
|
||||||
await tripsApi.update(trip.id, { cover_image: null })
|
|
||||||
setCoverPreview(null)
|
|
||||||
onCoverUpdate?.(trip.id, null)
|
|
||||||
} catch {
|
|
||||||
toast.error(t('dashboard.coverRemoveError'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = (field, value) => setFormData(prev => {
|
|
||||||
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)
|
|
||||||
const newEnd = new Date(value + 'T00:00:00')
|
|
||||||
newEnd.setDate(newEnd.getDate() + duration)
|
|
||||||
next.end_date = newEnd.toISOString().split('T')[0]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
|
||||||
size="md"
|
|
||||||
footer={
|
|
||||||
<div className="flex gap-3 justify-end">
|
|
||||||
<button type="button" onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button onClick={handleSubmit} disabled={isLoading}
|
|
||||||
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg transition-colors flex items-center gap-2">
|
|
||||||
{isLoading
|
|
||||||
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('common.saving')}</>
|
|
||||||
: isEditing ? t('common.update') : t('dashboard.createTrip')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-600">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cover image — only for existing trips */}
|
|
||||||
{isEditing && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.coverImage')}</label>
|
|
||||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: 'none' }} onChange={handleCoverChange} />
|
|
||||||
{coverPreview ? (
|
|
||||||
<div style={{ position: 'relative', borderRadius: 10, overflow: 'hidden', height: 130 }}>
|
|
||||||
<img src={coverPreview} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
||||||
<div style={{ position: 'absolute', bottom: 8, right: 8, display: 'flex', gap: 6 }}>
|
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '5px 10px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', fontSize: 11.5, fontWeight: 600, cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
|
||||||
<Camera size={12} /> {uploadingCover ? t('common.uploading') : t('common.change')}
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={handleRemoveCover}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', padding: '5px 8px', borderRadius: 8, background: 'rgba(0,0,0,0.55)', border: 'none', color: 'white', cursor: 'pointer', backdropFilter: 'blur(4px)' }}>
|
|
||||||
<X size={12} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
|
||||||
style={{ width: '100%', padding: '18px', border: '2px dashed #e5e7eb', borderRadius: 10, background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, fontSize: 13, color: '#9ca3af', fontFamily: 'inherit' }}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
|
||||||
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
|
||||||
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
|
||||||
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
|
||||||
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
|
||||||
className={`${inputCls} resize-none`} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.startDate')}
|
|
||||||
</label>
|
|
||||||
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
|
||||||
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.endDate')}
|
|
||||||
</label>
|
|
||||||
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!formData.start_date && !formData.end_date && (
|
|
||||||
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
|
||||||
{t('dashboard.noDateHint')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import Modal from '../shared/Modal'
|
||||||
|
import { Calendar, Camera, X, Clipboard, UserPlus } from 'lucide-react'
|
||||||
|
import { tripsApi, authApi } from '../../api/client'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import { useAuthStore } from '../../store/authStore'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
|
import type { Trip } from '../../types'
|
||||||
|
|
||||||
|
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()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
})
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [coverPreview, setCoverPreview] = useState(null)
|
||||||
|
const [pendingCoverFile, setPendingCoverFile] = useState(null)
|
||||||
|
const [uploadingCover, setUploadingCover] = useState(false)
|
||||||
|
const [allUsers, setAllUsers] = useState<{ id: number; username: string }[]>([])
|
||||||
|
const [selectedMembers, setSelectedMembers] = useState<number[]>([])
|
||||||
|
const [memberSelectValue, setMemberSelectValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (trip) {
|
||||||
|
setFormData({
|
||||||
|
title: trip.title || '',
|
||||||
|
description: trip.description || '',
|
||||||
|
start_date: trip.start_date || '',
|
||||||
|
end_date: trip.end_date || '',
|
||||||
|
})
|
||||||
|
setCoverPreview(trip.cover_image || null)
|
||||||
|
} else {
|
||||||
|
setFormData({ title: '', description: '', start_date: '', end_date: '' })
|
||||||
|
setCoverPreview(null)
|
||||||
|
}
|
||||||
|
setPendingCoverFile(null)
|
||||||
|
setSelectedMembers([])
|
||||||
|
setError('')
|
||||||
|
if (!trip) {
|
||||||
|
authApi.listUsers().then(d => setAllUsers(d.users || [])).catch(() => {})
|
||||||
|
}
|
||||||
|
}, [trip, isOpen])
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
if (!formData.title.trim()) { setError(t('dashboard.titleRequired')); return }
|
||||||
|
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||||
|
setError(t('dashboard.endDateError')); return
|
||||||
|
}
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
// Add selected members for newly created trips
|
||||||
|
if (selectedMembers.length > 0 && result?.trip?.id) {
|
||||||
|
for (const userId of selectedMembers) {
|
||||||
|
const user = allUsers.find(u => u.id === userId)
|
||||||
|
if (user) {
|
||||||
|
try { await tripsApi.addMember(result.trip.id, user.username) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 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: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : t('places.saveError'))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
fd.append('cover', file)
|
||||||
|
const data = await tripsApi.uploadCover(trip.id, fd)
|
||||||
|
setCoverPreview(data.cover_image)
|
||||||
|
onCoverUpdate?.(trip.id, data.cover_image)
|
||||||
|
toast.success(t('dashboard.coverSaved'))
|
||||||
|
} catch {
|
||||||
|
toast.error(t('dashboard.coverUploadError'))
|
||||||
|
} finally {
|
||||||
|
setUploadingCover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveCover = async () => {
|
||||||
|
if (pendingCoverFile) {
|
||||||
|
setPendingCoverFile(null)
|
||||||
|
setCoverPreview(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!trip?.id) return
|
||||||
|
try {
|
||||||
|
await tripsApi.update(trip.id, { cover_image: null })
|
||||||
|
setCoverPreview(null)
|
||||||
|
onCoverUpdate?.(trip.id, null)
|
||||||
|
} catch {
|
||||||
|
toast.error(t('dashboard.coverRemoveError'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
if (field === 'start_date' && value) {
|
||||||
|
if (!prev.end_date || prev.end_date < value) {
|
||||||
|
next.end_date = value
|
||||||
|
} else if (prev.start_date) {
|
||||||
|
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)
|
||||||
|
const newEnd = new Date(value + 'T00:00:00')
|
||||||
|
newEnd.setDate(newEnd.getDate() + duration)
|
||||||
|
next.end_date = newEnd.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputCls = "w-full px-3 py-2.5 border border-slate-200 rounded-lg text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-300 focus:border-transparent text-sm"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title={isEditing ? t('dashboard.editTrip') : t('dashboard.createTrip')}
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button type="button" onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-slate-600 hover:text-slate-800 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleSubmit} disabled={isLoading}
|
||||||
|
className="px-4 py-2 text-sm bg-slate-900 hover:bg-slate-700 disabled:bg-slate-400 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
{isLoading
|
||||||
|
? <><div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />{t('common.saving')}</>
|
||||||
|
: isEditing ? t('common.update') : t('dashboard.createTrip')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4" 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 — 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>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => fileRef.current?.click()} disabled={uploadingCover}
|
||||||
|
onDragOver={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#6366f1'; e.currentTarget.style.background = 'rgba(99,102,241,0.04)' }}
|
||||||
|
onDragLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none' }}
|
||||||
|
onDrop={e => { e.preventDefault(); e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.background = 'none'; const file = e.dataTransfer.files?.[0]; if (file?.type.startsWith('image/')) handleCoverSelect(file) }}
|
||||||
|
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', transition: 'all 0.15s' }}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.borderColor = '#d1d5db'; e.currentTarget.style.color = '#6b7280' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.borderColor = '#e5e7eb'; e.currentTarget.style.color = '#9ca3af' }}>
|
||||||
|
<Camera size={15} /> {uploadingCover ? t('common.uploading') : t('dashboard.addCoverImage')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
{t('dashboard.tripTitle')} <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" value={formData.title} onChange={e => update('title', e.target.value)}
|
||||||
|
required placeholder={t('dashboard.tripTitlePlaceholder')} className={inputCls} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('dashboard.tripDescription')}</label>
|
||||||
|
<textarea value={formData.description} onChange={e => update('description', e.target.value)}
|
||||||
|
placeholder={t('dashboard.tripDescriptionPlaceholder')} rows={3}
|
||||||
|
className={`${inputCls} resize-none`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.startDate')}
|
||||||
|
</label>
|
||||||
|
<CustomDatePicker value={formData.start_date} onChange={v => update('start_date', v)} placeholder={t('dashboard.startDate')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
<Calendar className="inline w-4 h-4 mr-1" />{t('dashboard.endDate')}
|
||||||
|
</label>
|
||||||
|
<CustomDatePicker value={formData.end_date} onChange={v => update('end_date', v)} placeholder={t('dashboard.endDate')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members — only for new trips */}
|
||||||
|
{!isEditing && allUsers.filter(u => u.id !== currentUser?.id).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">
|
||||||
|
<UserPlus className="inline w-4 h-4 mr-1" />{t('dashboard.addMembers')}
|
||||||
|
</label>
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 8 }}>
|
||||||
|
{selectedMembers.map(uid => {
|
||||||
|
const user = allUsers.find(u => u.id === uid)
|
||||||
|
if (!user) return null
|
||||||
|
return (
|
||||||
|
<span key={uid} onClick={() => setSelectedMembers(prev => prev.filter(id => id !== uid))}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 99,
|
||||||
|
background: 'var(--bg-secondary)', fontSize: 12, fontWeight: 500, color: 'var(--text-primary)', cursor: 'pointer',
|
||||||
|
border: '1px solid var(--border-primary)',
|
||||||
|
}}>
|
||||||
|
{user.username}
|
||||||
|
<X size={11} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<CustomSelect
|
||||||
|
value={memberSelectValue}
|
||||||
|
onChange={value => {
|
||||||
|
if (value) { setSelectedMembers(prev => prev.includes(Number(value)) ? prev : [...prev, Number(value)]); setMemberSelectValue('') }
|
||||||
|
}}
|
||||||
|
placeholder={t('dashboard.addMember')}
|
||||||
|
options={allUsers.filter(u => u.id !== currentUser?.id && !selectedMembers.includes(u.id)).map(u => ({ value: u.id, label: u.username }))}
|
||||||
|
searchable
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!formData.start_date && !formData.end_date && (
|
||||||
|
<p className="text-xs text-slate-400 bg-slate-50 rounded-lg p-3">
|
||||||
|
{t('dashboard.noDateHint')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import React, { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import { tripsApi, authApi } from '../../api/client'
|
import { tripsApi, authApi, shareApi } from '../../api/client'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { Crown, UserMinus, UserPlus, Users, LogOut } from 'lucide-react'
|
import { Crown, UserMinus, UserPlus, Users, LogOut, Link2, Trash2, Copy, Check } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { getApiErrorMessage } from '../../types'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
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) {
|
if (avatarUrl) {
|
||||||
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
return <img src={avatarUrl} alt="" style={{ width: size, height: size, borderRadius: '50%', objectFit: 'cover', flexShrink: 0 }} />
|
||||||
}
|
}
|
||||||
@@ -25,7 +32,137 @@ function Avatar({ username, avatarUrl, size = 32 }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle }) {
|
function ShareLinkSection({ tripId, t }: { tripId: number; t: any }) {
|
||||||
|
const [shareToken, setShareToken] = useState<string | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [perms, setPerms] = useState({ share_map: true, share_bookings: true, share_packing: false, share_budget: false, share_collab: false })
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
shareApi.getLink(tripId).then(d => {
|
||||||
|
setShareToken(d.token)
|
||||||
|
if (d.token) setPerms({ share_map: d.share_map ?? true, share_bookings: d.share_bookings ?? true, share_packing: d.share_packing ?? false, share_budget: d.share_budget ?? false, share_collab: d.share_collab ?? false })
|
||||||
|
setLoading(false)
|
||||||
|
}).catch(() => setLoading(false))
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
|
const shareUrl = shareToken ? `${window.location.origin}/shared/${shareToken}` : null
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
try {
|
||||||
|
const d = await shareApi.createLink(tripId, perms)
|
||||||
|
setShareToken(d.token)
|
||||||
|
} catch { toast.error(t('share.createError')) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdatePerms = async (key: string, val: boolean) => {
|
||||||
|
const newPerms = { ...perms, [key]: val }
|
||||||
|
setPerms(newPerms)
|
||||||
|
if (shareToken) {
|
||||||
|
try { await shareApi.createLink(tripId, newPerms) } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
try {
|
||||||
|
await shareApi.deleteLink(tripId)
|
||||||
|
setShareToken(null)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopy = () => {
|
||||||
|
if (shareUrl) {
|
||||||
|
navigator.clipboard.writeText(shareUrl)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 10 }}>
|
||||||
|
<Link2 size={14} style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>{t('share.linkTitle')}</span>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: 11, color: 'var(--text-faint)', marginBottom: 10, lineHeight: 1.5 }}>{t('share.linkHint')}</p>
|
||||||
|
|
||||||
|
{/* Permission checkboxes */}
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, marginBottom: 12 }}>
|
||||||
|
{[
|
||||||
|
{ key: 'share_map', label: t('share.permMap'), always: true },
|
||||||
|
{ key: 'share_bookings', label: t('share.permBookings') },
|
||||||
|
{ key: 'share_packing', label: t('share.permPacking') },
|
||||||
|
{ key: 'share_budget', label: t('share.permBudget') },
|
||||||
|
{ key: 'share_collab', label: t('share.permCollab') },
|
||||||
|
].map(opt => (
|
||||||
|
<button key={opt.key} onClick={() => !opt.always && handleUpdatePerms(opt.key, !perms[opt.key])}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 5, padding: '4px 10px', borderRadius: 20,
|
||||||
|
border: '1.5px solid', fontSize: 11, fontWeight: 500, cursor: opt.always ? 'default' : 'pointer',
|
||||||
|
fontFamily: 'inherit', transition: 'all 0.12s',
|
||||||
|
background: perms[opt.key] ? 'var(--text-primary)' : 'transparent',
|
||||||
|
borderColor: perms[opt.key] ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: perms[opt.key] ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
opacity: opt.always ? 0.7 : 1,
|
||||||
|
}}>
|
||||||
|
{perms[opt.key] ? <Check size={10} /> : null}
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{shareUrl ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 10px',
|
||||||
|
background: 'var(--bg-tertiary)', borderRadius: 8, border: '1px solid var(--border-faint)',
|
||||||
|
}}>
|
||||||
|
<input type="text" value={shareUrl} readOnly style={{
|
||||||
|
flex: 1, border: 'none', background: 'none', fontSize: 11, color: 'var(--text-primary)',
|
||||||
|
outline: 'none', fontFamily: 'monospace',
|
||||||
|
}} />
|
||||||
|
<button onClick={handleCopy} style={{
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 8px', borderRadius: 6,
|
||||||
|
border: 'none', background: copied ? '#16a34a' : 'var(--accent)', color: copied ? 'white' : 'var(--accent-text)',
|
||||||
|
fontSize: 10, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', transition: 'background 0.2s',
|
||||||
|
}}>
|
||||||
|
{copied ? <><Check size={10} /> {t('common.copied')}</> : <><Copy size={10} /> {t('common.copy')}</>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button onClick={handleDelete} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
|
padding: '6px 0', borderRadius: 8, border: '1px solid rgba(239,68,68,0.3)',
|
||||||
|
background: 'rgba(239,68,68,0.06)', color: '#ef4444', fontSize: 11, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Trash2 size={11} /> {t('share.deleteLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button onClick={handleCreate} style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6,
|
||||||
|
width: '100%', padding: '8px 0', borderRadius: 8, border: '1px dashed var(--border-primary)',
|
||||||
|
background: 'none', color: 'var(--text-muted)', fontSize: 12, fontWeight: 500,
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
}}>
|
||||||
|
<Link2 size={12} /> {t('share.createLink')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 [data, setData] = useState(null)
|
||||||
const [allUsers, setAllUsers] = useState([])
|
const [allUsers, setAllUsers] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -71,8 +208,8 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
|||||||
setSelectedUserId('')
|
setSelectedUserId('')
|
||||||
await loadMembers()
|
await loadMembers()
|
||||||
toast.success(`${target.username} ${t('members.added')}`)
|
toast.success(`${target.username} ${t('members.added')}`)
|
||||||
} catch (err) {
|
} catch (err: unknown) {
|
||||||
toast.error(err.response?.data?.error || t('members.addError'))
|
toast.error(getApiErrorMessage(err, t('members.addError')))
|
||||||
} finally {
|
} finally {
|
||||||
setAdding(false)
|
setAdding(false)
|
||||||
}
|
}
|
||||||
@@ -109,8 +246,12 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
|||||||
] : []
|
] : []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="sm">
|
<Modal isOpen={isOpen} onClose={onClose} title={t('members.shareTrip')} size="3xl">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 20, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }} className="share-modal-grid">
|
||||||
|
<style>{`@media (max-width: 640px) { .share-modal-grid { grid-template-columns: 1fr !important; } }`}</style>
|
||||||
|
|
||||||
|
{/* Left column: Members */}
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
|
||||||
{/* Trip name */}
|
{/* Trip name */}
|
||||||
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
<div style={{ padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-secondary)' }}>
|
||||||
@@ -144,7 +285,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
|||||||
disabled={adding || !selectedUserId}
|
disabled={adding || !selectedUserId}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
|
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',
|
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
|
||||||
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
@@ -214,6 +355,13 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: Share Link */}
|
||||||
|
<div style={{ borderLeft: '1px solid var(--border-faint)', paddingLeft: 24 }}>
|
||||||
|
<ShareLinkSection tripId={tripId} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
<style>{`@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }`}</style>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useMemo, useState, useCallback } from 'react'
|
||||||
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { isWeekend } from './holidays'
|
||||||
|
import VacayMonthCard from './VacayMonthCard'
|
||||||
|
import { Building2, MousePointer2 } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function VacayCalendar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { selectedYear, selectedUserId, entries, companyHolidays, toggleEntry, toggleCompanyHoliday, plan, users, holidays } = useVacayStore()
|
||||||
|
const [companyMode, setCompanyMode] = useState(false)
|
||||||
|
|
||||||
|
const companyHolidaySet = useMemo(() => {
|
||||||
|
const s = new Set()
|
||||||
|
companyHolidays.forEach(h => s.add(h.date))
|
||||||
|
return s
|
||||||
|
}, [companyHolidays])
|
||||||
|
|
||||||
|
const entryMap = useMemo(() => {
|
||||||
|
const map = {}
|
||||||
|
entries.forEach(e => {
|
||||||
|
if (!map[e.date]) map[e.date] = []
|
||||||
|
map[e.date].push(e)
|
||||||
|
})
|
||||||
|
return map
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const blockWeekends = plan?.block_weekends !== false
|
||||||
|
const weekendDays: number[] = plan?.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||||
|
const companyHolidaysEnabled = plan?.company_holidays_enabled !== false
|
||||||
|
|
||||||
|
const handleCellClick = useCallback(async (dateStr) => {
|
||||||
|
if (companyMode) {
|
||||||
|
if (!companyHolidaysEnabled) return
|
||||||
|
await toggleCompanyHoliday(dateStr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (holidays[dateStr]) return
|
||||||
|
if (blockWeekends && isWeekend(dateStr, weekendDays)) return
|
||||||
|
if (companyHolidaysEnabled && companyHolidaySet.has(dateStr)) return
|
||||||
|
await toggleEntry(dateStr, selectedUserId || undefined)
|
||||||
|
}, [companyMode, toggleEntry, toggleCompanyHoliday, holidays, companyHolidaySet, blockWeekends, companyHolidaysEnabled, selectedUserId])
|
||||||
|
|
||||||
|
const selectedUser = users.find(u => u.id === selectedUserId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
|
<VacayMonthCard
|
||||||
|
key={i}
|
||||||
|
year={selectedYear}
|
||||||
|
month={i}
|
||||||
|
holidays={holidays}
|
||||||
|
companyHolidaySet={companyHolidaySet}
|
||||||
|
companyHolidaysEnabled={companyHolidaysEnabled}
|
||||||
|
entryMap={entryMap}
|
||||||
|
onCellClick={handleCellClick}
|
||||||
|
companyMode={companyMode}
|
||||||
|
blockWeekends={blockWeekends}
|
||||||
|
weekendDays={weekendDays}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating toolbar */}
|
||||||
|
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
||||||
|
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)', boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setCompanyMode(false)}
|
||||||
|
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: !companyMode ? 'var(--text-primary)' : 'transparent',
|
||||||
|
color: !companyMode ? 'var(--bg-card)' : 'var(--text-muted)',
|
||||||
|
border: companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
}}>
|
||||||
|
<MousePointer2 size={13} />
|
||||||
|
{selectedUser && <span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: selectedUser.color }} />}
|
||||||
|
{selectedUser ? selectedUser.username : t('vacay.modeVacation')}
|
||||||
|
</button>
|
||||||
|
{companyHolidaysEnabled && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCompanyMode(true)}
|
||||||
|
className="flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-1.5 rounded-lg text-[11px] sm:text-xs font-medium transition-all"
|
||||||
|
style={{
|
||||||
|
background: companyMode ? '#d97706' : 'transparent',
|
||||||
|
color: companyMode ? '#fff' : 'var(--text-muted)',
|
||||||
|
border: !companyMode ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
}}>
|
||||||
|
<Building2 size={13} />
|
||||||
|
{t('vacay.modeCompany')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useTranslation } from '../../i18n'
|
||||||
|
import { isWeekend } from './holidays'
|
||||||
|
import type { HolidaysMap, VacayEntry } from '../../types'
|
||||||
|
|
||||||
|
const WEEKDAY_KEYS = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||||
|
|
||||||
|
function hexToRgba(hex: string, alpha: number): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16)
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16)
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16)
|
||||||
|
return `rgba(${r},${g},${b},${alpha})`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
weekendDays?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VacayMonthCard({
|
||||||
|
year, month, holidays, companyHolidaySet, companyHolidaysEnabled = true, entryMap,
|
||||||
|
onCellClick, companyMode, blockWeekends, weekendDays = [0, 6]
|
||||||
|
}: VacayMonthCardProps) {
|
||||||
|
const { t, locale } = useTranslation()
|
||||||
|
|
||||||
|
const weekdays = WEEKDAY_KEYS.map(k => t(k))
|
||||||
|
const monthName = useMemo(() => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(year, month, 1)), [locale, year, month])
|
||||||
|
|
||||||
|
const weeks = useMemo(() => {
|
||||||
|
const firstDay = new Date(year, month, 1)
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
let startDow = firstDay.getDay() - 1
|
||||||
|
if (startDow < 0) startDow = 6
|
||||||
|
const cells = []
|
||||||
|
for (let i = 0; i < startDow; i++) cells.push(null)
|
||||||
|
for (let d = 1; d <= daysInMonth; d++) cells.push(d)
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null)
|
||||||
|
const w = []
|
||||||
|
for (let i = 0; i < cells.length; i += 7) w.push(cells.slice(i, i + 7))
|
||||||
|
return w
|
||||||
|
}, [year, month])
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border overflow-hidden" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="px-3 py-2 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<span className="text-xs font-semibold" style={{ color: 'var(--text-primary)', textTransform: 'capitalize' }}>{monthName}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 border-b" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
{weekdays.map((wd, i) => (
|
||||||
|
<div key={wd} className="text-center text-[10px] font-medium py-1" style={{ color: i >= 5 ? 'var(--text-faint)' : 'var(--text-muted)' }}>
|
||||||
|
{wd}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{weeks.map((week, wi) => (
|
||||||
|
<div key={wi} className="grid grid-cols-7">
|
||||||
|
{week.map((day, di) => {
|
||||||
|
if (day === null) return <div key={di} style={{ height: 28 }} />
|
||||||
|
|
||||||
|
const dateStr = `${year}-${pad(month + 1)}-${pad(day)}`
|
||||||
|
const dayOfWeek = new Date(year, month, day).getDay()
|
||||||
|
const weekend = weekendDays.includes(dayOfWeek)
|
||||||
|
const holiday = holidays[dateStr]
|
||||||
|
const isCompany = companyHolidaysEnabled && companyHolidaySet.has(dateStr)
|
||||||
|
const dayEntries = entryMap[dateStr] || []
|
||||||
|
const isBlocked = !!holiday || (weekend && blockWeekends) || (isCompany && !companyMode)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={di}
|
||||||
|
className="relative flex items-center justify-center cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
background: weekend ? 'var(--bg-secondary)' : 'transparent',
|
||||||
|
borderTop: '1px solid var(--border-secondary)',
|
||||||
|
borderRight: '1px solid var(--border-secondary)',
|
||||||
|
cursor: isBlocked ? 'default' : 'pointer',
|
||||||
|
}}
|
||||||
|
onClick={() => onCellClick(dateStr)}
|
||||||
|
onMouseEnter={e => { if (!isBlocked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = weekend ? 'var(--bg-secondary)' : 'transparent' }}
|
||||||
|
>
|
||||||
|
{holiday && <div className="absolute inset-0.5 rounded" style={{ background: hexToRgba(holiday.color, 0.12) }} />}
|
||||||
|
{isCompany && <div className="absolute inset-0.5 rounded" style={{ background: 'rgba(245,158,11,0.15)' }} />}
|
||||||
|
|
||||||
|
{dayEntries.length === 1 && (
|
||||||
|
<div className="absolute inset-0.5 rounded" style={{ backgroundColor: dayEntries[0].person_color, opacity: 0.4 }} />
|
||||||
|
)}
|
||||||
|
{dayEntries.length === 2 && (
|
||||||
|
<div className="absolute inset-0.5 rounded" style={{
|
||||||
|
background: `linear-gradient(135deg, ${dayEntries[0].person_color} 50%, ${dayEntries[1].person_color} 50%)`,
|
||||||
|
opacity: 0.4,
|
||||||
|
}} />
|
||||||
|
)}
|
||||||
|
{dayEntries.length === 3 && (
|
||||||
|
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
|
||||||
|
<div className="absolute top-0 left-0 w-1/2 h-full" style={{ backgroundColor: dayEntries[0].person_color }} />
|
||||||
|
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
|
||||||
|
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{dayEntries.length >= 4 && (
|
||||||
|
<div className="absolute inset-0.5 rounded overflow-hidden" style={{ opacity: 0.4 }}>
|
||||||
|
<div className="absolute top-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[0].person_color }} />
|
||||||
|
<div className="absolute top-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[1].person_color }} />
|
||||||
|
<div className="absolute bottom-0 left-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[2].person_color }} />
|
||||||
|
<div className="absolute bottom-0 right-0 w-1/2 h-1/2" style={{ backgroundColor: dayEntries[3].person_color }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="relative z-[1] text-[11px] font-medium" style={{
|
||||||
|
color: holiday ? holiday.color : weekend ? 'var(--text-faint)' : 'var(--text-primary)',
|
||||||
|
fontWeight: dayEntries.length > 0 ? 700 : 500,
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
'#6366f1', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444',
|
||||||
|
'#3b82f6', '#22c55e', '#06b6d4', '#f43f5e', '#a855f7',
|
||||||
|
'#10b981', '#0ea5e9', '#64748b', '#be185d', '#0d9488',
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function VacayPersons() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const { users, pendingInvites, invite, cancelInvite, updateColor, selectedUserId, setSelectedUserId, isFused } = useVacayStore()
|
||||||
|
const { user: currentUser } = useAuthStore()
|
||||||
|
|
||||||
|
// Default selectedUserId to current user
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedUserId && currentUser) setSelectedUserId(currentUser.id)
|
||||||
|
}, [currentUser, selectedUserId])
|
||||||
|
const [showInvite, setShowInvite] = useState(false)
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
|
const [colorEditUserId, setColorEditUserId] = useState(null)
|
||||||
|
const [availableUsers, setAvailableUsers] = useState([])
|
||||||
|
const [selectedInviteUser, setSelectedInviteUser] = useState(null)
|
||||||
|
const [inviting, setInviting] = useState(false)
|
||||||
|
|
||||||
|
const loadAvailable = async () => {
|
||||||
|
try {
|
||||||
|
const data = await apiClient.get('/addons/vacay/available-users').then(r => r.data)
|
||||||
|
setAvailableUsers(data.users)
|
||||||
|
} catch { /* */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
if (!selectedInviteUser) return
|
||||||
|
setInviting(true)
|
||||||
|
try {
|
||||||
|
await invite(selectedInviteUser)
|
||||||
|
toast.success(t('vacay.inviteSent'))
|
||||||
|
setShowInvite(false)
|
||||||
|
setSelectedInviteUser(null)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
toast.error(getApiErrorMessage(err, t('vacay.inviteError')))
|
||||||
|
} finally {
|
||||||
|
setInviting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleColorChange = async (color) => {
|
||||||
|
await updateColor(color, colorEditUserId)
|
||||||
|
setShowColorPicker(false)
|
||||||
|
setColorEditUserId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editingUserColor = users.find(u => u.id === colorEditUserId)?.color || '#6366f1'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>{t('vacay.persons')}</span>
|
||||||
|
<button onClick={() => { setShowInvite(true); loadAvailable() }}
|
||||||
|
className="p-0.5 rounded transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{users.map(u => {
|
||||||
|
const isSelected = selectedUserId === u.id
|
||||||
|
return (
|
||||||
|
<div key={u.id}
|
||||||
|
onClick={() => { if (isFused) setSelectedUserId(u.id) }}
|
||||||
|
className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group transition-all"
|
||||||
|
style={{
|
||||||
|
background: isSelected ? 'var(--bg-hover)' : 'transparent',
|
||||||
|
border: isSelected ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
cursor: isFused ? 'pointer' : 'default',
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setColorEditUserId(u.id); setShowColorPicker(true) }}
|
||||||
|
className="w-3.5 h-3.5 rounded-full shrink-0 transition-transform hover:scale-125"
|
||||||
|
style={{ backgroundColor: u.color, cursor: 'pointer' }}
|
||||||
|
title={t('vacay.changeColor')}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{u.username}
|
||||||
|
{u.id === currentUser?.id && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||||
|
</span>
|
||||||
|
{isSelected && isFused && (
|
||||||
|
<Check size={12} style={{ color: 'var(--text-primary)' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Pending invites */}
|
||||||
|
{pendingInvites.map(inv => (
|
||||||
|
<div key={inv.id} className="flex items-center gap-2 px-2.5 py-1.5 rounded-lg group"
|
||||||
|
style={{ background: 'var(--bg-secondary)', opacity: 0.7 }}>
|
||||||
|
<Clock size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-xs flex-1 truncate" style={{ color: 'var(--text-muted)' }}>
|
||||||
|
{inv.username} <span className="text-[10px]">({t('vacay.pending')})</span>
|
||||||
|
</span>
|
||||||
|
<button onClick={() => cancelInvite(inv.user_id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-[10px] px-1.5 py-0.5 rounded transition-all"
|
||||||
|
style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invite Modal — Portal to body to avoid z-index issues */}
|
||||||
|
{showInvite && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||||
|
onClick={() => setShowInvite(false)}>
|
||||||
|
<div className="rounded-2xl shadow-2xl w-full max-w-sm" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.inviteUser')}</h2>
|
||||||
|
<button onClick={() => setShowInvite(false)} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5 space-y-4">
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>{t('vacay.inviteHint')}</p>
|
||||||
|
{availableUsers.length === 0 ? (
|
||||||
|
<p className="text-xs text-center py-4" style={{ color: 'var(--text-faint)' }}>{t('vacay.noUsersAvailable')}</p>
|
||||||
|
) : (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedInviteUser}
|
||||||
|
onChange={setSelectedInviteUser}
|
||||||
|
options={availableUsers.map(u => ({ value: u.id, label: `${u.username} (${u.email})` }))}
|
||||||
|
placeholder={t('vacay.selectUser')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 justify-end pt-2">
|
||||||
|
<button onClick={() => setShowInvite(false)} className="px-4 py-2 text-sm rounded-lg"
|
||||||
|
style={{ color: 'var(--text-muted)', border: '1px solid var(--border-primary)' }}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleInvite} disabled={!selectedInviteUser || inviting}
|
||||||
|
className="px-4 py-2 text-sm rounded-lg transition-colors flex items-center gap-1.5 disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}>
|
||||||
|
{inviting && <Loader2 size={13} className="animate-spin" />}
|
||||||
|
{t('vacay.sendInvite')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Color Picker Modal — Portal to body */}
|
||||||
|
{showColorPicker && ReactDOM.createPortal(
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center px-4" style={{ zIndex: 99990, backgroundColor: 'rgba(15,23,42,0.5)', paddingTop: 70 }}
|
||||||
|
onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }}>
|
||||||
|
<div className="rounded-2xl shadow-2xl w-full max-w-xs" style={{ background: 'var(--bg-card)', animation: 'modalIn 0.2s ease-out' }}
|
||||||
|
onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between p-5" style={{ borderBottom: '1px solid var(--border-secondary)' }}>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.changeColor')}</h2>
|
||||||
|
<button onClick={() => { setShowColorPicker(false); setColorEditUserId(null) }} className="p-1.5 rounded-lg transition-colors" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex flex-wrap gap-2 justify-center">
|
||||||
|
{PRESET_COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => handleColorChange(c)}
|
||||||
|
className={`w-8 h-8 rounded-full transition-all ${editingUserColor === c ? 'ring-2 ring-offset-2 scale-110' : 'hover:scale-110'}`}
|
||||||
|
style={{ backgroundColor: c }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { type LucideIcon, CalendarOff, AlertCircle, Building2, Unlink, ArrowRightLeft, Globe, Plus, Trash2 } from 'lucide-react'
|
||||||
|
import { useVacayStore } from '../../store/vacayStore'
|
||||||
|
import { getIntlLanguage, useTranslation } from '../../i18n'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
|
import apiClient from '../../api/client'
|
||||||
|
import type { VacayHolidayCalendar } from '../../types'
|
||||||
|
|
||||||
|
interface VacaySettingsProps {
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VacaySettings({ onClose }: VacaySettingsProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
|
const { plan, updatePlan, addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, isFused, dissolve, users } = useVacayStore()
|
||||||
|
const [countries, setCountries] = useState<{ value: string; label: string }[]>([])
|
||||||
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
|
|
||||||
|
const { language } = useTranslation()
|
||||||
|
|
||||||
|
// Load available countries with localized names
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.get('/addons/vacay/holidays/countries').then(r => {
|
||||||
|
let displayNames
|
||||||
|
try { displayNames = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) } catch { /* */ }
|
||||||
|
const list = r.data.map(c => ({
|
||||||
|
value: c.countryCode,
|
||||||
|
label: displayNames ? (displayNames.of(c.countryCode) || c.name) : c.name,
|
||||||
|
}))
|
||||||
|
list.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
|
setCountries(list)
|
||||||
|
}).catch(() => {})
|
||||||
|
}, [language])
|
||||||
|
|
||||||
|
if (!plan) return null
|
||||||
|
|
||||||
|
const toggle = (key: string) => updatePlan({ [key]: !plan[key] })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Block weekends */}
|
||||||
|
<SettingToggle
|
||||||
|
icon={CalendarOff}
|
||||||
|
label={t('vacay.blockWeekends')}
|
||||||
|
hint={t('vacay.blockWeekendsHint')}
|
||||||
|
value={plan.block_weekends}
|
||||||
|
onChange={() => toggle('block_weekends')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Weekend days selector */}
|
||||||
|
{plan.block_weekends !== false && (
|
||||||
|
<div style={{ paddingLeft: 36 }}>
|
||||||
|
<p className="text-xs font-medium mb-2" style={{ color: 'var(--text-muted)' }}>{t('vacay.weekendDays')}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[
|
||||||
|
{ day: 1, label: t('vacay.mon') },
|
||||||
|
{ day: 2, label: t('vacay.tue') },
|
||||||
|
{ day: 3, label: t('vacay.wed') },
|
||||||
|
{ day: 4, label: t('vacay.thu') },
|
||||||
|
{ day: 5, label: t('vacay.fri') },
|
||||||
|
{ day: 6, label: t('vacay.sat') },
|
||||||
|
{ day: 0, label: t('vacay.sun') },
|
||||||
|
].map(({ day, label }) => {
|
||||||
|
const current: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6]
|
||||||
|
const active = current.includes(day)
|
||||||
|
return (
|
||||||
|
<button key={day} onClick={() => {
|
||||||
|
const next = active ? current.filter(d => d !== day) : [...current, day]
|
||||||
|
updatePlan({ weekend_days: next.join(',') })
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '4px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer',
|
||||||
|
fontFamily: 'inherit', border: '1px solid', transition: 'all 0.12s',
|
||||||
|
background: active ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||||
|
borderColor: active ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||||
|
color: active ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||||
|
}}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Carry-over */}
|
||||||
|
<SettingToggle
|
||||||
|
icon={ArrowRightLeft}
|
||||||
|
label={t('vacay.carryOver')}
|
||||||
|
hint={t('vacay.carryOverHint')}
|
||||||
|
value={plan.carry_over_enabled}
|
||||||
|
onChange={() => toggle('carry_over_enabled')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Company holidays */}
|
||||||
|
<div>
|
||||||
|
<SettingToggle
|
||||||
|
icon={Building2}
|
||||||
|
label={t('vacay.companyHolidays')}
|
||||||
|
hint={t('vacay.companyHolidaysHint')}
|
||||||
|
value={plan.company_holidays_enabled}
|
||||||
|
onChange={() => toggle('company_holidays_enabled')}
|
||||||
|
/>
|
||||||
|
{plan.company_holidays_enabled && (
|
||||||
|
<div className="ml-7 mt-2">
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1.5 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<AlertCircle size={12} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-[10px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.companyHolidaysNoDeduct')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Public holidays */}
|
||||||
|
<div>
|
||||||
|
<SettingToggle
|
||||||
|
icon={Globe}
|
||||||
|
label={t('vacay.publicHolidays')}
|
||||||
|
hint={t('vacay.publicHolidaysHint')}
|
||||||
|
value={plan.holidays_enabled}
|
||||||
|
onChange={() => toggle('holidays_enabled')}
|
||||||
|
/>
|
||||||
|
{plan.holidays_enabled && (
|
||||||
|
<div className="ml-7 mt-2 space-y-2">
|
||||||
|
{(plan.holiday_calendars ?? []).length === 0 && (
|
||||||
|
<p className="text-xs" style={{ color: 'var(--text-faint)' }}>{t('vacay.noCalendars')}</p>
|
||||||
|
)}
|
||||||
|
{(plan.holiday_calendars ?? []).map(cal => (
|
||||||
|
<CalendarRow
|
||||||
|
key={cal.id}
|
||||||
|
cal={cal}
|
||||||
|
countries={countries}
|
||||||
|
language={language}
|
||||||
|
onUpdate={(data) => updateHolidayCalendar(cal.id, data)}
|
||||||
|
onDelete={() => deleteHolidayCalendar(cal.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{showAddForm ? (
|
||||||
|
<AddCalendarForm
|
||||||
|
countries={countries}
|
||||||
|
language={language}
|
||||||
|
onAdd={async (data) => { await addHolidayCalendar(data); setShowAddForm(false) }}
|
||||||
|
onCancel={() => setShowAddForm(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAddForm(true)}
|
||||||
|
className="flex items-center gap-1.5 text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||||
|
style={{ color: 'var(--text-muted)', background: 'var(--bg-secondary)' }}
|
||||||
|
>
|
||||||
|
<Plus size={12} />
|
||||||
|
{t('vacay.addCalendar')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dissolve fusion */}
|
||||||
|
{isFused && (
|
||||||
|
<div className="pt-4 mt-2 border-t" style={{ borderColor: 'var(--border-secondary)' }}>
|
||||||
|
<div className="rounded-xl overflow-hidden" style={{ border: '1px solid rgba(239,68,68,0.2)' }}>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-3" style={{ background: 'rgba(239,68,68,0.06)' }}>
|
||||||
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ background: 'rgba(239,68,68,0.1)' }}>
|
||||||
|
<Unlink size={16} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>{t('vacay.dissolve')}</p>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{t('vacay.dissolveHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 flex items-center gap-2 flex-wrap" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
|
||||||
|
{users.map(u => (
|
||||||
|
<div key={u.id} className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: u.color || '#6366f1' }} />
|
||||||
|
<span className="text-xs font-medium" style={{ color: 'var(--text-primary)' }}>{u.username}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3" style={{ borderTop: '1px solid rgba(239,68,68,0.1)' }}>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
await dissolve()
|
||||||
|
toast.success(t('vacay.dissolved'))
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-xs font-medium bg-red-500 hover:bg-red-600 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{t('vacay.dissolveAction')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingToggleProps {
|
||||||
|
icon: LucideIcon
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
value: boolean
|
||||||
|
onChange: () => 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">
|
||||||
|
<Icon size={15} className="shrink-0" style={{ color: 'var(--text-muted)' }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>{label}</p>
|
||||||
|
<p className="text-[11px]" style={{ color: 'var(--text-faint)' }}>{hint}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={onChange}
|
||||||
|
className="relative shrink-0 inline-flex h-6 w-11 items-center rounded-full transition-colors"
|
||||||
|
style={{ background: value ? 'var(--text-primary)' : 'var(--border-primary)' }}>
|
||||||
|
<span className="absolute left-1 h-4 w-4 rounded-full transition-transform duration-200"
|
||||||
|
style={{ background: 'var(--bg-card)', transform: value ? 'translateX(20px)' : 'translateX(0)' }} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared region-loading helper ─────────────────────────────────────────────
|
||||||
|
async function fetchRegionOptions(country: string): Promise<{ value: string; label: string }[]> {
|
||||||
|
try {
|
||||||
|
const year = new Date().getFullYear()
|
||||||
|
const r = await apiClient.get(`/addons/vacay/holidays/${year}/${country}`)
|
||||||
|
const allCounties = new Set<string>()
|
||||||
|
r.data.forEach(h => { if (h.counties) h.counties.forEach(c => allCounties.add(c)) })
|
||||||
|
if (allCounties.size === 0) return []
|
||||||
|
return [...allCounties].sort().map(c => {
|
||||||
|
let label = c.split('-')[1] || c
|
||||||
|
if (c.startsWith('DE-')) {
|
||||||
|
const m: 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' }
|
||||||
|
label = m[c.split('-')[1]] || label
|
||||||
|
} else if (c.startsWith('CH-')) {
|
||||||
|
const m: Record<string, string> = { AG:'Aargau',AI:'Appenzell Innerrhoden',AR:'Appenzell Ausserrhoden',BE:'Bern',BL:'Basel-Landschaft',BS:'Basel-Stadt',FR:'Freiburg',GE:'Genf',GL:'Glarus',GR:'Graubünden',JU:'Jura',LU:'Luzern',NE:'Neuenburg',NW:'Nidwalden',OW:'Obwalden',SG:'St. Gallen',SH:'Schaffhausen',SO:'Solothurn',SZ:'Schwyz',TG:'Thurgau',TI:'Tessin',UR:'Uri',VD:'Waadt',VS:'Wallis',ZG:'Zug',ZH:'Zürich' }
|
||||||
|
label = m[c.split('-')[1]] || label
|
||||||
|
}
|
||||||
|
return { value: c, label }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Existing calendar row (inline edit) ──────────────────────────────────────
|
||||||
|
function CalendarRow({ cal, countries, onUpdate, onDelete }: {
|
||||||
|
cal: VacayHolidayCalendar
|
||||||
|
countries: { value: string; label: string }[]
|
||||||
|
language: string
|
||||||
|
onUpdate: (data: { region?: string; color?: string; label?: string | null }) => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [localColor, setLocalColor] = useState(cal.color)
|
||||||
|
const [localLabel, setLocalLabel] = useState(cal.label || '')
|
||||||
|
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||||
|
|
||||||
|
const selectedCountry = cal.region.split('-')[0]
|
||||||
|
const selectedRegion = cal.region.includes('-') ? cal.region : ''
|
||||||
|
|
||||||
|
useEffect(() => { setLocalColor(cal.color) }, [cal.color])
|
||||||
|
useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCountry) { setRegions([]); return }
|
||||||
|
fetchRegionOptions(selectedCountry).then(setRegions)
|
||||||
|
}, [selectedCountry])
|
||||||
|
|
||||||
|
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 items-start p-3 rounded-xl" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: 8, background: localColor, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||||
|
title={t('vacay.calendarColor')}
|
||||||
|
/>
|
||||||
|
{showColorPicker && (
|
||||||
|
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||||
|
{PRESET_COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => { setLocalColor(c); setShowColorPicker(false); if (c !== cal.color) onUpdate({ color: c }) }}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: localColor === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={localLabel}
|
||||||
|
onChange={e => setLocalLabel(e.target.value)}
|
||||||
|
onBlur={() => { const v = localLabel.trim() || null; if (v !== cal.label) onUpdate({ label: v }) }}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }}
|
||||||
|
placeholder={t('vacay.calendarLabel')}
|
||||||
|
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={v => onUpdate({ region: v })}
|
||||||
|
options={countries}
|
||||||
|
placeholder={t('vacay.selectCountry')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedRegion}
|
||||||
|
onChange={v => onUpdate({ region: v })}
|
||||||
|
options={regions}
|
||||||
|
placeholder={t('vacay.selectRegion')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
className="shrink-0 p-1.5 rounded-md transition-colors"
|
||||||
|
style={{ color: 'var(--text-faint)' }}
|
||||||
|
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.background = 'rgba(239,68,68,0.1)' }}
|
||||||
|
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add-new-calendar form ─────────────────────────────────────────────────────
|
||||||
|
function AddCalendarForm({ countries, onAdd, onCancel }: {
|
||||||
|
countries: { value: string; label: string }[]
|
||||||
|
language: string
|
||||||
|
onAdd: (data: { region: string; color: string; label: string | null }) => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [region, setRegion] = useState('')
|
||||||
|
const [color, setColor] = useState('#fecaca')
|
||||||
|
const [label, setLabel] = useState('')
|
||||||
|
const [regions, setRegions] = useState<{ value: string; label: string }[]>([])
|
||||||
|
const [loadingRegions, setLoadingRegions] = useState(false)
|
||||||
|
|
||||||
|
const selectedCountry = region.split('-')[0] || ''
|
||||||
|
const selectedRegion = region.includes('-') ? region : ''
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedCountry) { setRegions([]); return }
|
||||||
|
setLoadingRegions(true)
|
||||||
|
fetchRegionOptions(selectedCountry).then(list => { setRegions(list) }).finally(() => setLoadingRegions(false))
|
||||||
|
}, [selectedCountry])
|
||||||
|
|
||||||
|
const canAdd = selectedCountry && (regions.length === 0 || selectedRegion !== '')
|
||||||
|
|
||||||
|
const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6']
|
||||||
|
const [showColorPicker, setShowColorPicker] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 items-start p-3 rounded-xl border border-dashed" style={{ borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowColorPicker(!showColorPicker)}
|
||||||
|
style={{ width: 28, height: 28, borderRadius: 8, background: color, border: '2px solid var(--border-primary)', cursor: 'pointer' }}
|
||||||
|
title={t('vacay.calendarColor')}
|
||||||
|
/>
|
||||||
|
{showColorPicker && (
|
||||||
|
<div style={{ position: 'absolute', top: 34, left: 0, zIndex: 50, background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 12, padding: 8, boxShadow: '0 8px 24px rgba(0,0,0,0.12)', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 4, width: 120 }}>
|
||||||
|
{PRESET_COLORS.map(c => (
|
||||||
|
<button key={c} onClick={() => { setColor(c); setShowColorPicker(false) }}
|
||||||
|
style={{ width: 24, height: 24, borderRadius: 6, background: c, border: color === c ? '2px solid var(--text-primary)' : '2px solid transparent', cursor: 'pointer' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={label}
|
||||||
|
onChange={e => setLabel(e.target.value)}
|
||||||
|
placeholder={t('vacay.calendarLabel')}
|
||||||
|
style={{ width: '100%', fontSize: 12, padding: '6px 10px', borderRadius: 8, background: 'var(--bg-input)', border: '1px solid var(--border-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedCountry}
|
||||||
|
onChange={v => { setRegion(v); setRegions([]) }}
|
||||||
|
options={countries}
|
||||||
|
placeholder={t('vacay.selectCountry')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
{regions.length > 0 && (
|
||||||
|
<CustomSelect
|
||||||
|
value={selectedRegion}
|
||||||
|
onChange={v => setRegion(v)}
|
||||||
|
options={regions}
|
||||||
|
placeholder={t('vacay.selectRegion')}
|
||||||
|
searchable
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5 pt-0.5">
|
||||||
|
<button
|
||||||
|
disabled={!canAdd}
|
||||||
|
onClick={() => onAdd({ region: region || selectedCountry, color, label: label.trim() || null })}
|
||||||
|
className="flex-1 text-xs px-2 py-1.5 rounded-md font-medium transition-colors disabled:opacity-40"
|
||||||
|
style={{ background: 'var(--text-primary)', color: 'var(--bg-card)' }}
|
||||||
|
>
|
||||||
|
{t('vacay.add')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-xs px-2 py-1.5 rounded-md transition-colors"
|
||||||
|
style={{ background: 'var(--bg-secondary)', color: 'var(--text-muted)' }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
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()
|
||||||
|
const { stats, selectedYear, loadStats, updateVacationDays, isFused } = useVacayStore()
|
||||||
|
const { user: currentUser } = useAuthStore()
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(selectedYear) }, [selectedYear])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border p-3" style={{ background: 'var(--bg-card)', borderColor: 'var(--border-primary)' }}>
|
||||||
|
<div className="flex items-center gap-1.5 mb-3">
|
||||||
|
<Briefcase size={13} style={{ color: 'var(--text-faint)' }} />
|
||||||
|
<span className="text-[11px] font-medium uppercase tracking-wider" style={{ color: 'var(--text-faint)' }}>
|
||||||
|
{t('vacay.entitlement')} {selectedYear}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stats.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-center py-3" style={{ color: 'var(--text-faint)' }}>{t('vacay.noData')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stats.map(s => (
|
||||||
|
<StatCard
|
||||||
|
key={s.user_id}
|
||||||
|
stat={s}
|
||||||
|
isMe={s.user_id === currentUser?.id}
|
||||||
|
canEdit={s.user_id === currentUser?.id || isFused}
|
||||||
|
selectedYear={selectedYear}
|
||||||
|
onSave={updateVacationDays}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Sync local state when stats reload from server
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing) setLocalDays(s.vacation_days)
|
||||||
|
}, [s.vacation_days, editing])
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
setEditing(false)
|
||||||
|
const days = parseInt(localDays)
|
||||||
|
if (!isNaN(days) && days >= 0 && days <= 365 && days !== s.vacation_days) {
|
||||||
|
onSave(selectedYear, days, s.user_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg p-2.5 space-y-2" style={{ border: '1px solid var(--border-secondary)' }}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: s.person_color }} />
|
||||||
|
<span className="text-xs font-semibold flex-1 truncate" style={{ color: 'var(--text-primary)' }}>
|
||||||
|
{s.person_name}
|
||||||
|
{isMe && <span style={{ color: 'var(--text-faint)' }}> ({t('vacay.you')})</span>}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] tabular-nums" style={{ color: 'var(--text-faint)' }}>{s.used}/{s.total_available}</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: s.person_color }} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-1.5">
|
||||||
|
{/* Days — editable */}
|
||||||
|
<div
|
||||||
|
className="rounded-md px-2 py-2 group/days"
|
||||||
|
style={{
|
||||||
|
background: canEdit ? 'var(--bg-card)' : 'var(--bg-secondary)',
|
||||||
|
border: canEdit ? '1px solid var(--border-primary)' : '1px solid transparent',
|
||||||
|
cursor: canEdit ? 'pointer' : 'default',
|
||||||
|
}}
|
||||||
|
onClick={() => { if (canEdit && !editing) setEditing(true) }}
|
||||||
|
>
|
||||||
|
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>
|
||||||
|
{t('vacay.entitlementDays')} {canEdit && !editing && <Pencil size={9} className="inline opacity-0 group-hover/days:opacity-100 transition-opacity" style={{ color: 'var(--text-faint)', verticalAlign: 'middle' }} />}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={localDays}
|
||||||
|
onChange={e => setLocalDays(e.target.value)}
|
||||||
|
onBlur={handleSave}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleSave(); if (e.key === 'Escape') { setEditing(false); setLocalDays(s.vacation_days) } }}
|
||||||
|
autoFocus
|
||||||
|
className="w-full bg-transparent text-sm font-bold outline-none p-0 m-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
|
||||||
|
style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.vacation_days}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Used */}
|
||||||
|
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.used')}</div>
|
||||||
|
<div className="text-sm font-bold" style={{ color: 'var(--text-primary)', height: 18, lineHeight: '18px' }}>{s.used}</div>
|
||||||
|
</div>
|
||||||
|
{/* Remaining */}
|
||||||
|
<div className="rounded-md px-2 py-2" style={{ background: 'var(--bg-secondary)' }}>
|
||||||
|
<div className="text-[10px] mb-1" style={{ color: 'var(--text-faint)', height: 14, lineHeight: '14px' }}>{t('vacay.remaining')}</div>
|
||||||
|
<div className="text-sm font-bold" style={{ color: s.remaining < 0 ? '#ef4444' : s.remaining <= 3 ? '#f59e0b' : '#22c55e', height: 18, lineHeight: '18px' }}>
|
||||||
|
{s.remaining}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{s.carried_over > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md" style={{ background: 'rgba(245,158,11,0.08)', border: '1px solid rgba(245,158,11,0.15)' }}>
|
||||||
|
<span className="text-[10px]" style={{ color: '#d97706' }}>+{s.carried_over} {t('vacay.carriedOver', { year: selectedYear - 1 })}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
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, weekendDays: number[] = [0, 6]): boolean {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
|
return weekendDays.includes(d.getDay())
|
||||||
|
}
|
||||||
|
|
||||||
|
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, locale?: string): string {
|
||||||
|
const d = new Date(dateStr + 'T00:00:00')
|
||||||
|
return d.toLocaleDateString(locale || undefined, { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BUNDESLAENDER }
|
||||||
@@ -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 { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||||
import { weatherApi } from '../../api/client'
|
import { weatherApi } from '../../api/client'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -15,7 +15,12 @@ const WEATHER_ICON_MAP = {
|
|||||||
Haze: Wind,
|
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
|
const Icon = WEATHER_ICON_MAP[main] || Cloud
|
||||||
return <Icon size={size} strokeWidth={1.8} />
|
return <Icon size={size} strokeWidth={1.8} />
|
||||||
}
|
}
|
||||||
@@ -32,7 +37,14 @@ function setWeatherCache(key, value) {
|
|||||||
try { sessionStorage.setItem(key, JSON.stringify(value)) } catch {}
|
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 [weather, setWeather] = useState(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [failed, setFailed] = useState(false)
|
const [failed, setFailed] = useState(false)
|
||||||
@@ -46,21 +58,35 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
|||||||
const cached = getWeatherCache(cacheKey)
|
const cached = getWeatherCache(cacheKey)
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
if (cached === null) setFailed(true)
|
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
|
return
|
||||||
}
|
}
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
weatherApi.get(lat, lng, date)
|
weatherApi.get(lat, lng, date)
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.error || data.temp === undefined) {
|
if (data.error || data.temp === undefined) {
|
||||||
setWeatherCache(cacheKey, null)
|
|
||||||
setFailed(true)
|
setFailed(true)
|
||||||
} else {
|
} else {
|
||||||
setWeatherCache(cacheKey, data)
|
setWeatherCache(cacheKey, data)
|
||||||
setWeather(data)
|
setWeather(data)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => { setWeatherCache(cacheKey, null); setFailed(true) })
|
.catch(() => { setFailed(true) })
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [lat, lng, date])
|
}, [lat, lng, date])
|
||||||
|
|
||||||
@@ -83,20 +109,21 @@ export default function WeatherWidget({ lat, lng, date, compact = false }) {
|
|||||||
const rawTemp = weather.temp
|
const rawTemp = weather.temp
|
||||||
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
const temp = rawTemp !== undefined ? Math.round(isFahrenheit ? rawTemp * 9/5 + 32 : rawTemp) : null
|
||||||
const unit = isFahrenheit ? '°F' : '°C'
|
const unit = isFahrenheit ? '°F' : '°C'
|
||||||
|
const isClimate = weather.type === 'climate'
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
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} />
|
<WeatherIcon main={weather.main} size={12} />
|
||||||
{temp !== null && <span>{temp}{unit}</span>}
|
{temp !== null && <span>{isClimate ? 'Ø ' : ''}{temp}{unit}</span>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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} />
|
<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>}
|
{weather.description && <span style={{ fontSize: 11, color: '#9ca3af', textTransform: 'capitalize' }}>{weather.description}</span>}
|
||||||
</div>
|
</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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,24 +3,30 @@ import ReactDOM from 'react-dom'
|
|||||||
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
|
import { Calendar, Clock, ChevronLeft, ChevronRight, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
|
|
||||||
function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() }
|
function daysInMonth(year: number, month: number): number { return new Date(year, month + 1, 0).getDate() }
|
||||||
function getWeekday(year, month, day) { return new Date(year, month, day).getDay() }
|
function getWeekday(year: number, month: number, day: number): number { return new Date(year, month, day).getDay() }
|
||||||
|
|
||||||
// ── Datum-Only Picker ────────────────────────────────────────────────────────
|
interface CustomDatePickerProps {
|
||||||
export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomDatePicker({ value, onChange, placeholder, style = {} }: CustomDatePickerProps) {
|
||||||
const { locale, t } = useTranslation()
|
const { locale, t } = useTranslation()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const ref = useRef(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const dropRef = useRef(null)
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const parsed = value ? new Date(value + 'T00:00:00') : null
|
const parsed = value ? new Date(value + 'T00:00:00') : null
|
||||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || new Date().getFullYear())
|
||||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() ?? new Date().getMonth())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (ref.current?.contains(e.target)) return
|
if (ref.current?.contains(e.target as Node)) return
|
||||||
if (dropRef.current?.contains(e.target)) return
|
if (dropRef.current?.contains(e.target as Node)) return
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
if (open) document.addEventListener('mousedown', handler)
|
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 monthLabel = new Date(viewYear, viewMonth).toLocaleDateString(locale, { month: 'long', year: 'numeric' })
|
||||||
const days = daysInMonth(viewYear, viewMonth)
|
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 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 displayValue = parsed ? parsed.toLocaleDateString(locale, { day: 'numeric', month: 'short', year: 'numeric' }) : null
|
||||||
|
|
||||||
const selectDay = (day) => {
|
const selectDay = (day: number) => {
|
||||||
const y = String(viewYear)
|
const y = String(viewYear)
|
||||||
const m = String(viewMonth + 1).padStart(2, '0')
|
const m = String(viewMonth + 1).padStart(2, '0')
|
||||||
const d = String(day).padStart(2, '0')
|
const d = String(day).padStart(2, '0')
|
||||||
@@ -51,11 +57,45 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
|||||||
|
|
||||||
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
const selectedDay = parsed && parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth ? parsed.getDate() : null
|
||||||
const today = new Date()
|
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
|
||||||
|
|
||||||
|
const [textInput, setTextInput] = useState('')
|
||||||
|
const [isTyping, setIsTyping] = useState(false)
|
||||||
|
|
||||||
|
const handleTextSubmit = () => {
|
||||||
|
setIsTyping(false)
|
||||||
|
if (!textInput.trim()) return
|
||||||
|
// Try to parse various date formats
|
||||||
|
const input = textInput.trim()
|
||||||
|
// ISO: 2026-03-29
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(input)) { onChange(input); return }
|
||||||
|
// EU: 29.03.2026 or 29/03/2026
|
||||||
|
const euMatch = input.match(/^(\d{1,2})[./](\d{1,2})[./](\d{2,4})$/)
|
||||||
|
if (euMatch) {
|
||||||
|
const y = euMatch[3].length === 2 ? 2000 + parseInt(euMatch[3]) : parseInt(euMatch[3])
|
||||||
|
onChange(`${y}-${String(euMatch[2]).padStart(2, '0')}-${String(euMatch[1]).padStart(2, '0')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Try native Date parse as fallback
|
||||||
|
const d = new Date(input)
|
||||||
|
if (!isNaN(d.getTime())) {
|
||||||
|
onChange(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} style={{ position: 'relative', ...style }}>
|
<div ref={ref} style={{ position: 'relative', ...style }}>
|
||||||
<button type="button" onClick={() => setOpen(o => !o)}
|
{isTyping ? (
|
||||||
|
<input autoFocus type="text" value={textInput} onChange={e => setTextInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') handleTextSubmit(); if (e.key === 'Escape') setIsTyping(false) }}
|
||||||
|
onBlur={handleTextSubmit}
|
||||||
|
placeholder="DD.MM.YYYY"
|
||||||
|
style={{
|
||||||
|
width: '100%', padding: '8px 14px', borderRadius: 10, border: '1px solid var(--text-faint)',
|
||||||
|
background: 'var(--bg-input)', color: 'var(--text-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none',
|
||||||
|
}} />
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => setOpen(o => !o)} onDoubleClick={() => { setTextInput(value || ''); setIsTyping(true) }}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
width: '100%', display: 'flex', alignItems: 'center', gap: 8,
|
||||||
padding: '8px 14px', borderRadius: 10,
|
padding: '8px 14px', borderRadius: 10,
|
||||||
@@ -67,8 +107,9 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
|||||||
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}>
|
||||||
<Calendar size={14} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
|
<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>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
@@ -81,11 +122,8 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
|||||||
const vh = window.innerHeight
|
const vh = window.innerHeight
|
||||||
let left = r.left
|
let left = r.left
|
||||||
let top = r.bottom + 4
|
let top = r.bottom + 4
|
||||||
// Keep within viewport horizontally
|
|
||||||
if (left + w > vw - pad) left = Math.max(pad, vw - w - pad)
|
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)
|
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)
|
if (vw < 360) left = Math.max(pad, (vw - w) / 2)
|
||||||
return { top, left }
|
return { top, left }
|
||||||
})(),
|
})(),
|
||||||
@@ -161,18 +199,23 @@ export function CustomDatePicker({ value, onChange, placeholder, style = {} }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── DateTime Picker (Datum + Uhrzeit kombiniert) ─────────────────────────────
|
interface CustomDateTimePickerProps {
|
||||||
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }) {
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
style?: React.CSSProperties
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomDateTimePicker({ value, onChange, placeholder, style = {} }: CustomDateTimePickerProps) {
|
||||||
const { locale } = useTranslation()
|
const { locale } = useTranslation()
|
||||||
// value = "2024-03-15T14:30" oder ""
|
|
||||||
const [datePart, timePart] = (value || '').split('T')
|
const [datePart, timePart] = (value || '').split('T')
|
||||||
|
|
||||||
const handleDateChange = (d) => {
|
const handleDateChange = (d: string) => {
|
||||||
onChange(d ? `${d}T${timePart || '12:00'}` : '')
|
onChange(d ? `${d}T${timePart || '12:00'}` : '')
|
||||||
}
|
}
|
||||||
const handleTimeChange = (t) => {
|
const handleTimeChange = (t: string) => {
|
||||||
const d = datePart || new Date().toISOString().split('T')[0]
|
const d = datePart || new Date().toISOString().split('T')[0]
|
||||||
onChange(t ? `${d}T${t}` : `${d}T00:00`)
|
onChange(t ? `${d}T${t}` : d)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -185,5 +228,4 @@ export function CustomDateTimePicker({ value, onChange, placeholder, style = {}
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline re-export for convenience
|
|
||||||
import CustomTimePicker from './CustomTimePicker'
|
import CustomTimePicker from './CustomTimePicker'
|
||||||
@@ -2,29 +2,48 @@ import React, { useState, useRef, useEffect } from 'react'
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { ChevronDown, Check } from 'lucide-react'
|
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({
|
export default function CustomSelect({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options = [], // [{ value, label, icon? }]
|
options = [],
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
searchable = false,
|
searchable = false,
|
||||||
style = {},
|
style = {},
|
||||||
size = 'md', // 'sm' | 'md'
|
size = 'md',
|
||||||
}) {
|
}: CustomSelectProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const ref = useRef(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const dropRef = useRef(null)
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
const searchRef = useRef(null)
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open && searchable && searchRef.current) searchRef.current.focus()
|
if (open && searchable && searchRef.current) searchRef.current.focus()
|
||||||
}, [open, searchable])
|
}, [open, searchable])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClick = (e) => {
|
const handleClick = (e: MouseEvent) => {
|
||||||
if (ref.current?.contains(e.target)) return
|
if (ref.current?.contains(e.target as Node)) return
|
||||||
if (dropRef.current?.contains(e.target)) return
|
if (dropRef.current?.contains(e.target as Node)) return
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
if (open) document.addEventListener('mousedown', handleClick)
|
if (open) document.addEventListener('mousedown', handleClick)
|
||||||
@@ -33,7 +52,28 @@ export default function CustomSelect({
|
|||||||
|
|
||||||
const selected = options.find(o => o.value === value)
|
const selected = options.find(o => o.value === value)
|
||||||
const filtered = searchable && search
|
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
|
: options
|
||||||
|
|
||||||
const sm = size === 'sm'
|
const sm = size === 'sm'
|
||||||
@@ -51,7 +91,7 @@ export default function CustomSelect({
|
|||||||
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
background: 'var(--bg-input)', color: 'var(--text-primary)',
|
||||||
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
fontSize: 13, fontWeight: 500, fontFamily: 'inherit',
|
||||||
cursor: 'pointer', outline: 'none', textAlign: 'left',
|
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)'}
|
onMouseEnter={e => e.currentTarget.style.borderColor = 'var(--text-faint)'}
|
||||||
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
onMouseLeave={e => { if (!open) e.currentTarget.style.borderColor = 'var(--border-primary)' }}
|
||||||
@@ -67,9 +107,15 @@ export default function CustomSelect({
|
|||||||
{open && ReactDOM.createPortal(
|
{open && ReactDOM.createPortal(
|
||||||
<div ref={dropRef} style={{
|
<div ref={dropRef} style={{
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.bottom + 4 : 0 })(),
|
...(() => {
|
||||||
left: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.left : 0 })(),
|
const r = ref.current?.getBoundingClientRect()
|
||||||
width: (() => { const r = ref.current?.getBoundingClientRect(); return r ? r.width : 200 })(),
|
if (!r) return { top: 0, left: 0, width: 200 }
|
||||||
|
const spaceBelow = window.innerHeight - r.bottom
|
||||||
|
const openUp = spaceBelow < 220 && r.top > spaceBelow
|
||||||
|
return openUp
|
||||||
|
? { bottom: window.innerHeight - r.top + 4, left: r.left, width: r.width }
|
||||||
|
: { top: r.bottom + 4, left: r.left, width: r.width }
|
||||||
|
})(),
|
||||||
zIndex: 99999,
|
zIndex: 99999,
|
||||||
background: 'var(--bg-card)',
|
background: 'var(--bg-card)',
|
||||||
backdropFilter: 'blur(24px) saturate(180%)',
|
backdropFilter: 'blur(24px) saturate(180%)',
|
||||||
@@ -105,6 +151,17 @@ export default function CustomSelect({
|
|||||||
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
<div style={{ padding: '10px 12px', fontSize: 12, color: 'var(--text-faint)', textAlign: 'center' }}>—</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map(option => {
|
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
|
const isSelected = option.value === value
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'
|
|||||||
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
|
import { Clock, ChevronUp, ChevronDown } from 'lucide-react'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
|
|
||||||
function formatDisplay(val, is12h) {
|
function formatDisplay(val: string, is12h: boolean): string {
|
||||||
if (!val) return ''
|
if (!val) return ''
|
||||||
const [h, m] = val.split(':').map(Number)
|
const [h, m] = val.split(':').map(Number)
|
||||||
if (isNaN(h) || isNaN(m)) return val
|
if (isNaN(h) || isNaN(m)) return val
|
||||||
@@ -13,28 +13,35 @@ function formatDisplay(val, is12h) {
|
|||||||
return `${h12}:${String(m).padStart(2, '0')} ${period}`
|
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 is12h = useSettingsStore(s => s.settings.time_format) === '12h'
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [inputFocused, setInputFocused] = useState(false)
|
const [inputFocused, setInputFocused] = useState(false)
|
||||||
const ref = useRef(null)
|
const ref = useRef<HTMLDivElement>(null)
|
||||||
const dropRef = useRef(null)
|
const dropRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const [h, m] = (value || '').split(':').map(Number)
|
const [h, m] = (value || '').split(':').map(Number)
|
||||||
const hour = isNaN(h) ? null : h
|
const hour = isNaN(h) ? null : h
|
||||||
const minute = isNaN(m) ? null : m
|
const minute = isNaN(m) ? null : m
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e) => {
|
const handler = (e: MouseEvent) => {
|
||||||
if (ref.current?.contains(e.target)) return
|
if (ref.current?.contains(e.target as Node)) return
|
||||||
if (dropRef.current?.contains(e.target)) return
|
if (dropRef.current?.contains(e.target as Node)) return
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
}
|
}
|
||||||
if (open) document.addEventListener('mousedown', handler)
|
if (open) document.addEventListener('mousedown', handler)
|
||||||
return () => document.removeEventListener('mousedown', handler)
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
}, [open])
|
}, [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 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')
|
const mm = String(Math.max(0, Math.min(59, newM))).padStart(2, '0')
|
||||||
onChange(`${hh}:${mm}`)
|
onChange(`${hh}:${mm}`)
|
||||||
@@ -53,16 +60,15 @@ export default function CustomTimePicker({ value, onChange, placeholder = '00:00
|
|||||||
update(newH, newM)
|
update(newH, newM)
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnStyle = {
|
const btnStyle: React.CSSProperties = {
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
background: 'none', border: 'none', cursor: 'pointer', padding: 2,
|
||||||
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
|
color: 'var(--text-faint)', display: 'flex', borderRadius: 4,
|
||||||
transition: 'color 0.15s',
|
transition: 'color 0.15s',
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInput = (e) => {
|
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const raw = e.target.value
|
const raw = e.target.value
|
||||||
onChange(raw)
|
onChange(raw)
|
||||||
// Auto-format: wenn "1430" → "14:30"
|
|
||||||
const clean = raw.replace(/[^0-9:]/g, '')
|
const clean = raw.replace(/[^0-9:]/g, '')
|
||||||
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
if (/^\d{2}:\d{2}$/.test(clean)) onChange(clean)
|
||||||
else if (/^\d{4}$/.test(clean)) onChange(clean.slice(0, 2) + ':' + clean.slice(2))
|
else if (/^\d{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 h = Math.min(23, Math.max(0, parseInt(s.slice(0, 2))))
|
||||||
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
|
const m = Math.min(59, Math.max(0, parseInt(s.slice(2))))
|
||||||
onChange(String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'))
|
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',
|
animation: 'selectIn 0.15s ease-out',
|
||||||
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
backdropFilter: 'blur(24px)', WebkitBackdropFilter: 'blur(24px)',
|
||||||
}}>
|
}}>
|
||||||
{/* Stunden */}
|
{/* Hours */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
<button type="button" onClick={incHour} style={btnStyle}
|
<button type="button" onClick={incHour} style={btnStyle}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
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>
|
<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 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2 }}>
|
||||||
<button type="button" onClick={incMin} style={btnStyle}
|
<button type="button" onClick={incMin} style={btnStyle}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||