mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
Compare commits
32 Commits
main
..
070ef01328
| Author | SHA1 | Date | |
|---|---|---|---|
| 070ef01328 | |||
| a876fb2634 | |||
| 247433fb2a | |||
| 6ef3c7ae6b | |||
| abe1c549bd | |||
| 10bea35a91 | |||
| a77ee4b4d5 | |||
| 9bec97fc19 | |||
| 20791a29a7 | |||
| 6d2dd37414 | |||
| 0d2657ee37 | |||
| 0a8fb1f53b | |||
| 2fe6657edd | |||
| 5f964b9524 | |||
| 8bda980028 | |||
| 831a4fd478 | |||
| 4ff4435f8b | |||
| 69b699c9bf | |||
| 98032fda0c | |||
| e04ceeb1ee | |||
| e5000ff7dd | |||
| 126f2df21b | |||
| 324d930ca3 | |||
| e050814c42 | |||
| c130ed41be | |||
| db5c403239 | |||
| bd29fcb0c0 | |||
| be71cae0d3 | |||
| ee2089e81d | |||
| 352f94612d | |||
| 0257e4e71e | |||
| 0b218d53b2 |
@@ -34,5 +34,4 @@ jobs:
|
|||||||
command: cves
|
command: cves
|
||||||
image: trek:scan
|
image: trek:scan
|
||||||
only-severities: critical,high
|
only-severities: critical,high
|
||||||
only-fixed: true
|
|
||||||
exit-code: true
|
exit-code: true
|
||||||
|
|||||||
+2
-24
@@ -1,10 +1,3 @@
|
|||||||
# ── Stage 0: gosu ────────────────────────────────────────────────────────────
|
|
||||||
# Rebuild gosu with a current Go toolchain so the runtime image ships no stale
|
|
||||||
# Go stdlib (Debian's apt gosu is built with an old Go that trips CVE scanners).
|
|
||||||
# The binary and its runtime behaviour are identical to the apt package.
|
|
||||||
FROM golang:1.25-alpine AS gosu-build
|
|
||||||
RUN CGO_ENABLED=0 GOBIN=/out go install github.com/tianon/gosu@latest
|
|
||||||
|
|
||||||
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
# ── Stage 1: shared ──────────────────────────────────────────────────────────
|
||||||
FROM node:24-alpine AS shared-builder
|
FROM node:24-alpine AS shared-builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -51,7 +44,7 @@ COPY server/package.json ./server/
|
|||||||
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
# amd64 — static binary from KDE CDN (glibc 2.17+; wget stays for healthcheck)
|
||||||
# arm64 — apt package (KDE publishes no arm64 static binary)
|
# arm64 — apt package (KDE publishes no arm64 static binary)
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends tzdata dumb-init wget ca-certificates python3 build-essential && \
|
apt-get install -y --no-install-recommends tzdata dumb-init gosu wget ca-certificates python3 build-essential && \
|
||||||
npm ci --workspace=server --omit=dev && \
|
npm ci --workspace=server --omit=dev && \
|
||||||
ARCH=$(dpkg --print-architecture) && \
|
ARCH=$(dpkg --print-architecture) && \
|
||||||
if [ "$ARCH" = "amd64" ]; then \
|
if [ "$ARCH" = "amd64" ]; then \
|
||||||
@@ -67,9 +60,6 @@ RUN apt-get update && \
|
|||||||
apt-get autoremove -y && \
|
apt-get autoremove -y && \
|
||||||
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
rm -rf /var/lib/apt/lists/* /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
|
||||||
|
|
||||||
# gosu rebuilt with a current Go toolchain (stage 0) — used by CMD to drop to node.
|
|
||||||
COPY --from=gosu-build /out/gosu /usr/local/bin/gosu
|
|
||||||
|
|
||||||
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
ENV XDG_CACHE_HOME=/tmp/kf6-cache
|
||||||
# Prevent Qt from probing for a display in headless containers.
|
# Prevent Qt from probing for a display in headless containers.
|
||||||
ENV QT_QPA_PLATFORM=offscreen
|
ENV QT_QPA_PLATFORM=offscreen
|
||||||
@@ -78,17 +68,8 @@ ENV QT_QPA_PLATFORM=offscreen
|
|||||||
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
ENV KITINERARY_EXTRACTOR_PATH=/usr/local/bin/kitinerary-extractor
|
||||||
|
|
||||||
COPY --from=server-builder /app/server/dist ./server/dist
|
COPY --from=server-builder /app/server/dist ./server/dist
|
||||||
# Runtime data assets read from server/assets at runtime: airports.json (flight
|
|
||||||
# transport search) and atlas/*.geojson.gz (Atlas country/region map). The build
|
|
||||||
# only emits dist, so these must be copied explicitly or the features silently
|
|
||||||
# degrade to empty in the image.
|
|
||||||
COPY --from=server-builder /app/server/assets ./server/assets
|
|
||||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||||
COPY server/tsconfig.json ./server/
|
COPY server/tsconfig.json ./server/
|
||||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
|
||||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
|
||||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
|
||||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
|
||||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||||
COPY --from=client-builder /app/client/dist ./server/public
|
COPY --from=client-builder /app/client/dist ./server/public
|
||||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||||
@@ -109,8 +90,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
|||||||
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
CMD wget -qO- http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
# Preflight: if the app code is missing, a volume was almost certainly mounted
|
|
||||||
# over /app (it hides the image's node_modules + dist). Fail with actionable
|
|
||||||
# guidance instead of a cryptic "Cannot find module 'tsconfig-paths/register'".
|
|
||||||
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
# cd into server/ so tsconfig-paths/register finds tsconfig.json and ../node_modules resolves correctly.
|
||||||
CMD ["sh", "-c", "if [ ! -f /app/server/dist/index.js ] || [ ! -d /app/node_modules/tsconfig-paths ]; then echo 'FATAL: TREK application files are missing from the image.'; echo 'A volume is likely mounted over /app, which hides the app code.'; echo 'Mount ONLY your data and uploads dirs: -v ./data:/app/data -v ./uploads:/app/uploads'; echo 'Do NOT mount a volume at /app. See the Troubleshooting section of the README.'; exit 1; fi; chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
CMD ["sh", "-c", "chown -R node:node /app/data /app/uploads 2>/dev/null || true; cd /app/server && exec gosu node node --require tsconfig-paths/register dist/index.js"]
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Third-party data & attributions
|
|
||||||
|
|
||||||
TREK bundles and uses third-party data that requires attribution.
|
|
||||||
|
|
||||||
## geoBoundaries — country & sub-national boundaries
|
|
||||||
|
|
||||||
The Atlas map's administrative boundaries (admin-0 countries and admin-1
|
|
||||||
provinces/counties), shipped at `server/assets/atlas/admin0.geojson.gz` and
|
|
||||||
`server/assets/atlas/admin1.geojson.gz` and generated by
|
|
||||||
`server/scripts/build-atlas-geo.mjs`, are derived from **geoBoundaries**.
|
|
||||||
|
|
||||||
> Runfola, D. et al. (2020) geoBoundaries: A global database of political
|
|
||||||
> administrative boundaries. PLoS ONE 15(4): e0231866.
|
|
||||||
> https://doi.org/10.1371/journal.pone.0231866
|
|
||||||
|
|
||||||
geoBoundaries is licensed under **CC BY 4.0**
|
|
||||||
(https://creativecommons.org/licenses/by/4.0/). Source: https://www.geoboundaries.org/
|
|
||||||
|
|
||||||
The bundled files are simplified (coordinate-quantized) and re-tagged with the
|
|
||||||
property names TREK consumes. Country borders (`admin0`) derive from the geoBoundaries
|
|
||||||
CGAZ composite; sub-national regions (`admin1`) derive from the per-country open
|
|
||||||
(gbOpen) release.
|
|
||||||
|
|
||||||
## OpenStreetMap — geocoding
|
|
||||||
|
|
||||||
Atlas reverse-geocodes places via the **Nominatim** service. Geocoding data is
|
|
||||||
© OpenStreetMap contributors, licensed under the Open Database License (ODbL).
|
|
||||||
https://www.openstreetmap.org/copyright
|
|
||||||
|
|
||||||
## OurAirports — airport reference data
|
|
||||||
|
|
||||||
`server/assets/airports.json` is built from **OurAirports**
|
|
||||||
(https://ourairports.com/data/), released into the public domain.
|
|
||||||
@@ -51,10 +51,10 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
<a href="docs/screenshots/dashboard.png"><img src="docs/screenshots/dashboard.png" alt="Dashboard" width="49%" /></a>
|
||||||
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
<a href="docs/screenshots/trip-planner.png"><img src="docs/screenshots/trip-planner.png" alt="Trip planner with 3D map" width="49%" /></a>
|
||||||
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
<a href="docs/screenshots/journey.png"><img src="docs/screenshots/journey.png" alt="Journey journal" width="49%" /></a>
|
||||||
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Costs · expense splitting" width="49%" /></a>
|
<a href="docs/screenshots/budget.png"><img src="docs/screenshots/budget.png" alt="Budget tracker" width="49%" /></a>
|
||||||
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
<a href="docs/screenshots/atlas.png"><img src="docs/screenshots/atlas.png" alt="Atlas · visited countries" width="49%" /></a>
|
||||||
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
<a href="docs/screenshots/vacay.png"><img src="docs/screenshots/vacay.png" alt="Vacay planner" width="49%" /></a>
|
||||||
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Trip planner · day plan and route" width="49%" /></a>
|
<a href="docs/screenshots/trip-iceland.png"><img src="docs/screenshots/trip-iceland.png" alt="Iceland Ring Road" width="49%" /></a>
|
||||||
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
<a href="docs/screenshots/admin.png"><img src="docs/screenshots/admin.png" alt="Admin panel" width="49%" /></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -79,7 +79,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
- **Drag & drop planner** — organise places into day plans with reordering and cross-day moves
|
||||||
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
- **Interactive map** — Leaflet or Mapbox GL with 3D buildings, terrain, photo markers, clustering, route visualization
|
||||||
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
- **Place search** — Google Places (photos, ratings, hours) or OpenStreetMap (free, no API key)
|
||||||
- **Place import** — shared Google Maps / Naver Maps lists, plus GPX and KML/KMZ/GeoJSON map files
|
|
||||||
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
- **Day notes** — timestamped, icon-tagged notes with drag-and-drop reordering
|
||||||
- **Route optimisation** — auto-sort places and export to Google Maps
|
- **Route optimisation** — auto-sort places and export to Google Maps
|
||||||
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
- **Weather forecasts** — 16-day via Open-Meteo (no key) + historical climate fallback
|
||||||
@@ -91,7 +90,7 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### 🧳 Travel management
|
#### 🧳 Travel management
|
||||||
|
|
||||||
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
- **Reservations** — flights, accommodations, restaurants with status, confirmation numbers, files; import from booking confirmation emails and PDFs ([KDE Itinerary](https://invent.kde.org/pim/kitinerary))
|
||||||
- **Costs** — track and split trip expenses (Splitwise-style): per-person / per-day breakdowns, settle-up, multi-currency
|
- **Budget tracking** — category-based expenses with pie chart, per-person / per-day splits, multi-currency
|
||||||
- **Packing lists** — categories, templates, user assignment, progress tracking
|
- **Packing lists** — categories, templates, user assignment, progress tracking
|
||||||
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
- **Bag tracking** — optional weight tracking with iOS-style distribution
|
||||||
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
- **Document manager** — attach docs, tickets, PDFs to trips / places / reservations (≤ 50 MB each)
|
||||||
@@ -109,7 +108,6 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
- **Invite links** — one-time or reusable links with expiry
|
- **Invite links** — one-time or reusable links with expiry
|
||||||
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
- **SSO (OIDC)** — Google, Apple, Authentik, Keycloak, or any OIDC provider
|
||||||
- **2FA** — TOTP + backup codes
|
- **2FA** — TOTP + backup codes
|
||||||
- **Passkeys** — passwordless WebAuthn login (fingerprint / face / PIN / security key), admin-toggleable
|
|
||||||
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
- **Collab suite** — group chat, shared notes, polls, day check-ins
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -130,13 +128,13 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### 🧩 Addons (admin-toggleable)
|
#### 🧩 Addons (admin-toggleable)
|
||||||
|
|
||||||
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
- **Lists** — packing lists + to-dos with templates, member assignments, optional bag tracking
|
||||||
- **Costs** — expense tracker with splits and settle-up (who owes whom), multi-currency
|
- **Budget** — expense tracker with splits, pie chart, multi-currency
|
||||||
- **Documents** — file attachments on trips, places, and reservations
|
- **Documents** — file attachments on trips, places, and reservations
|
||||||
- **Collab** — chat, notes, polls, day-by-day attendance
|
- **Collab** — chat, notes, polls, day-by-day attendance
|
||||||
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
- **Vacay** — personal vacation planner with calendar, 100+ country holidays, carry-over tracking
|
||||||
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
- **Atlas** — world map of visited countries, bucket list, travel stats, streak tracking, liquid-glass UI
|
||||||
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
- **Journey** — magazine-style travel journal with entries, photos (Immich/Synology), maps, moods
|
||||||
- **AirTrail** — connect a self-hosted AirTrail instance to import and sync flights into reservations
|
- **Naver List Import** — one-click import from shared Naver Maps lists
|
||||||
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
- **MCP** — expose TREK to AI assistants via OAuth 2.1
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -158,9 +156,8 @@ A self-hosted, real-time collaborative travel planner — with maps, budgets, pa
|
|||||||
#### ⚙️ Admin & customisation
|
#### ⚙️ Admin & customisation
|
||||||
|
|
||||||
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
- **Dashboard views** — card grid or compact list · **Dark mode** — full theme with matching status bar
|
||||||
- **20 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID, TR, JA, KO, UK, GR
|
- **15 languages** — EN, DE, ES, FR, IT, NL, HU, RU, ZH, ZH-TW, PL, CS, AR (RTL), BR, ID
|
||||||
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
- **Admin panel** — users, invites, packing templates, categories, addons, API keys, backups, GitHub history
|
||||||
- **Notifications** — per-user preferences across email (SMTP), webhook, ntfy, and an in-app notification center
|
|
||||||
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
- **Auto-backups** — scheduled with configurable retention · **Units** — °C/°F, 12h/24h, map tile sources, default coordinates
|
||||||
|
|
||||||
</td>
|
</td>
|
||||||
@@ -194,9 +191,9 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -205,7 +202,7 @@ Open `http://localhost:3000`. On first boot TREK seeds an admin account — if y
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
Real-time sync via WebSocket (`ws`). Backend on NestJS 11. State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + Passkeys (WebAuthn) + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
Real-time sync via WebSocket (`ws`). State with Zustand. Auth via JWT + OAuth 2.1 + OIDC + TOTP MFA. Weather via Open-Meteo (no key required). Maps with Leaflet and Mapbox GL.
|
||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
@@ -266,7 +263,7 @@ Then:
|
|||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells the server how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
**HTTPS notes:** `FORCE_HTTPS=true` is optional — it adds a 301 redirect, HSTS, CSP upgrade-insecure-requests, and forces the `secure` cookie flag. Only use it behind a TLS-terminating reverse proxy. `TRUST_PROXY=1` tells Express how many proxies sit in front so real client IPs and `X-Forwarded-Proto` work.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -314,9 +311,6 @@ docker run -d --name trek -p 3000:3000 -v ./data:/app/data -v ./uploads:/app/upl
|
|||||||
|
|
||||||
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
Your data stays in the mounted `data` and `uploads` volumes — updates never touch it.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
|
||||||
> Mount **only** the data and uploads directories — `-v ./data:/app/data -v ./uploads:/app/uploads`. **Never mount a volume at `/app`.** Doing so hides the application code shipped in the image and the container fails to start with `Cannot find module 'tsconfig-paths/register'`. If you previously mounted `/app`, switch to the two mounts above; your data in `data/` and `uploads/` is preserved.
|
|
||||||
|
|
||||||
<h3>Rotating the Encryption Key</h3>
|
<h3>Rotating the Encryption Key</h3>
|
||||||
|
|
||||||
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
If you need to rotate `ENCRYPTION_KEY` (e.g. upgrading from a version that derived encryption from `JWT_SECRET`):
|
||||||
@@ -403,14 +397,12 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
| `ENCRYPTION_KEY` | At-rest encryption key for stored secrets (API keys, MFA, SMTP, OIDC). Recommended: generate with `openssl rand -hex 32`. If unset, falls back to `data/.jwt_secret` (existing installs) or auto-generates a key (fresh installs). | Auto |
|
||||||
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
| `TZ` | Timezone for logs, reminders and cron jobs (e.g. `Europe/Berlin`) | `UTC` |
|
||||||
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
| `LOG_LEVEL` | `info` = concise user actions, `debug` = verbose details | `info` |
|
||||||
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar`, `id`, `tr`, `ja`, `ko`, `uk`, `gr` | `en` |
|
| `DEFAULT_LANGUAGE` | Default language on the login page for users with no saved preference. Browser/OS language is auto-detected first; this is the fallback. Supported: `de`, `en`, `es`, `fr`, `hu`, `nl`, `br`, `cs`, `pl`, `ru`, `zh`, `zh-TW`, `it`, `ar` | `en` |
|
||||||
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
| `ALLOWED_ORIGINS` | Comma-separated origins for CORS and email links | same-origin |
|
||||||
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
| `FORCE_HTTPS` | Optional. When `true`: 301-redirects HTTP to HTTPS, sends HSTS, adds CSP `upgrade-insecure-requests`, forces the session cookie `secure` flag. Useful behind a TLS-terminating reverse proxy. Requires `TRUST_PROXY`. | `false` |
|
||||||
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
| `HSTS_INCLUDE_SUBDOMAINS` | When `true`: adds the `includeSubDomains` directive to the HSTS header, extending HTTPS enforcement to all subdomains. Only effective when HSTS is active (`FORCE_HTTPS=true` or `NODE_ENV=production`). Leave `false` if you run other services on sibling subdomains over plain HTTP. | `false` |
|
||||||
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
| `COOKIE_SECURE` | Controls the `secure` flag on the `trek_session` cookie. Auto-derived: on when `NODE_ENV=production` or `FORCE_HTTPS=true`. Escape hatch: set `false` to allow session cookies over plain HTTP. Not recommended in production. | auto |
|
||||||
| `SESSION_DURATION` | How long a login session stays valid when **"Remember me" is unchecked** (the default): sets the `trek_session` JWT `exp` and issues a browser-session cookie (cleared when the browser closes). Accepts `ms`-style strings: `1h`, `12h`, `7d`, `30d`, `90d`. Invalid values warn at startup and fall back to the default. | `24h` |
|
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells Express to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
||||||
| `SESSION_DURATION_REMEMBER` | Session length when **"Remember me" is ticked** at login: a longer-lived JWT plus a persistent `trek_session` cookie that survives browser restarts. Same format and startup-fallback behaviour as `SESSION_DURATION`. | `30d` |
|
|
||||||
| `TRUST_PROXY` | Number of trusted reverse proxies. Tells the server to read client IP from `X-Forwarded-For` and protocol from `X-Forwarded-Proto`. Defaults to `1` in production; off in dev unless set. | `1` |
|
|
||||||
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
| `ALLOW_INTERNAL_NETWORK` | Allow outbound requests to private/RFC-1918 IPs (e.g. Immich on your LAN). Loopback and link-local addresses remain blocked. | `false` |
|
||||||
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
| `APP_URL` | Public base URL of this instance (e.g. `https://trek.example.com`). Required when OIDC is enabled; used as base for email notification links. | — |
|
||||||
| **OIDC / SSO** | | |
|
| **OIDC / SSO** | | |
|
||||||
@@ -445,13 +437,6 @@ Caddy handles TLS and WebSockets automatically.
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
## Data sources
|
|
||||||
|
|
||||||
The Atlas map's country and sub-national (province/county) boundaries come from
|
|
||||||
[**geoBoundaries**](https://www.geoboundaries.org/) (Runfola et al., 2020), licensed
|
|
||||||
[CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). See [NOTICE.md](NOTICE.md)
|
|
||||||
for full third-party attributions.
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
TREK is [AGPL v3](LICENSE). Self-host freely for personal or internal company use. If you modify and offer TREK as a network service to third parties, your modifications must be open-sourced under the same licence.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: trek
|
name: trek
|
||||||
version: 3.1.1
|
version: 3.0.22
|
||||||
description: Minimal Helm chart for TREK app
|
description: Minimal Helm chart for TREK app
|
||||||
appVersion: "3.1.1"
|
appVersion: "3.0.22"
|
||||||
|
|||||||
@@ -28,12 +28,6 @@ data:
|
|||||||
{{- if .Values.env.COOKIE_SECURE }}
|
{{- if .Values.env.COOKIE_SECURE }}
|
||||||
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
COOKIE_SECURE: {{ .Values.env.COOKIE_SECURE | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if .Values.env.SESSION_DURATION }}
|
|
||||||
SESSION_DURATION: {{ .Values.env.SESSION_DURATION | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.env.SESSION_DURATION_REMEMBER }}
|
|
||||||
SESSION_DURATION_REMEMBER: {{ .Values.env.SESSION_DURATION_REMEMBER | quote }}
|
|
||||||
{{- end }}
|
|
||||||
{{- if .Values.env.TRUST_PROXY }}
|
{{- if .Values.env.TRUST_PROXY }}
|
||||||
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
TRUST_PROXY: {{ .Values.env.TRUST_PROXY | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -34,10 +34,6 @@ env:
|
|||||||
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
# When "true": adds includeSubDomains to the HSTS header. Only effective when HSTS is active. Leave "false" if sibling subdomains still run over plain HTTP.
|
||||||
# COOKIE_SECURE: "true"
|
# COOKIE_SECURE: "true"
|
||||||
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
# Auto-derived (true in production or when FORCE_HTTPS=true). Set "false" to force cookies over plain HTTP. Not recommended for production.
|
||||||
# SESSION_DURATION: "24h"
|
|
||||||
# How long a login session stays valid when "Remember me" is unchecked (the default): trek_session JWT exp + a browser-session cookie. Accepts 1h, 12h, 7d, 30d, 90d. Defaults to 24h.
|
|
||||||
# SESSION_DURATION_REMEMBER: "30d"
|
|
||||||
# Session length when "Remember me" is ticked: a longer-lived JWT + persistent cookie that survives browser restarts. Same format as SESSION_DURATION. Defaults to 30d.
|
|
||||||
# TRUST_PROXY: "1"
|
# TRUST_PROXY: "1"
|
||||||
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
# Trusted proxy hops for X-Forwarded-For/X-Forwarded-Proto. Defaults to 1 in production. Must be set for FORCE_HTTPS to work.
|
||||||
# ALLOW_INTERNAL_NETWORK: "false"
|
# ALLOW_INTERNAL_NETWORK: "false"
|
||||||
|
|||||||
+6
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@trek/client",
|
"name": "@trek/client",
|
||||||
"version": "3.1.1",
|
"version": "3.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -58,12 +58,11 @@
|
|||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||||
"@types/leaflet": "^1.9.8",
|
"@types/leaflet": "^1.9.8",
|
||||||
"@types/node": "^25.9.3",
|
|
||||||
"@types/react": "^19.2.15",
|
"@types/react": "^19.2.15",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-window": "^1.8.8",
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"@vitest/coverage-v8": "^4.1.9",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
"eslint": "^10.2.1",
|
"eslint": "^10.2.1",
|
||||||
"eslint-config-flat-gitignore": "^2.3.0",
|
"eslint-config-flat-gitignore": "^2.3.0",
|
||||||
@@ -81,8 +80,8 @@
|
|||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"typescript-eslint": "^8.58.2",
|
"typescript-eslint": "^8.58.2",
|
||||||
"vite": "^8.0.16",
|
"vite": "^5.1.4",
|
||||||
"vite-plugin-pwa": "^1.3.0",
|
"vite-plugin-pwa": "^0.21.0",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "^3.2.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
type DayCreateRequest, type DayUpdateRequest,
|
||||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||||
@@ -341,7 +341,6 @@ export const daysApi = {
|
|||||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => 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),
|
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const placesApi = {
|
export const placesApi = {
|
||||||
@@ -366,10 +365,10 @@ export const placesApi = {
|
|||||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||||
},
|
},
|
||||||
importGoogleList: (tripId: number | string, url: string, enrich?: boolean) =>
|
importGoogleList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url } satisfies PlaceImportListRequest).then(r => r.data),
|
||||||
importNaverList: (tripId: number | string, url: string, enrich?: boolean) =>
|
importNaverList: (tripId: number | string, url: string) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url, enrich } satisfies PlaceImportListRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids } satisfies PlaceBulkDeleteRequest).then(r => r.data),
|
||||||
}
|
}
|
||||||
@@ -395,7 +394,6 @@ export const packingApi = {
|
|||||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/packing/reorder`, { orderedIds } satisfies PackingReorderRequest).then(r => r.data),
|
||||||
getCategoryAssignees: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/category-assignees`).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 } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
setCategoryAssignees: (tripId: number | string, categoryName: string, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/category-assignees/${encodeURIComponent(categoryName)}`, { user_ids: userIds } satisfies PackingCategoryAssigneesRequest).then(r => r.data),
|
||||||
listTemplates: (tripId: number | string) => apiClient.get(`/trips/${tripId}/packing/templates`).then(r => r.data),
|
|
||||||
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
applyTemplate: (tripId: number | string, templateId: number) => apiClient.post(`/trips/${tripId}/packing/apply-template/${templateId}`).then(r => r.data),
|
||||||
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
saveAsTemplate: (tripId: number | string, name: string) => apiClient.post(`/trips/${tripId}/packing/save-as-template`, { name }).then(r => r.data),
|
||||||
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
setBagMembers: (tripId: number | string, bagId: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/packing/bags/${bagId}/members`, { user_ids: userIds } satisfies PackingBagMembersRequest).then(r => r.data),
|
||||||
@@ -487,20 +485,6 @@ export const addonsApi = {
|
|||||||
enabled: () => apiClient.get('/addons').then(r => r.data),
|
enabled: () => apiClient.get('/addons').then(r => r.data),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const airtrailApi = {
|
|
||||||
getSettings: () => apiClient.get('/integrations/airtrail/settings').then(r => r.data),
|
|
||||||
saveSettings: (data: { url: string; apiKey?: string; allowInsecureTls?: boolean; writeEnabled?: boolean }) =>
|
|
||||||
apiClient.put('/integrations/airtrail/settings', data).then(r => r.data),
|
|
||||||
status: () => apiClient.get('/integrations/airtrail/status').then(r => r.data),
|
|
||||||
test: (data: { url?: string; apiKey?: string; allowInsecureTls?: boolean }) =>
|
|
||||||
apiClient.post('/integrations/airtrail/test', data).then(r => r.data),
|
|
||||||
sync: (): Promise<{ changed: number }> => apiClient.post('/integrations/airtrail/sync').then(r => r.data),
|
|
||||||
// flights + import are added with the trip-planner import (P2)
|
|
||||||
flights: () => apiClient.get('/integrations/airtrail/flights').then(r => r.data),
|
|
||||||
import: (tripId: number, flightIds: string[]) =>
|
|
||||||
apiClient.post(`/trips/${tripId}/reservations/import/airtrail`, { flightIds }).then(r => r.data),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const journeyApi = {
|
export const journeyApi = {
|
||||||
list: () => apiClient.get('/journeys').then(r => r.data),
|
list: () => apiClient.get('/journeys').then(r => r.data),
|
||||||
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
create: (data: JourneyCreateRequest) => apiClient.post('/journeys', data).then(r => r.data),
|
||||||
@@ -572,11 +556,6 @@ export const mapsApi = {
|
|||||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
|
||||||
// Overpass can be slow on a fresh (uncached) area, so this call gets a longer
|
|
||||||
// timeout than the global default instead of aborting at 8s and showing nothing.
|
|
||||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
|
||||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal, timeout: 20000 }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean; clamped?: boolean }),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const airportsApi = {
|
export const airportsApi = {
|
||||||
@@ -595,7 +574,6 @@ export const budgetApi = {
|
|||||||
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data),
|
||||||
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
settlement: (tripId: number | string, base?: string) => apiClient.get(`/trips/${tripId}/budget/settlement`, base ? { params: { base } } : undefined).then(r => r.data),
|
||||||
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
createSettlement: (tripId: number | string, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.post(`/trips/${tripId}/budget/settlements`, data).then(r => r.data),
|
||||||
updateSettlement: (tripId: number | string, settlementId: number, data: { from_user_id: number; to_user_id: number; amount: number }) => apiClient.put(`/trips/${tripId}/budget/settlements/${settlementId}`, data).then(r => r.data),
|
|
||||||
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
deleteSettlement: (tripId: number | string, settlementId: number) => apiClient.delete(`/trips/${tripId}/budget/settlements/${settlementId}`).then(r => r.data),
|
||||||
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
reorderItems: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/budget/reorder/items`, { orderedIds }).then(r => r.data),
|
||||||
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
reorderCategories: (tripId: number | string, orderedCategories: string[]) => apiClient.put(`/trips/${tripId}/budget/reorder/categories`, { orderedCategories } satisfies BudgetReorderCategoriesRequest).then(r => r.data),
|
||||||
|
|||||||
@@ -20,12 +20,6 @@ export function getSocketId(): string | null {
|
|||||||
return mySocketId
|
return mySocketId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trip ids the app currently has open (joined). Used to re-hydrate the active
|
|
||||||
* trip's store after the network comes back via the `online` event. */
|
|
||||||
export function getActiveTrips(): string[] {
|
|
||||||
return Array.from(activeTrips)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
export function setRefetchCallback(fn: RefetchCallback | null): void {
|
||||||
refetchCallback = fn
|
refetchCallback = fn
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage, Plane } from 'lucide-react'
|
import { Puzzle, ListChecks, Wallet, FileText, CalendarDays, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, MessageCircle, StickyNote, BarChart3, Sparkles, Luggage } from 'lucide-react'
|
||||||
|
|
||||||
const ICON_MAP = {
|
const ICON_MAP = {
|
||||||
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen, Plane,
|
ListChecks, Wallet, FileText, CalendarDays, Puzzle, Globe, Briefcase, Image, Terminal, Link2, Compass, BookOpen,
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImmichIcon({ size = 14 }: { size?: number }) {
|
function ImmichIcon({ size = 14 }: { size?: number }) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { useToast } from '../shared/Toast'
|
|||||||
import Section from '../Settings/Section'
|
import Section from '../Settings/Section'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import { MapView } from '../Map/MapView'
|
import { MapView } from '../Map/MapView'
|
||||||
import { CURRENCIES, SYMBOLS } from '../Budget/BudgetPanel.constants'
|
|
||||||
import type { Place } from '../../types'
|
import type { Place } from '../../types'
|
||||||
|
|
||||||
const MAP_PRESETS = [
|
const MAP_PRESETS = [
|
||||||
@@ -21,25 +20,10 @@ type Defaults = {
|
|||||||
temperature_unit?: string
|
temperature_unit?: string
|
||||||
dark_mode?: string | boolean
|
dark_mode?: string | boolean
|
||||||
time_format?: string
|
time_format?: string
|
||||||
default_currency?: string
|
|
||||||
blur_booking_codes?: boolean
|
blur_booking_codes?: boolean
|
||||||
map_tile_url?: string
|
map_tile_url?: string
|
||||||
map_provider?: string
|
|
||||||
mapbox_access_token?: string
|
|
||||||
mapbox_style?: string
|
|
||||||
mapbox_3d_enabled?: boolean
|
|
||||||
mapbox_quality_mode?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAPBOX_STYLE_PRESETS = [
|
|
||||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
|
||||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
|
||||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
|
||||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
|
||||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
|
||||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function OptionRow({
|
function OptionRow({
|
||||||
label,
|
label,
|
||||||
hint,
|
hint,
|
||||||
@@ -93,15 +77,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
const [defaults, setDefaults] = useState<Defaults>({})
|
const [defaults, setDefaults] = useState<Defaults>({})
|
||||||
const [loaded, setLoaded] = useState(false)
|
const [loaded, setLoaded] = useState(false)
|
||||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||||
const [mapboxToken, setMapboxToken] = useState('')
|
|
||||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||||
setDefaults(data)
|
setDefaults(data)
|
||||||
setMapTileUrl(data.map_tile_url || '')
|
setMapTileUrl(data.map_tile_url || '')
|
||||||
setMapboxToken(data.mapbox_access_token || '')
|
|
||||||
setMapboxStyle(data.mapbox_style || '')
|
|
||||||
setLoaded(true)
|
setLoaded(true)
|
||||||
}).catch(() => setLoaded(true))
|
}).catch(() => setLoaded(true))
|
||||||
}, [])
|
}, [])
|
||||||
@@ -121,8 +101,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||||
setDefaults(updated)
|
setDefaults(updated)
|
||||||
if (key === 'map_tile_url') setMapTileUrl('')
|
if (key === 'map_tile_url') setMapTileUrl('')
|
||||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
|
||||||
if (key === 'mapbox_style') setMapboxStyle('')
|
|
||||||
toast.success(t('admin.defaultSettings.reset'))
|
toast.success(t('admin.defaultSettings.reset'))
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||||
@@ -228,23 +206,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</OptionRow>
|
</OptionRow>
|
||||||
|
|
||||||
{/* Default Currency */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
||||||
{t('settings.currency')} <ResetButton field="default_currency" />
|
|
||||||
</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={defaults.default_currency || ''}
|
|
||||||
onChange={(value: string) => { if (value) save({ default_currency: value }) }}
|
|
||||||
placeholder={t('settings.currency')}
|
|
||||||
searchable
|
|
||||||
options={CURRENCIES.map(c => ({ value: c, label: SYMBOLS[c] ? `${c} ${SYMBOLS[c]}` : c }))}
|
|
||||||
size="sm"
|
|
||||||
style={{ maxWidth: 240 }}
|
|
||||||
/>
|
|
||||||
<p className="text-xs mt-1 text-content-faint">{t('settings.currencyHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
<OptionRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
|
||||||
{([
|
{([
|
||||||
@@ -306,94 +267,6 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
|
||||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
|
||||||
<OptionRow
|
|
||||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
|
||||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
|
||||||
>
|
|
||||||
{([
|
|
||||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
|
||||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
|
||||||
] as const).map(opt => (
|
|
||||||
<OptionButton
|
|
||||||
key={opt.value}
|
|
||||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
|
||||||
onClick={() => save({ map_provider: opt.value })}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</OptionButton>
|
|
||||||
))}
|
|
||||||
</OptionRow>
|
|
||||||
|
|
||||||
{defaults.map_provider === 'mapbox-gl' && (
|
|
||||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
||||||
{t('admin.defaultSettings.mapboxToken')}
|
|
||||||
<ResetButton field="mapbox_access_token" />
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={mapboxToken}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
|
||||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
|
||||||
placeholder="pk.eyJ…"
|
|
||||||
spellCheck={false}
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
|
||||||
{t('admin.defaultSettings.mapboxStyle')}
|
|
||||||
<ResetButton field="mapbox_style" />
|
|
||||||
</label>
|
|
||||||
<CustomSelect
|
|
||||||
value={mapboxStyle}
|
|
||||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
|
||||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
|
||||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
|
||||||
size="sm"
|
|
||||||
style={{ marginBottom: 8 }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={mapboxStyle}
|
|
||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
|
||||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
|
||||||
placeholder="mapbox://styles/mapbox/standard"
|
|
||||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
|
||||||
{([
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
] as const).map(opt => (
|
|
||||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
|
||||||
{opt.label}
|
|
||||||
</OptionButton>
|
|
||||||
))}
|
|
||||||
</OptionRow>
|
|
||||||
|
|
||||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
|
||||||
{([
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
] as const).map(opt => (
|
|
||||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
|
||||||
{opt.label}
|
|
||||||
</OptionButton>
|
|
||||||
))}
|
|
||||||
</OptionRow>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
// FE-COMP-COSTS: settlements surfaced inline in the Costs ledger (issue #1241)
|
|
||||||
import { render, screen, waitFor } from '../../../tests/helpers/render'
|
|
||||||
import { http, HttpResponse } from 'msw'
|
|
||||||
import { server } from '../../../tests/helpers/msw/server'
|
|
||||||
import { useAuthStore } from '../../store/authStore'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
|
||||||
import { buildUser, buildTrip, buildBudgetItem } from '../../../tests/helpers/factories'
|
|
||||||
import CostsPanel from './CostsPanel'
|
|
||||||
|
|
||||||
const tripMembers = [
|
|
||||||
{ id: 1, username: 'alice', avatar_url: null },
|
|
||||||
{ id: 2, username: 'bob', avatar_url: null },
|
|
||||||
]
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resetAllStores()
|
|
||||||
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true })
|
|
||||||
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('CostsPanel — settlements in the ledger', () => {
|
|
||||||
it('renders a settle-up payment as a ledger row with an undo action', async () => {
|
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
balances: [],
|
|
||||||
flows: [],
|
|
||||||
settlements: [
|
|
||||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
|
|
||||||
// The expense and the settlement (payment) both appear in the unified ledger.
|
|
||||||
await screen.findByText('Dinner')
|
|
||||||
await screen.findByText('Payment')
|
|
||||||
// The payment row exposes an inline undo (no need to open a separate History modal).
|
|
||||||
expect(screen.getByTitle('Undo')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('records a manual payment via the Add payment button', async () => {
|
|
||||||
let posted: Record<string, unknown> | null = null
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
|
||||||
http.post('/api/trips/1/budget/settlements', async ({ request }) => {
|
|
||||||
posted = await request.json() as Record<string, unknown>
|
|
||||||
return HttpResponse.json({ settlement: { id: 1, ...posted } })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const { default: userEvent } = await import('@testing-library/user-event')
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add payment' }))
|
|
||||||
await user.type(await screen.findByPlaceholderText('0.00'), '25')
|
|
||||||
// The footer submit is the second "Add payment" control once the modal is open.
|
|
||||||
const addButtons = screen.getAllByRole('button', { name: 'Add payment' })
|
|
||||||
const submit = addButtons[addButtons.length - 1]
|
|
||||||
await user.click(submit)
|
|
||||||
await waitFor(() => expect(posted).toMatchObject({ amount: 25 }))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hides payment rows while a text search is active', async () => {
|
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }), total_price: 90, expense_date: '2025-06-15' }
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
balances: [],
|
|
||||||
flows: [],
|
|
||||||
settlements: [
|
|
||||||
{ id: 7, trip_id: 1, from_user_id: 2, to_user_id: 1, amount: 30, created_at: '2025-06-16 10:00:00', from_username: 'bob', to_username: 'alice' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
),
|
|
||||||
)
|
|
||||||
const { default: userEvent } = await import('@testing-library/user-event')
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
|
|
||||||
await screen.findByText('Payment')
|
|
||||||
await user.type(screen.getByPlaceholderText('Search expenses…'), 'Dinner')
|
|
||||||
// Payment rows have no name, so a search hides them while the matching expense stays.
|
|
||||||
expect(screen.queryByText('Payment')).not.toBeInTheDocument()
|
|
||||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
|
||||||
let posted: Record<string, unknown> | null = null
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
|
||||||
http.post('/api/trips/1/budget', async ({ request }) => {
|
|
||||||
posted = await request.json() as Record<string, unknown>
|
|
||||||
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'Dinner' }), id: 5 } })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const { default: userEvent } = await import('@testing-library/user-event')
|
|
||||||
const user = userEvent.setup()
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
|
||||||
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
|
||||||
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
|
||||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
|
||||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
|
||||||
expect(nums()[2].value).toBe('50')
|
|
||||||
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
|
||||||
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
|
||||||
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
|
||||||
|
|
||||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
|
||||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
|
||||||
await waitFor(() => expect(posted).toBeTruthy())
|
|
||||||
expect(posted!.total_price).toBe(100)
|
|
||||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
|
||||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
|
||||||
expect.objectContaining({ user_id: 2, amount: 70 }),
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marks an expense with no payer as Unfinished', async () => {
|
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
|
||||||
server.use(
|
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
|
||||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
|
||||||
)
|
|
||||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
|
||||||
await screen.findByText('Hotel')
|
|
||||||
expect(screen.getByText('Unfinished')).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, ArrowLeftRight, Check, RotateCcw, Pencil, Trash2 } from 'lucide-react'
|
import { ArrowDown, ArrowUp, BarChart3, Plus, Search, ArrowRight, Check, RotateCcw, History, Pencil, Trash2 } from 'lucide-react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
@@ -39,12 +39,6 @@ interface SettlementData {
|
|||||||
settlements: Settlement[]
|
settlements: Settlement[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row in the unified Costs ledger — either an expense or a settle-up payment,
|
|
||||||
// carrying the date used to group it by day.
|
|
||||||
type LedgerEntry =
|
|
||||||
| { kind: 'expense'; date: string; e: BudgetItem }
|
|
||||||
| { kind: 'payment'; date: string; s: Settlement }
|
|
||||||
|
|
||||||
const round2 = (n: number) => Math.round(n * 100) / 100
|
const round2 = (n: number) => Math.round(n * 100) / 100
|
||||||
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
const FIELD_H = 40 // shared height for the amount / currency / day row in the modal
|
||||||
|
|
||||||
@@ -68,10 +62,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
const [settlement, setSettlement] = useState<SettlementData | null>(null)
|
||||||
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
const [filter, setFilter] = useState<'all' | 'mine' | 'owed'>('all')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const [histOpen, setHistOpen] = useState(false)
|
||||||
const [modalOpen, setModalOpen] = useState(false)
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
const [editing, setEditing] = useState<BudgetItem | null>(null)
|
||||||
const [editingSettlement, setEditingSettlement] = useState<Settlement | null>(null)
|
|
||||||
const [addingPayment, setAddingPayment] = useState(false)
|
|
||||||
|
|
||||||
const people = tripMembers
|
const people = tripMembers
|
||||||
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
const personById = useCallback((id: number) => people.find(p => p.id === id), [people])
|
||||||
@@ -129,37 +122,21 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
return list
|
return list
|
||||||
}, [budgetItems, filter, search, me])
|
}, [budgetItems, filter, search, me])
|
||||||
|
|
||||||
// Settlements ("payments") shown inline in the ledger. They have no name, so a
|
|
||||||
// text search hides them; they're excluded from the "owed" expense filter and,
|
|
||||||
// under "mine", only show transfers I'm part of.
|
|
||||||
const filteredSettlements = useMemo(() => {
|
|
||||||
if (search.trim()) return []
|
|
||||||
if (filter === 'owed') return []
|
|
||||||
let list = settlement?.settlements || []
|
|
||||||
if (filter === 'mine') list = list.filter(s => s.from_user_id === me || s.to_user_id === me)
|
|
||||||
return list
|
|
||||||
}, [settlement, filter, search, me])
|
|
||||||
|
|
||||||
const dayGroups = useMemo(() => {
|
const dayGroups = useMemo(() => {
|
||||||
const entries: LedgerEntry[] = [
|
const groups: { day: string; items: BudgetItem[] }[] = []
|
||||||
...filtered.map(e => ({ kind: 'expense' as const, date: e.expense_date || '', e })),
|
const labelOf = (e: BudgetItem) => {
|
||||||
...filteredSettlements.map(s => ({ kind: 'payment' as const, date: (s.created_at || '').slice(0, 10), s })),
|
if (!e.expense_date) return t('costs.noDate')
|
||||||
]
|
try { return new Date(e.expense_date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return e.expense_date }
|
||||||
const labelOf = (date: string) => {
|
|
||||||
if (!date) return t('costs.noDate')
|
|
||||||
try { return new Date(date + 'T00:00:00Z').toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }) } catch { return date }
|
|
||||||
}
|
}
|
||||||
// Newest day first; within a day, expenses before payments (insertion order).
|
const sorted = filtered.slice().sort((a, b) => (b.expense_date || '').localeCompare(a.expense_date || ''))
|
||||||
const sorted = entries.slice().sort((a, b) => (b.date || '').localeCompare(a.date || ''))
|
for (const e of sorted) {
|
||||||
const groups: { day: string; entries: LedgerEntry[] }[] = []
|
const day = labelOf(e)
|
||||||
for (const en of sorted) {
|
|
||||||
const day = labelOf(en.date)
|
|
||||||
let g = groups.find(x => x.day === day)
|
let g = groups.find(x => x.day === day)
|
||||||
if (!g) { g = { day, entries: [] }; groups.push(g) }
|
if (!g) { g = { day, items: [] }; groups.push(g) }
|
||||||
g.entries.push(en)
|
g.items.push(e)
|
||||||
}
|
}
|
||||||
return groups
|
return groups
|
||||||
}, [filtered, filteredSettlements, locale, t])
|
}, [filtered, locale, t])
|
||||||
|
|
||||||
// ── settle actions ──────────────────────────────────────────────────────
|
// ── settle actions ──────────────────────────────────────────────────────
|
||||||
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
const settleFlow = async (fromId: number, toId: number, amount: number) => {
|
||||||
@@ -303,16 +280,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
{search ? t('costs.noMatch') : t('costs.emptyText')}
|
||||||
</div>
|
</div>
|
||||||
) : dayGroups.map(g => {
|
) : dayGroups.map(g => {
|
||||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||||
return (
|
return (
|
||||||
<div key={g.day} style={{ marginBottom: 22 }}>
|
<div key={g.day} style={{ marginBottom: 22 }}>
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', margin: '0 0 10px 4px' }}>
|
||||||
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 12 }}>{t('costs.spent', { amount: fmt(dtot) })}</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
{g.entries.map(en => en.kind === 'expense'
|
{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}
|
||||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
|
||||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -325,13 +300,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
<div className={cardCls} style={{ borderRadius: 22, padding: '22px 24px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||||
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
<div className={labelCls}>{t('costs.settleUp')} · <span className="text-content">{(settlement?.flows || []).length}</span></div>
|
||||||
{canEdit && (
|
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)}
|
||||||
<button onClick={() => setAddingPayment(true)}
|
className="text-content-muted bg-surface-secondary border border-edge disabled:opacity-40"
|
||||||
className="text-content-muted bg-surface-secondary border border-edge"
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '5px 9px', borderRadius: 8, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
<History size={13} /> {t('costs.history')}{(settlement?.settlements || []).length ? ` (${settlement!.settlements.length})` : ''}
|
||||||
<Plus size={13} /> {t('costs.addPayment')}
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<SettleFlows />
|
<SettleFlows />
|
||||||
</div>
|
</div>
|
||||||
@@ -357,11 +330,9 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
onSaved={() => { setModalOpen(false); loadBudgetItems(tripId); loadSettlement() }} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(editingSettlement || addingPayment) && (
|
<Modal isOpen={histOpen} onClose={() => setHistOpen(false)} title={t('costs.settleHistory')} size="md">
|
||||||
<SettlementModal tripId={tripId} people={people} me={me} editing={editingSettlement}
|
<SettleHistory settlements={settlement?.settlements || []} fmt={fmt} Avatar={Avatar} name={personName} onUndo={undoSettlement} canEdit={canEdit} />
|
||||||
onClose={() => { setEditingSettlement(null); setAddingPayment(false) }}
|
</Modal>
|
||||||
onSaved={() => { setEditingSettlement(null); setAddingPayment(false); loadSettlement() }} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.costs-root {
|
.costs-root {
|
||||||
@@ -467,9 +438,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
<div className={cardCls} style={{ borderRadius: 18, padding: 16 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14, gap: 8 }}>
|
||||||
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
<div className="text-content" style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em', display: 'flex', alignItems: 'baseline', gap: 8 }}>{t('costs.settleUp')} <span className="text-content-faint" style={{ fontSize: 12, fontWeight: 500 }}>{(settlement?.flows || []).length}</span></div>
|
||||||
{canEdit && (
|
<button disabled={!(settlement?.settlements || []).length} onClick={() => setHistOpen(true)} className="text-content-muted bg-surface-card border border-edge disabled:opacity-40" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><History size={13} /> {t('costs.history')}</button>
|
||||||
<button onClick={() => setAddingPayment(true)} className="text-content-muted bg-surface-card border border-edge" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 9, fontSize: 11.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><Plus size={13} /> {t('costs.addPayment')}</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<SettleFlows />
|
<SettleFlows />
|
||||||
</div>
|
</div>
|
||||||
@@ -489,13 +458,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
{dayGroups.length === 0
|
{dayGroups.length === 0
|
||||||
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
? <div className="text-content-faint" style={{ textAlign: 'center', padding: '36px 16px', fontSize: 13 }}>{search ? t('costs.noMatch') : t('costs.emptyText')}</div>
|
||||||
: dayGroups.map(g => {
|
: dayGroups.map(g => {
|
||||||
const dtot = g.entries.reduce((a, en) => en.kind === 'expense' ? a + baseTotal(en.e) : a, 0)
|
const dtot = g.items.reduce((a, e) => a + baseTotal(e), 0)
|
||||||
return (
|
return (
|
||||||
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div key={g.day} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
<div className={labelCls} style={{ display: 'flex', alignItems: 'center', padding: '0 2px' }}>{g.day}<span className="text-content-muted" style={{ marginLeft: 'auto', textTransform: 'none', letterSpacing: 0, fontWeight: 500, fontSize: 11.5 }}>{t('costs.spent', { amount: fmt(dtot) })}</span></div>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.entries.map(en => en.kind === 'expense'
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>{g.items.map(e => <ExpenseRow key={e.id} e={e} />)}</div>
|
||||||
? <ExpenseRow key={'e' + en.e.id} e={en.e} />
|
|
||||||
: <SettlementRow key={'s' + en.s.id} s={en.s} />)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -523,22 +490,11 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const cur = curOf(e)
|
const cur = curOf(e)
|
||||||
const payers = (e.payers || []).filter(p => p.amount > 0)
|
const payers = (e.payers || []).filter(p => p.amount > 0)
|
||||||
const net = round2(myPaidOf(e) - myShareOf(e))
|
const net = round2(myPaidOf(e) - myShareOf(e))
|
||||||
// "Unfinished": a recorded total nobody has paid yet — counts toward the trip
|
|
||||||
// total but stays out of settlements until who-paid is filled in.
|
|
||||||
const isUnfinished = baseTotal(e) > 0 && payers.length === 0
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
||||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: c.color + '22', color: c.color }}><Icon size={21} /></span>
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{e.name}</div>
|
||||||
<span className="text-content" style={{ fontSize: 15, fontWeight: 600 }}>{e.name}</span>
|
|
||||||
{isUnfinished && (
|
|
||||||
<span title={t('costs.unfinishedHint')} style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px 2px 6px', borderRadius: 999, background: 'rgba(217,119,6,0.14)', color: '#d97706', fontSize: 11, fontWeight: 700, flexShrink: 0 }}>
|
|
||||||
<span style={{ width: 14, height: 14, borderRadius: '50%', background: '#d97706', color: '#fff', display: 'grid', placeItems: 'center', fontSize: 10, fontWeight: 800 }}>!</span>
|
|
||||||
{t('costs.unfinished')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{payers.length > 0 && (
|
{payers.length > 0 && (
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, marginBottom: 5 }}>
|
||||||
{payers.map(p => (
|
{payers.map(p => (
|
||||||
@@ -558,7 +514,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
||||||
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
<div style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
<div className="text-content" style={{ fontSize: 18, fontWeight: 600 }}>{fmt(baseTotal(e))}</div>
|
||||||
{!isUnfinished && (e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
{(e.members || []).length > 0 && Math.abs(net) > 0.01 && (
|
||||||
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
<div style={{ fontSize: 12, marginTop: 2, fontWeight: 500, whiteSpace: 'nowrap', color: net > 0 ? '#16a34a' : '#dc2626' }}>
|
||||||
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
{net > 0 ? t('costs.youLent', { amount: fmt(net) }) : t('costs.youBorrowed', { amount: fmt(-net) })}
|
||||||
</div>
|
</div>
|
||||||
@@ -575,32 +531,6 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A settle-up payment as a ledger row — visually distinct from an expense, with
|
|
||||||
// inline edit + undo (reuses deleteSettlement) so it isn't buried in a modal.
|
|
||||||
function SettlementRow({ s }: { s: Settlement }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-surface-card border border-edge exp-row" style={{ display: 'grid', gridTemplateColumns: '46px 1fr auto', gap: 16, alignItems: 'center', borderRadius: 18, padding: '16px 20px' }}>
|
|
||||||
<span style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(22,163,74,0.12)', color: '#16a34a' }}><ArrowLeftRight size={21} /></span>
|
|
||||||
<div style={{ minWidth: 0 }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 15, fontWeight: 600, marginBottom: 6 }}>{t('costs.payment')}</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0 }} title={`${personName(s.from_user_id)} → ${personName(s.to_user_id)}`}>
|
|
||||||
<Avatar id={s.from_user_id} size={20} /><ArrowRight size={13} className="text-content-faint" /><Avatar id={s.to_user_id} size={20} />
|
|
||||||
<span className="text-content-faint" style={{ fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{personName(s.from_user_id)} → {personName(s.to_user_id)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, alignSelf: 'center' }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 18, fontWeight: 600, whiteSpace: 'nowrap' }}>{fmt(s.amount)}</div>
|
|
||||||
{canEdit && (
|
|
||||||
<div className="exp-actions" style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
|
|
||||||
<button title={t('common.edit')} onClick={() => setEditingSettlement(s)} className="bg-surface-secondary border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer' }}><Pencil size={13} /></button>
|
|
||||||
<button title={t('costs.undo')} onClick={() => undoSettlement(s.id)} className="bg-surface-secondary border border-edge" style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 28, height: 28, borderRadius: 999, cursor: 'pointer', color: '#dc2626' }}><RotateCcw size={13} /></button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
function BalancesList({ balances }: { balances: SettlementData['balances'] }) {
|
||||||
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
const rows = people.map(p => balances.find(b => b.user_id === p.id) || { user_id: p.id, username: p.username, avatar_url: null, balance: 0 })
|
||||||
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
const max = Math.max(1, ...rows.map(r => Math.abs(r.balance)))
|
||||||
@@ -703,75 +633,37 @@ function FlowPills({ ids, lead, Avatar, name }: { ids: number[]; lead: string; A
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add or edit a settle-up payment (from / to / amount). Reachable inline from the
|
function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||||
// ledger row and from a manual "Add payment" button, so recording "I sent money to
|
settlements: Settlement[]; fmt: (v: number) => string; Avatar: (p: { id: number; size?: number }) => React.JSX.Element; name: (id: number) => string; onUndo: (id: number) => void; canEdit: boolean
|
||||||
// X" works the same whether or not there's an outstanding expense behind it.
|
|
||||||
function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
|
||||||
tripId: number; people: TripMember[]; me: number; editing: Settlement | null; onClose: () => void; onSaved: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const toast = useToast()
|
if (settlements.length === 0) return <div className="text-content-faint" style={{ textAlign: 'center', padding: 30, fontSize: 13 }}>{t('costs.noSettlements')}</div>
|
||||||
const otherDefault = people.find(p => p.id !== me)?.id ?? me
|
const total = settlements.reduce((a, s) => a + s.amount, 0)
|
||||||
const [fromId, setFromId] = useState<string>(String(editing?.from_user_id ?? me))
|
|
||||||
const [toId, setToId] = useState<string>(String(editing?.to_user_id ?? otherDefault))
|
|
||||||
const [amount, setAmount] = useState<string>(editing ? String(editing.amount) : '')
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
|
|
||||||
const amt = parseFloat(amount) || 0
|
|
||||||
const valid = amt > 0 && fromId !== toId
|
|
||||||
const opts = people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!valid) return
|
|
||||||
setSaving(true)
|
|
||||||
const data = { from_user_id: Number(fromId), to_user_id: Number(toId), amount: amt }
|
|
||||||
try {
|
|
||||||
if (editing) await budgetApi.updateSettlement(tripId, editing.id, data)
|
|
||||||
else await budgetApi.createSettlement(tripId, data)
|
|
||||||
onSaved()
|
|
||||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
|
||||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen onClose={onClose} title={editing ? t('costs.editPayment') : t('costs.addPayment')} size="md"
|
<div>
|
||||||
footer={
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '12px 14px', borderRadius: 12, marginBottom: 14, background: 'rgba(22,163,74,0.1)', color: '#16a34a', fontWeight: 600, fontSize: 13 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
<span>{t('costs.paymentsSettled', { count: settlements.length })}</span><span>{fmt(total)}</span>
|
||||||
<button onClick={onClose} className="text-content-muted border border-edge" style={{ padding: '8px 16px', borderRadius: 10, background: 'none', fontSize: 13, cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
|
||||||
<button onClick={save} disabled={!valid || saving} className="bg-[var(--text-primary)] text-[var(--bg-primary)]" style={{ padding: '8px 20px', borderRadius: 10, border: 0, fontSize: 13, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: !valid || saving ? 0.5 : 1 }}>{editing ? t('common.save') : t('costs.addPayment')}</button>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.from')}</label>
|
|
||||||
<CustomSelect value={fromId} onChange={v => setFromId(String(v))} options={opts} style={{ width: '100%' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.to')}</label>
|
|
||||||
<CustomSelect value={toId} onChange={v => setToId(String(v))} options={opts} style={{ width: '100%' }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('costs.amount')}</label>
|
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
|
||||||
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{settlements.map(s => (
|
||||||
|
<div key={s.id} className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, padding: '12px 14px', borderRadius: 12 }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }} title={`${name(s.from_user_id)} → ${name(s.to_user_id)}`}>
|
||||||
|
<Avatar id={s.from_user_id} size={30} /><ArrowRight size={15} className="text-content-faint" /><Avatar id={s.to_user_id} size={30} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 600 }}>{fmt(s.amount)}</span>
|
||||||
|
{canEdit && <button onClick={() => onUndo(s.id)} className="bg-surface-card border border-edge text-content-muted" style={{ display: 'inline-flex', alignItems: 'center', gap: 5, padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}><RotateCcw size={12} /> {t('costs.undo')}</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||||
export interface ExpensePrefill {
|
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||||
name?: string
|
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||||
category?: string
|
|
||||||
amount?: number
|
|
||||||
reservationId?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
|
||||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
|
||||||
}) {
|
}) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -779,94 +671,34 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
const { convert } = useExchangeRates(base)
|
const { convert } = useExchangeRates(base)
|
||||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||||
|
|
||||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
const [name, setName] = useState(editing?.name || '')
|
||||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||||
// One participant list: a person is "in" the split and may have paid an amount.
|
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||||
// Entering the total auto-distributes it equally across the non-pinned participants;
|
|
||||||
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
|
||||||
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
|
||||||
// toward the trip total only, never settlements, until who-paid is filled in).
|
|
||||||
const [total, setTotal] = useState<string>(() => {
|
|
||||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
|
||||||
if (prefill?.amount != null) return String(prefill.amount)
|
|
||||||
return ''
|
|
||||||
})
|
|
||||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
|
||||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
|
||||||
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
|
||||||
const m: Record<number, string> = {}
|
const m: Record<number, string> = {}
|
||||||
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
const [split, setSplit] = useState<Set<number>>(() =>
|
||||||
// payer amounts load as pinned so opening an expense never reshuffles them.
|
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||||
const [dirty, setDirty] = useState<Set<number>>(() =>
|
|
||||||
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const totalNum = parseFloat(total) || 0
|
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||||
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||||
const paidEntered = paidSum > 0
|
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
|
||||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
|
||||||
const valid = name.trim().length > 0 && totalNum > 0 && participants.size > 0 && (!paidEntered || balanced)
|
|
||||||
|
|
||||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
|
||||||
const splitCents = (amount: number, n: number): number[] => {
|
|
||||||
if (n <= 0) return []
|
|
||||||
const cents = Math.max(0, Math.round(amount * 100))
|
|
||||||
const base = Math.floor(cents / n), rem = cents - base * n
|
|
||||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
|
||||||
}
|
|
||||||
// Recompute the non-pinned participants so every paid amount sums to the total.
|
|
||||||
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
|
||||||
const ids = [...parts]
|
|
||||||
const free = ids.filter(id => !dirtySet.has(id))
|
|
||||||
if (free.length === 0) return paidMap
|
|
||||||
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
|
||||||
const shares = splitCents(totalVal - pinnedSum, free.length)
|
|
||||||
const next = { ...paidMap }
|
|
||||||
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTotalChange = (v: string) => {
|
|
||||||
setTotal(v)
|
|
||||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
|
||||||
}
|
|
||||||
const onPaidChange = (id: number, v: string) => {
|
|
||||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
|
||||||
setDirty(nextDirty)
|
|
||||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
|
||||||
}
|
|
||||||
const toggleParticipant = (id: number) => {
|
|
||||||
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
|
||||||
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
|
||||||
else nextParts.add(id)
|
|
||||||
setParticipants(nextParts); setDirty(nextDirty)
|
|
||||||
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
|
||||||
}
|
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const payerList = [...participants]
|
const payerList = Object.entries(payers).map(([uid, v]) => ({ user_id: Number(uid), amount: parseFloat(v) || 0 })).filter(p => p.amount > 0)
|
||||||
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
|
||||||
.filter(p => p.amount > 0)
|
|
||||||
const data = {
|
const data = {
|
||||||
name: name.trim(), category: cat,
|
name: name.trim(), category: cat,
|
||||||
// Store the actual currency the amounts were entered in; conversion to the
|
// Store the actual currency the amounts were entered in; conversion to the
|
||||||
// viewer's display currency happens live (real rates), no manual rate.
|
// viewer's display currency happens live (real rates), no manual rate.
|
||||||
currency,
|
currency,
|
||||||
payers: payerList, member_ids: [...participants],
|
payers: payerList, member_ids: [...split],
|
||||||
expense_date: day || null,
|
expense_date: day || null,
|
||||||
// Always record the entered total: the server keeps it as-is for an unfinished
|
|
||||||
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
|
||||||
total_price: totalNum,
|
|
||||||
// Link a freshly-created expense to its booking (create-from-booking flow).
|
|
||||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||||
@@ -896,9 +728,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
|
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||||
onChange={e => onTotalChange(e.target.value)}
|
|
||||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||||
@@ -914,11 +744,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{currency !== base && totalNum > 0 && (
|
{currency !== base && payersTotal > 0 && (
|
||||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||||
<span>{formatMoney(totalNum, currency, locale)}</span>
|
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||||
<span className="text-content-faint">≈</span>
|
<span className="text-content-faint">≈</span>
|
||||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(totalNum, currency), base, locale)}</span>
|
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -943,37 +773,39 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{people.map((p, idx) => {
|
{people.map(p => (
|
||||||
const on = participants.has(p.id)
|
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10 }}>
|
||||||
return (
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 500 }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
<div key={p.id} className="bg-surface-secondary border border-edge" style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
||||||
<button onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||||
{p.avatar_url
|
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={payers[p.id] || ''}
|
||||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
onChange={e => setPayers(prev => ({ ...prev, [p.id]: e.target.value }))}
|
||||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
|
||||||
</button>
|
|
||||||
{on ? (
|
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
|
||||||
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
|
|
||||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
|
||||||
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className={labelCls}>{t('costs.splitBetween')}</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 7 }}>
|
||||||
|
{people.map(p => {
|
||||||
|
const on = split.has(p.id)
|
||||||
|
return (
|
||||||
|
<button key={p.id} onClick={() => setSplit(prev => { const n = new Set(prev); n.has(p.id) ? n.delete(p.id) : n.add(p.id); return n })}
|
||||||
|
className={on ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-faint border border-edge'}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 7, padding: '6px 13px 6px 7px', borderRadius: 999, fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', borderColor: on ? 'var(--text-primary)' : undefined }}>
|
||||||
|
{p.avatar_url
|
||||||
|
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', opacity: on ? 1 : 0.45 }} />
|
||||||
|
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[people.findIndex(x => x.id === p.id) % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||||
|
{p.id === me ? t('costs.you') : p.username}
|
||||||
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
<div className="text-content-faint" style={{ marginTop: 10, fontSize: 12.5 }}>
|
||||||
<span className="text-content-faint">
|
{split.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: split.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
{participants.size === 0 ? t('costs.pickSomeone') : t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
|
||||||
</span>
|
|
||||||
{paidEntered
|
|
||||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
|
||||||
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,32 +32,8 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
|||||||
|
|
||||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||||
|
|
||||||
/**
|
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
|
||||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
|
||||||
* which never matched the lowercase keys and fell through to `other`.
|
|
||||||
*/
|
|
||||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
|
||||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
|
||||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
|
||||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
|
||||||
transport: 'transport', transportation: 'transport',
|
|
||||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
|
||||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
|
||||||
grocery: 'groceries', groceries: 'groceries',
|
|
||||||
activity: 'activities', activities: 'activities',
|
|
||||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
|
||||||
shop: 'shopping', shopping: 'shopping',
|
|
||||||
fee: 'fees', fees: 'fees',
|
|
||||||
health: 'health', medical: 'health',
|
|
||||||
tip: 'tips', tips: 'tips',
|
|
||||||
other: 'other', misc: 'other',
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
|
||||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||||
if (!cat) return COST_CAT_META.other
|
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
return COST_CAT_META.other
|
||||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
|
||||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ describe('CollabNotes', () => {
|
|||||||
expect(document.body).toBeInTheDocument();
|
expect(document.body).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-NOTES-013: deleting a note asks for confirmation, then calls DELETE API and removes it', async () => {
|
it('FE-COMP-NOTES-013: delete note calls DELETE API and removes it from grid', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/1/collab/notes', () =>
|
http.get('/api/trips/1/collab/notes', () =>
|
||||||
@@ -193,11 +193,8 @@ describe('CollabNotes', () => {
|
|||||||
);
|
);
|
||||||
render(<CollabNotes {...defaultProps} />);
|
render(<CollabNotes {...defaultProps} />);
|
||||||
await screen.findByText('Remove Me');
|
await screen.findByText('Remove Me');
|
||||||
await user.click(screen.getByTitle('Delete'));
|
const deleteBtn = screen.getByTitle('Delete');
|
||||||
// Deleting now asks for confirmation first — the note stays until confirmed.
|
await user.click(deleteBtn);
|
||||||
expect(screen.getByText('Delete note?')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Remove Me')).toBeInTheDocument();
|
|
||||||
await user.click(document.querySelector('button.bg-red-600') as HTMLElement);
|
|
||||||
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
|
await waitFor(() => expect(screen.queryByText('Remove Me')).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { addListener, removeListener } from '../../api/websocket'
|
import { addListener, removeListener } from '../../api/websocket'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
|
||||||
import type { User } from '../../types'
|
import type { User } from '../../types'
|
||||||
import type { CollabNote } from './CollabNotes.types'
|
import type { CollabNote } from './CollabNotes.types'
|
||||||
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
import { FONT, NOTE_COLORS } from './CollabNotes.constants'
|
||||||
@@ -45,7 +44,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
const [previewFile, setPreviewFile] = useState(null)
|
const [previewFile, setPreviewFile] = useState(null)
|
||||||
const [showSettings, setShowSettings] = useState(false)
|
const [showSettings, setShowSettings] = useState(false)
|
||||||
const [activeCategory, setActiveCategory] = useState(null)
|
const [activeCategory, setActiveCategory] = useState(null)
|
||||||
const [pendingDeleteNoteId, setPendingDeleteNoteId] = useState<number | null>(null)
|
|
||||||
|
|
||||||
// Empty categories (no notes yet) stored in localStorage
|
// Empty categories (no notes yet) stored in localStorage
|
||||||
const [emptyCategories, setEmptyCategories] = useState(() => {
|
const [emptyCategories, setEmptyCategories] = useState(() => {
|
||||||
@@ -233,7 +231,6 @@ function useCollabNotes({ tripId, currentUser }: CollabNotesProps) {
|
|||||||
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
|
activeCategory, setActiveCategory, categoryColors, getCategoryColor,
|
||||||
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
|
handleCreateNote, handleUpdateNote, saveCategoryColors, handleEditSubmit,
|
||||||
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
|
handleDeleteNoteFile, handleDeleteNote, categories, sortedNotes,
|
||||||
pendingDeleteNoteId, setPendingDeleteNoteId,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +319,7 @@ function CollabCategoryPills({ categories, activeCategory, setActiveCategory, t
|
|||||||
|
|
||||||
function CollabNotesGrid(S: NotesState) {
|
function CollabNotesGrid(S: NotesState) {
|
||||||
const {
|
const {
|
||||||
sortedNotes, currentUser, canEdit, handleUpdateNote, setPendingDeleteNoteId,
|
sortedNotes, currentUser, canEdit, handleUpdateNote, handleDeleteNote,
|
||||||
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
|
setEditingNote, setViewingNote, setPreviewFile, getCategoryColor, tripId, t,
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
@@ -355,7 +352,7 @@ function CollabNotesGrid(S: NotesState) {
|
|||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
onUpdate={handleUpdateNote}
|
onUpdate={handleUpdateNote}
|
||||||
onDelete={setPendingDeleteNoteId}
|
onDelete={handleDeleteNote}
|
||||||
onEdit={setEditingNote}
|
onEdit={setEditingNote}
|
||||||
onView={setViewingNote}
|
onView={setViewingNote}
|
||||||
onPreviewFile={setPreviewFile}
|
onPreviewFile={setPreviewFile}
|
||||||
@@ -473,7 +470,6 @@ export default function CollabNotes(props: CollabNotesProps) {
|
|||||||
viewingNote, showNewModal, editingNote, previewFile, showSettings,
|
viewingNote, showNewModal, editingNote, previewFile, showSettings,
|
||||||
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
|
setShowNewModal, setEditingNote, setPreviewFile, setShowSettings,
|
||||||
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
|
handleCreateNote, handleEditSubmit, handleDeleteNoteFile, saveCategoryColors, handleUpdateNote,
|
||||||
handleDeleteNote, pendingDeleteNoteId, setPendingDeleteNoteId,
|
|
||||||
} = S
|
} = S
|
||||||
|
|
||||||
if (loading) return <CollabNotesLoading {...S} />
|
if (loading) return <CollabNotesLoading {...S} />
|
||||||
@@ -531,15 +527,6 @@ export default function CollabNotes(props: CollabNotesProps) {
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Confirm: delete a collab note — guards against accidental deletion */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={pendingDeleteNoteId !== null}
|
|
||||||
onClose={() => setPendingDeleteNoteId(null)}
|
|
||||||
onConfirm={() => { if (pendingDeleteNoteId !== null) handleDeleteNote(pendingDeleteNoteId) }}
|
|
||||||
title={t('collab.notes.confirmDeleteTitle')}
|
|
||||||
message={t('collab.notes.confirmDeleteBody')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface NoteCardProps {
|
|||||||
currentUser: User
|
currentUser: User
|
||||||
canEdit: boolean
|
canEdit: boolean
|
||||||
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
onUpdate: (noteId: number, data: Partial<CollabNote>) => Promise<void>
|
||||||
onDelete: (noteId: number) => void
|
onDelete: (noteId: number) => Promise<void>
|
||||||
onEdit: (note: CollabNote) => void
|
onEdit: (note: CollabNote) => void
|
||||||
onView: (note: CollabNote) => void
|
onView: (note: CollabNote) => void
|
||||||
onPreviewFile: (file: NoteFile) => void
|
onPreviewFile: (file: NoteFile) => void
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
// FE-COMP-MDTOOLBAR-001 to FE-COMP-MDTOOLBAR-006
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
import { render, screen, fireEvent } from '../../../tests/helpers/render';
|
||||||
import MarkdownToolbar from './MarkdownToolbar';
|
import MarkdownToolbar from './MarkdownToolbar';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -16,10 +16,10 @@ function createTextareaRef(value = '', selectionStart = 0, selectionEnd = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('MarkdownToolbar', () => {
|
describe('MarkdownToolbar', () => {
|
||||||
let onUpdate: Mock<(value: string) => void>;
|
let onUpdate: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
onUpdate = vi.fn<(value: string) => void>();
|
onUpdate = vi.fn();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
it('FE-COMP-MDTOOLBAR-001: renders all 8 toolbar buttons', () => {
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
||||||
import { screen, waitFor } from '@testing-library/react'
|
|
||||||
import { render } from '../../../tests/helpers/render'
|
|
||||||
import OfflineBanner from './OfflineBanner'
|
|
||||||
|
|
||||||
vi.mock('../../sync/mutationQueue', () => ({
|
|
||||||
mutationQueue: {
|
|
||||||
pendingCount: vi.fn(),
|
|
||||||
failedCount: vi.fn(),
|
|
||||||
},
|
|
||||||
}))
|
|
||||||
|
|
||||||
import { mutationQueue } from '../../sync/mutationQueue'
|
|
||||||
|
|
||||||
const pendingCount = mutationQueue.pendingCount as ReturnType<typeof vi.fn>
|
|
||||||
const failedCount = mutationQueue.failedCount as ReturnType<typeof vi.fn>
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks()
|
|
||||||
Object.defineProperty(navigator, 'onLine', { value: true, writable: true, configurable: true })
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('OfflineBanner (B3 surface)', () => {
|
|
||||||
it('shows the failed pill when failedCount > 0 while online', async () => {
|
|
||||||
pendingCount.mockResolvedValue(0)
|
|
||||||
failedCount.mockResolvedValue(2)
|
|
||||||
|
|
||||||
render(<OfflineBanner />)
|
|
||||||
|
|
||||||
expect(await screen.findByText(/2 changes failed to sync/i)).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stays hidden when online with nothing pending or failed', async () => {
|
|
||||||
pendingCount.mockResolvedValue(0)
|
|
||||||
failedCount.mockResolvedValue(0)
|
|
||||||
|
|
||||||
const { container } = render(<OfflineBanner />)
|
|
||||||
// Give the async poll a tick to resolve.
|
|
||||||
await waitFor(() => expect(failedCount).toHaveBeenCalled())
|
|
||||||
expect(container.querySelector('[role="status"]')).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
* OfflineBanner — connectivity + sync state indicator.
|
* OfflineBanner — connectivity + sync state indicator.
|
||||||
*
|
*
|
||||||
* States:
|
* States:
|
||||||
* N failed → red pill "N changes failed to sync" (takes priority)
|
|
||||||
* offline + N queued → amber pill "Offline · N queued"
|
* offline + N queued → amber pill "Offline · N queued"
|
||||||
* offline + 0 queued → amber pill "Offline"
|
* offline + 0 queued → amber pill "Offline"
|
||||||
* online + N pending → blue pill "Syncing N…"
|
* online + N pending → blue pill "Syncing N…"
|
||||||
@@ -13,7 +12,7 @@
|
|||||||
* headers. On mobile it hovers just above the bottom tab bar.
|
* headers. On mobile it hovers just above the bottom tab bar.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { WifiOff, RefreshCw, AlertTriangle } from 'lucide-react'
|
import { WifiOff, RefreshCw } from 'lucide-react'
|
||||||
import { mutationQueue } from '../../sync/mutationQueue'
|
import { mutationQueue } from '../../sync/mutationQueue'
|
||||||
|
|
||||||
const POLL_MS = 3_000
|
const POLL_MS = 3_000
|
||||||
@@ -21,7 +20,6 @@ const POLL_MS = 3_000
|
|||||||
export default function OfflineBanner(): React.ReactElement | null {
|
export default function OfflineBanner(): React.ReactElement | null {
|
||||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
const [failedCount, setFailedCount] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onOnline = () => setIsOnline(true)
|
const onOnline = () => setIsOnline(true)
|
||||||
@@ -37,36 +35,26 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function poll() {
|
async function poll() {
|
||||||
const [n, failed] = await Promise.all([
|
const n = await mutationQueue.pendingCount()
|
||||||
mutationQueue.pendingCount(),
|
if (!cancelled) setPendingCount(n)
|
||||||
mutationQueue.failedCount(),
|
|
||||||
])
|
|
||||||
if (!cancelled) {
|
|
||||||
setPendingCount(n)
|
|
||||||
setFailedCount(failed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
poll()
|
poll()
|
||||||
const id = setInterval(poll, POLL_MS)
|
const id = setInterval(poll, POLL_MS)
|
||||||
return () => { cancelled = true; clearInterval(id) }
|
return () => { cancelled = true; clearInterval(id) }
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const hidden = isOnline && pendingCount === 0 && failedCount === 0
|
const hidden = isOnline && pendingCount === 0
|
||||||
if (hidden) return null
|
if (hidden) return null
|
||||||
|
|
||||||
const offline = !isOnline
|
const offline = !isOnline
|
||||||
// Failed mutations are the most important signal — they mean data was dropped.
|
const bg = offline ? '#92400e' : '#1e40af'
|
||||||
const failed = failedCount > 0
|
|
||||||
const bg = failed ? '#b91c1c' : offline ? '#92400e' : '#1e40af'
|
|
||||||
const text = '#fff'
|
const text = '#fff'
|
||||||
|
|
||||||
const label = failed
|
const label = offline
|
||||||
? `${failedCount} change${failedCount !== 1 ? 's' : ''} failed to sync`
|
? pendingCount > 0
|
||||||
: offline
|
? `Offline · ${pendingCount} queued`
|
||||||
? pendingCount > 0
|
: 'Offline'
|
||||||
? `Offline · ${pendingCount} queued`
|
: `Syncing ${pendingCount}…`
|
||||||
: 'Offline'
|
|
||||||
: `Syncing ${pendingCount}…`
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -94,11 +82,9 @@ export default function OfflineBanner(): React.ReactElement | null {
|
|||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{failed
|
{offline
|
||||||
? <AlertTriangle size={12} />
|
? <WifiOff size={12} />
|
||||||
: offline
|
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
||||||
? <WifiOff size={12} />
|
|
||||||
: <RefreshCw size={12} style={{ animation: 'spin 1s linear infinite' }} />
|
|
||||||
}
|
}
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { Navigation } from 'lucide-react'
|
|
||||||
import type mapboxgl from 'mapbox-gl'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Round compass pill for the Mapbox planner map. The Mapbox map can be rotated and
|
|
||||||
* pitched, so this shows the current bearing (the arrow points to north) and snaps
|
|
||||||
* the camera back to north + flat on click. Rendered next to the POI "explore" pill
|
|
||||||
* (Mapbox only) and built as the SAME frosted shell (padding 4 around a 34px button)
|
|
||||||
* so its height and transparency match the POI pill exactly.
|
|
||||||
*/
|
|
||||||
export function MapCompassPill({ map }: { map: mapboxgl.Map }) {
|
|
||||||
const [bearing, setBearing] = useState(() => map.getBearing())
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const update = () => setBearing(map.getBearing())
|
|
||||||
update()
|
|
||||||
map.on('rotate', update)
|
|
||||||
return () => { map.off('rotate', update) }
|
|
||||||
}, [map])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', padding: 4, borderRadius: 999, pointerEvents: 'auto',
|
|
||||||
background: 'var(--sidebar-bg)',
|
|
||||||
backdropFilter: 'blur(20px) saturate(180%)',
|
|
||||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
|
||||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
|
||||||
}}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => map.easeTo({ bearing: 0, pitch: 0, duration: 300 })}
|
|
||||||
aria-label="Reset north"
|
|
||||||
className="text-content-muted"
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
|
||||||
background: 'transparent', padding: 0,
|
|
||||||
transition: 'background 0.14s, color 0.14s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
|
||||||
>
|
|
||||||
<Navigation size={16} strokeWidth={2} style={{ transform: `rotate(${-bearing}deg)`, transition: 'transform 0.1s linear' }} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||||
import DOM from 'react-dom'
|
import DOM from 'react-dom'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||||
@@ -10,7 +10,6 @@ import { mapsApi } from '../../api/client'
|
|||||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||||
import ReservationOverlay from './ReservationOverlay'
|
import ReservationOverlay from './ReservationOverlay'
|
||||||
import type { Reservation } from '../../types'
|
import type { Reservation } from '../../types'
|
||||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
|
||||||
|
|
||||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
@@ -119,44 +118,6 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
|||||||
return fallbackIcon
|
return fallbackIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
|
||||||
// markers of planned places; the colour matches its pill category.
|
|
||||||
const poiIconCache = new Map<string, L.DivIcon>()
|
|
||||||
function createPoiIcon(category: string) {
|
|
||||||
const cached = poiIconCache.get(category)
|
|
||||||
if (cached) return cached
|
|
||||||
const cat = POI_CATEGORY_BY_KEY[category]
|
|
||||||
const color = cat?.color || '#6b7280'
|
|
||||||
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
|
||||||
const icon = L.divIcon({
|
|
||||||
className: '',
|
|
||||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
|
||||||
iconSize: [26, 26],
|
|
||||||
iconAnchor: [13, 13],
|
|
||||||
tooltipAnchor: [0, -14],
|
|
||||||
})
|
|
||||||
poiIconCache.set(category, icon)
|
|
||||||
return icon
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
|
||||||
// OSM places for the visible area.
|
|
||||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
|
||||||
const map = useMap()
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onViewportChange) return
|
|
||||||
const emit = () => {
|
|
||||||
const b = map.getBounds()
|
|
||||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
|
||||||
}
|
|
||||||
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
|
||||||
map.on('moveend', emit)
|
|
||||||
map.on('zoomend', emit)
|
|
||||||
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
|
|
||||||
}, [map, onViewportChange])
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectionControllerProps {
|
interface SelectionControllerProps {
|
||||||
places: Place[]
|
places: Place[]
|
||||||
selectedPlaceId: number | null
|
selectedPlaceId: number | null
|
||||||
@@ -170,21 +131,10 @@ function SelectionController({ places, selectedPlaceId, dayPlaces, paddingOpts }
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
if (selectedPlaceId && selectedPlaceId !== prev.current) {
|
||||||
// Pan to the selected place without changing zoom. Offset the centre by the
|
// Pan to the selected place without changing zoom
|
||||||
// side-panel + bottom-inspector padding so the pin lands in the middle of the
|
|
||||||
// *visible* map area rather than the geometric centre (where the bottom panel
|
|
||||||
// would cover it). Reuses the same paddingOpts the fit-bounds path uses.
|
|
||||||
const selected = places.find(p => p.id === selectedPlaceId)
|
const selected = places.find(p => p.id === selectedPlaceId)
|
||||||
if (selected?.lat != null && selected?.lng != null) {
|
if (selected?.lat && selected?.lng) {
|
||||||
const latlng: [number, number] = [selected.lat, selected.lng]
|
map.panTo([selected.lat, selected.lng], { animate: true })
|
||||||
const tl = paddingOpts.paddingTopLeft as [number, number] | undefined
|
|
||||||
const br = paddingOpts.paddingBottomRight as [number, number] | undefined
|
|
||||||
if (tl && br && typeof map.project === 'function' && typeof map.unproject === 'function') {
|
|
||||||
const point = map.project(latlng).add([(br[0] - tl[0]) / 2, (br[1] - tl[1]) / 2])
|
|
||||||
map.panTo(map.unproject(point), { animate: true })
|
|
||||||
} else {
|
|
||||||
map.panTo(latlng, { animate: true })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
prev.current = selectedPlaceId
|
prev.current = selectedPlaceId
|
||||||
@@ -406,21 +356,7 @@ export const MapView = memo(function MapView({
|
|||||||
showReservationStats = false,
|
showReservationStats = false,
|
||||||
visibleConnectionIds = [] as number[],
|
visibleConnectionIds = [] as number[],
|
||||||
onReservationClick,
|
onReservationClick,
|
||||||
pois = [] as Poi[],
|
|
||||||
onPoiClick,
|
|
||||||
onViewportChange,
|
|
||||||
}: any) {
|
}: any) {
|
||||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
|
||||||
<Marker
|
|
||||||
key={`poi-${poi.osm_id}`}
|
|
||||||
position={[poi.lat, poi.lng]}
|
|
||||||
icon={createPoiIcon(poi.category)}
|
|
||||||
zIndexOffset={500}
|
|
||||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
|
||||||
>
|
|
||||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
|
||||||
</Marker>
|
|
||||||
)), [pois, onPoiClick])
|
|
||||||
const visibleReservations = useMemo(() => {
|
const visibleReservations = useMemo(() => {
|
||||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||||
const set = new Set(visibleConnectionIds)
|
const set = new Set(visibleConnectionIds)
|
||||||
@@ -596,7 +532,6 @@ export const MapView = memo(function MapView({
|
|||||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||||
<MapClickHandler onClick={onMapClick} />
|
<MapClickHandler onClick={onMapClick} />
|
||||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||||
<ViewportController onViewportChange={onViewportChange} />
|
|
||||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||||
|
|
||||||
<MarkerClusterGroup
|
<MarkerClusterGroup
|
||||||
@@ -637,8 +572,6 @@ export const MapView = memo(function MapView({
|
|||||||
showStats={showReservationStats}
|
showStats={showReservationStats}
|
||||||
onEndpointClick={onReservationClick}
|
onEndpointClick={onReservationClick}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poiMarkers}
|
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
{isMobile && <LocationButton
|
{isMobile && <LocationButton
|
||||||
mode={trackingMode}
|
mode={trackingMode}
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ import { MapViewGL } from './MapViewGL'
|
|||||||
// Auto-selects the map renderer based on user settings. Keeps the existing
|
// Auto-selects the map renderer based on user settings. Keeps the existing
|
||||||
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
// Leaflet MapView untouched so the Mapbox GL variant can mature iteratively
|
||||||
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
// behind a toggle. Atlas is not affected — it imports Leaflet directly.
|
||||||
//
|
|
||||||
// Offline maps: only the Leaflet renderer supports full pre-download (raster
|
|
||||||
// tiles via sync/tilePrefetcher.ts). Mapbox GL is best-effort offline — its
|
|
||||||
// vector tiles are cached opportunistically by the Service Worker as you view
|
|
||||||
// them online (see the mapbox-tiles rule in vite.config.js), not prefetched.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export function MapViewAuto(props: any) {
|
export function MapViewAuto(props: any) {
|
||||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||||
|
|||||||
@@ -31,29 +31,15 @@ const glMap = vi.hoisted(() => ({
|
|||||||
vi.mock('mapbox-gl', () => ({
|
vi.mock('mapbox-gl', () => ({
|
||||||
default: {
|
default: {
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
Map: vi.fn(function () {
|
Map: vi.fn(() => glMap),
|
||||||
return glMap
|
Marker: vi.fn(() => ({
|
||||||
}),
|
setLngLat: vi.fn().mockReturnThis(),
|
||||||
Marker: vi.fn(function () {
|
addTo: vi.fn().mockReturnThis(),
|
||||||
return {
|
remove: vi.fn(),
|
||||||
setLngLat: vi.fn().mockReturnThis(),
|
getElement: vi.fn(() => document.createElement('div')),
|
||||||
addTo: vi.fn().mockReturnThis(),
|
})),
|
||||||
remove: vi.fn(),
|
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
|
||||||
getElement: vi.fn(() => document.createElement('div')),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
LngLatBounds: vi.fn(function () {
|
|
||||||
return { extend: vi.fn().mockReturnThis() }
|
|
||||||
}),
|
|
||||||
NavigationControl: vi.fn(),
|
NavigationControl: vi.fn(),
|
||||||
Popup: vi.fn(function () {
|
|
||||||
return {
|
|
||||||
setLngLat: vi.fn().mockReturnThis(),
|
|
||||||
setHTML: vi.fn().mockReturnThis(),
|
|
||||||
addTo: vi.fn().mockReturnThis(),
|
|
||||||
remove: vi.fn(),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||||
@@ -71,9 +57,7 @@ vi.mock('./locationMarkerMapbox', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('./reservationsMapbox', () => ({
|
vi.mock('./reservationsMapbox', () => ({
|
||||||
ReservationMapboxOverlay: vi.fn(function () {
|
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
|
||||||
return { update: vi.fn() }
|
|
||||||
}),
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../../hooks/useGeolocation', () => ({
|
vi.mock('../../hooks/useGeolocation', () => ({
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
|
|||||||
import LocationButton from './LocationButton'
|
import LocationButton from './LocationButton'
|
||||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||||
import type { Place, Reservation } from '../../types'
|
import type { Place, Reservation } from '../../types'
|
||||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
|
||||||
import { buildPlacePopupHtml, buildPoiPopupHtml } from './placePopup'
|
|
||||||
|
|
||||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||||
@@ -51,10 +49,6 @@ interface Props {
|
|||||||
visibleConnectionIds?: number[]
|
visibleConnectionIds?: number[]
|
||||||
showReservationStats?: boolean
|
showReservationStats?: boolean
|
||||||
onReservationClick?: (reservationId: number) => void
|
onReservationClick?: (reservationId: number) => void
|
||||||
pois?: Poi[]
|
|
||||||
onPoiClick?: (poi: Poi) => void
|
|
||||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
|
||||||
onMapReady?: (map: mapboxgl.Map | null) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||||
@@ -134,17 +128,6 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
|||||||
return wrap
|
return wrap
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
|
||||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
|
||||||
const cat = POI_CATEGORY_BY_KEY[category]
|
|
||||||
const color = cat?.color || '#6b7280'
|
|
||||||
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
|
||||||
const el = document.createElement('div')
|
|
||||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
|
||||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
|
||||||
return el
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MapViewGL({
|
export function MapViewGL({
|
||||||
places = [],
|
places = [],
|
||||||
dayPlaces = [],
|
dayPlaces = [],
|
||||||
@@ -166,10 +149,6 @@ export function MapViewGL({
|
|||||||
visibleConnectionIds = [],
|
visibleConnectionIds = [],
|
||||||
showReservationStats = false,
|
showReservationStats = false,
|
||||||
onReservationClick,
|
onReservationClick,
|
||||||
pois = [],
|
|
||||||
onPoiClick,
|
|
||||||
onViewportChange,
|
|
||||||
onMapReady,
|
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||||
@@ -188,16 +167,6 @@ export function MapViewGL({
|
|||||||
// options without forcing a full overlay rebuild on every prop change.
|
// options without forcing a full overlay rebuild on every prop change.
|
||||||
const onReservationClickRef = useRef(onReservationClick)
|
const onReservationClickRef = useRef(onReservationClick)
|
||||||
onReservationClickRef.current = onReservationClick
|
onReservationClickRef.current = onReservationClick
|
||||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
|
||||||
// Single reusable hover popup (name/category/address card) shared by planned
|
|
||||||
// places and POI markers — mirrors the Leaflet map's hover tooltip.
|
|
||||||
const popupRef = useRef<mapboxgl.Popup | null>(null)
|
|
||||||
const onPoiClickRef = useRef(onPoiClick)
|
|
||||||
onPoiClickRef.current = onPoiClick
|
|
||||||
const onViewportChangeRef = useRef(onViewportChange)
|
|
||||||
onViewportChangeRef.current = onViewportChange
|
|
||||||
const onMapReadyRef = useRef(onMapReady)
|
|
||||||
onMapReadyRef.current = onMapReady
|
|
||||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||||
onClickRefs.current.marker = onMarkerClick
|
onClickRefs.current.marker = onMarkerClick
|
||||||
@@ -220,16 +189,6 @@ export function MapViewGL({
|
|||||||
projection: mapboxQuality ? 'globe' : 'mercator',
|
projection: mapboxQuality ? 'globe' : 'mercator',
|
||||||
})
|
})
|
||||||
mapRef.current = map
|
mapRef.current = map
|
||||||
popupRef.current = new mapboxgl.Popup({
|
|
||||||
closeButton: false,
|
|
||||||
closeOnClick: false,
|
|
||||||
offset: 18,
|
|
||||||
maxWidth: '240px',
|
|
||||||
className: 'trek-map-popup',
|
|
||||||
})
|
|
||||||
// Hand the map out so the trip planner can render its own compass pill next to
|
|
||||||
// the POI pill (a custom round control instead of Mapbox's default top-right one).
|
|
||||||
onMapReadyRef.current?.(map)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
;(window as any).__trek_map = map
|
;(window as any).__trek_map = map
|
||||||
|
|
||||||
@@ -301,14 +260,6 @@ export function MapViewGL({
|
|||||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||||
})
|
})
|
||||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
|
||||||
// pill can fetch OSM places for the visible area.
|
|
||||||
const emitViewport = () => {
|
|
||||||
const b = map.getBounds()
|
|
||||||
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
|
||||||
}
|
|
||||||
map.on('moveend', emitViewport)
|
|
||||||
map.once('idle', emitViewport)
|
|
||||||
// In the mapbox-gl map the right mouse button is reserved for the
|
// In the mapbox-gl map the right mouse button is reserved for the
|
||||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||||
// to the middle mouse button (button === 1) instead.
|
// to the middle mouse button (button === 1) instead.
|
||||||
@@ -375,8 +326,6 @@ export function MapViewGL({
|
|||||||
canvas.removeEventListener('auxclick', onAuxClick)
|
canvas.removeEventListener('auxclick', onAuxClick)
|
||||||
markersRef.current.forEach(m => m.remove())
|
markersRef.current.forEach(m => m.remove())
|
||||||
markersRef.current.clear()
|
markersRef.current.clear()
|
||||||
if (popupRef.current) { popupRef.current.remove(); popupRef.current = null }
|
|
||||||
onMapReadyRef.current?.(null)
|
|
||||||
if (reservationOverlayRef.current) {
|
if (reservationOverlayRef.current) {
|
||||||
reservationOverlayRef.current.destroy()
|
reservationOverlayRef.current.destroy()
|
||||||
reservationOverlayRef.current = null
|
reservationOverlayRef.current = null
|
||||||
@@ -450,10 +399,6 @@ export function MapViewGL({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
if (!map) return
|
if (!map) return
|
||||||
// Markers are about to be rebuilt; drop any open hover popup first. A marker
|
|
||||||
// recreated under the pointer (e.g. when its photo streams in) never fires
|
|
||||||
// mouseleave, which would otherwise leave the popup orphaned on the map.
|
|
||||||
popupRef.current?.remove()
|
|
||||||
const ids = new Set(places.map(p => p.id))
|
const ids = new Set(places.map(p => p.id))
|
||||||
|
|
||||||
markersRef.current.forEach((marker, id) => {
|
markersRef.current.forEach((marker, id) => {
|
||||||
@@ -474,12 +419,6 @@ export function MapViewGL({
|
|||||||
ev.stopPropagation()
|
ev.stopPropagation()
|
||||||
onClickRefs.current.marker?.(place.id)
|
onClickRefs.current.marker?.(place.id)
|
||||||
})
|
})
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
popupRef.current?.setLngLat([place.lng, place.lat])
|
|
||||||
.setHTML(buildPlacePopupHtml(place as Place & { category_color?: string; category_icon?: string; category_name?: string }, photoUrl))
|
|
||||||
.addTo(map)
|
|
||||||
})
|
|
||||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
|
||||||
// Recreate marker each time rather than patching internal state —
|
// Recreate marker each time rather than patching internal state —
|
||||||
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
// mapbox-gl's internal _element bookkeeping breaks under DOM swaps.
|
||||||
const existing = markersRef.current.get(place.id)
|
const existing = markersRef.current.get(place.id)
|
||||||
@@ -496,26 +435,6 @@ export function MapViewGL({
|
|||||||
})
|
})
|
||||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||||
|
|
||||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
|
||||||
// planned-place markers so they don't cluster or get confused with them).
|
|
||||||
useEffect(() => {
|
|
||||||
const map = mapRef.current
|
|
||||||
if (!map || !mapReady) return
|
|
||||||
popupRef.current?.remove() // same orphan-popup guard as the place markers
|
|
||||||
poiMarkersRef.current.forEach(m => m.remove())
|
|
||||||
poiMarkersRef.current = []
|
|
||||||
for (const poi of (pois as Poi[])) {
|
|
||||||
const el = createPoiMarkerElement(poi.category)
|
|
||||||
el.addEventListener('mouseenter', () => {
|
|
||||||
popupRef.current?.setLngLat([poi.lng, poi.lat]).setHTML(buildPoiPopupHtml(poi)).addTo(map)
|
|
||||||
})
|
|
||||||
el.addEventListener('mouseleave', () => { popupRef.current?.remove() })
|
|
||||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
|
||||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
|
||||||
poiMarkersRef.current.push(m)
|
|
||||||
}
|
|
||||||
}, [pois, mapReady])
|
|
||||||
|
|
||||||
// Update route geojson
|
// Update route geojson
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current
|
const map = mapRef.current
|
||||||
@@ -634,10 +553,6 @@ export function MapViewGL({
|
|||||||
zoom: Math.max(map.getZoom(), 14),
|
zoom: Math.max(map.getZoom(), 14),
|
||||||
pitch: mapbox3d ? 45 : 0,
|
pitch: mapbox3d ? 45 : 0,
|
||||||
duration: 400,
|
duration: 400,
|
||||||
// Account for the side panels and the bottom inspector / day-detail panel
|
|
||||||
// so the selected pin lands in the centre of the *visible* map area rather
|
|
||||||
// than the geometric centre (where the bottom panel would cover it).
|
|
||||||
padding: paddingOpts,
|
|
||||||
})
|
})
|
||||||
} catch { /* noop */ }
|
} catch { /* noop */ }
|
||||||
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
}, [selectedPlaceId, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import { RotateCw, AlertTriangle } from 'lucide-react'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { Tooltip } from '../shared/Tooltip'
|
|
||||||
import { POI_CATEGORIES } from './poiCategories'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
active: Set<string>
|
|
||||||
onToggle: (key: string) => void
|
|
||||||
loadingKeys?: Set<string>
|
|
||||||
/** categories whose last fetch failed → show a retry affordance */
|
|
||||||
errorKeys?: Set<string>
|
|
||||||
/** true when the map moved since the last search → offer "search this area" */
|
|
||||||
moved?: boolean
|
|
||||||
onSearchArea?: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
|
||||||
// fill with the category colour (matching their markers); the label shows in a
|
|
||||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
|
||||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, errorKeys, moved, onSearchArea }: Props) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const anyError = !!errorKeys && Array.from(active).some(k => errorKeys.has(k))
|
|
||||||
|
|
||||||
const frosted: React.CSSProperties = {
|
|
||||||
background: 'var(--sidebar-bg)',
|
|
||||||
backdropFilter: 'blur(20px) saturate(180%)',
|
|
||||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
|
||||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
|
||||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
|
|
||||||
{POI_CATEGORIES.map(cat => {
|
|
||||||
const on = active.has(cat.key)
|
|
||||||
const loading = loadingKeys?.has(cat.key)
|
|
||||||
return (
|
|
||||||
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onToggle(cat.key)}
|
|
||||||
aria-pressed={on}
|
|
||||||
aria-label={t(cat.labelKey)}
|
|
||||||
className={on ? '' : 'text-content-muted'}
|
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
|
||||||
background: on ? cat.color : 'transparent',
|
|
||||||
color: on ? '#fff' : undefined,
|
|
||||||
transition: 'background 0.14s, color 0.14s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { if (!on) e.currentTarget.style.background = 'transparent' }}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<span
|
|
||||||
className="animate-spin"
|
|
||||||
style={{
|
|
||||||
width: 14, height: 14, borderRadius: 999, display: 'inline-block',
|
|
||||||
border: '2px solid', borderColor: on ? 'rgba(255,255,255,0.45)' : 'var(--border-primary)',
|
|
||||||
borderTopColor: on ? '#fff' : 'var(--text-muted)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<cat.Icon size={16} strokeWidth={2} />
|
|
||||||
)}
|
|
||||||
{on && !loading && errorKeys?.has(cat.key) && (
|
|
||||||
<span style={{
|
|
||||||
position: 'absolute', top: 2, right: 2, width: 8, height: 8,
|
|
||||||
borderRadius: 999, background: '#ef4444', border: '1.5px solid var(--sidebar-bg)',
|
|
||||||
}} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(moved || anyError) && active.size > 0 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSearchArea}
|
|
||||||
className="text-content"
|
|
||||||
style={{
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
|
||||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
|
||||||
color: anyError ? '#ef4444' : undefined,
|
|
||||||
...frosted,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{anyError
|
|
||||||
? <AlertTriangle size={13} strokeWidth={2.4} />
|
|
||||||
: <RotateCw size={13} strokeWidth={2.4} />}
|
|
||||||
{t('poi.searchThisArea')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
|||||||
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
import { Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet'
|
||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||||
import { escapeHtml } from '@trek/shared'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ function useEndpointPane() {
|
|||||||
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
||||||
const { icon: IconCmp, color } = TYPE_META[type]
|
const { icon: IconCmp, color } = TYPE_META[type]
|
||||||
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||||
const labelHtml = label ? `<span>${escapeHtml(label)}</span>` : ''
|
const labelHtml = label ? `<span>${label}</span>` : ''
|
||||||
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
const estWidth = label ? Math.max(40, label.length * 6 + 28) : 26
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: 'trek-endpoint-marker',
|
className: 'trek-endpoint-marker',
|
||||||
@@ -54,7 +53,7 @@ function endpointIcon(type: TransportType, label: string | null): L.DivIcon {
|
|||||||
border:1.5px solid #fff;color:#fff;
|
border:1.5px solid #fff;color:#fff;
|
||||||
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
font-family:var(--font-system);font-size:11px;font-weight:600;letter-spacing:0.3px;line-height:1;
|
||||||
box-sizing:border-box;height:22px;white-space:nowrap;
|
box-sizing:border-box;height:22px;white-space:nowrap;
|
||||||
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''}</div>`,
|
"><span style="display:inline-flex;align-items:center;">${svg}</span>${labelHtml ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''}</div>`,
|
||||||
iconSize: [estWidth, 22],
|
iconSize: [estWidth, 22],
|
||||||
iconAnchor: [estWidth / 2, 11],
|
iconAnchor: [estWidth / 2, 11],
|
||||||
popupAnchor: [0, -11],
|
popupAnchor: [0, -11],
|
||||||
@@ -158,7 +157,6 @@ interface TransportItem {
|
|||||||
res: Reservation
|
res: Reservation
|
||||||
from: ReservationEndpoint
|
from: ReservationEndpoint
|
||||||
to: ReservationEndpoint
|
to: ReservationEndpoint
|
||||||
waypoints: ReservationEndpoint[]
|
|
||||||
type: TransportType
|
type: TransportType
|
||||||
arcs: [number, number][][]
|
arcs: [number, number][][]
|
||||||
primaryArc: [number, number][]
|
primaryArc: [number, number][]
|
||||||
@@ -174,8 +172,8 @@ function buildStatsHtml(color: string, mainLabel: string | null, subLabel: strin
|
|||||||
) + 22
|
) + 22
|
||||||
const hasBoth = !!mainLabel && !!subLabel
|
const hasBoth = !!mainLabel && !!subLabel
|
||||||
const height = hasBoth ? 36 : 22
|
const height = hasBoth ? 36 : 22
|
||||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
|
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
|
||||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
|
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
|
||||||
const html = `<div class="trek-stats-inner" style="
|
const html = `<div class="trek-stats-inner" style="
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
width:100%;height:100%;
|
width:100%;height:100%;
|
||||||
@@ -354,29 +352,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
const out: TransportItem[] = []
|
const out: TransportItem[] = []
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
|
const eps = r.endpoints || []
|
||||||
// so the arc + markers below are byte-identical to before for it.
|
const from = eps.find(e => e.role === 'from')
|
||||||
const waypoints = (r.endpoints || [])
|
const to = eps.find(e => e.role === 'to')
|
||||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
if (!from || !to) continue
|
||||||
.slice()
|
|
||||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
|
||||||
if (waypoints.length < 2) continue
|
|
||||||
const from = waypoints[0]
|
|
||||||
const to = waypoints[waypoints.length - 1]
|
|
||||||
const type = r.type as TransportType
|
const type = r.type as TransportType
|
||||||
const isGeo = TYPE_META[type].geodesic
|
const isGeo = TYPE_META[type].geodesic
|
||||||
// One arc per leg (between consecutive waypoints), concatenated.
|
const arcs = isGeo
|
||||||
const arcs: [number, number][][] = []
|
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||||
let distanceKm = 0
|
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
||||||
const a = waypoints[i]
|
|
||||||
const b = waypoints[i + 1]
|
|
||||||
const segArcs = isGeo
|
|
||||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
|
||||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
|
||||||
arcs.push(...segArcs)
|
|
||||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
|
||||||
}
|
|
||||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
const primaryArc = arcs[primaryIdx] ?? []
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
const fallback: [number, number] = primaryArc.length > 0
|
const fallback: [number, number] = primaryArc.length > 0
|
||||||
@@ -384,15 +368,12 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
||||||
|
|
||||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||||
const distance = `${Math.round(distanceKm)} km`
|
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||||
// Show the full route (FRA → BER → HND) when every waypoint has a code.
|
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||||
const mainLabel = waypoints.every(w => w.code)
|
|
||||||
? waypoints.map(w => w.code).join(' → ')
|
|
||||||
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
|
||||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||||
|
|
||||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}, [reservations])
|
}, [reservations])
|
||||||
@@ -434,21 +415,38 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
/>
|
/>
|
||||||
)))}
|
)))}
|
||||||
|
|
||||||
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
{visibleItems.flatMap(item => [
|
||||||
<Marker
|
<Marker
|
||||||
key={`wp-${item.res.id}-${wi}`}
|
key={`from-${item.res.id}`}
|
||||||
position={[wp.lat, wp.lng]}
|
position={[item.from.lat, item.from.lng]}
|
||||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
||||||
pane={ENDPOINT_PANE}
|
pane={ENDPOINT_PANE}
|
||||||
zIndexOffset={1000}
|
zIndexOffset={1000}
|
||||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div>
|
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
||||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>
|
</Marker>,
|
||||||
)))}
|
<Marker
|
||||||
|
key={`to-${item.res.id}`}
|
||||||
|
position={[item.to.lat, item.to.lng]}
|
||||||
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
|
||||||
|
pane={ENDPOINT_PANE}
|
||||||
|
zIndexOffset={1000}
|
||||||
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
||||||
|
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>,
|
||||||
|
])}
|
||||||
|
|
||||||
|
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
|
||||||
|
<StatsLabel key={`stats-${item.res.id}`} item={item} />
|
||||||
|
))}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,62 +161,6 @@ describe('optimizeRoute', () => {
|
|||||||
expect(result[1]).toEqual(c)
|
expect(result[1]).toEqual(c)
|
||||||
expect(result[2]).toEqual(b)
|
expect(result[2]).toEqual(b)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-016: start anchor begins the chain at the anchor-nearest stop', () => {
|
|
||||||
const a = { lat: 10, lng: 1 }
|
|
||||||
const b = { lat: 2, lng: 1 }
|
|
||||||
const c = { lat: 5, lng: 1 }
|
|
||||||
// From the accommodation anchor (1,1): nearest is b(2,1), then c(5,1), then a(10,1)
|
|
||||||
const result = optimizeRoute([a, b, c], { start: { lat: 1, lng: 1 } })
|
|
||||||
expect(result).toEqual([b, c, a])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-017: start + end anchors reorder a shuffled day and keep the end-nearest stop last', () => {
|
|
||||||
const a = { lat: 2, lng: 1 }
|
|
||||||
const b = { lat: 5, lng: 1 }
|
|
||||||
const c = { lat: 8, lng: 1 }
|
|
||||||
// Transfer day: start at hotel A (1,1), end at hotel B (9,1). c is nearest B, so it must be last.
|
|
||||||
const result = optimizeRoute([c, a, b], { start: { lat: 1, lng: 1 }, end: { lat: 9, lng: 1 } })
|
|
||||||
expect(result).toEqual([a, b, c])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-018: an anchor makes even a two-stop day sortable', () => {
|
|
||||||
const a = { lat: 10, lng: 1 }
|
|
||||||
const b = { lat: 2, lng: 1 }
|
|
||||||
// Without anchors two stops are returned unchanged; the start anchor orders them by proximity.
|
|
||||||
const result = optimizeRoute([a, b], { start: { lat: 1, lng: 1 } })
|
|
||||||
expect(result).toEqual([b, a])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-019: 2-opt untangles a round-trip into a clean loop around the hotel', () => {
|
|
||||||
const hotel = { lat: 48.8668, lng: 2.3013 } // Rue Marbeuf
|
|
||||||
const stops = [
|
|
||||||
{ id: 1, lat: 48.8565, lng: 2.3324 },
|
|
||||||
{ id: 2, lat: 48.8813, lng: 2.3151 },
|
|
||||||
{ id: 3, lat: 48.8796, lng: 2.308 },
|
|
||||||
{ id: 4, lat: 48.8723, lng: 2.2926 },
|
|
||||||
{ id: 5, lat: 48.866, lng: 2.3102 }, // nearest the hotel
|
|
||||||
]
|
|
||||||
const d = (a: { lat: number; lng: number }, b: { lat: number; lng: number }) =>
|
|
||||||
Math.hypot(a.lat - b.lat, a.lng - b.lng)
|
|
||||||
const loop = (order: typeof stops) =>
|
|
||||||
d(hotel, order[0]) + order.slice(1).reduce((s, p, i) => s + d(order[i], p), 0) + d(order[order.length - 1], hotel)
|
|
||||||
|
|
||||||
const result = optimizeRoute(stops, { start: hotel, end: hotel })
|
|
||||||
// The optimized loop is no longer than the original order…
|
|
||||||
expect(loop(result)).toBeLessThanOrEqual(loop(stops) + 1e-9)
|
|
||||||
// …and the hotel-adjacent stop sits at one end of the loop, right next to the hotel.
|
|
||||||
expect([result[0].id, result[result.length - 1].id]).toContain(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-ROUTECALCULATOR-020: an end anchor without a start finishes at the stop nearest it', () => {
|
|
||||||
const a = { lat: 2, lng: 1 }
|
|
||||||
const b = { lat: 5, lng: 1 }
|
|
||||||
const c = { lat: 9, lng: 1 }
|
|
||||||
// a is nearest the end anchor, so the route must finish at a rather than start there.
|
|
||||||
const result = optimizeRoute([a, b, c], { end: { lat: 1, lng: 1 } })
|
|
||||||
expect(result[result.length - 1]).toEqual(a)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
|
// ── generateGoogleMapsUrl ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint, RouteAnchors } from '../../types'
|
import type { RouteResult, RouteSegment, RouteWithLegs, Waypoint } from '../../types'
|
||||||
|
|
||||||
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
const OSRM_BASE = 'https://router.project-osrm.org/route/v1'
|
||||||
|
|
||||||
@@ -77,98 +77,35 @@ export function generateGoogleMapsUrl(places: Waypoint[]): string | null {
|
|||||||
return `https://www.google.com/maps/dir/${stops}`
|
return `https://www.google.com/maps/dir/${stops}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Squared planar distance — enough for nearest-neighbor comparisons and cheaper than a full haversine.
|
/** Reorders waypoints using a nearest-neighbor heuristic to minimize total Euclidean distance. */
|
||||||
function sqDist(a: Waypoint, b: Waypoint): number {
|
export function optimizeRoute<T extends Waypoint>(places: T[]): T[] {
|
||||||
return (a.lat - b.lat) ** 2 + (a.lng - b.lng) ** 2
|
const valid = places.filter((p) => p.lat && p.lng)
|
||||||
}
|
if (valid.length <= 2) return places
|
||||||
|
|
||||||
// Length of visiting `order` in sequence, optionally pinned to a fixed start and/or end anchor.
|
|
||||||
// With start === end this is a closed loop back to the anchor (a day out from and back to the hotel).
|
|
||||||
function tourLength(order: Waypoint[], start?: Waypoint, end?: Waypoint): number {
|
|
||||||
if (order.length === 0) return 0
|
|
||||||
let total = 0
|
|
||||||
if (start) total += Math.sqrt(sqDist(start, order[0]))
|
|
||||||
for (let i = 0; i < order.length - 1; i++) total += Math.sqrt(sqDist(order[i], order[i + 1]))
|
|
||||||
if (end) total += Math.sqrt(sqDist(order[order.length - 1], end))
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
// Greedy nearest-neighbor ordering, seeded at the start anchor when there is one.
|
|
||||||
function nearestNeighborOrder<T extends Waypoint>(valid: T[], start?: Waypoint): T[] {
|
|
||||||
const visited = new Set<number>()
|
const visited = new Set<number>()
|
||||||
const result: T[] = []
|
const result: T[] = []
|
||||||
let current: Waypoint
|
let current = valid[0]
|
||||||
if (start) {
|
visited.add(0)
|
||||||
current = start
|
result.push(current)
|
||||||
} else {
|
|
||||||
current = valid[0]
|
|
||||||
visited.add(0)
|
|
||||||
result.push(valid[0])
|
|
||||||
}
|
|
||||||
while (result.length < valid.length) {
|
while (result.length < valid.length) {
|
||||||
let nearestIdx = -1
|
let nearestIdx = -1
|
||||||
let minDist = Infinity
|
let minDist = Infinity
|
||||||
for (let i = 0; i < valid.length; i++) {
|
for (let i = 0; i < valid.length; i++) {
|
||||||
if (visited.has(i)) continue
|
if (visited.has(i)) continue
|
||||||
const d = sqDist(valid[i], current)
|
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 (d < minDist) { minDist = d; nearestIdx = i }
|
||||||
}
|
}
|
||||||
if (nearestIdx === -1) break
|
if (nearestIdx === -1) break
|
||||||
visited.add(nearestIdx)
|
visited.add(nearestIdx)
|
||||||
current = valid[nearestIdx]
|
current = valid[nearestIdx]
|
||||||
result.push(valid[nearestIdx])
|
result.push(current)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2-opt: repeatedly reverse a sub-segment whenever it shortens the tour. This removes the crossings
|
|
||||||
// a pure nearest-neighbor pass leaves behind. The start/end anchors stay fixed, so a round trip
|
|
||||||
// (start === end) is untangled into a clean loop rather than an open path.
|
|
||||||
function twoOptImprove<T extends Waypoint>(order: T[], start?: Waypoint, end?: Waypoint): T[] {
|
|
||||||
if (order.length < 3) return order
|
|
||||||
let best = order
|
|
||||||
let bestLen = tourLength(best, start, end)
|
|
||||||
let improved = true
|
|
||||||
while (improved) {
|
|
||||||
improved = false
|
|
||||||
for (let i = 0; i < best.length - 1; i++) {
|
|
||||||
for (let j = i + 1; j < best.length; j++) {
|
|
||||||
const candidate = best.slice(0, i).concat(best.slice(i, j + 1).reverse(), best.slice(j + 1))
|
|
||||||
const len = tourLength(candidate, start, end)
|
|
||||||
if (len < bestLen - 1e-12) {
|
|
||||||
best = candidate
|
|
||||||
bestLen = len
|
|
||||||
improved = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reorders waypoints to minimize travel distance: a nearest-neighbor pass for a good starting order,
|
|
||||||
* then 2-opt to untangle crossings. Optional anchors (e.g. the day's accommodation) pin the route's
|
|
||||||
* ends — start === end makes it a loop out from and back to the hotel; a transfer day runs start → end.
|
|
||||||
*/
|
|
||||||
export function optimizeRoute<T extends Waypoint>(places: T[], anchors: RouteAnchors = {}): T[] {
|
|
||||||
const { start, end } = anchors
|
|
||||||
const valid = places.filter((p) => p.lat && p.lng)
|
|
||||||
if (valid.length <= 1) return places
|
|
||||||
// Two unanchored stops have no meaningful order to optimize; anchors can still flip them.
|
|
||||||
if (valid.length === 2 && !start && !end) return places
|
|
||||||
|
|
||||||
const order = twoOptImprove(nearestNeighborOrder(valid, start), start, end)
|
|
||||||
|
|
||||||
// A round trip's loop direction is arbitrary, so orient it to begin at the stop nearest the hotel —
|
|
||||||
// that reads naturally as "leave the hotel, head to the closest place, …, come back".
|
|
||||||
if (start && end && start.lat === end.lat && start.lng === end.lng && order.length > 1) {
|
|
||||||
if (sqDist(order[order.length - 1], start) < sqDist(order[0], start)) order.reverse()
|
|
||||||
}
|
|
||||||
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
|
/** Fetches per-leg distance/duration from OSRM and returns segment metadata (midpoints, walking/driving times). */
|
||||||
export async function calculateSegments(
|
export async function calculateSegments(
|
||||||
waypoints: Waypoint[],
|
waypoints: Waypoint[],
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
import { createElement } from 'react'
|
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
|
||||||
import { CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
|
||||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
|
||||||
import type { Place } from '../../types'
|
|
||||||
|
|
||||||
// HTML builders for the Mapbox GL hover popup. The Leaflet map already shows a
|
|
||||||
// name/category/address card on hover (a cursor-following overlay); Mapbox GL has
|
|
||||||
// no equivalent, so these produce the same card as an HTML string for a
|
|
||||||
// mapboxgl.Popup. Kept framework-agnostic (plain strings) on purpose.
|
|
||||||
|
|
||||||
type PlaceWithCategory = Place & {
|
|
||||||
category_color?: string | null
|
|
||||||
category_icon?: string | null
|
|
||||||
category_name?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function esc(s: string | null | undefined): string {
|
|
||||||
if (!s) return ''
|
|
||||||
return String(s)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render a lucide category icon to an inline SVG string in the given colour.
|
|
||||||
function iconSvg(iconName: string | null | undefined, size: number, color: string): string {
|
|
||||||
const Icon = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
|
||||||
try {
|
|
||||||
return renderToStaticMarkup(createElement(Icon, { size, color, strokeWidth: 2 }))
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only data: thumbnails and our own photo-proxy URLs are safe to drop straight
|
|
||||||
// into an <img src> — everything else is a fetch seed, not a displayable URL.
|
|
||||||
function isDisplayablePhoto(url: string | null | undefined): url is string {
|
|
||||||
return !!url && (url.startsWith('data:') || url.startsWith('/api/maps/place-photo/'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const CARD_OPEN = '<div style="font-family:var(--font-system);max-width:220px;">'
|
|
||||||
const NAME_STYLE = 'font-weight:600;font-size:12.5px;color:#111827;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
|
||||||
const ADDR_STYLE = 'font-size:11px;color:#9ca3af;margin-top:3px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
|
|
||||||
|
|
||||||
/** Hover-popup card for a planned place: optional photo, name, category row, address. */
|
|
||||||
export function buildPlacePopupHtml(place: PlaceWithCategory, photoUrl: string | null): string {
|
|
||||||
const img = isDisplayablePhoto(photoUrl)
|
|
||||||
? `<div style="width:100%;height:84px;border-radius:8px;overflow:hidden;margin-bottom:6px;background:#f3f4f6;"><img src="${esc(photoUrl)}" style="width:100%;height:100%;object-fit:cover;display:block;" /></div>`
|
|
||||||
: ''
|
|
||||||
const category =
|
|
||||||
place.category_name && place.category_icon
|
|
||||||
? `<div style="display:flex;align-items:center;gap:4px;margin-top:2px;">${iconSvg(place.category_icon, 11, place.category_color || '#6b7280')}<span style="font-size:11px;color:#6b7280;">${esc(place.category_name)}</span></div>`
|
|
||||||
: ''
|
|
||||||
const address = place.address ? `<div style="${ADDR_STYLE}">${esc(place.address)}</div>` : ''
|
|
||||||
return `${CARD_OPEN}${img}<div style="${NAME_STYLE}">${esc(place.name)}</div>${category}${address}</div>`
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Hover-popup card for an OSM "explore" POI: category-coloured icon, name, address. */
|
|
||||||
export function buildPoiPopupHtml(poi: Poi): string {
|
|
||||||
const cat = POI_CATEGORY_BY_KEY[poi.category]
|
|
||||||
const color = cat?.color || '#6b7280'
|
|
||||||
const icon = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 12, color, strokeWidth: 2 })) : ''
|
|
||||||
const head = `<div style="display:flex;align-items:center;gap:5px;"><span style="flex-shrink:0;display:inline-flex;line-height:0;">${icon}</span><span style="${NAME_STYLE}">${esc(poi.name)}</span></div>`
|
|
||||||
const address = poi.address ? `<div style="${ADDR_STYLE}">${esc(poi.address)}</div>` : ''
|
|
||||||
return `${CARD_OPEN}${head}${address}</div>`
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
|
||||||
|
|
||||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
|
||||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
|
||||||
// lives there; label/icon/colour live here. `color` doubles as the active-pill
|
|
||||||
// fill AND the marker colour, so the pill and the map agree visually.
|
|
||||||
export interface PoiCategory {
|
|
||||||
key: string
|
|
||||||
labelKey: string
|
|
||||||
Icon: LucideIcon
|
|
||||||
color: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POI_CATEGORIES: PoiCategory[] = [
|
|
||||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
|
||||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
|
||||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
|
||||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
|
||||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
|
||||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
|
||||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
|
||||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
|
||||||
]
|
|
||||||
|
|
||||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
|
||||||
POI_CATEGORIES.map(c => [c.key, c]),
|
|
||||||
)
|
|
||||||
|
|
||||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
|
||||||
export interface Poi {
|
|
||||||
osm_id: string
|
|
||||||
name: string
|
|
||||||
lat: number
|
|
||||||
lng: number
|
|
||||||
category: string
|
|
||||||
poi_type: string
|
|
||||||
address: string | null
|
|
||||||
website: string | null
|
|
||||||
phone: string | null
|
|
||||||
opening_hours: string | null
|
|
||||||
cuisine: string | null
|
|
||||||
source: 'openstreetmap'
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ import { createElement } from 'react'
|
|||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import mapboxgl from 'mapbox-gl'
|
import mapboxgl from 'mapbox-gl'
|
||||||
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
import { Plane, Train, Ship, Car, Bus, Sailboat, Bike, CarTaxiFront, Route } from 'lucide-react'
|
||||||
import { escapeHtml } from '@trek/shared'
|
|
||||||
import type { Reservation, ReservationEndpoint } from '../../types'
|
import type { Reservation, ReservationEndpoint } from '../../types'
|
||||||
|
|
||||||
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
export const RESERVATION_SOURCE_ID = 'trek-reservations'
|
||||||
@@ -126,7 +125,6 @@ interface TransportItem {
|
|||||||
res: Reservation
|
res: Reservation
|
||||||
from: ReservationEndpoint
|
from: ReservationEndpoint
|
||||||
to: ReservationEndpoint
|
to: ReservationEndpoint
|
||||||
waypoints: ReservationEndpoint[]
|
|
||||||
type: TransportType
|
type: TransportType
|
||||||
arcs: [number, number][][]
|
arcs: [number, number][][]
|
||||||
primaryArc: [number, number][]
|
primaryArc: [number, number][]
|
||||||
@@ -138,38 +136,23 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
|||||||
const out: TransportItem[] = []
|
const out: TransportItem[] = []
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
|
const eps = r.endpoints || []
|
||||||
const waypoints = (r.endpoints || [])
|
const from = eps.find(e => e.role === 'from')
|
||||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
const to = eps.find(e => e.role === 'to')
|
||||||
.slice()
|
if (!from || !to) continue
|
||||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
|
||||||
if (waypoints.length < 2) continue
|
|
||||||
const from = waypoints[0]
|
|
||||||
const to = waypoints[waypoints.length - 1]
|
|
||||||
const type = r.type as TransportType
|
const type = r.type as TransportType
|
||||||
const isGeo = TYPE_META[type].geodesic
|
const isGeo = TYPE_META[type].geodesic
|
||||||
// One arc per leg (between consecutive waypoints), concatenated.
|
const arcs = isGeo
|
||||||
const arcs: [number, number][][] = []
|
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||||
let distanceKm = 0
|
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
|
||||||
const a = waypoints[i]
|
|
||||||
const b = waypoints[i + 1]
|
|
||||||
const segArcs = isGeo
|
|
||||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
|
||||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
|
||||||
arcs.push(...segArcs)
|
|
||||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
|
||||||
}
|
|
||||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
const primaryArc = arcs[primaryIdx] ?? []
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||||
const distance = `${Math.round(distanceKm)} km`
|
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||||
const mainLabel = waypoints.every(w => w.code)
|
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||||
? waypoints.map(w => w.code).join(' → ')
|
|
||||||
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
|
||||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel })
|
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
@@ -178,7 +161,7 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
|||||||
function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
function endpointMarkerHtml(type: TransportType, label: string | null): string {
|
||||||
const { icon: IconCmp } = TYPE_META[type]
|
const { icon: IconCmp } = TYPE_META[type]
|
||||||
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
const svg = renderToStaticMarkup(createElement(IconCmp, { size: 13, color: 'white', strokeWidth: 2.5 }))
|
||||||
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${escapeHtml(label)}</span>` : ''
|
const labelHtml = label ? `<span style="display:inline-flex;align-items:center;line-height:1">${label}</span>` : ''
|
||||||
return `<div style="
|
return `<div style="
|
||||||
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
display:inline-flex;align-items:center;justify-content:center;gap:4px;
|
||||||
padding:0 8px;border-radius:999px;
|
padding:0 8px;border-radius:999px;
|
||||||
@@ -196,8 +179,8 @@ function buildStatsHtml(mainLabel: string | null, subLabel: string | null): { ht
|
|||||||
) + 22
|
) + 22
|
||||||
const hasBoth = !!mainLabel && !!subLabel
|
const hasBoth = !!mainLabel && !!subLabel
|
||||||
const height = hasBoth ? 36 : 22
|
const height = hasBoth ? 36 : 22
|
||||||
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${escapeHtml(mainLabel)}</span>` : ''
|
const main = mainLabel ? `<span style="font-size:12px;font-weight:700;line-height:1;display:block">${mainLabel}</span>` : ''
|
||||||
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${escapeHtml(subLabel)}</span>` : ''
|
const sub = subLabel ? `<span style="font-size:10px;font-weight:500;line-height:1;opacity:0.85;display:block${hasBoth ? ';margin-top:4px' : ''}">${subLabel}</span>` : ''
|
||||||
const html = `<div class="trek-stats-inner" style="
|
const html = `<div class="trek-stats-inner" style="
|
||||||
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||||
width:100%;height:100%;
|
width:100%;height:100%;
|
||||||
@@ -337,7 +320,7 @@ export class ReservationMapboxOverlay {
|
|||||||
if (show) {
|
if (show) {
|
||||||
for (const item of visibleItems) {
|
for (const item of visibleItems) {
|
||||||
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
||||||
for (const ep of item.waypoints) {
|
for (const ep of [item.from, item.to]) {
|
||||||
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.innerHTML = endpointMarkerHtml(item.type, label)
|
el.innerHTML = endpointMarkerHtml(item.type, label)
|
||||||
@@ -358,10 +341,29 @@ export class ReservationMapboxOverlay {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stats badge removed — the floating route/duration label on the arc is no
|
// ── stats label (flights only) ──────────────────────────────────
|
||||||
// longer drawn; only the connection line and the airport markers remain.
|
|
||||||
this.statsMarkers.forEach(s => s.marker.remove())
|
this.statsMarkers.forEach(s => s.marker.remove())
|
||||||
this.statsMarkers = []
|
this.statsMarkers = []
|
||||||
|
if (show && this.opts.showStats) {
|
||||||
|
for (const item of visibleItems) {
|
||||||
|
if (item.type !== 'flight') continue
|
||||||
|
if (!labelVisibleIds.has(item.res.id)) continue
|
||||||
|
if (!item.mainLabel && !item.subLabel) continue
|
||||||
|
const arc = item.primaryArc
|
||||||
|
if (arc.length < 2) continue
|
||||||
|
const mid = arc[Math.floor(arc.length / 2)]!
|
||||||
|
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
|
||||||
|
const el = document.createElement('div')
|
||||||
|
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
|
||||||
|
el.innerHTML = html
|
||||||
|
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||||
|
.setLngLat([mid[1], mid[0]])
|
||||||
|
.addTo(map)
|
||||||
|
this.statsMarkers.push({ marker, arc })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Prime rotation once so labels don't flash horizontal on first paint.
|
||||||
|
this.updateStatsRotation()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
import { useState, useRef, useCallback, useMemo } from 'react'
|
|
||||||
import { mapsApi } from '../../api/client'
|
|
||||||
import type { Poi } from './poiCategories'
|
|
||||||
|
|
||||||
export interface Bbox { south: number; west: number; north: number; east: number }
|
|
||||||
|
|
||||||
// A request we cancelled on purpose (newer search superseded it) — not a failure.
|
|
||||||
function isAbortError(err: unknown): boolean {
|
|
||||||
const e = err as { name?: string; code?: string } | null
|
|
||||||
return e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED' || e?.name === 'AbortError'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
|
||||||
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
|
||||||
* the results stale (`moved`) so the pill can offer "search this area". This keeps
|
|
||||||
* Overpass load (and visual churn) down.
|
|
||||||
*/
|
|
||||||
export function usePoiExplore() {
|
|
||||||
const [active, setActive] = useState<Set<string>>(() => new Set())
|
|
||||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
|
||||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
|
||||||
const [moved, setMoved] = useState(false)
|
|
||||||
// Categories whose last fetch genuinely failed (all Overpass mirrors down), so
|
|
||||||
// the pill can offer a retry instead of looking like "no places here".
|
|
||||||
const [errorKeys, setErrorKeys] = useState<Set<string>>(() => new Set())
|
|
||||||
|
|
||||||
const bboxRef = useRef<Bbox | null>(null)
|
|
||||||
// activeRef always mirrors the latest active set so async callbacks (fetch
|
|
||||||
// completions) can check whether a category is still wanted.
|
|
||||||
const activeRef = useRef(active)
|
|
||||||
activeRef.current = active
|
|
||||||
// One in-flight AbortController per category, so re-toggling / re-searching
|
|
||||||
// cancels the previous (possibly slow) Overpass request instead of racing it.
|
|
||||||
const abortRef = useRef<Record<string, AbortController>>({})
|
|
||||||
|
|
||||||
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (on) next.add(key); else next.delete(key)
|
|
||||||
return next
|
|
||||||
}), [])
|
|
||||||
|
|
||||||
const setError = useCallback((key: string, on: boolean) => setErrorKeys(prev => {
|
|
||||||
if (on === prev.has(key)) return prev
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (on) next.add(key); else next.delete(key)
|
|
||||||
return next
|
|
||||||
}), [])
|
|
||||||
|
|
||||||
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
|
||||||
abortRef.current[key]?.abort()
|
|
||||||
const ctrl = new AbortController()
|
|
||||||
abortRef.current[key] = ctrl
|
|
||||||
setLoading(key, true)
|
|
||||||
setError(key, false)
|
|
||||||
try {
|
|
||||||
const res = await mapsApi.pois(key, bbox, ctrl.signal)
|
|
||||||
// Drop the result if the user toggled this category off while the (slow)
|
|
||||||
// Overpass request was in flight — otherwise stale results re-appear.
|
|
||||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
|
||||||
} catch (err) {
|
|
||||||
// A superseded request was aborted on purpose — leave its state untouched
|
|
||||||
// so the newer request owns the spinner and results.
|
|
||||||
if (isAbortError(err)) return
|
|
||||||
// A real failure (every Overpass mirror down/timed out): surface it instead
|
|
||||||
// of a silent empty so the user can retry rather than assume "no places".
|
|
||||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
|
||||||
if (activeRef.current.has(key)) setError(key, true)
|
|
||||||
} finally {
|
|
||||||
// Only the latest controller for this key clears the spinner; a superseded
|
|
||||||
// one must not, or it would hide the newer request's in-flight state.
|
|
||||||
if (abortRef.current[key] === ctrl) {
|
|
||||||
setLoading(key, false)
|
|
||||||
delete abortRef.current[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [setLoading, setError])
|
|
||||||
|
|
||||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
|
||||||
bboxRef.current = bbox
|
|
||||||
if (activeRef.current.size > 0) setMoved(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Single-select: clicking a category switches to it (dropping the previous one
|
|
||||||
// and its markers immediately) and fetches it for the current viewport; clicking
|
|
||||||
// the already-active category turns it off.
|
|
||||||
const toggle = useCallback((key: string) => {
|
|
||||||
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
|
||||||
setMoved(false)
|
|
||||||
setErrorKeys(new Set())
|
|
||||||
// Switching to another category (or turning off) — cancel any in-flight
|
|
||||||
// fetches so their results can't land after the selection changed.
|
|
||||||
Object.values(abortRef.current).forEach(c => c.abort())
|
|
||||||
abortRef.current = {}
|
|
||||||
if (isOnlyActive) {
|
|
||||||
setActive(new Set())
|
|
||||||
setByCat({})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setActive(new Set([key]))
|
|
||||||
setByCat({})
|
|
||||||
if (bboxRef.current) fetchCat(key, bboxRef.current)
|
|
||||||
}, [fetchCat])
|
|
||||||
|
|
||||||
const searchArea = useCallback(() => {
|
|
||||||
const bbox = bboxRef.current
|
|
||||||
if (!bbox) return
|
|
||||||
setMoved(false)
|
|
||||||
activeRef.current.forEach(key => fetchCat(key, bbox))
|
|
||||||
}, [fetchCat])
|
|
||||||
|
|
||||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
|
||||||
|
|
||||||
return { active, pois, loadingKeys, errorKeys, moved, toggle, searchArea, onViewportChange }
|
|
||||||
}
|
|
||||||
@@ -146,20 +146,4 @@ describe('downloadJourneyBookPDF', () => {
|
|||||||
expect(html).toContain('Journey Book');
|
expect(html).toContain('Journey Book');
|
||||||
expect(html).toContain('The End');
|
expect(html).toContain('The End');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
|
|
||||||
const journey = buildJourney();
|
|
||||||
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
|
|
||||||
await downloadJourneyBookPDF(journey);
|
|
||||||
const iframe = getIframe()!;
|
|
||||||
const html = iframe.srcdoc;
|
|
||||||
|
|
||||||
// The script tag, image beacon and event handler are stripped from the story.
|
|
||||||
expect(html).not.toContain('<script');
|
|
||||||
expect(html).not.toContain('onerror');
|
|
||||||
expect(html).not.toContain('alert(2)');
|
|
||||||
// Benign prose survives.
|
|
||||||
expect(html).toContain('Hello');
|
|
||||||
expect(html).toContain('world');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
|
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
|
||||||
import { marked } from 'marked'
|
import { marked } from 'marked'
|
||||||
import { sanitizeRichTextHtml } from '@trek/shared'
|
|
||||||
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
|
||||||
|
|
||||||
function esc(str: string | null | undefined): string {
|
function esc(str: string | null | undefined): string {
|
||||||
@@ -10,9 +9,7 @@ function esc(str: string | null | undefined): string {
|
|||||||
|
|
||||||
function md(str: string | null | undefined): string {
|
function md(str: string | null | undefined): string {
|
||||||
if (!str) return ''
|
if (!str) return ''
|
||||||
// marked passes embedded raw HTML through by default, so sanitise the result
|
return marked.parse(str, { async: false, breaks: true }) as string
|
||||||
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
|
|
||||||
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function abs(url: string | null | undefined): string {
|
function abs(url: string | null | undefined): string {
|
||||||
@@ -311,9 +308,7 @@ export async function downloadJourneyBookPDF(journey: JourneyDetail) {
|
|||||||
|
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||||
// No script runs inside the document (print is triggered from the parent via
|
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||||
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
|
|
||||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
|
||||||
iframe.srcdoc = html
|
iframe.srcdoc = html
|
||||||
|
|
||||||
card.appendChild(header)
|
card.appendChild(header)
|
||||||
|
|||||||
@@ -84,22 +84,6 @@ const transportReservation = {
|
|||||||
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
metadata: JSON.stringify({ airline: 'Air Italia', flight_number: 'AI123', departure_airport: 'CDG', arrival_airport: 'FCO' }),
|
||||||
} as any
|
} as any
|
||||||
|
|
||||||
const multiLegFlight = {
|
|
||||||
id: 401,
|
|
||||||
title: 'Flight to Tokyo',
|
|
||||||
type: 'flight',
|
|
||||||
day_id: 10,
|
|
||||||
reservation_time: '2025-06-01T08:00:00',
|
|
||||||
confirmation_number: 'XYZ789',
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
legs: [
|
|
||||||
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1' },
|
|
||||||
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2' },
|
|
||||||
],
|
|
||||||
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
|
||||||
}),
|
|
||||||
} as any
|
|
||||||
|
|
||||||
const richArgs = {
|
const richArgs = {
|
||||||
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
trip: { id: 10, title: 'Italy Trip', description: 'Summer adventure', cover_image: '/uploads/cover.jpg' } as any,
|
||||||
days: [dayWithPlaces],
|
days: [dayWithPlaces],
|
||||||
@@ -212,16 +196,6 @@ describe('downloadTripPDF', () => {
|
|||||||
const iframe = getIframe()
|
const iframe = getIframe()
|
||||||
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
expect(iframe!.srcdoc).toContain('Flight to Rome')
|
||||||
expect(iframe!.srcdoc).toContain('ABC123')
|
expect(iframe!.srcdoc).toContain('ABC123')
|
||||||
// Single-leg flight keeps its full-route subtitle.
|
|
||||||
expect(iframe!.srcdoc).toContain('Air Italia · AI123 · CDG → FCO')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-013b: renders every flight number for a multi-leg flight', async () => {
|
|
||||||
await downloadTripPDF({ ...richArgs, reservations: [multiLegFlight] })
|
|
||||||
const iframe = getIframe()
|
|
||||||
// One subtitle line per leg, each with its own flight number and segment route.
|
|
||||||
expect(iframe!.srcdoc).toContain('Lufthansa · LH1 · FRA → BER')
|
|
||||||
expect(iframe!.srcdoc).toContain('Lufthansa · LH2 · BER → HND')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
it('FE-COMP-TRIPPDF-014: renders cover image when trip has cover_image', async () => {
|
||||||
@@ -285,23 +259,6 @@ describe('downloadTripPDF', () => {
|
|||||||
expect(iframe!.srcdoc).toContain('colosseum.jpg')
|
expect(iframe!.srcdoc).toContain('colosseum.jpg')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-018b: renders a persisted place-photo proxy image_url as an <img>, not the category icon (#1130)', async () => {
|
|
||||||
const args = {
|
|
||||||
...richArgs,
|
|
||||||
assignments: {
|
|
||||||
'10': [{
|
|
||||||
...assignmentForDay,
|
|
||||||
place: { ...placeWithDetails, image_url: '/api/maps/place-photo/ChIJabc/bytes' },
|
|
||||||
}],
|
|
||||||
} as any,
|
|
||||||
}
|
|
||||||
await downloadTripPDF(args)
|
|
||||||
const iframe = getIframe()
|
|
||||||
// The proxy path (no file extension) must still embed as an absolute <img>.
|
|
||||||
expect(iframe!.srcdoc).toContain('http://localhost:3000/api/maps/place-photo/ChIJabc/bytes')
|
|
||||||
expect(iframe!.srcdoc).toContain('class="place-thumb"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
|
it('FE-COMP-TRIPPDF-019: fetches google place photos for places with google_place_id', async () => {
|
||||||
let photoCalled = false
|
let photoCalled = false
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { accommodationsApi, mapsApi } from '../../api/client'
|
|||||||
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, DayNote } from '../../types'
|
||||||
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
|
||||||
import { splitReservationDateTime } from '../../utils/formatters'
|
import { splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { getFlightLegs } from '../../utils/flightLegs'
|
|
||||||
|
|
||||||
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
function renderLucideIcon(icon:LucideIcon, props = {}) {
|
||||||
if (!_renderToStaticMarkup) return ''
|
if (!_renderToStaticMarkup) return ''
|
||||||
@@ -56,10 +55,6 @@ function absUrl(url) {
|
|||||||
function safeImg(url) {
|
function safeImg(url) {
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
if (url.startsWith('https://') || url.startsWith('http://')) return url
|
||||||
// The in-app place-photo proxy always streams a JPEG but has no file extension
|
|
||||||
// (it ends in …/bytes), so the extension check below would wrongly reject it —
|
|
||||||
// which is why persisted place photos showed as category icons in the PDF.
|
|
||||||
if (url.startsWith('/api/maps/place-photo/')) return absUrl(url)
|
|
||||||
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
return /\.(jpe?g|png|webp|bmp|tiff?)(\?.*)?$/i.test(url) ? absUrl(url) : null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,30 +211,11 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const icon = reservationIconSvg(r.type)
|
const icon = reservationIconSvg(r.type)
|
||||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
// Flights render one subtitle line per leg (see below); everything else is a single line.
|
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(' · ')
|
||||||
let subtitleLines: string[] = []
|
|
||||||
if (r.type === 'flight') {
|
|
||||||
const legs = getFlightLegs(r)
|
|
||||||
if (legs.length > 1) {
|
|
||||||
// Multi-leg: one line per leg so every flight number + segment route is shown.
|
|
||||||
subtitleLines = legs.map(l =>
|
|
||||||
[l.airline, l.flight_number,
|
|
||||||
(l.from || l.to) ? [l.from, l.to].filter(Boolean).join(' → ') : '']
|
|
||||||
.filter(Boolean).join(' · '))
|
|
||||||
.filter(Boolean)
|
|
||||||
} else {
|
|
||||||
// Single-leg: full route over all waypoints (FRA → BER → HND), falling back to the
|
|
||||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
|
||||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
|
||||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
|
||||||
subtitle = [meta.airline, meta.flight_number, route].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(' · ')
|
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
else if (r.type === 'tour') subtitle = [meta.operator].filter(Boolean).join(' · ')
|
||||||
if (subtitleLines.length === 0 && subtitle) subtitleLines = [subtitle]
|
|
||||||
const locationLine = r.location || meta.location || ''
|
const locationLine = r.location || meta.location || ''
|
||||||
const phase = pdfGetSpanPhase(r, day.id)
|
const phase = pdfGetSpanPhase(r, day.id)
|
||||||
const spanLabel = pdfGetSpanLabel(r, phase)
|
const spanLabel = pdfGetSpanLabel(r, phase)
|
||||||
@@ -252,7 +228,7 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
<span class="note-icon">${icon}</span>
|
<span class="note-icon">${icon}</span>
|
||||||
<div class="note-body">
|
<div class="note-body">
|
||||||
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
<div class="note-text" style="font-weight: 600;">${titleHtml}${time ? ` <span style="color:#6b7280;font-weight:400;font-size:10px;">${time}</span>` : ''}</div>
|
||||||
${subtitleLines.filter(Boolean).map(s => `<div class="note-time">${escHtml(s)}</div>`).join('')}
|
${subtitle ? `<div class="note-time">${escHtml(subtitle)}</div>` : ''}
|
||||||
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
${locationLine ? `<div class="note-time">${escHtml(locationLine)}</div>` : ''}
|
||||||
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
${r.confirmation_number ? `<div class="note-time" style="font-size:9px;">Code: ${escHtml(r.confirmation_number)}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
@@ -278,10 +254,9 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const cat = categories.find(c => c.id === place.category_id)
|
const cat = categories.find(c => c.id === place.category_id)
|
||||||
const color = cat?.color || '#6366f1'
|
const color = cat?.color || '#6366f1'
|
||||||
|
|
||||||
// Image: direct > google photo > fallback icon. Both go through safeImg
|
// Image: direct > google photo > fallback icon
|
||||||
// so the proxy path is resolved to an absolute URL the PDF can load.
|
|
||||||
const directImg = safeImg(place.image_url)
|
const directImg = safeImg(place.image_url)
|
||||||
const googleImg = safeImg(photoMap[place.id])
|
const googleImg = photoMap[place.id] || null
|
||||||
const img = directImg || googleImg
|
const img = directImg || googleImg
|
||||||
|
|
||||||
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
const iconSvg = categoryIconSvg(cat?.icon, color, 24)
|
||||||
@@ -307,7 +282,6 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
${cat ? `<span class="cat-badge" style="background:${color}">${escHtml(cat.name)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
${place.address ? `<div class="info-row">${svgPin}<span class="info-text">${escHtml(place.address)}</span></div>` : ''}
|
||||||
${(place.lat != null && place.lng != null) ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted">${Number(place.lat).toFixed(5)}, ${Number(place.lng).toFixed(5)}</span></div>` : ''}
|
|
||||||
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
${place.description ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.description)}</span></div>` : ''}
|
||||||
${chips ? `<div class="chips">${chips}</div>` : ''}
|
${chips ? `<div class="chips">${chips}</div>` : ''}
|
||||||
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
${place.notes ? `<div class="info-row"><span class="info-spacer"></span><span class="info-text muted italic">${escHtml(place.notes)}</span></div>` : ''}
|
||||||
@@ -595,9 +569,7 @@ ${daysHtml}
|
|||||||
|
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
iframe.style.cssText = 'flex:1;width:100%;border:none;'
|
||||||
// No script runs inside the document (print is parent-initiated), so withhold
|
iframe.sandbox = 'allow-same-origin allow-modals allow-scripts'
|
||||||
// allow-scripts to keep the sandbox tight.
|
|
||||||
iframe.sandbox = 'allow-same-origin allow-modals'
|
|
||||||
iframe.srcdoc = html
|
iframe.srcdoc = html
|
||||||
|
|
||||||
card.appendChild(header)
|
card.appendChild(header)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react'
|
import React, { useEffect, useRef, useState } from 'react'
|
||||||
import { Package } from 'lucide-react'
|
import { Package } from 'lucide-react'
|
||||||
import { packingApi } from '../../api/client'
|
import { adminApi, packingApi } from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
@@ -28,7 +28,7 @@ export default function ApplyTemplateButton({ tripId, style, className }: ApplyT
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
packingApi.listTemplates(tripId).then(d => setTemplates(d.templates || [])).catch(() => {})
|
adminApi.packingTemplates().then(d => setTemplates(d.templates || [])).catch(() => {})
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { server } from '../../../tests/helpers/msw/server';
|
|||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
import { useTripStore } from '../../store/tripStore';
|
import { useTripStore } from '../../store/tripStore';
|
||||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||||
import { buildUser, buildAdmin, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
import { buildUser, buildTrip, buildPackingItem } from '../../../tests/helpers/factories';
|
||||||
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
import PackingListPanel, { itemWeight } from './PackingListPanel';
|
||||||
|
|
||||||
describe('itemWeight (bag total weight calc)', () => {
|
describe('itemWeight (bag total weight calc)', () => {
|
||||||
@@ -34,10 +34,10 @@ beforeEach(() => {
|
|||||||
http.get('/api/trips/:id/packing/category-assignees', () =>
|
http.get('/api/trips/:id/packing/category-assignees', () =>
|
||||||
HttpResponse.json({ assignees: {} })
|
HttpResponse.json({ assignees: {} })
|
||||||
),
|
),
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: false, addons: [] })
|
HttpResponse.json({ enabled: false })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/templates', () =>
|
http.get('/api/admin/packing-templates', () =>
|
||||||
HttpResponse.json({ templates: [] })
|
HttpResponse.json({ templates: [] })
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -381,7 +381,7 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
|
it('FE-COMP-PACKING-030: packing template button present when templates available', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/:id/packing/templates', () =>
|
http.get('/api/admin/packing-templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
|
HttpResponse.json({ templates: [{ id: 1, name: 'Beach Trip', item_count: 5 }] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -457,8 +457,8 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
|
it('FE-COMP-PACKING-034: bag tracking enabled shows Bags button and bag sidebar', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Carry-on', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -556,8 +556,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
|
it('FE-COMP-PACKING-039: bag modal opens when Bags button clicked with bag tracking enabled', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -585,8 +585,8 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
|
it('FE-COMP-PACKING-040: bag sidebar renders BagCard with bag name when enabled and bags exist', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 5, name: 'Backpack', color: '#10b981', weight_limit_grams: 10000, members: [] }] })
|
||||||
@@ -601,36 +601,26 @@ describe('PackingListPanel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-041: save-as-template button present for admins when items exist', async () => {
|
it('FE-COMP-PACKING-041: save-as-template button present when items exist', async () => {
|
||||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
||||||
render(<PackingListPanel tripId={1} items={items} />);
|
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||||
|
|
||||||
// Save-as-template button shows its label "Save as template"
|
// Save-as-template button uses FolderPlus icon and "Save as template" text
|
||||||
const saveBtn = screen.getByText('Save as template').closest('button');
|
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
|
||||||
expect(saveBtn).toBeTruthy();
|
expect(folderPlusBtn).toBeTruthy();
|
||||||
|
|
||||||
// Click to show the name input
|
// Click to show the name input
|
||||||
await user.click(saveBtn!);
|
await user.click(folderPlusBtn!);
|
||||||
|
|
||||||
// Template name input appears
|
// Template name input appears
|
||||||
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
|
expect(await screen.findByPlaceholderText('Template name')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-041b: save-as-template button hidden for non-admins', () => {
|
|
||||||
// Default seeded user (beforeEach) is a non-admin trip owner with edit rights.
|
|
||||||
const items = [buildPackingItem({ name: 'Sunscreen', category: 'Toiletries' })];
|
|
||||||
render(<PackingListPanel tripId={1} items={items} />);
|
|
||||||
|
|
||||||
// The "Save as template" action must not be available to normal users.
|
|
||||||
expect(screen.queryByText('Save as template')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
|
it('FE-COMP-PACKING-042: apply template dropdown opens when template button clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/:id/packing/templates', () =>
|
http.get('/api/admin/packing-templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
|
HttpResponse.json({ templates: [{ id: 2, name: 'Summer Packing', item_count: 10 }] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -668,8 +658,8 @@ describe('PackingListPanel', () => {
|
|||||||
|
|
||||||
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
|
it('FE-COMP-PACKING-044: bag item row shows weight input and bag button when bag tracking enabled', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [] })
|
HttpResponse.json({ bags: [] })
|
||||||
@@ -716,7 +706,6 @@ describe('PackingListPanel', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
|
it('FE-COMP-PACKING-046: save-as-template form submission calls saveAsTemplate API', async () => {
|
||||||
seedStore(useAuthStore, { user: buildAdmin(), isAuthenticated: true });
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let savedTemplateName = '';
|
let savedTemplateName = '';
|
||||||
server.use(
|
server.use(
|
||||||
@@ -725,16 +714,16 @@ describe('PackingListPanel', () => {
|
|||||||
savedTemplateName = String(body.name);
|
savedTemplateName = String(body.name);
|
||||||
return HttpResponse.json({ success: true });
|
return HttpResponse.json({ success: true });
|
||||||
}),
|
}),
|
||||||
http.get('/api/trips/:id/packing/templates', () =>
|
http.get('/api/admin/packing-templates', () =>
|
||||||
HttpResponse.json({ templates: [] })
|
HttpResponse.json({ templates: [] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
|
const items = [buildPackingItem({ name: 'Item', category: 'Test' })];
|
||||||
render(<PackingListPanel tripId={1} items={items} />);
|
const { container } = render(<PackingListPanel tripId={1} items={items} />);
|
||||||
|
|
||||||
// Click the "Save as template" button
|
// Click the FolderPlus "Save as template" button
|
||||||
const saveBtn = screen.getByText('Save as template').closest('button');
|
const folderPlusBtn = container.querySelector('svg.lucide-folder-plus')?.closest('button');
|
||||||
await user.click(saveBtn!);
|
await user.click(folderPlusBtn!);
|
||||||
|
|
||||||
// Type template name
|
// Type template name
|
||||||
const nameInput = await screen.findByPlaceholderText('Template name');
|
const nameInput = await screen.findByPlaceholderText('Template name');
|
||||||
@@ -747,8 +736,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
|
it('FE-COMP-PACKING-047: bag picker in item row opens when clicked with bag tracking enabled', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 3, name: 'Carry-on', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -776,8 +765,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
|
it('FE-COMP-PACKING-048: add bag in bag modal opens form when "Add bag" clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Main Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -816,8 +805,8 @@ describe('PackingListPanel', () => {
|
|||||||
let putBody: Record<string, unknown> | null = null;
|
let putBody: Record<string, unknown> | null = null;
|
||||||
const itemId = 120;
|
const itemId = 120;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [] })
|
HttpResponse.json({ bags: [] })
|
||||||
@@ -872,8 +861,8 @@ describe('PackingListPanel', () => {
|
|||||||
const itemId = 130;
|
const itemId = 130;
|
||||||
let putBody: Record<string, unknown> | null = null;
|
let putBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 7, name: 'Trolley', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -941,8 +930,8 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
|
it('FE-COMP-PACKING-054: item with assigned bag shows "Unassigned" option in bag picker', async () => {
|
||||||
const itemId = 140;
|
const itemId = 140;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () =>
|
http.get('/api/admin/bag-tracking', () =>
|
||||||
HttpResponse.json({ bagTracking: true, addons: [] })
|
HttpResponse.json({ enabled: true })
|
||||||
),
|
),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 5, name: 'MyBag', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -968,7 +957,7 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
|
it('FE-COMP-PACKING-055: apply template button click opens template dropdown and shows template', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/:id/packing/templates', () =>
|
http.get('/api/admin/packing-templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
|
HttpResponse.json({ templates: [{ id: 3, name: 'Weekend Pack', item_count: 8 }] })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -1135,7 +1124,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let applyCalled = false;
|
let applyCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/:id/packing/templates', () =>
|
http.get('/api/admin/packing-templates', () =>
|
||||||
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
|
HttpResponse.json({ templates: [{ id: 5, name: 'Beach Trip', item_count: 12 }] })
|
||||||
),
|
),
|
||||||
http.post('/api/trips/1/packing/apply-template/5', () => {
|
http.post('/api/trips/1/packing/apply-template/5', () => {
|
||||||
@@ -1188,7 +1177,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let createBody: Record<string, unknown> | null = null;
|
let createBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||||
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
|
// Start with one bag so the sidebar renders (sidebar requires bags.length > 0)
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 1, name: 'Existing Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
@@ -1218,7 +1207,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let deleteCalled = false;
|
let deleteCalled = false;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 9, name: 'Old Bag', color: '#6366f1', weight_limit_grams: null, members: [] }] })
|
||||||
),
|
),
|
||||||
@@ -1246,7 +1235,7 @@ describe('PackingListPanel', () => {
|
|||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
let updateBody: Record<string, unknown> | null = null;
|
let updateBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 11, name: 'Carry-on', color: '#10b981', weight_limit_grams: null, members: [] }] })
|
||||||
),
|
),
|
||||||
@@ -1284,7 +1273,7 @@ describe('PackingListPanel', () => {
|
|||||||
current_user_id: 1,
|
current_user_id: 1,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 12, name: 'Day Pack', color: '#ec4899', weight_limit_grams: null, members: [] }] })
|
||||||
)
|
)
|
||||||
@@ -1325,7 +1314,7 @@ describe('PackingListPanel', () => {
|
|||||||
current_user_id: 1,
|
current_user_id: 1,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||||
http.get('/api/trips/:id/packing/bags', () =>
|
http.get('/api/trips/:id/packing/bags', () =>
|
||||||
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
|
HttpResponse.json({ bags: [{ id: 13, name: 'Weekend Bag', color: '#f97316', weight_limit_grams: null, members: [] }] })
|
||||||
),
|
),
|
||||||
@@ -1363,7 +1352,7 @@ describe('PackingListPanel', () => {
|
|||||||
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
|
it('FE-COMP-PACKING-068: inline bag create in item row picker creates bag and assigns it', async () => {
|
||||||
let createBody: Record<string, unknown> | null = null;
|
let createBody: Record<string, unknown> | null = null;
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons', () => HttpResponse.json({ bagTracking: true, addons: [] })),
|
http.get('/api/admin/bag-tracking', () => HttpResponse.json({ enabled: true })),
|
||||||
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
|
http.get('/api/trips/:id/packing/bags', () => HttpResponse.json({ bags: [] })),
|
||||||
http.post('/api/trips/1/packing/bags', async ({ request }) => {
|
http.post('/api/trips/1/packing/bags', async ({ request }) => {
|
||||||
createBody = await request.json() as Record<string, unknown>;
|
createBody = await request.json() as Record<string, unknown>;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { PackingState } from './usePackingListPanel'
|
|||||||
|
|
||||||
export function PackingHeader(S: PackingState) {
|
export function PackingHeader(S: PackingState) {
|
||||||
const {
|
const {
|
||||||
inlineHeader, t, items, abgehakt, fortschritt, canEdit, isAdmin,
|
inlineHeader, t, items, abgehakt, fortschritt, canEdit,
|
||||||
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
|
showSaveTemplate, saveTemplateName, setSaveTemplateName, handleSaveAsTemplate, setShowSaveTemplate,
|
||||||
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
|
setShowImportModal, handleClearChecked, availableTemplates, templateDropdownRef,
|
||||||
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
|
showTemplateDropdown, setShowTemplateDropdown, applyingTemplate, handleApplyTemplate,
|
||||||
@@ -26,7 +26,7 @@ export function PackingHeader(S: PackingState) {
|
|||||||
</div>
|
</div>
|
||||||
) : <span />}
|
) : <span />}
|
||||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
|
||||||
{canEdit && isAdmin && items.length > 0 && showSaveTemplate && (
|
{canEdit && items.length > 0 && showSaveTemplate && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<input
|
<input
|
||||||
type="text" autoFocus
|
type="text" autoFocus
|
||||||
@@ -97,7 +97,7 @@ export function PackingHeader(S: PackingState) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{inlineHeader && canEdit && isAdmin && items.length > 0 && !showSaveTemplate && (
|
{inlineHeader && canEdit && items.length > 0 && !showSaveTemplate && (
|
||||||
<button onClick={() => setShowSaveTemplate(true)} style={{
|
<button onClick={() => setShowSaveTemplate(true)} style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
display: 'flex', alignItems: 'center', gap: 5, padding: '5px 11px', borderRadius: 99,
|
||||||
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
border: '1px solid var(--border-primary)', fontSize: 12, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export const KAT_COLORS = [
|
|||||||
'#14b8a6', // teal
|
'#14b8a6', // teal
|
||||||
]
|
]
|
||||||
|
|
||||||
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b', '#3b82f6', '#84cc16', '#d946ef', '#14b8a6', '#f43f5e', '#a855f7', '#eab308', '#64748b']
|
export const BAG_COLORS = ['#6366f1', '#ec4899', '#f97316', '#10b981', '#06b6d4', '#8b5cf6', '#ef4444', '#f59e0b']
|
||||||
|
|
||||||
// A category's first item is seeded with this sentinel because the server
|
// A category's first item is seeded with this sentinel because the server
|
||||||
// rejects empty names. Treat it as a placeholder in the UI.
|
// rejects empty names. Treat it as a placeholder in the UI.
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import { useState, useMemo, useRef, useEffect } from 'react'
|
|||||||
import type { ChangeEvent } from 'react'
|
import type { ChangeEvent } from 'react'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { packingApi, tripsApi } from '../../api/client'
|
import { packingApi, tripsApi, adminApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
|
||||||
import type { PackingItem, PackingBag } from '../../types'
|
import type { PackingItem, PackingBag } from '../../types'
|
||||||
import { BAG_COLORS } from './packingListPanel.constants'
|
import { BAG_COLORS } from './packingListPanel.constants'
|
||||||
import { parseImportLines } from './packingListPanel.helpers'
|
import { parseImportLines } from './packingListPanel.helpers'
|
||||||
@@ -48,7 +46,6 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
const canEdit = can('packing_edit', trip)
|
const canEdit = can('packing_edit', trip)
|
||||||
const isAdmin = useAuthStore((s) => s.user?.role === 'admin')
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -148,24 +145,19 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
if (failed) toast.error(t('packing.toast.deleteError'))
|
if (failed) toast.error(t('packing.toast.deleteError'))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bag tracking — the global toggle is a packing sub-flag surfaced to every
|
// Bag tracking
|
||||||
// authenticated user via the addon store (loaded on app start), not the
|
const [bagTrackingEnabled, setBagTrackingEnabled] = useState(false)
|
||||||
// admin-only endpoint, so non-admin members see weights/bags too.
|
|
||||||
const bagTrackingEnabled = useAddonStore(s => s.bagTracking)
|
|
||||||
const addonsLoaded = useAddonStore(s => s.loaded)
|
|
||||||
const loadAddons = useAddonStore(s => s.loadAddons)
|
|
||||||
const [bags, setBags] = useState<PackingBag[]>([])
|
const [bags, setBags] = useState<PackingBag[]>([])
|
||||||
const [newBagName, setNewBagName] = useState('')
|
const [newBagName, setNewBagName] = useState('')
|
||||||
const [showAddBag, setShowAddBag] = useState(false)
|
const [showAddBag, setShowAddBag] = useState(false)
|
||||||
const [showBagModal, setShowBagModal] = useState(false)
|
const [showBagModal, setShowBagModal] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!addonsLoaded) loadAddons()
|
adminApi.getBagTracking().then(d => {
|
||||||
}, [addonsLoaded, loadAddons])
|
setBagTrackingEnabled(d.enabled)
|
||||||
|
if (d.enabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
||||||
useEffect(() => {
|
}).catch(() => {})
|
||||||
if (bagTrackingEnabled) packingApi.listBags(tripId).then(r => setBags(r.bags || [])).catch(() => {})
|
}, [tripId])
|
||||||
}, [tripId, bagTrackingEnabled])
|
|
||||||
|
|
||||||
const handleCreateBag = async () => {
|
const handleCreateBag = async () => {
|
||||||
if (!newBagName.trim()) return
|
if (!newBagName.trim()) return
|
||||||
@@ -242,7 +234,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
const templateDropdownRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -275,7 +267,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
toast.success(t('packing.templateSaved'))
|
toast.success(t('packing.templateSaved'))
|
||||||
setShowSaveTemplate(false)
|
setShowSaveTemplate(false)
|
||||||
setSaveTemplateName('')
|
setSaveTemplateName('')
|
||||||
packingApi.listTemplates(tripId).then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
adminApi.packingTemplates().then(d => setAvailableTemplates(d.templates || [])).catch(() => {})
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('common.error'))
|
toast.error(t('common.error'))
|
||||||
}
|
}
|
||||||
@@ -305,7 +297,7 @@ export function usePackingList({ tripId, items, openImportSignal = 0, clearCheck
|
|||||||
const font = { fontFamily: "var(--font-system)" }
|
const font = { fontFamily: "var(--font-system)" }
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tripId, items, inlineHeader, t, canEdit, isAdmin, font,
|
tripId, items, inlineHeader, t, canEdit, font,
|
||||||
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
filter, setFilter, addingCategory, setAddingCategory, newCatName, setNewCatName,
|
||||||
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
tripMembers, categoryAssignees, handleSetAssignees, allCategories, gruppiert, abgehakt, fortschritt,
|
||||||
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
handleAddItemToCategory, handleAddNewCategory, handleRenameCategory, handleDeleteCategory, handleClearChecked,
|
||||||
|
|||||||
@@ -1,261 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom'
|
|
||||||
import { useState, useRef, useEffect, useMemo } from 'react'
|
|
||||||
import { Plane, X, Check } from 'lucide-react'
|
|
||||||
import type { AirtrailFlight, AirtrailImportResult } from '@trek/shared'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { airtrailApi, reservationsApi } from '../../api/client'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
|
|
||||||
interface AirTrailImportModalProps {
|
|
||||||
isOpen: boolean
|
|
||||||
onClose: () => void
|
|
||||||
tripId: number
|
|
||||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Locale-aware date (e.g. de → 13.06.2026, en-US → 06/13/2026). */
|
|
||||||
function fmtDate(d: string | null, locale: string): string {
|
|
||||||
if (!d) return ''
|
|
||||||
try {
|
|
||||||
return new Date(d + 'T00:00:00Z').toLocaleDateString(locale, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AirTrailImportModal({ isOpen, onClose, tripId, pushUndo }: AirTrailImportModalProps) {
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const toast = useToast()
|
|
||||||
const trip = useTripStore(s => s.trip)
|
|
||||||
const reservations = useTripStore(s => s.reservations)
|
|
||||||
const loadReservations = useTripStore(s => s.loadReservations)
|
|
||||||
const mouseDownTarget = useRef<EventTarget | null>(null)
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [importing, setImporting] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
const [flights, setFlights] = useState<AirtrailFlight[]>([])
|
|
||||||
const [selected, setSelected] = useState<Set<string>>(() => new Set())
|
|
||||||
|
|
||||||
// AirTrail flight ids already linked to a reservation in this trip.
|
|
||||||
const importedIds = useMemo(() => {
|
|
||||||
const set = new Set<string>()
|
|
||||||
for (const r of reservations) {
|
|
||||||
if (r.external_source === 'airtrail' && r.external_id) set.add(String(r.external_id))
|
|
||||||
}
|
|
||||||
return set
|
|
||||||
}, [reservations])
|
|
||||||
|
|
||||||
const inRange = (f: AirtrailFlight): boolean =>
|
|
||||||
!!(f.date && trip?.start_date && trip?.end_date && f.date >= trip.start_date && f.date <= trip.end_date)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return
|
|
||||||
setError('')
|
|
||||||
setSelected(new Set())
|
|
||||||
setLoading(true)
|
|
||||||
airtrailApi
|
|
||||||
.flights()
|
|
||||||
.then((d: { flights: AirtrailFlight[] }) => {
|
|
||||||
const list = d.flights ?? []
|
|
||||||
setFlights(list)
|
|
||||||
// Pre-select the flights that fall inside the trip and aren't imported yet.
|
|
||||||
const pre = new Set<string>()
|
|
||||||
for (const f of list) if (inRange(f) && !importedIds.has(f.id)) pre.add(f.id)
|
|
||||||
setSelected(pre)
|
|
||||||
})
|
|
||||||
.catch((err: any) => setError(err?.response?.data?.error ?? t('reservations.airtrail.loadError')))
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const { during, others } = useMemo(() => {
|
|
||||||
const during: AirtrailFlight[] = []
|
|
||||||
const others: AirtrailFlight[] = []
|
|
||||||
for (const f of flights) (inRange(f) ? during : others).push(f)
|
|
||||||
const byDateDesc = (a: AirtrailFlight, b: AirtrailFlight) => (b.date ?? '').localeCompare(a.date ?? '')
|
|
||||||
return { during: during.sort(byDateDesc), others: others.sort(byDateDesc) }
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [flights, trip?.start_date, trip?.end_date])
|
|
||||||
|
|
||||||
const toggle = (id: string) => {
|
|
||||||
setSelected(prev => {
|
|
||||||
const next = new Set(prev)
|
|
||||||
if (next.has(id)) next.delete(id)
|
|
||||||
else next.add(id)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = () => { onClose() }
|
|
||||||
|
|
||||||
const handleImport = async () => {
|
|
||||||
const ids = [...selected].filter(id => !importedIds.has(id))
|
|
||||||
if (ids.length === 0 || importing) return
|
|
||||||
setImporting(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const result: AirtrailImportResult = await airtrailApi.import(tripId, ids)
|
|
||||||
await loadReservations(tripId)
|
|
||||||
|
|
||||||
const imported = result.imported ?? []
|
|
||||||
if (imported.length > 0) {
|
|
||||||
pushUndo?.(t('reservations.airtrail.undo'), async () => {
|
|
||||||
const linked = useTripStore.getState().reservations.filter(
|
|
||||||
r => r.external_source === 'airtrail' && r.external_id && imported.includes(String(r.external_id)),
|
|
||||||
)
|
|
||||||
await Promise.all(linked.map(r => reservationsApi.delete(tripId, r.id).catch(() => {})))
|
|
||||||
await loadReservations(tripId)
|
|
||||||
})
|
|
||||||
toast.success(t('reservations.airtrail.imported', { count: imported.length }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const skippedInTrip = (result.skipped ?? []).filter(s => s.reason === 'already-in-trip').length
|
|
||||||
if (skippedInTrip > 0) toast.warning(t('reservations.airtrail.skippedDuplicate', { count: skippedInTrip }))
|
|
||||||
if (imported.length === 0 && skippedInTrip === 0) toast.warning(t('reservations.airtrail.nothingImported'))
|
|
||||||
|
|
||||||
handleClose()
|
|
||||||
} catch (err: any) {
|
|
||||||
setError(err?.response?.data?.error ?? t('reservations.airtrail.importError'))
|
|
||||||
} finally {
|
|
||||||
setImporting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectableCount = [...selected].filter(id => !importedIds.has(id)).length
|
|
||||||
|
|
||||||
if (!isOpen) return null
|
|
||||||
|
|
||||||
const renderFlight = (f: AirtrailFlight) => {
|
|
||||||
const already = importedIds.has(f.id)
|
|
||||||
const isSelected = selected.has(f.id)
|
|
||||||
const label = f.flightNumber ? `${f.airline ? `${f.airline} ` : ''}${f.flightNumber}` : `${f.fromCode ?? '?'} → ${f.toCode ?? '?'}`
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={f.id}
|
|
||||||
onClick={() => !already && toggle(f.id)}
|
|
||||||
disabled={already}
|
|
||||||
className={already ? 'bg-surface-tertiary' : isSelected ? 'bg-surface-secondary' : 'bg-transparent'}
|
|
||||||
style={{
|
|
||||||
width: '100%', textAlign: 'left', borderRadius: 10, padding: '10px 12px', marginBottom: 8,
|
|
||||||
border: `1px solid ${isSelected && !already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
|
||||||
opacity: already ? 0.55 : 1, cursor: already ? 'default' : 'pointer',
|
|
||||||
display: 'flex', gap: 10, alignItems: 'center', fontFamily: 'inherit',
|
|
||||||
transition: 'border-color 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{
|
|
||||||
flexShrink: 0, width: 18, height: 18, borderRadius: 5,
|
|
||||||
border: `1.5px solid ${isSelected || already ? 'var(--accent)' : 'var(--border-primary)'}`,
|
|
||||||
background: isSelected || already ? 'var(--accent)' : 'transparent',
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
}}>
|
|
||||||
{(isSelected || already) && <Check size={12} color="var(--accent-text)" strokeWidth={3} />}
|
|
||||||
</span>
|
|
||||||
<Plane size={15} color="#3b82f6" style={{ flexShrink: 0 }} />
|
|
||||||
<span style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<span style={{ display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
|
||||||
<span style={{ display: 'block', fontSize: 11, color: 'var(--text-muted)' }}>
|
|
||||||
{f.fromCode ?? f.fromName ?? '?'} → {f.toCode ?? f.toName ?? '?'}{f.date ? ` · ${fmtDate(f.date, locale)}` : ''}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{already && (
|
|
||||||
<span style={{ flexShrink: 0, fontSize: 10, fontWeight: 600, color: 'var(--text-faint)' }}>
|
|
||||||
{t('reservations.airtrail.alreadyImported')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ReactDOM.createPortal(
|
|
||||||
<div
|
|
||||||
className="bg-[rgba(0,0,0,0.4)]"
|
|
||||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
|
|
||||||
onMouseDown={e => { mouseDownTarget.current = e.target }}
|
|
||||||
onClick={e => {
|
|
||||||
if (e.target === e.currentTarget && mouseDownTarget.current === e.currentTarget) handleClose()
|
|
||||||
mouseDownTarget.current = null
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={e => e.stopPropagation()}
|
|
||||||
className="bg-surface-card"
|
|
||||||
style={{ borderRadius: 16, width: '100%', maxWidth: 540, padding: 24, boxShadow: '0 8px 32px rgba(0,0,0,0.2)', fontFamily: 'var(--font-system)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 14 }}>
|
|
||||||
<Plane size={16} color="#3b82f6" />
|
|
||||||
<div style={{ flex: 1, fontSize: 15, fontWeight: 700, color: 'var(--text-primary)' }}>
|
|
||||||
{t('reservations.airtrail.title')}
|
|
||||||
</div>
|
|
||||||
<button onClick={handleClose} className="bg-transparent text-content-faint" style={{ border: 'none', cursor: 'pointer', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }}>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
|
||||||
{loading && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
|
||||||
{t('common.loading')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && flights.length === 0 && !error && (
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 13, textAlign: 'center', padding: '24px 0' }}>
|
|
||||||
{t('reservations.airtrail.empty')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && during.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-primary)', margin: '2px 0 8px' }}>
|
|
||||||
{t('reservations.airtrail.duringTrip')}
|
|
||||||
</div>
|
|
||||||
{during.map(renderFlight)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && others.length > 0 && (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-faint)', margin: `${during.length > 0 ? 14 : 2}px 0 8px` }}>
|
|
||||||
{t('reservations.airtrail.otherFlights')}
|
|
||||||
</div>
|
|
||||||
{others.map(renderFlight)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-[rgba(239,68,68,0.08)] text-[#b91c1c]" style={{ border: '1px solid rgba(239,68,68,0.35)', borderRadius: 10, padding: '8px 10px', fontSize: 12, whiteSpace: 'pre-wrap', marginTop: 8 }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--border-faint)' }}>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', color: 'var(--text-primary)', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{t('common.cancel')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleImport}
|
|
||||||
disabled={selectableCount === 0 || importing}
|
|
||||||
className={selectableCount > 0 && !importing ? 'bg-accent text-accent-text' : 'bg-surface-tertiary text-content-faint'}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500, cursor: selectableCount > 0 && !importing ? 'pointer' : 'default', fontFamily: 'inherit' }}
|
|
||||||
>
|
|
||||||
{importing ? t('common.loading') : t('reservations.airtrail.importCta', { count: selectableCount })}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
|
||||||
import { useTripStore } from '../../store/tripStore'
|
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { formatMoney } from '../../utils/formatters'
|
|
||||||
import { catMeta } from '../Budget/costsCategories'
|
|
||||||
import type { BudgetItem } from '../../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
|
||||||
* category fields: when no expense is linked yet it offers a "create expense"
|
|
||||||
* button (the modal saves the booking first, then opens the full Costs editor);
|
|
||||||
* once linked it shows the expense with edit / remove actions.
|
|
||||||
*/
|
|
||||||
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
|
|
||||||
reservationId: number | null
|
|
||||||
onCreate: () => void
|
|
||||||
onEdit: (item: BudgetItem) => void
|
|
||||||
onRemove: (item: BudgetItem) => void
|
|
||||||
}) {
|
|
||||||
const { t, locale } = useTranslation()
|
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
|
||||||
const trip = useTripStore(s => s.trip)
|
|
||||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
|
||||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
|
||||||
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
|
||||||
|
|
||||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
|
||||||
|
|
||||||
if (linked) {
|
|
||||||
const meta = catMeta(linked.category)
|
|
||||||
const Icon = meta.Icon
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
|
||||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
|
||||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
|
||||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
|
||||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
|
||||||
<button type="button" onClick={onCreate}
|
|
||||||
className="bg-surface-secondary border border-edge text-content"
|
|
||||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<Plus size={15} /> {t('reservations.createExpense')}
|
|
||||||
</button>
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
import type { BudgetItem } from '../../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A request from a booking modal to open the Costs expense editor — either to
|
|
||||||
* edit the already-linked expense, or to create a new one prefilled from the
|
|
||||||
* booking (the modal saves the booking first so `reservationId` is known).
|
|
||||||
*/
|
|
||||||
export interface BookingExpenseRequest {
|
|
||||||
editItem?: BudgetItem
|
|
||||||
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship,
|
||||||
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle,
|
||||||
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
ShoppingBag, Bookmark, Hotel, Utensils, Users, Sailboat, Bike, CarTaxiFront, Route,
|
||||||
Wine, ParkingSquare, Fuel, Footprints, Mountain, Waves, Sun, Umbrella, Music, Landmark, Gift,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
export const RES_ICONS = { flight: Plane, hotel: Hotel, restaurant: Utensils, train: Train, car: Car, cruise: Ship, bus: Bus, ferry: Sailboat, bicycle: Bike, taxi: CarTaxiFront, transport_other: Route, event: Ticket, tour: Users, other: FileText }
|
||||||
@@ -28,18 +27,6 @@ export const NOTE_ICONS = [
|
|||||||
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
{ id: 'AlertTriangle', Icon: AlertTriangle },
|
||||||
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
{ id: 'ShoppingBag', Icon: ShoppingBag },
|
||||||
{ id: 'Bookmark', Icon: Bookmark },
|
{ id: 'Bookmark', Icon: Bookmark },
|
||||||
{ id: 'Utensils', Icon: Utensils },
|
|
||||||
{ id: 'Wine', Icon: Wine },
|
|
||||||
{ id: 'ParkingSquare', Icon: ParkingSquare },
|
|
||||||
{ id: 'Fuel', Icon: Fuel },
|
|
||||||
{ id: 'Footprints', Icon: Footprints },
|
|
||||||
{ id: 'Mountain', Icon: Mountain },
|
|
||||||
{ id: 'Waves', Icon: Waves },
|
|
||||||
{ id: 'Sun', Icon: Sun },
|
|
||||||
{ id: 'Umbrella', Icon: Umbrella },
|
|
||||||
{ id: 'Music', Icon: Music },
|
|
||||||
{ id: 'Landmark', Icon: Landmark },
|
|
||||||
{ id: 'Gift', Icon: Gift },
|
|
||||||
]
|
]
|
||||||
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
const NOTE_ICON_MAP = Object.fromEntries(NOTE_ICONS.map(({ id, Icon }) => [id, Icon]))
|
||||||
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
export function getNoteIcon(iconId) { return NOTE_ICON_MAP[iconId] || FileText }
|
||||||
|
|||||||
@@ -982,7 +982,7 @@ describe('DayPlanSidebar', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-065: deleting a note asks for confirmation before calling deleteNote', async () => {
|
it('FE-PLANNER-DAYPLAN-065: note card delete button calls deleteNote', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||||
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
const note = buildDayNote({ id: 55, day_id: 10, text: 'My note' })
|
||||||
@@ -992,11 +992,6 @@ describe('DayPlanSidebar', () => {
|
|||||||
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
const noteEditBtns = document.querySelectorAll('.note-edit-buttons button')
|
||||||
if (noteEditBtns.length > 1) {
|
if (noteEditBtns.length > 1) {
|
||||||
await user.click(noteEditBtns[1] as HTMLElement)
|
await user.click(noteEditBtns[1] as HTMLElement)
|
||||||
// Clicking delete opens a confirmation dialog rather than deleting immediately.
|
|
||||||
expect(mockDayNotesState.deleteNote).not.toHaveBeenCalled()
|
|
||||||
expect(screen.getByText('Delete note?')).toBeInTheDocument()
|
|
||||||
// Confirming triggers the actual delete.
|
|
||||||
await user.click(screen.getByRole('button', { name: /^delete$/i }))
|
|
||||||
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
expect(mockDayNotesState.deleteNote).toHaveBeenCalled()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1708,49 +1703,4 @@ describe('DayPlanSidebar', () => {
|
|||||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||||
expect(onEditReservation).not.toHaveBeenCalled()
|
expect(onEditReservation).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── showRouteToolsWhenExpanded (mobile route tools) ───────────────────────
|
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-099: showRouteToolsWhenExpanded shows route tools on expanded day without selection', () => {
|
|
||||||
const places = [
|
|
||||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
|
||||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
|
||||||
]
|
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
|
||||||
const assigns = {
|
|
||||||
'10': [
|
|
||||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
|
||||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({
|
|
||||||
days: [day], places, assignments: assigns, selectedDayId: null, showRouteToolsWhenExpanded: true,
|
|
||||||
})} />)
|
|
||||||
// Days are expanded by default, so route tools must be visible even with no selected day
|
|
||||||
expect(screen.getByRole('button', { name: /optimize/i })).toBeInTheDocument()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('FE-PLANNER-DAYPLAN-100: optimize via showRouteToolsWhenExpanded reorders the expanded day', async () => {
|
|
||||||
const user = userEvent.setup()
|
|
||||||
const onReorder = vi.fn().mockResolvedValue(undefined)
|
|
||||||
const places = [
|
|
||||||
buildPlace({ id: 1, name: 'A', lat: 48.85, lng: 2.35 }),
|
|
||||||
buildPlace({ id: 2, name: 'B', lat: 48.86, lng: 2.36 }),
|
|
||||||
buildPlace({ id: 3, name: 'C', lat: 48.87, lng: 2.37 }),
|
|
||||||
]
|
|
||||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
|
||||||
const assigns = {
|
|
||||||
'10': [
|
|
||||||
buildAssignment({ id: 1, day_id: 10, order_index: 0, place: places[0] }),
|
|
||||||
buildAssignment({ id: 2, day_id: 10, order_index: 1, place: places[1] }),
|
|
||||||
buildAssignment({ id: 3, day_id: 10, order_index: 2, place: places[2] }),
|
|
||||||
],
|
|
||||||
}
|
|
||||||
render(<DayPlanSidebar {...makeDefaultProps({
|
|
||||||
days: [day], places, assignments: assigns, selectedDayId: null, onReorder, showRouteToolsWhenExpanded: true,
|
|
||||||
})} />)
|
|
||||||
const optimizeBtn = screen.getByRole('button', { name: /optimize/i })
|
|
||||||
await user.click(optimizeBtn)
|
|
||||||
await waitFor(() => expect(onReorder).toHaveBeenCalledWith(10, expect.any(Array)))
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { ChevronDown, ChevronRight, ChevronUp, Navigation, RotateCcw, ExternalLi
|
|||||||
import { assignmentsApi, reservationsApi } from '../../api/client'
|
import { assignmentsApi, reservationsApi } from '../../api/client'
|
||||||
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
import { calculateRoute, calculateRouteWithLegs, optimizeRoute } from '../Map/RouteCalculator'
|
||||||
import PlaceAvatar from '../shared/PlaceAvatar'
|
import PlaceAvatar from '../shared/PlaceAvatar'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
|
||||||
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
|
||||||
import Markdown from 'react-markdown'
|
import Markdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
@@ -18,16 +17,16 @@ import { useTripStore } from '../../store/tripStore'
|
|||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../../store/settingsStore'
|
import { useSettingsStore } from '../../store/settingsStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { isDayInAccommodationRange, getAccommodationAnchors, getDayBookendHotels } from '../../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../../utils/dayOrder'
|
||||||
import {
|
import {
|
||||||
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay, getTransportRouteEndpoints,
|
TRANSPORT_TYPES, parseTimeToMinutes, getSpanPhase, getDisplayTimeForDay,
|
||||||
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
getTransportForDay as _getTransportForDay, getMergedItems as _getMergedItems,
|
||||||
type MergedItem,
|
type MergedItem,
|
||||||
} from '../../utils/dayMerge'
|
} from '../../utils/dayMerge'
|
||||||
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
import { RES_ICONS, getNoteIcon } from './DayPlanSidebar.constants'
|
||||||
import { RouteConnector, HotelRouteConnector } from './DayPlanSidebarRouteConnector'
|
import { RouteConnector } from './DayPlanSidebarRouteConnector'
|
||||||
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
import { MobileAddPlaceButton } from './DayPlanSidebarMobileAddPlaceButton'
|
||||||
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
import { DayPlanSidebarToolbar } from './DayPlanSidebarToolbar'
|
||||||
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
import { DayPlanSidebarNoteModal } from './DayPlanSidebarNoteModal'
|
||||||
@@ -51,8 +50,6 @@ interface DayPlanSidebarProps {
|
|||||||
onDayDetail: (day: Day) => void
|
onDayDetail: (day: Day) => void
|
||||||
accommodations?: Accommodation[]
|
accommodations?: Accommodation[]
|
||||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||||
onReorderDays?: (orderedIds: number[]) => void
|
|
||||||
onAddDay?: (position?: number) => void
|
|
||||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||||
onRouteCalculated: (route: RouteResult | null) => void
|
onRouteCalculated: (route: RouteResult | null) => void
|
||||||
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
||||||
@@ -84,8 +81,6 @@ interface DayPlanSidebarProps {
|
|||||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||||
initialScrollTop?: number
|
initialScrollTop?: number
|
||||||
onScrollTopChange?: (top: number) => void
|
onScrollTopChange?: (top: number) => void
|
||||||
/** Mobile: show the route tools footer (Route toggle / Optimize / travel profile) on expanded days, since selecting a day closes the sheet */
|
|
||||||
showRouteToolsWhenExpanded?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,7 +95,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
trip, days, places, categories, assignments,
|
trip, days, places, categories, assignments,
|
||||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||||
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated,
|
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||||
reservations = [],
|
reservations = [],
|
||||||
visibleConnectionIds = [],
|
visibleConnectionIds = [],
|
||||||
@@ -127,7 +122,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
showRouteToolsWhenExpanded = false,
|
|
||||||
} = props
|
} = props
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { t, language, locale } = useTranslation()
|
const { t, language, locale } = useTranslation()
|
||||||
@@ -152,8 +146,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [isCalculating, setIsCalculating] = useState(false)
|
const [isCalculating, setIsCalculating] = useState(false)
|
||||||
const [routeInfo, setRouteInfo] = useState(null)
|
const [routeInfo, setRouteInfo] = useState(null)
|
||||||
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
const [routeLegs, setRouteLegs] = useState<Record<number, RouteSegment>>({})
|
||||||
const [hotelLegs, setHotelLegs] = useState<{ top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } }>({})
|
|
||||||
const optimizeFromAccommodation = useSettingsStore(s => s.settings.optimize_from_accommodation)
|
|
||||||
const legsAbortRef = useRef<AbortController | null>(null)
|
const legsAbortRef = useRef<AbortController | null>(null)
|
||||||
const [draggingId, setDraggingId] = useState(null)
|
const [draggingId, setDraggingId] = useState(null)
|
||||||
const [lockedIds, setLockedIds] = useState(new Set())
|
const [lockedIds, setLockedIds] = useState(new Set())
|
||||||
@@ -178,7 +170,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [timeConfirm, setTimeConfirm] = useState<{
|
const [timeConfirm, setTimeConfirm] = useState<{
|
||||||
dayId: number; fromId: number; time: string;
|
dayId: number; fromId: number; time: string;
|
||||||
// For drag & drop reorder
|
// For drag & drop reorder
|
||||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
||||||
// For arrow reorder
|
// For arrow reorder
|
||||||
reorderIds?: number[];
|
reorderIds?: number[];
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -381,7 +373,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
// the start place's assignment id. Shares RouteCalculator's cache with the map.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); setHotelLegs({}); return }
|
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||||
const merged = mergedItemsMap[selectedDayId] || []
|
const merged = mergedItemsMap[selectedDayId] || []
|
||||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||||
let cur: { id: number; lat: number; lng: number }[] = []
|
let cur: { id: number; lat: number; lng: number }[] = []
|
||||||
@@ -389,49 +381,12 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||||
} else if (it.type === 'transport') {
|
} else if (it.type === 'transport') {
|
||||||
const r = it.data
|
if (cur.length >= 2) runs.push(cur)
|
||||||
const { from, to } = getTransportRouteEndpoints(r, selectedDayId)
|
cur = []
|
||||||
if (from || to) {
|
|
||||||
// Located transport: route to its departure point, break the run (the
|
|
||||||
// flight/train itself isn't driven), and let its arrival start the next.
|
|
||||||
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
|
|
||||||
if (cur.length >= 2) runs.push(cur)
|
|
||||||
cur = []
|
|
||||||
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
|
|
||||||
} else if (cur.length > 0) {
|
|
||||||
// No location: ignore for routing, but attribute the through-leg to the
|
|
||||||
// booking so its distance/duration shows under it (purely cosmetic).
|
|
||||||
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cur.length >= 2) runs.push(cur)
|
if (cur.length >= 2) runs.push(cur)
|
||||||
|
if (runs.length === 0) { setRouteLegs({}); return }
|
||||||
// Hotel bookend legs: the drive from the day's accommodation to the first located
|
|
||||||
// waypoint of the day (morning) and from the last one back to it (evening). Only when
|
|
||||||
// the "optimize from accommodation" setting is on and the day has a hotel.
|
|
||||||
const day = days.find(d => d.id === selectedDayId)
|
|
||||||
const { morning: startHotel, evening: endHotel } =
|
|
||||||
day && optimizeFromAccommodation !== false ? getDayBookendHotels(day, days, accommodations) : {}
|
|
||||||
const hotelName = (a: Accommodation) => (a as any).place_name || (a as any).reservation_title || ''
|
|
||||||
// Waypoints include transport endpoints (a car return, a taxi/train arrival), so the hotel
|
|
||||||
// legs connect even when the day starts or ends with a booking rather than a place.
|
|
||||||
const wayPts: { lat: number; lng: number }[] = []
|
|
||||||
for (const it of merged) {
|
|
||||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
|
||||||
wayPts.push({ lat: it.data.place.lat, lng: it.data.place.lng })
|
|
||||||
} else if (it.type === 'transport') {
|
|
||||||
const { from, to } = getTransportRouteEndpoints(it.data, selectedDayId)
|
|
||||||
if (from) wayPts.push({ lat: from.lat, lng: from.lng })
|
|
||||||
if (to) wayPts.push({ lat: to.lat, lng: to.lng })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const firstWay = wayPts[0]
|
|
||||||
const lastWay = wayPts[wayPts.length - 1]
|
|
||||||
const wantTop = !!(startHotel && firstWay)
|
|
||||||
const wantBottom = !!(endHotel && lastWay)
|
|
||||||
|
|
||||||
if (runs.length === 0 && !wantTop && !wantBottom) { setRouteLegs({}); setHotelLegs({}); return }
|
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
legsAbortRef.current = controller
|
legsAbortRef.current = controller
|
||||||
@@ -445,27 +400,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (err instanceof Error && err.name === 'AbortError') return
|
if (err instanceof Error && err.name === 'AbortError') return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!controller.signal.aborted) setRouteLegs(map)
|
||||||
// One extra cached OSRM call per bookend; shares RouteCalculator's cache.
|
|
||||||
const legBetween = async (a: { lat: number; lng: number }, b: { lat: number; lng: number }): Promise<RouteSegment | undefined> => {
|
|
||||||
try {
|
|
||||||
const r = await calculateRouteWithLegs([a, b], { signal: controller.signal, profile: routeProfile })
|
|
||||||
return r.legs[0]
|
|
||||||
} catch { return undefined }
|
|
||||||
}
|
|
||||||
const hotel: { top?: { seg: RouteSegment; name: string }; bottom?: { seg: RouteSegment; name: string } } = {}
|
|
||||||
if (wantTop) {
|
|
||||||
const seg = await legBetween({ lat: startHotel!.place_lat as number, lng: startHotel!.place_lng as number }, { lat: firstWay.lat, lng: firstWay.lng })
|
|
||||||
if (seg) hotel.top = { seg, name: hotelName(startHotel!) }
|
|
||||||
}
|
|
||||||
if (wantBottom) {
|
|
||||||
const seg = await legBetween({ lat: lastWay.lat, lng: lastWay.lng }, { lat: endHotel!.place_lat as number, lng: endHotel!.place_lng as number })
|
|
||||||
if (seg) hotel.bottom = { seg, name: hotelName(endHotel!) }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!controller.signal.aborted) { setRouteLegs(map); setHotelLegs(hotel) }
|
|
||||||
})()
|
})()
|
||||||
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap, accommodations, days, optimizeFromAccommodation])
|
}, [selectedDayId, routeShown, routeProfile, mergedItemsMap])
|
||||||
|
|
||||||
const openAddNote = (dayId, e) => {
|
const openAddNote = (dayId, e) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
@@ -514,10 +451,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
_openEditNote(dayId, note)
|
_openEditNote(dayId, note)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deleting a note asks for confirmation first — the edit/delete icons sit close together and are
|
|
||||||
// easy to mis-tap on touch devices, where an accidental delete was previously unrecoverable.
|
|
||||||
const [pendingDeleteNote, setPendingDeleteNote] = useState<{ dayId: number; noteId: number } | null>(null)
|
|
||||||
|
|
||||||
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
const deleteNote = async (dayId: number, noteId: number, e?: React.MouseEvent) => {
|
||||||
e?.stopPropagation()
|
e?.stopPropagation()
|
||||||
await _deleteNote(dayId, noteId)
|
await _deleteNote(dayId, noteId)
|
||||||
@@ -533,9 +466,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const assignmentIds: number[] = []
|
const assignmentIds: number[] = []
|
||||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||||
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||||
// Multi-leg flight legs share a reservation id, so their positions can't live in
|
|
||||||
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
|
|
||||||
const legPosUpdates: Record<number, Record<number, number>> = {}
|
|
||||||
|
|
||||||
let placeCount = 0
|
let placeCount = 0
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -556,10 +486,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
group.forEach((g, idx) => {
|
group.forEach((g, idx) => {
|
||||||
const pos = base + (idx + 1) / (group.length + 1)
|
const pos = base + (idx + 1) / (group.length + 1)
|
||||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||||
else if (g.type === 'transport') {
|
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||||
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
|
|
||||||
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -578,30 +505,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
}))
|
}))
|
||||||
setTransportPosVersion(v => v + 1)
|
setTransportPosVersion(v => v + 1)
|
||||||
}
|
}
|
||||||
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
|
|
||||||
// (the single per-booking slot can't hold one position per leg).
|
|
||||||
const legResIds = Object.keys(legPosUpdates)
|
|
||||||
if (legResIds.length) {
|
|
||||||
for (const ridStr of legResIds) {
|
|
||||||
const rid = Number(ridStr)
|
|
||||||
const r = useTripStore.getState().reservations.find(x => x.id === rid)
|
|
||||||
if (!r) continue
|
|
||||||
let parsed: any = {}
|
|
||||||
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
|
|
||||||
if (!Array.isArray(parsed.legs)) continue
|
|
||||||
const legs = parsed.legs.map((leg: any, i: number) => {
|
|
||||||
const pos = legPosUpdates[rid][i]
|
|
||||||
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
|
|
||||||
})
|
|
||||||
// Send metadata as an OBJECT (like the form does) — passing a JSON string
|
|
||||||
// here double-encodes it on the server, which wipes metadata.legs on read
|
|
||||||
// and collapses the flight back to a single span.
|
|
||||||
const newMeta = { ...parsed, legs }
|
|
||||||
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
|
|
||||||
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
|
|
||||||
}
|
|
||||||
setTransportPosVersion(v => v + 1)
|
|
||||||
}
|
|
||||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||||
if (transportUpdates.length) {
|
if (transportUpdates.length) {
|
||||||
onRouteRefresh?.()
|
onRouteRefresh?.()
|
||||||
@@ -620,11 +523,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
|
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||||
const m = getMergedItems(dayId)
|
const m = getMergedItems(dayId)
|
||||||
// Multi-leg flights expose one item per leg sharing the same reservation id;
|
|
||||||
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
|
|
||||||
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
|
||||||
|
|
||||||
// Check if a timed place is being moved → would it break chronological order?
|
// Check if a timed place is being moved → would it break chronological order?
|
||||||
if (fromType === 'place') {
|
if (fromType === 'place') {
|
||||||
@@ -632,11 +532,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||||
if (fromItem && fromMinutes !== null) {
|
if (fromItem && fromMinutes !== null) {
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(matchTo)
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (fromIdx !== -1 && toIdx !== -1) {
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
const simulated = [...m]
|
const simulated = [...m]
|
||||||
const [moved] = simulated.splice(fromIdx, 1)
|
const [moved] = simulated.splice(fromIdx, 1)
|
||||||
let insertIdx = simulated.findIndex(matchTo)
|
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (insertIdx === -1) insertIdx = simulated.length
|
if (insertIdx === -1) insertIdx = simulated.length
|
||||||
if (insertAfter) insertIdx += 1
|
if (insertAfter) insertIdx += 1
|
||||||
simulated.splice(insertIdx, 0, moved)
|
simulated.splice(insertIdx, 0, moved)
|
||||||
@@ -653,7 +553,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!isChronological) {
|
if (!isChronological) {
|
||||||
const placeTime = fromItem.data.place.place_time
|
const placeTime = fromItem.data.place.place_time
|
||||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr })
|
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -663,7 +563,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Build new order: remove the dragged item, insert at target position
|
// Build new order: remove the dragged item, insert at target position
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(matchTo)
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
@@ -671,7 +571,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
let adjustedTo = newOrder.findIndex(matchTo)
|
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -685,7 +585,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const confirmTimeRemoval = async () => {
|
const confirmTimeRemoval = async () => {
|
||||||
if (!timeConfirm) return
|
if (!timeConfirm) return
|
||||||
const saved = { ...timeConfirm }
|
const saved = { ...timeConfirm }
|
||||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
||||||
setTimeConfirm(null)
|
setTimeConfirm(null)
|
||||||
|
|
||||||
// Remove time from assignment
|
// Remove time from assignment
|
||||||
@@ -728,14 +628,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Drag & drop reorder
|
// Drag & drop reorder
|
||||||
if (fromType && toType) {
|
if (fromType && toType) {
|
||||||
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||||
const toIdx = m.findIndex(matchTo)
|
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
const [moved] = newOrder.splice(fromIdx, 1)
|
||||||
let adjustedTo = newOrder.findIndex(matchTo)
|
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -786,34 +685,26 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
pushUndo?.(t('undo.lock'), () => { setLockedIds(prevLocked) })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOptimize = async (dayId: number | null = selectedDayId) => {
|
const handleOptimize = async () => {
|
||||||
if (!dayId) return
|
if (!selectedDayId) return
|
||||||
const da = getDayAssignments(dayId)
|
const da = getDayAssignments(selectedDayId)
|
||||||
if (da.length < 3) return
|
if (da.length < 3) return
|
||||||
|
|
||||||
const prevIds = da.map(a => a.id)
|
const prevIds = da.map(a => a.id)
|
||||||
|
|
||||||
// Separate fixed (stay at their index) and movable assignments. A place is
|
// Separate locked (stay at their index) and unlocked assignments
|
||||||
// fixed if it's locked OR has a set time — timed places are anchored by their
|
|
||||||
// time, so the optimizer must not reshuffle them.
|
|
||||||
const locked = new Map() // index -> assignment
|
const locked = new Map() // index -> assignment
|
||||||
const unlocked = []
|
const unlocked = []
|
||||||
da.forEach((a, i) => {
|
da.forEach((a, i) => {
|
||||||
if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a)
|
if (lockedIds.has(a.id)) locked.set(i, a)
|
||||||
else unlocked.push(a)
|
else unlocked.push(a)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Optimize only unlocked assignments (work on assignments, not places)
|
// Optimize only unlocked assignments (work on assignments, not places)
|
||||||
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
|
const unlockedWithCoords = unlocked.filter(a => a.place?.lat && a.place?.lng)
|
||||||
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
const unlockedNoCoords = unlocked.filter(a => !a.place?.lat || !a.place?.lng)
|
||||||
// Anchor the route on the day's accommodation (when enabled): a loop out from and back to the
|
|
||||||
// hotel, or — on a transfer day — a run from the hotel you leave to the one you arrive at.
|
|
||||||
const day = days.find(d => d.id === dayId)
|
|
||||||
const anchors = day && useSettingsStore.getState().settings.optimize_from_accommodation !== false
|
|
||||||
? getAccommodationAnchors(day, days, accommodations)
|
|
||||||
: {}
|
|
||||||
const optimizedAssignments = unlockedWithCoords.length >= 2
|
const optimizedAssignments = unlockedWithCoords.length >= 2
|
||||||
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id })), anchors).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
? optimizeRoute(unlockedWithCoords.map(a => ({ ...a.place, _assignmentId: a.id }))).map(p => unlockedWithCoords.find(a => a.id === p._assignmentId)).filter(Boolean)
|
||||||
: unlockedWithCoords
|
: unlockedWithCoords
|
||||||
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
|
const optimizedQueue = [...optimizedAssignments, ...unlockedNoCoords]
|
||||||
|
|
||||||
@@ -825,10 +716,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!result[i]) result[i] = optimizedQueue[qi++]
|
if (!result[i]) result[i] = optimizedQueue[qi++]
|
||||||
}
|
}
|
||||||
|
|
||||||
await onReorder(dayId, result.map(a => a.id))
|
await onReorder(selectedDayId, result.map(a => a.id))
|
||||||
const usedHotel = !!(anchors.start || anchors.end)
|
toast.success(t('dayplan.toast.routeOptimized'))
|
||||||
toast.success(usedHotel ? t('dayplan.toast.routeOptimizedFromHotel') : t('dayplan.toast.routeOptimized'))
|
const capturedDayId = selectedDayId
|
||||||
const capturedDayId = dayId
|
|
||||||
pushUndo?.(t('undo.optimize'), async () => {
|
pushUndo?.(t('undo.optimize'), async () => {
|
||||||
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
await tripActions.reorderAssignments(tripId, capturedDayId, prevIds)
|
||||||
})
|
})
|
||||||
@@ -912,8 +802,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onDayDetail,
|
onDayDetail,
|
||||||
accommodations,
|
accommodations,
|
||||||
onReorder,
|
onReorder,
|
||||||
onReorderDays,
|
|
||||||
onAddDay,
|
|
||||||
onUpdateDayTitle,
|
onUpdateDayTitle,
|
||||||
onRouteCalculated,
|
onRouteCalculated,
|
||||||
onAssignToDay,
|
onAssignToDay,
|
||||||
@@ -945,7 +833,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
showRouteToolsWhenExpanded,
|
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
language,
|
language,
|
||||||
@@ -964,8 +851,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
cancelNote,
|
cancelNote,
|
||||||
saveNote,
|
saveNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
pendingDeleteNote,
|
|
||||||
setPendingDeleteNote,
|
|
||||||
moveNote,
|
moveNote,
|
||||||
expandedDays,
|
expandedDays,
|
||||||
setExpandedDays,
|
setExpandedDays,
|
||||||
@@ -979,8 +864,6 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
hotelLegs,
|
|
||||||
setHotelLegs,
|
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -1061,8 +944,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
onDayDetail,
|
onDayDetail,
|
||||||
accommodations,
|
accommodations,
|
||||||
onReorder,
|
onReorder,
|
||||||
onReorderDays,
|
|
||||||
onAddDay,
|
|
||||||
onUpdateDayTitle,
|
onUpdateDayTitle,
|
||||||
onRouteCalculated,
|
onRouteCalculated,
|
||||||
onAssignToDay,
|
onAssignToDay,
|
||||||
@@ -1094,7 +975,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
onAddBookingToAssignment,
|
onAddBookingToAssignment,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
onScrollTopChange,
|
onScrollTopChange,
|
||||||
showRouteToolsWhenExpanded,
|
|
||||||
toast,
|
toast,
|
||||||
t,
|
t,
|
||||||
language,
|
language,
|
||||||
@@ -1113,8 +993,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
cancelNote,
|
cancelNote,
|
||||||
saveNote,
|
saveNote,
|
||||||
deleteNote,
|
deleteNote,
|
||||||
pendingDeleteNote,
|
|
||||||
setPendingDeleteNote,
|
|
||||||
moveNote,
|
moveNote,
|
||||||
expandedDays,
|
expandedDays,
|
||||||
setExpandedDays,
|
setExpandedDays,
|
||||||
@@ -1128,8 +1006,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
setRouteInfo,
|
setRouteInfo,
|
||||||
routeLegs,
|
routeLegs,
|
||||||
setRouteLegs,
|
setRouteLegs,
|
||||||
hotelLegs,
|
|
||||||
setHotelLegs,
|
|
||||||
legsAbortRef,
|
legsAbortRef,
|
||||||
draggingId,
|
draggingId,
|
||||||
setDraggingId,
|
setDraggingId,
|
||||||
@@ -1217,9 +1093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
undoHover={undoHover}
|
undoHover={undoHover}
|
||||||
setUndoHover={setUndoHover}
|
setUndoHover={setUndoHover}
|
||||||
lastActionLabel={lastActionLabel}
|
lastActionLabel={lastActionLabel}
|
||||||
canEditDays={canEditDays}
|
|
||||||
onReorderDays={onReorderDays}
|
|
||||||
onAddDay={onAddDay}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Tagesliste */}
|
{/* Tagesliste */}
|
||||||
@@ -1422,8 +1295,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||||
const transportId = Number(parts[0])
|
const transportId = Number(parts[0])
|
||||||
const legPart = parts.find(p => /^leg\d+$/.test(p))
|
|
||||||
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
|
|
||||||
|
|
||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), day.id)
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
@@ -1431,15 +1302,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
} else if (fromReservationId) {
|
} else if (fromReservationId) {
|
||||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
||||||
} else if (assignmentId && fromDayId !== day.id) {
|
} else if (assignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (assignmentId) {
|
} else if (assignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
|
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
||||||
}
|
}
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
return
|
return
|
||||||
@@ -1472,9 +1343,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
handleMergedDrop(day.id, 'note', Number(noteId), lastItem.type, lastItem.data.id, true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isSelected && hotelLegs.top && (
|
|
||||||
<HotelRouteConnector seg={hotelLegs.top.seg} name={hotelLegs.top.name} profile={routeProfile} placement="top" />
|
|
||||||
)}
|
|
||||||
{merged.length === 0 && !dayNoteUi ? (
|
{merged.length === 0 && !dayNoteUi ? (
|
||||||
<div
|
<div
|
||||||
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
onDragOver={e => { e.preventDefault(); if (dragOverDayId !== day.id) setDragOverDayId(day.id) }}
|
||||||
@@ -1488,10 +1356,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
merged.map((item, idx) => {
|
merged.map((item, idx) => {
|
||||||
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
|
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
|
||||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}`
|
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
|
||||||
|
|
||||||
if (item.type === 'place') {
|
if (item.type === 'place') {
|
||||||
const assignment = item.data
|
const assignment = item.data
|
||||||
@@ -1839,13 +1706,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
|
|
||||||
// Subtitle aus Metadaten zusammensetzen
|
// Subtitle aus Metadaten zusammensetzen
|
||||||
let subtitle = ''
|
let subtitle = ''
|
||||||
if (res.__leg) {
|
if (res.type === 'flight') {
|
||||||
// One leg of a multi-leg flight — show this segment's own route.
|
|
||||||
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
|
||||||
if (res.__leg.from || res.__leg.to)
|
|
||||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
|
||||||
subtitle = parts.join(' · ')
|
|
||||||
} else if (res.type === 'flight') {
|
|
||||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||||
if (meta.departure_airport || meta.arrival_airport)
|
if (meta.departure_airport || meta.arrival_airport)
|
||||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||||
@@ -1854,32 +1715,28 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-day span phase (single-leg / non-flight only — a
|
// Multi-day span phase
|
||||||
// multi-leg flight is shown as one row per leg, see below).
|
const spanLabel = getSpanLabel(res, spanPhase)
|
||||||
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
|
|
||||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||||
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canEditDays) return
|
if (!canEditDays) return
|
||||||
const target = reservations.find(x => x.id === res.id) ?? res
|
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
||||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
else onEditReservation?.(res)
|
||||||
else onEditReservation?.(target)
|
|
||||||
}}
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
const inBottom = e.clientY > rect.top + rect.height / 2
|
||||||
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
|
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||||
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
|
||||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||||
}}
|
}}
|
||||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
draggable={canEditDays && spanPhase !== 'middle'}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||||
// setData is required for the drag to start reliably (Firefox) and
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
// matches how place/note items initiate their drag.
|
// matches how place/note items initiate their drag.
|
||||||
e.dataTransfer.setData('reservationId', String(res.id))
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
@@ -1900,15 +1757,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||||
} else if (fromReservationId) {
|
} else if (fromReservationId) {
|
||||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
||||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (fromAssignmentId) {
|
} else if (fromAssignmentId) {
|
||||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
||||||
} else if (noteId && fromDayId !== day.id) {
|
} else if (noteId && fromDayId !== day.id) {
|
||||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} else if (noteId) {
|
||||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
||||||
}
|
}
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
@@ -1928,7 +1785,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
{canEditDays && spanPhase !== 'middle' && (
|
||||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||||
<GripVertical size={13} strokeWidth={1.8} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1973,7 +1830,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
||||||
const active = visibleConnectionIds.includes(res.id)
|
const active = visibleConnectionIds.includes(res.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -1997,7 +1854,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
|
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2052,7 +1908,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
|
onContextMenu={canEditDays ? e => ctxMenu.open(e, [
|
||||||
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
{ label: t('common.edit'), icon: Pencil, onClick: () => openEditNote(day.id, note) },
|
||||||
{ divider: true },
|
{ divider: true },
|
||||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => setPendingDeleteNote({ dayId: day.id, noteId: note.id }) },
|
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||||
]) : undefined}
|
]) : undefined}
|
||||||
onMouseEnter={e => {
|
onMouseEnter={e => {
|
||||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||||
@@ -2094,7 +1950,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
|
<button onClick={e => openEditNote(day.id, note, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Pencil size={10} /></button>
|
||||||
<button onClick={e => { e.stopPropagation(); setPendingDeleteNote({ dayId: day.id, noteId: note.id }) }} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
|
<button onClick={e => deleteNote(day.id, note.id, e)} className="text-content-faint" style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', display: 'flex' }}><Trash2 size={10} /></button>
|
||||||
</div>}
|
</div>}
|
||||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} className={noteIdx === 0 ? 'text-[var(--border-primary)]' : 'text-content-faint'} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||||
@@ -2105,9 +1961,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
{isSelected && hotelLegs.bottom && (
|
|
||||||
<HotelRouteConnector seg={hotelLegs.bottom.seg} name={hotelLegs.bottom.name} profile={routeProfile} placement="bottom" />
|
|
||||||
)}
|
|
||||||
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
{/* Drop-Zone am Listenende — immer vorhanden als Drop-Target */}
|
||||||
<div
|
<div
|
||||||
style={{ minHeight: 12, padding: '2px 8px' }}
|
style={{ minHeight: 12, padding: '2px 8px' }}
|
||||||
@@ -2152,7 +2005,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
{/* Routen-Werkzeuge (ausgewählter Tag, 2+ Orte) */}
|
||||||
{(isSelected || (showRouteToolsWhenExpanded && isExpanded)) && getDayAssignments(day.id).length >= 2 && (
|
{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={{ padding: '10px 16px 12px', borderTop: '1px solid var(--border-faint)', display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
<div style={{ display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<button
|
<button
|
||||||
@@ -2168,7 +2021,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
<RouteIcon size={12} strokeWidth={2} />
|
<RouteIcon size={12} strokeWidth={2} />
|
||||||
{t('dayplan.route')}
|
{t('dayplan.route')}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => handleOptimize(day.id)} className="bg-surface-hover text-content-secondary" style={{
|
<button onClick={handleOptimize} className="bg-surface-hover text-content-secondary" style={{
|
||||||
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||||
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
padding: '6px 0', fontSize: 11, fontWeight: 500, borderRadius: 8, border: 'none',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -2197,7 +2050,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isSelected && routeInfo && (
|
{routeInfo && (
|
||||||
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
<div className="text-content-secondary bg-surface-hover" style={{ display: 'flex', justifyContent: 'center', gap: 12, fontSize: 12, borderRadius: 8, padding: '5px 10px' }}>
|
||||||
<span>{routeInfo.distance}</span>
|
<span>{routeInfo.distance}</span>
|
||||||
<span className="text-content-faint">·</span>
|
<span className="text-content-faint">·</span>
|
||||||
@@ -2240,15 +2093,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
t={t}
|
t={t}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Confirm: delete a day note — guards against accidental taps on touch devices */}
|
|
||||||
<ConfirmDialog
|
|
||||||
isOpen={!!pendingDeleteNote}
|
|
||||||
onClose={() => setPendingDeleteNote(null)}
|
|
||||||
onConfirm={() => { if (pendingDeleteNote) deleteNote(pendingDeleteNote.dayId, pendingDeleteNote.noteId) }}
|
|
||||||
title={t('dayplan.confirmDeleteNoteTitle')}
|
|
||||||
message={t('dayplan.confirmDeleteNoteBody')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Transport-Detail-Modal */}
|
{/* Transport-Detail-Modal */}
|
||||||
<DayPlanSidebarTransportDetailModal
|
<DayPlanSidebarTransportDetailModal
|
||||||
transportDetail={transportDetail}
|
transportDetail={transportDetail}
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
|||||||
/>
|
/>
|
||||||
<textarea
|
<textarea
|
||||||
value={ui.time}
|
value={ui.time}
|
||||||
maxLength={250}
|
maxLength={150}
|
||||||
rows={3}
|
rows={3}
|
||||||
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
onChange={e => setNoteUi(prev => ({ ...prev, [dayId]: { ...prev[dayId], time: e.target.value } }))}
|
||||||
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
onKeyDown={e => { if (e.key === 'Escape') cancelNote(Number(dayId)) }}
|
||||||
@@ -66,7 +66,7 @@ export function DayPlanSidebarNoteModal({ noteUi, setNoteUi, noteInputRef, cance
|
|||||||
className="text-content"
|
className="text-content"
|
||||||
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
style={{ fontSize: 12, border: '1px solid var(--border-primary)', borderRadius: 8, padding: '7px 10px', fontFamily: 'inherit', outline: 'none', width: '100%', boxSizing: 'border-box', resize: 'none', lineHeight: 1.4 }}
|
||||||
/>
|
/>
|
||||||
<div className={(ui.time?.length || 0) >= 240 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/250</div>
|
<div className={(ui.time?.length || 0) >= 140 ? 'text-[#d97706]' : 'text-content-faint'} style={{ textAlign: 'right', fontSize: 11, marginTop: -2 }}>{ui.time?.length || 0}/150</div>
|
||||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
<button onClick={() => cancelNote(Number(dayId))} className="text-content-muted" style={{ fontSize: 12, background: 'none', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '6px 14px', cursor: 'pointer', fontFamily: 'inherit' }}>{t('common.cancel')}</button>
|
||||||
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
<button onClick={() => saveNote(Number(dayId))} disabled={!ui.text?.trim()} className={!ui.text?.trim() ? 'bg-[var(--border-primary)] text-content-faint' : 'bg-accent text-accent-text'} style={{ fontSize: 12, border: 'none', borderRadius: 8, padding: '6px 16px', cursor: !ui.text?.trim() ? 'not-allowed' : 'pointer', fontWeight: 600, fontFamily: 'inherit', transition: 'background 0.15s, color 0.15s' }}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Car, Footprints, Hotel } from 'lucide-react'
|
import { Car, Footprints } from 'lucide-react'
|
||||||
import type { RouteSegment } from '../../types'
|
import type { RouteSegment } from '../../types'
|
||||||
|
|
||||||
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
/** Slim travel-time connector shown between two consecutive located stops in a day. */
|
||||||
@@ -19,60 +19,3 @@ export function RouteConnector({ seg, profile }: { seg: RouteSegment; profile: '
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The hotel's bookend legs for a day: a two-line connector naming the day's
|
|
||||||
* accommodation with the drive to/from it. Rendered above the first place (the
|
|
||||||
* morning departure from the hotel) and below the last place (the evening return),
|
|
||||||
* when the "optimize from accommodation" setting is on and the day has a hotel.
|
|
||||||
*/
|
|
||||||
export function HotelRouteConnector({
|
|
||||||
seg,
|
|
||||||
profile,
|
|
||||||
name,
|
|
||||||
placement,
|
|
||||||
}: {
|
|
||||||
seg: RouteSegment
|
|
||||||
profile: 'driving' | 'walking'
|
|
||||||
name: string
|
|
||||||
placement: 'top' | 'bottom'
|
|
||||||
}) {
|
|
||||||
const driving = profile === 'driving'
|
|
||||||
const Icon = driving ? Car : Footprints
|
|
||||||
const line = { flex: 1, height: 1, minHeight: 1, alignSelf: 'center', background: 'var(--border-primary)' }
|
|
||||||
const hotelRow = (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '0 14px', minWidth: 0 }}>
|
|
||||||
<Hotel size={12} strokeWidth={1.8} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
|
|
||||||
<span style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
const travelRow = (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '3px 14px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.2 }}>
|
|
||||||
<div style={line} />
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0 }}>
|
|
||||||
<Icon size={11} strokeWidth={2} />
|
|
||||||
<span>{seg.durationText ?? (driving ? seg.drivingText : seg.walkingText)}</span>
|
|
||||||
<span style={{ opacity: 0.4 }}>·</span>
|
|
||||||
<span>{seg.distanceText}</span>
|
|
||||||
</div>
|
|
||||||
<div style={line} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: placement === 'top' ? '2px 0 6px' : '6px 0 2px' }}>
|
|
||||||
{placement === 'top' ? (
|
|
||||||
<>
|
|
||||||
{hotelRow}
|
|
||||||
{travelRow}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{travelRow}
|
|
||||||
{hotelRow}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2 } from 'lucide-react'
|
||||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
|
|
||||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||||
import { DayReorderPopup } from './DayReorderPopup'
|
|
||||||
import Tooltip from '../shared/Tooltip'
|
import Tooltip from '../shared/Tooltip'
|
||||||
import { useToast } from '../shared/Toast'
|
import { useToast } from '../shared/Toast'
|
||||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||||
@@ -29,18 +27,13 @@ interface DayPlanSidebarToolbarProps {
|
|||||||
undoHover: boolean
|
undoHover: boolean
|
||||||
setUndoHover: (v: boolean) => void
|
setUndoHover: (v: boolean) => void
|
||||||
lastActionLabel: string | null
|
lastActionLabel: string | null
|
||||||
canEditDays?: boolean
|
|
||||||
onReorderDays?: (orderedIds: number[]) => void
|
|
||||||
onAddDay?: (position?: number) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DayPlanSidebarToolbar({
|
export function DayPlanSidebarToolbar({
|
||||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||||
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
||||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||||
canEditDays, onReorderDays, onAddDay,
|
|
||||||
}: DayPlanSidebarToolbarProps) {
|
}: DayPlanSidebarToolbarProps) {
|
||||||
const [reorderOpen, setReorderOpen] = useState(false)
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
@@ -204,38 +197,6 @@ export function DayPlanSidebarToolbar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
|
|
||||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
|
||||||
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
|
|
||||||
<button
|
|
||||||
onClick={() => setReorderOpen(v => !v)}
|
|
||||||
aria-label={t('dayplan.reorderDays')}
|
|
||||||
aria-pressed={reorderOpen}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
|
||||||
width: 30, height: 30, borderRadius: 8,
|
|
||||||
border: '1px solid var(--border-primary)',
|
|
||||||
background: reorderOpen ? 'var(--bg-hover)' : 'none',
|
|
||||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
|
||||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => { if (!reorderOpen) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
|
||||||
onMouseLeave={e => { if (!reorderOpen) e.currentTarget.style.background = 'transparent' }}
|
|
||||||
>
|
|
||||||
<ArrowUpDown size={14} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
<DayReorderPopup
|
|
||||||
isOpen={reorderOpen}
|
|
||||||
days={days}
|
|
||||||
t={t}
|
|
||||||
locale={locale}
|
|
||||||
onReorder={onReorderDays}
|
|
||||||
onAddDay={() => onAddDay()}
|
|
||||||
onClose={() => setReorderOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
|
||||||
import Modal from '../shared/Modal'
|
|
||||||
import type { Day } from '../../types'
|
|
||||||
|
|
||||||
interface DayReorderPopupProps {
|
|
||||||
isOpen: boolean
|
|
||||||
days: Day[]
|
|
||||||
t: (key: string, params?: Record<string, any>) => string
|
|
||||||
locale: string
|
|
||||||
onReorder: (orderedIds: number[]) => void
|
|
||||||
onAddDay: () => void
|
|
||||||
onClose: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Modal for moving whole days around: drag a row by its grip or use the up/down
|
|
||||||
* arrows, and add a day at the end. Day headers stay untouched — this is the
|
|
||||||
* single surface for ordering. Reorders are applied optimistically by the store,
|
|
||||||
* so the list reflects each move immediately.
|
|
||||||
*/
|
|
||||||
export function DayReorderPopup({ isOpen, days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
|
||||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
|
||||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
|
||||||
|
|
||||||
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
|
||||||
|
|
||||||
const label = (day: Day, index: number) => {
|
|
||||||
if (day.title) return day.title
|
|
||||||
if (day.date) {
|
|
||||||
const d = new Date(day.date + 'T00:00:00')
|
|
||||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
|
||||||
}
|
|
||||||
return t('dayplan.dayN', { n: index + 1 })
|
|
||||||
}
|
|
||||||
|
|
||||||
const move = (from: number, to: number) => {
|
|
||||||
if (to < 0 || to >= ordered.length || from === to) return
|
|
||||||
const ids = ordered.map(d => d.id)
|
|
||||||
const [moved] = ids.splice(from, 1)
|
|
||||||
ids.splice(to, 0, moved)
|
|
||||||
onReorder(ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellBtn = {
|
|
||||||
display: 'grid', placeItems: 'center', width: 28, height: 28,
|
|
||||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
|
||||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
title={t('dayplan.reorderTitle')}
|
|
||||||
size="md"
|
|
||||||
footer={
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px', borderRadius: 8, fontSize: 13, fontWeight: 500,
|
|
||||||
border: '1px solid var(--border-primary)', background: 'none',
|
|
||||||
color: 'var(--text-muted)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('common.close')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onAddDay}
|
|
||||||
className="bg-accent text-accent-text"
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 6, padding: '8px 16px',
|
|
||||||
borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={15} strokeWidth={2} />
|
|
||||||
{t('dayplan.addDay')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<p style={{ margin: '0 0 14px', fontSize: 12.5, color: 'var(--text-faint)', lineHeight: 1.4 }}>
|
|
||||||
{t('dayplan.reorderHint')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
|
||||||
{ordered.map((day, index) => (
|
|
||||||
<div
|
|
||||||
key={day.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={() => setDragIndex(index)}
|
|
||||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
|
||||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
|
||||||
onDrop={e => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
|
||||||
setDragIndex(null); setOverIndex(null)
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 10px',
|
|
||||||
borderRadius: 9,
|
|
||||||
border: '1px solid var(--border-faint)',
|
|
||||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'var(--bg-card, white)',
|
|
||||||
opacity: dragIndex === index ? 0.5 : 1,
|
|
||||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
|
||||||
outlineOffset: -2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GripVertical size={15} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
|
||||||
<span style={{
|
|
||||||
flexShrink: 0, width: 24, height: 24, borderRadius: '50%',
|
|
||||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
|
||||||
display: 'grid', placeItems: 'center', fontSize: 11, fontWeight: 700,
|
|
||||||
}}>
|
|
||||||
{index + 1}
|
|
||||||
</span>
|
|
||||||
<span style={{ flex: 1, minWidth: 0, fontSize: 13.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{label(day, index)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => move(index, index - 1)}
|
|
||||||
disabled={index === 0}
|
|
||||||
aria-label={t('dayplan.moveUp')}
|
|
||||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
|
||||||
>
|
|
||||||
<ArrowUp size={14} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => move(index, index + 1)}
|
|
||||||
disabled={index === ordered.length - 1}
|
|
||||||
aria-label={t('dayplan.moveDown')}
|
|
||||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
|
||||||
>
|
|
||||||
<ArrowDown size={14} strokeWidth={2} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -253,101 +253,6 @@ describe('PlaceFormModal', () => {
|
|||||||
delete window.__addToast;
|
delete window.__addToast;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Autocomplete suggestion click (#1192) ─────────────────────────────────────
|
|
||||||
// Selecting a dropdown suggestion does a second `details` lookup which is fragile
|
|
||||||
// (details kill-switch, an overloaded OSM Overpass mirror, upstream errors). When
|
|
||||||
// it yields no usable place the modal must fall back to the reliable text search
|
|
||||||
// instead of dead-ending on "Place search failed".
|
|
||||||
|
|
||||||
async function openSuggestion(user: ReturnType<typeof userEvent.setup>) {
|
|
||||||
const searchInput = screen.getByPlaceholderText('Search places...');
|
|
||||||
await user.type(searchInput, 'Eiffel');
|
|
||||||
// Debounced autocomplete (300ms) then the dropdown renders the suggestion.
|
|
||||||
return screen.findByText('Paris, France');
|
|
||||||
}
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-021b: suggestion click falls back to search when details fails', async () => {
|
|
||||||
const addToast = vi.fn();
|
|
||||||
window.__addToast = addToast;
|
|
||||||
const user = userEvent.setup();
|
|
||||||
server.use(
|
|
||||||
http.post('/api/maps/autocomplete', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
||||||
source: 'nominatim',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
// details rejects (e.g. proxy 504 from a hung Overpass mirror)
|
|
||||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ error: 'boom' }, { status: 500 })),
|
|
||||||
http.post('/api/maps/search', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
|
||||||
source: 'openstreetmap',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
|
||||||
const suggestion = await openSuggestion(user);
|
|
||||||
await user.click(suggestion);
|
|
||||||
|
|
||||||
// Form is populated from the search fallback, and no error toast is shown.
|
|
||||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
|
||||||
expect(screen.getByDisplayValue('2.2945')).toBeInTheDocument();
|
|
||||||
expect(addToast).not.toHaveBeenCalledWith(expect.anything(), 'error', expect.anything());
|
|
||||||
delete window.__addToast;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-021c: suggestion click falls back when details is disabled (place: null)', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
server.use(
|
|
||||||
http.post('/api/maps/autocomplete', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
||||||
source: 'nominatim',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
|
||||||
http.post('/api/maps/search', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
places: [{ name: 'Eiffel Tower', address: 'Paris, France', lat: '48.8584', lng: '2.2945' }],
|
|
||||||
source: 'openstreetmap',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
|
||||||
const suggestion = await openSuggestion(user);
|
|
||||||
await user.click(suggestion);
|
|
||||||
|
|
||||||
expect(await screen.findByDisplayValue('48.8584')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-021d: suggestion click shows error only when the fallback also finds nothing', async () => {
|
|
||||||
const addToast = vi.fn();
|
|
||||||
window.__addToast = addToast;
|
|
||||||
const user = userEvent.setup();
|
|
||||||
server.use(
|
|
||||||
http.post('/api/maps/autocomplete', () =>
|
|
||||||
HttpResponse.json({
|
|
||||||
suggestions: [{ placeId: 'node:123', mainText: 'Eiffel Tower', secondaryText: 'Paris, France' }],
|
|
||||||
source: 'nominatim',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
http.get('/api/maps/details/:placeId', () => HttpResponse.json({ place: null, disabled: true })),
|
|
||||||
http.post('/api/maps/search', () => HttpResponse.json({ places: [], source: 'openstreetmap' })),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
|
||||||
const suggestion = await openSuggestion(user);
|
|
||||||
await user.click(suggestion);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(addToast).toHaveBeenCalledWith('Place search failed.', 'error', undefined);
|
|
||||||
});
|
|
||||||
delete window.__addToast;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
it('FE-PLANNER-PLACEFORM-022: hasMapsKey=false shows OSM active message', () => {
|
||||||
// hasMapsKey is false by default in beforeEach
|
// hasMapsKey is false by default in beforeEach
|
||||||
render(<PlaceFormModal {...defaultProps} />);
|
render(<PlaceFormModal {...defaultProps} />);
|
||||||
@@ -365,18 +270,6 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
expect(screen.getByText(/No category/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-023b: editing a place shows its assigned category, not the placeholder (#1134)', () => {
|
|
||||||
// Regression: form.category_id is a string but the option values were numbers,
|
|
||||||
// so CustomSelect's strict-equality match failed and the trigger fell back to
|
|
||||||
// "No category". With string option values the chosen category renders.
|
|
||||||
const cat = buildCategory({ name: 'Museums' });
|
|
||||||
const place = buildPlace({ name: 'Louvre', category_id: cat.id });
|
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} categories={[cat]} />);
|
|
||||||
// Dropdown is closed, so the only place the category name can appear is the trigger.
|
|
||||||
expect(screen.getByText('Museums')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText(/No category/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
it('FE-PLANNER-PLACEFORM-024: onCategoryCreated is called when creating a category', async () => {
|
||||||
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
const onCategoryCreated = vi.fn().mockResolvedValue({ id: 99, name: 'Beaches', color: '#6366f1', icon: 'MapPin' });
|
||||||
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
|
// Directly invoke handleCreateCategory by setting showNewCategory via the category name input
|
||||||
@@ -399,38 +292,17 @@ describe('PlaceFormModal', () => {
|
|||||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026: time section is hidden in edit mode when no assignment is in context', () => {
|
it('FE-PLANNER-PLACEFORM-026: time section IS shown in edit mode', () => {
|
||||||
// Times are per day-assignment; editing a pool place with no day in context
|
|
||||||
// (assignmentId null) hides the fields, which otherwise would not persist (#1247)
|
|
||||||
const place = buildPlace({ name: 'Test' });
|
const place = buildPlace({ name: 'Test' });
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||||
expect(screen.queryByTestId('time-picker')).not.toBeInTheDocument();
|
// Time pickers are rendered when editing
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026b: time section IS shown when an assignment is in context', () => {
|
|
||||||
const place = buildPlace({ name: 'Test', place_time: '09:00', end_time: '10:00' });
|
|
||||||
const assignment = buildAssignment({ id: 10, day_id: 5, place });
|
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={10} dayAssignments={[assignment]} />);
|
|
||||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThanOrEqual(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-026c: hydrates Start/End from the assignment when the pool place lacks times (#1247)', () => {
|
|
||||||
// The pool Place carries no times — they live on the day-assignment. Opening the
|
|
||||||
// editor with an assignmentId must hydrate the fields from assignment.place, not
|
|
||||||
// the (timeless) pool place that the Places panel passes in.
|
|
||||||
const poolPlace = buildPlace({ id: 7, name: 'Museum' });
|
|
||||||
const assignmentPlace = buildPlace({ id: 7, name: 'Museum', place_time: '20:20', end_time: '20:34' });
|
|
||||||
const assignment = buildAssignment({ id: 42, day_id: 3, place: assignmentPlace });
|
|
||||||
render(<PlaceFormModal {...defaultProps} place={poolPlace} assignmentId={42} dayAssignments={[assignment]} />);
|
|
||||||
expect(screen.getByDisplayValue('20:20')).toBeInTheDocument();
|
|
||||||
expect(screen.getByDisplayValue('20:34')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
it('FE-PLANNER-PLACEFORM-027: end-before-start error disables submit', () => {
|
||||||
// Build an assignment whose place has end_time before place_time
|
// Build a place with end_time before place_time
|
||||||
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
const place = buildPlace({ name: 'Test', place_time: '14:00', end_time: '13:00' });
|
||||||
const assignment = buildAssignment({ id: 11, day_id: 5, place });
|
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={null} />);
|
||||||
render(<PlaceFormModal {...defaultProps} place={place} assignmentId={11} dayAssignments={[assignment]} />);
|
|
||||||
|
|
||||||
// hasTimeError = true → submit button disabled
|
// hasTimeError = true → submit button disabled
|
||||||
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
const submitBtn = screen.getByRole('button', { name: /^Update$/i });
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface PlaceFormModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
||||||
place: Place | null
|
place: Place | null
|
||||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
|
||||||
tripId: number
|
tripId: number
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||||
@@ -39,31 +39,6 @@ interface PlaceFormModalProps {
|
|||||||
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
/** Place create/edit form state: maps search + Google-URL resolve + autocomplete,
|
||||||
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
* category creation, file attachments and submit. Keeps PlaceFormModal a thin
|
||||||
* render over the form fields. */
|
* render over the form fields. */
|
||||||
|
|
||||||
// #1152: a manually-added place is treated as a likely duplicate of an existing
|
|
||||||
// trip place if it shares the Google Place ID, the (case-insensitive) name, or
|
|
||||||
// near-identical coordinates (~11 m). Mirrors the server-side import dedup.
|
|
||||||
const DUP_COORD_TOLERANCE = 0.0001
|
|
||||||
function findDuplicatePlace(
|
|
||||||
form: PlaceFormData,
|
|
||||||
places: { name?: string | null; lat?: number | null; lng?: number | null; google_place_id?: string | null }[],
|
|
||||||
): { name?: string | null } | null {
|
|
||||||
const name = (form.name || '').trim().toLowerCase()
|
|
||||||
const gid = (form.google_place_id || '').trim()
|
|
||||||
const lat = form.lat ? parseFloat(form.lat) : null
|
|
||||||
const lng = form.lng ? parseFloat(form.lng) : null
|
|
||||||
for (const p of places || []) {
|
|
||||||
if (gid && p.google_place_id && p.google_place_id === gid) return p
|
|
||||||
if (name && p.name && p.name.trim().toLowerCase() === name) return p
|
|
||||||
if (
|
|
||||||
lat != null && lng != null && p.lat != null && p.lng != null &&
|
|
||||||
Math.abs(Number(p.lat) - lat) <= DUP_COORD_TOLERANCE &&
|
|
||||||
Math.abs(Number(p.lng) - lng) <= DUP_COORD_TOLERANCE
|
|
||||||
) return p
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePlaceFormModal(props: PlaceFormModalProps) {
|
function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||||
const {
|
const {
|
||||||
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
isOpen, onClose, onSave, place, prefillCoords, tripId, categories,
|
||||||
@@ -76,7 +51,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
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 [duplicateWarning, setDuplicateWarning] = useState<string | null>(null)
|
|
||||||
const [pendingFiles, setPendingFiles] = useState([])
|
const [pendingFiles, setPendingFiles] = useState([])
|
||||||
const fileRef = useRef(null)
|
const fileRef = useRef(null)
|
||||||
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
const [acSuggestions, setAcSuggestions] = useState<{ placeId: string; mainText: string; secondaryText: string }[]>([])
|
||||||
@@ -92,11 +66,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (place) {
|
if (place) {
|
||||||
// Times are stored per day-assignment, not on the pool place. When an
|
|
||||||
// assignment is in context (itinerary edit, or a single-assignment pool
|
|
||||||
// edit) read the times off its embedded place; fall back to the place prop.
|
|
||||||
const assignment = assignmentId ? dayAssignments.find(a => a.id === assignmentId) : null
|
|
||||||
const timeSource = assignment?.place ?? place
|
|
||||||
setForm({
|
setForm({
|
||||||
name: place.name || '',
|
name: place.name || '',
|
||||||
description: place.description || '',
|
description: place.description || '',
|
||||||
@@ -104,8 +73,8 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lat: place.lat != null ? String(place.lat) : '',
|
lat: place.lat != null ? String(place.lat) : '',
|
||||||
lng: place.lng != null ? String(place.lng) : '',
|
lng: place.lng != null ? String(place.lng) : '',
|
||||||
category_id: place.category_id != null ? String(place.category_id) : '',
|
category_id: place.category_id != null ? String(place.category_id) : '',
|
||||||
place_time: timeSource.place_time || '',
|
place_time: place.place_time || '',
|
||||||
end_time: timeSource.end_time || '',
|
end_time: place.end_time || '',
|
||||||
notes: place.notes || '',
|
notes: place.notes || '',
|
||||||
transport_mode: place.transport_mode || 'walking',
|
transport_mode: place.transport_mode || 'walking',
|
||||||
website: place.website || '',
|
website: place.website || '',
|
||||||
@@ -117,19 +86,12 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
lng: String(prefillCoords.lng),
|
lng: String(prefillCoords.lng),
|
||||||
name: prefillCoords.name || '',
|
name: prefillCoords.name || '',
|
||||||
address: prefillCoords.address || '',
|
address: prefillCoords.address || '',
|
||||||
website: prefillCoords.website || '',
|
|
||||||
phone: prefillCoords.phone || '',
|
|
||||||
osm_id: prefillCoords.osm_id,
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm(DEFAULT_FORM)
|
setForm(DEFAULT_FORM)
|
||||||
}
|
}
|
||||||
setPendingFiles([])
|
setPendingFiles([])
|
||||||
setDuplicateWarning(null)
|
}, [place, prefillCoords, isOpen])
|
||||||
// dayAssignments is a fresh array each render; read it at open-time only and
|
|
||||||
// re-run on identity changes (place/assignmentId/open), not on every render.
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [place, prefillCoords, isOpen, assignmentId])
|
|
||||||
|
|
||||||
// Derive location bias bounding box from the trip's existing places
|
// Derive location bias bounding box from the trip's existing places
|
||||||
const places = useTripStore((s) => s.places)
|
const places = useTripStore((s) => s.places)
|
||||||
@@ -257,34 +219,15 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
setForm(prev => ({ ...prev, name: suggestion.mainText }))
|
||||||
setIsSearchingMaps(true)
|
setIsSearchingMaps(true)
|
||||||
try {
|
try {
|
||||||
// The details lookup is a fragile second hop — it can fail when the
|
const result = await mapsApi.details(suggestion.placeId, language)
|
||||||
// details kill-switch is off, when the OSM Overpass mirror is overloaded,
|
if (result.place) {
|
||||||
// or on any upstream error. Treat a missing/coordinate-less place as a
|
handleSelectMapsResult(result.place)
|
||||||
// miss and fall back to the reliable text-search path the search button
|
|
||||||
// uses (its results already carry coordinates), so dropdown items stay
|
|
||||||
// clickable instead of dead-ending on "Place search failed". (#1192)
|
|
||||||
let place: Record<string, unknown> | null = null
|
|
||||||
try {
|
|
||||||
const result = await mapsApi.details(suggestion.placeId, language)
|
|
||||||
if (result.place && result.place.lat != null && result.place.lng != null) {
|
|
||||||
place = result.place
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch place details:', err)
|
|
||||||
}
|
|
||||||
if (!place) {
|
|
||||||
const query = [suggestion.mainText, suggestion.secondaryText].filter(Boolean).join(', ')
|
|
||||||
const search = await mapsApi.search(query, language)
|
|
||||||
place = search.places?.[0] ?? null
|
|
||||||
}
|
|
||||||
if (place) {
|
|
||||||
handleSelectMapsResult(place)
|
|
||||||
} else {
|
} else {
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(t('places.mapsSearchError'))
|
toast.error(t('places.mapsSearchError'))
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Place suggestion lookup failed:', err)
|
console.error('Failed to fetch place details:', err)
|
||||||
setMapsSearch(previousSearch)
|
setMapsSearch(previousSearch)
|
||||||
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
toast.error(getApiErrorMessage(err, t('places.mapsSearchError')))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -363,17 +306,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
toast.error(t('places.nameRequired'))
|
toast.error(t('places.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// #1152: only for new places, and only on the first attempt — a second click
|
|
||||||
// (with the warning already showing) is the explicit "add anyway" confirmation.
|
|
||||||
if (!place && !duplicateWarning) {
|
|
||||||
const dup = findDuplicatePlace(form, places)
|
|
||||||
if (dup) {
|
|
||||||
const dupName = dup.name || form.name
|
|
||||||
setDuplicateWarning(dupName)
|
|
||||||
toast.warning(t('places.duplicateExists', { name: dupName }))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
@@ -446,7 +378,6 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
duplicateWarning,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,7 +438,6 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
handlePaste,
|
handlePaste,
|
||||||
hasTimeError,
|
hasTimeError,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
duplicateWarning,
|
|
||||||
} = S
|
} = S
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -530,7 +460,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
disabled={isSaving || hasTimeError}
|
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') : duplicateWarning ? t('places.addAnyway') : t('common.add')}
|
{isSaving ? t('common.saving') : place ? t('common.update') : t('common.add')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -706,10 +636,7 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
options={[
|
options={[
|
||||||
{ value: '', label: t('places.noCategory') },
|
{ value: '', label: t('places.noCategory') },
|
||||||
...(categories || []).map(c => ({
|
...(categories || []).map(c => ({
|
||||||
// form.category_id is a string; CustomSelect matches options by
|
value: c.id,
|
||||||
// strict equality, so the option value must be a string too —
|
|
||||||
// otherwise the chosen category never renders in the trigger.
|
|
||||||
value: String(c.id),
|
|
||||||
label: c.name,
|
label: c.name,
|
||||||
})),
|
})),
|
||||||
]}
|
]}
|
||||||
@@ -736,11 +663,8 @@ export default function PlaceFormModal(props: PlaceFormModalProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time is per day-assignment: only shown when a single assignment is in
|
{/* Time — only shown when editing, not when creating */}
|
||||||
context (itinerary edit, or a single-assignment pool edit). Hidden when
|
{place && (
|
||||||
creating, and for unassigned / multi-day pool edits where a single time
|
|
||||||
is ambiguous and wouldn't persist. */}
|
|
||||||
{place && assignmentId && (
|
|
||||||
<TimeSection
|
<TimeSection
|
||||||
form={form}
|
form={form}
|
||||||
handleChange={handleChange}
|
handleChange={handleChange}
|
||||||
|
|||||||
@@ -647,43 +647,5 @@ describe('PlaceInspector', () => {
|
|||||||
expect(screen.queryByText('Participants')).toBeNull();
|
expect(screen.queryByText('Participants')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Scroll / overflow (issue #1195) ──────────────────────────────────────
|
|
||||||
|
|
||||||
it('FE-PLANNER-INSPECTOR-046: content area is a bounded flex scroll region', () => {
|
|
||||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
|
||||||
const p = buildPlace({ id: 200, description: longText, notes: longText } as any);
|
|
||||||
render(<PlaceInspector {...defaultProps} place={p} />);
|
|
||||||
const scroll = screen.getByTestId('inspector-scroll') as HTMLElement;
|
|
||||||
expect(scroll.style.overflowY).toBe('auto');
|
|
||||||
expect(scroll.style.minHeight).toBe('0px');
|
|
||||||
// flex must allow the region to shrink/grow within the capped card
|
|
||||||
expect(scroll.style.flex).not.toBe('');
|
|
||||||
expect(scroll.style.flex).not.toBe('0 0 auto');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-INSPECTOR-047: long unbroken description wraps instead of clipping horizontally', () => {
|
|
||||||
const longWord = 'https://example.com/' + 'a'.repeat(300);
|
|
||||||
const p = buildPlace({ id: 201, description: longWord } as any);
|
|
||||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
|
||||||
const descDiv = container.querySelector('.collab-note-md') as HTMLElement;
|
|
||||||
expect(descDiv).toBeTruthy();
|
|
||||||
expect(descDiv.style.overflowWrap).toBe('anywhere');
|
|
||||||
expect(descDiv.style.wordBreak).toBe('break-word');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-PLANNER-INSPECTOR-048: description/notes do not shrink so the card scrolls instead of clipping', () => {
|
|
||||||
const longText = 'Lorem ipsum dolor sit amet. '.repeat(200);
|
|
||||||
const p = buildPlace({ id: 202, description: longText, notes: longText } as any);
|
|
||||||
const { container } = render(<PlaceInspector {...defaultProps} place={p} />);
|
|
||||||
const notes = Array.from(container.querySelectorAll('.collab-note-md')) as HTMLElement[];
|
|
||||||
// Both description and notes containers must keep their natural height
|
|
||||||
// (flex-shrink: 0) — otherwise they compress inside the flex column and
|
|
||||||
// overflow:hidden clips the text with no scroll (issue #1195).
|
|
||||||
expect(notes.length).toBe(2);
|
|
||||||
for (const el of notes) {
|
|
||||||
expect(el.style.flexShrink).toBe('0');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export default function PlaceInspector({
|
|||||||
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
locale={locale} timeFormat={timeFormat} onClose={onClose} />
|
||||||
|
|
||||||
{/* Content — scrollable */}
|
{/* Content — scrollable */}
|
||||||
<div data-testid="inspector-scroll" style={{ flex: '1 1 auto', minHeight: 0, overflowY: 'auto', WebkitOverflowScrolling: 'touch', overscrollBehavior: 'contain', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
<div style={{ overflowY: 'auto', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
|
||||||
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
{/* Info-Chips — hidden on mobile, shown on desktop */}
|
||||||
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
<div className="hidden sm:flex" style={{ flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
|
||||||
@@ -253,14 +253,14 @@ export default function PlaceInspector({
|
|||||||
|
|
||||||
{/* Description / Summary */}
|
{/* Description / Summary */}
|
||||||
{(place.description || googleDetails?.summary) && (
|
{(place.description || googleDetails?.summary) && (
|
||||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.description || googleDetails?.summary || ''}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Notes */}
|
{/* Notes */}
|
||||||
{place.notes && (
|
{place.notes && (
|
||||||
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', flexShrink: 0, fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
<div className="collab-note-md bg-surface-hover text-content-muted" style={{ borderRadius: 10, overflow: 'hidden', fontSize: 12, lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
|
||||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +279,7 @@ export default function PlaceInspector({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer actions */}
|
{/* Footer actions */}
|
||||||
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap', flexShrink: 0 }}>
|
<div className="border-t border-edge-faint" style={{ padding: '10px 16px', display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
{selectedDayId && (
|
{selectedDayId && (
|
||||||
assignmentInDay ? (
|
assignmentInDay ? (
|
||||||
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
<ActionButton onClick={() => onRemoveAssignment(selectedDayId, assignmentInDay.id)} variant="ghost" icon={<Minus size={13} />}
|
||||||
@@ -497,7 +497,7 @@ function ParticipantsBox({ tripMembers, participantIds, allJoined, onSetParticip
|
|||||||
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
function PlaceInspectorHeader({ openNow, place, category, t, editingName, nameInputRef, nameValue, setNameValue,
|
||||||
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
commitNameEdit, handleNameKeyDown, startNameEdit, onUpdatePlace, locale, timeFormat, onClose }: any) {
|
||||||
return (
|
return (
|
||||||
<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)', flexShrink: 0 }}>
|
<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 */}
|
{/* Avatar with open/closed ring + tag */}
|
||||||
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
<div style={{ position: 'relative', flexShrink: 0, marginBottom: openNow !== null ? 8 : 0 }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import ToggleSwitch from '../Settings/ToggleSwitch'
|
|
||||||
import type { SidebarState } from './usePlacesSidebar'
|
import type { SidebarState } from './usePlacesSidebar'
|
||||||
|
|
||||||
export function ListImportModal(S: SidebarState) {
|
export function ListImportModal(S: SidebarState) {
|
||||||
const {
|
const {
|
||||||
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
setListImportOpen, setListImportUrl, t, hasMultipleListImportProviders, availableListImportProviders,
|
||||||
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
listImportProvider, setListImportProvider, listImportUrl, listImportLoading, handleListImport,
|
||||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
|
||||||
} = S
|
} = S
|
||||||
return ReactDOM.createPortal(
|
return ReactDOM.createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -57,15 +55,6 @@ export function ListImportModal(S: SidebarState) {
|
|||||||
fontFamily: 'inherit', boxSizing: 'border-box',
|
fontFamily: 'inherit', boxSizing: 'border-box',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{canEnrichImport && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 12, marginTop: 12 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<div className="text-content" style={{ fontSize: 12, fontWeight: 600 }}>{t('places.enrichOnImport')}</div>
|
|
||||||
<div className="text-content-faint" style={{ fontSize: 12, marginTop: 2 }}>{t('places.enrichOnImportHint')}</div>
|
|
||||||
</div>
|
|
||||||
<ToggleSwitch on={listImportEnrich} onToggle={() => setListImportEnrich(!listImportEnrich)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', gap: 8, marginTop: 16, justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
onClick={() => { setListImportOpen(false); setListImportUrl('') }}
|
||||||
|
|||||||
@@ -343,51 +343,56 @@ describe('ReservationModal', () => {
|
|||||||
|
|
||||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
|
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
const onSave = vi.fn().mockResolvedValue({ id: 55 });
|
render(<ReservationModal {...defaultProps} />);
|
||||||
const onOpenExpense = vi.fn();
|
const priceInput = screen.getByPlaceholderText('0.00');
|
||||||
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
await userEvent.type(priceInput, '99.99');
|
||||||
|
expect((priceInput as HTMLInputElement).value).toBe('99.99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
render(<ReservationModal {...defaultProps} />);
|
||||||
|
const priceInput = screen.getByPlaceholderText('0.00');
|
||||||
|
await userEvent.type(priceInput, '50');
|
||||||
|
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
|
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||||
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
||||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
await waitFor(() =>
|
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
|
||||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
|
|
||||||
seedStore(useAddonStore, {
|
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
|
||||||
loaded: true,
|
|
||||||
});
|
|
||||||
seedStore(useTripStore, {
|
|
||||||
trip: buildTrip({ id: 1 }),
|
|
||||||
budgetItems: [
|
|
||||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
|
|
||||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── File upload ───────────────────────────────────────────────────────────────
|
// ── File upload ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
||||||
@@ -594,6 +599,22 @@ describe('ReservationModal', () => {
|
|||||||
expect(filePickerItem).toBeInTheDocument();
|
expect(filePickerItem).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
trip: buildTrip({ id: 1 }),
|
||||||
|
budgetItems: [
|
||||||
|
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ReservationModal {...defaultProps} />);
|
||||||
|
// Budget section is visible
|
||||||
|
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||||
@@ -611,6 +632,31 @@ describe('ReservationModal', () => {
|
|||||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||||
|
seedStore(useAddonStore, {
|
||||||
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
|
loaded: true,
|
||||||
|
});
|
||||||
|
seedStore(useTripStore, {
|
||||||
|
trip: buildTrip({ id: 1 }),
|
||||||
|
budgetItems: [
|
||||||
|
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
render(<ReservationModal {...defaultProps} />);
|
||||||
|
|
||||||
|
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
|
||||||
|
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
|
||||||
|
await userEvent.click(budgetCategoryBtn);
|
||||||
|
|
||||||
|
// Click the "Transport" category option
|
||||||
|
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
|
||||||
|
await userEvent.click(screen.getByText('Transport'));
|
||||||
|
|
||||||
|
// The select should now show "Transport"
|
||||||
|
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||||
render(<ReservationModal {...defaultProps} />);
|
render(<ReservationModal {...defaultProps} />);
|
||||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ import { useTranslation } from '../../i18n'
|
|||||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||||
import { BookingCostsSection } from './BookingCostsSection'
|
|
||||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
|
||||||
import { typeToCostCategory } from '@trek/shared'
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||||
@@ -63,10 +60,9 @@ interface ReservationModalProps {
|
|||||||
onFileDelete: (fileId: number) => Promise<void>
|
onFileDelete: (fileId: number) => Promise<void>
|
||||||
accommodations?: Accommodation[]
|
accommodations?: Accommodation[]
|
||||||
defaultAssignmentId?: number | null
|
defaultAssignmentId?: number | null
|
||||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
|
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||||
const { id: tripId } = useParams<{ id: string }>()
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
const loadFiles = useTripStore(s => s.loadFiles)
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -74,14 +70,18 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
// Set right before submit when the user clicked create/edit expense (see TransportModal).
|
const budgetCategories = useMemo(() => {
|
||||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
const cats = new Set<string>()
|
||||||
|
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||||
|
return Array.from(cats).sort()
|
||||||
|
}, [budgetItems])
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||||
|
price: '', budget_category: '',
|
||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||||
})
|
})
|
||||||
@@ -127,12 +127,15 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
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_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 || '' })(),
|
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||||
|
price: meta.price || '',
|
||||||
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
setForm({
|
setForm({
|
||||||
title: '', type: 'other', status: 'pending',
|
title: '', type: 'other', status: 'pending',
|
||||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||||
|
price: '', budget_category: '',
|
||||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||||
})
|
})
|
||||||
@@ -164,8 +167,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
return endFull <= startFull
|
return endFull <= startFull
|
||||||
})()
|
})()
|
||||||
|
|
||||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
const handleSubmit = async (e) => {
|
||||||
e?.preventDefault?.()
|
e.preventDefault()
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
@@ -182,6 +185,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
} else if (form.reservation_end_time && form.reservation_time) {
|
} else if (form.reservation_end_time && form.reservation_time) {
|
||||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
if (form.price) metadata.price = form.price
|
||||||
|
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||||
|
}
|
||||||
|
|
||||||
const saveData: Record<string, any> & { title: string } = {
|
const saveData: Record<string, any> & { title: string } = {
|
||||||
title: form.title, type: form.type, status: form.status,
|
title: form.title, type: form.type, status: form.status,
|
||||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||||
@@ -194,6 +202,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
endpoints: [],
|
endpoints: [],
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||||
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
|
: { total_price: 0 }
|
||||||
|
}
|
||||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||||
saveData.create_accommodation = {
|
saveData.create_accommodation = {
|
||||||
place_id: form.hotel_place_id || null,
|
place_id: form.hotel_place_id || null,
|
||||||
@@ -215,25 +228,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
await onFileUpload(fd)
|
await onFileUpload(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Open the Costs editor for the saved booking when the user asked to
|
|
||||||
// create/edit its linked expense (gated on saved?.id).
|
|
||||||
const intent = expenseIntentRef.current
|
|
||||||
expenseIntentRef.current = null
|
|
||||||
if (intent && onOpenExpense && saved?.id) {
|
|
||||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
|
||||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
|
||||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
|
||||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
|
||||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (e) => {
|
const handleFileChange = async (e) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0]
|
const file = (e.target as HTMLInputElement).files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -611,14 +610,38 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Costs — create / view the expense linked to this booking */}
|
{/* Price + Budget Category */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<BookingCostsSection
|
<>
|
||||||
reservationId={reservation?.id ?? null}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
onCreate={handleCreateExpense}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
onEdit={handleEditExpense}
|
<label className={labelClass}>{t('reservations.price')}</label>
|
||||||
onRemove={handleRemoveExpense}
|
<input type="text" inputMode="decimal" value={form.price}
|
||||||
/>
|
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||||
|
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||||
|
placeholder="0.00"
|
||||||
|
className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.budget_category}
|
||||||
|
onChange={v => set('budget_category', v)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||||
|
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||||
|
]}
|
||||||
|
placeholder={t('reservations.budgetCategoryAuto')}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.price && parseFloat(form.price) > 0 && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||||
|
{t('reservations.budgetHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -179,16 +179,6 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
{t('reservations.needsReview')}
|
{t('reservations.needsReview')}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{r.external_source === 'airtrail' ? (
|
|
||||||
<span
|
|
||||||
className={r.sync_enabled ? 'text-[#2563eb] bg-[rgba(59,130,246,0.12)]' : 'text-content-faint bg-surface-tertiary'}
|
|
||||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: 11, fontWeight: 600, padding: '3px 8px', borderRadius: 6 }}
|
|
||||||
title={r.sync_enabled ? t('reservations.airtrail.syncedHint') : t('reservations.airtrail.notSyncedHint')}
|
|
||||||
>
|
|
||||||
<Plane size={11} />
|
|
||||||
{r.sync_enabled ? t('reservations.airtrail.synced') : t('reservations.airtrail.notSynced')}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||||
<span className="text-content" style={{
|
<span className="text-content" style={{
|
||||||
@@ -281,21 +271,19 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
// Full route over all waypoints (from · stops · to), ordered by sequence.
|
const eps = r.endpoints || []
|
||||||
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
const from = eps.find(e => e.role === 'from')
|
||||||
if (eps.length < 2) return null
|
const to = eps.find(e => e.role === 'to')
|
||||||
|
if (!from || !to) return null
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-tertiary text-content" style={{
|
<div className="bg-surface-tertiary text-content" style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
padding: '8px 12px', borderRadius: 10,
|
padding: '8px 12px', borderRadius: 10,
|
||||||
fontSize: 12.5, flexWrap: 'wrap',
|
fontSize: 12.5,
|
||||||
}}>
|
}}>
|
||||||
{eps.map((ep, i) => (
|
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
|
||||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||||
{i > 0 && <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />}
|
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
||||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ep.name}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
@@ -482,8 +470,6 @@ interface ReservationsPanelProps {
|
|||||||
onAdd: () => void
|
onAdd: () => void
|
||||||
onImport?: () => void
|
onImport?: () => void
|
||||||
bookingImportAvailable?: boolean
|
bookingImportAvailable?: boolean
|
||||||
onAirTrailImport?: () => void
|
|
||||||
airTrailAvailable?: boolean
|
|
||||||
onEdit: (reservation: Reservation) => void
|
onEdit: (reservation: Reservation) => void
|
||||||
onDelete: (id: number) => void
|
onDelete: (id: number) => void
|
||||||
onNavigateToFiles: () => void
|
onNavigateToFiles: () => void
|
||||||
@@ -491,7 +477,7 @@ interface ReservationsPanelProps {
|
|||||||
addManualKey?: string
|
addManualKey?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onAirTrailImport, airTrailAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onImport, bookingImportAvailable, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const trip = useTripStore((s) => s.trip)
|
const trip = useTripStore((s) => s.trip)
|
||||||
@@ -614,21 +600,6 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
|||||||
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
<span className="hidden sm:inline">{t('reservations.import.cta')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{onAirTrailImport && airTrailAvailable && (
|
|
||||||
<button onClick={onAirTrailImport} className="bg-surface-secondary text-content" style={{
|
|
||||||
appearance: 'none', border: '1px solid var(--border-primary)', cursor: 'pointer', fontFamily: 'inherit',
|
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
|
||||||
padding: '8px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500, boxSizing: 'border-box',
|
|
||||||
transition: 'opacity 0.15s ease',
|
|
||||||
}}
|
|
||||||
onMouseEnter={e => e.currentTarget.style.opacity = '0.75'}
|
|
||||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
|
||||||
title={t('reservations.airtrail.title')}
|
|
||||||
>
|
|
||||||
<Plane size={14} strokeWidth={2} />
|
|
||||||
<span className="hidden sm:inline">{t('reservations.airtrail.cta')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
<button onClick={onAdd} className="bg-accent text-accent-text" style={{
|
||||||
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||||
|
|||||||
@@ -132,37 +132,34 @@ describe('TransportModal', () => {
|
|||||||
|
|
||||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
|
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
render(<TransportModal {...defaultProps} />);
|
render(<TransportModal {...defaultProps} />);
|
||||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||||
render(<TransportModal {...defaultProps} />);
|
render(<TransportModal {...defaultProps} />);
|
||||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
||||||
seedStore(useAddonStore, {
|
seedStore(useAddonStore, {
|
||||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||||
loaded: true,
|
loaded: true,
|
||||||
});
|
});
|
||||||
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||||
const onOpenExpense = vi.fn();
|
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||||
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
|
||||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||||
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||||
await waitFor(() =>
|
|
||||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||||
import Modal from '../shared/Modal'
|
import Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
@@ -13,11 +13,7 @@ import { useAddonStore } from '../../store/addonStore'
|
|||||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
|
||||||
import { BookingCostsSection } from './BookingCostsSection'
|
|
||||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
|
||||||
import { typeToCostCategory } from '@trek/shared'
|
|
||||||
|
|
||||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
@@ -27,7 +23,7 @@ interface EndpointPick {
|
|||||||
location?: LocationPoint
|
location?: LocationPoint
|
||||||
}
|
}
|
||||||
|
|
||||||
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||||
return {
|
return {
|
||||||
role, sequence,
|
role, sequence,
|
||||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||||
@@ -67,25 +63,6 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
|
|||||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
|
|
||||||
// A flight is an ordered list of airports. The origin has only a departure, the
|
|
||||||
// destination only an arrival, and each intermediate stop has both — plus the
|
|
||||||
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
|
|
||||||
// single-leg flight is just two waypoints, so it persists exactly as before.
|
|
||||||
interface WaypointForm {
|
|
||||||
airport: Airport | null
|
|
||||||
arrDayId: string | number
|
|
||||||
arrTime: string
|
|
||||||
depDayId: string | number
|
|
||||||
depTime: string
|
|
||||||
airline: string
|
|
||||||
flight_number: string
|
|
||||||
seat: string
|
|
||||||
}
|
|
||||||
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
|
||||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '', seat: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||||
@@ -108,6 +85,8 @@ const defaultForm = {
|
|||||||
arrival_time: '',
|
arrival_time: '',
|
||||||
confirmation_number: '',
|
confirmation_number: '',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
price: '',
|
||||||
|
budget_category: '',
|
||||||
meta_airline: '',
|
meta_airline: '',
|
||||||
meta_flight_number: '',
|
meta_flight_number: '',
|
||||||
meta_train_number: '',
|
meta_train_number: '',
|
||||||
@@ -125,26 +104,24 @@ interface TransportModalProps {
|
|||||||
files?: TripFile[]
|
files?: TripFile[]
|
||||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||||
onFileDelete?: (fileId: number) => Promise<void>
|
onFileDelete?: (fileId: number) => Promise<void>
|
||||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
|
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
||||||
const { t, locale } = useTranslation()
|
const { t, locale } = useTranslation()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||||
const budgetItems = useTripStore(s => s.budgetItems)
|
const budgetItems = useTripStore(s => s.budgetItems)
|
||||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
|
||||||
const loadFiles = useTripStore(s => s.loadFiles)
|
const loadFiles = useTripStore(s => s.loadFiles)
|
||||||
|
const budgetCategories = useMemo(() => {
|
||||||
|
const cats = new Set<string>()
|
||||||
|
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||||
|
return Array.from(cats).sort()
|
||||||
|
}, [budgetItems])
|
||||||
const { id: tripId } = useParams<{ id: string }>()
|
const { id: tripId } = useParams<{ id: string }>()
|
||||||
// Set right before submitting when the user clicked "create/edit expense", so
|
|
||||||
// the post-save handler knows to open the Costs editor for the saved booking.
|
|
||||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
|
||||||
const [form, setForm] = useState({ ...defaultForm })
|
const [form, setForm] = useState({ ...defaultForm })
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
|
||||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
|
||||||
const [uploadingFile, setUploadingFile] = useState(false)
|
const [uploadingFile, setUploadingFile] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||||
@@ -178,42 +155,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
meta_train_number: meta.train_number || '',
|
meta_train_number: meta.train_number || '',
|
||||||
meta_platform: meta.platform || '',
|
meta_platform: meta.platform || '',
|
||||||
meta_seat: meta.seat || '',
|
meta_seat: meta.seat || '',
|
||||||
|
price: meta.price || '',
|
||||||
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
if (type === 'flight') {
|
if (type === 'flight') {
|
||||||
const orderedEps = orderedEndpoints(reservation)
|
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||||
let wps: WaypointForm[]
|
|
||||||
if (orderedEps.length >= 2) {
|
|
||||||
wps = orderedEps.map((ep, i) => {
|
|
||||||
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
|
|
||||||
const legOut = metaLegs[i] // leg departing FROM waypoint i
|
|
||||||
const isFirst = i === 0
|
|
||||||
const isLast = i === orderedEps.length - 1
|
|
||||||
return {
|
|
||||||
airport: airportFromEndpoint(ep),
|
|
||||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
|
|
||||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
|
||||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
|
|
||||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
|
||||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
|
||||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
|
||||||
seat: legOut?.seat ?? (isFirst ? (meta.seat ?? '') : ''),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
|
||||||
const dep = emptyWaypoint(reservation.day_id ?? '')
|
|
||||||
dep.airport = airportFromEndpoint(from)
|
|
||||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
|
||||||
dep.airline = meta.airline ?? ''
|
|
||||||
dep.flight_number = meta.flight_number ?? ''
|
|
||||||
dep.seat = meta.seat ?? ''
|
|
||||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
|
||||||
arr.airport = airportFromEndpoint(to)
|
|
||||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
|
||||||
wps = [dep, arr]
|
|
||||||
}
|
|
||||||
setWaypoints(wps)
|
|
||||||
} else {
|
} else {
|
||||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||||
@@ -222,14 +169,13 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||||
setFromPick({})
|
setFromPick({})
|
||||||
setToPick({})
|
setToPick({})
|
||||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
|
||||||
}
|
}
|
||||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||||
|
|
||||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||||
|
|
||||||
const handleSubmit = async (e?: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e?.preventDefault()
|
e.preventDefault()
|
||||||
if (!form.title.trim()) return
|
if (!form.title.trim()) return
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -241,86 +187,47 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
return day?.date ? `${day.date}T${time}` : time
|
return day?.date ? `${day.date}T${time}` : time
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
|
const metadata: Record<string, string> = {}
|
||||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
|
||||||
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
|
||||||
const firstWp = flightWps[0]
|
|
||||||
const lastWp = flightWps[flightWps.length - 1]
|
|
||||||
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
|
||||||
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
|
||||||
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
|
||||||
|
|
||||||
const metadata: Record<string, any> = {}
|
|
||||||
if (form.type === 'flight') {
|
if (form.type === 'flight') {
|
||||||
// Top-level keys mirror the first/last leg so legacy readers keep working.
|
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||||
if (firstWp?.airline) metadata.airline = firstWp.airline
|
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||||
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
|
if (fromPick.airport) {
|
||||||
if (firstWp?.airport) {
|
metadata.departure_airport = fromPick.airport.iata
|
||||||
metadata.departure_airport = firstWp.airport.iata
|
metadata.departure_timezone = fromPick.airport.tz
|
||||||
metadata.departure_timezone = firstWp.airport.tz
|
|
||||||
}
|
}
|
||||||
if (lastWp?.airport) {
|
if (toPick.airport) {
|
||||||
metadata.arrival_airport = lastWp.airport.iata
|
metadata.arrival_airport = toPick.airport.iata
|
||||||
metadata.arrival_timezone = lastWp.airport.tz
|
metadata.arrival_timezone = toPick.airport.tz
|
||||||
}
|
}
|
||||||
// Per-leg detail only for true multi-leg flights — a single-leg flight
|
|
||||||
// keeps the exact same (flat) metadata it had before this feature.
|
|
||||||
if (flightWps.length > 2) {
|
|
||||||
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
|
|
||||||
const next = flightWps[i + 1]
|
|
||||||
return {
|
|
||||||
from: w.airport!.iata,
|
|
||||||
to: next.airport!.iata,
|
|
||||||
...(w.airline ? { airline: w.airline } : {}),
|
|
||||||
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
|
||||||
...(w.seat ? { seat: w.seat } : {}),
|
|
||||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
|
||||||
dep_time: w.depTime || null,
|
|
||||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
|
||||||
arr_time: next.arrTime || null,
|
|
||||||
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (firstWp?.seat) metadata.seat = firstWp.seat
|
|
||||||
} else if (form.type === 'train') {
|
} else if (form.type === 'train') {
|
||||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
if (form.price) metadata.price = form.price
|
||||||
|
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||||
|
}
|
||||||
|
|
||||||
const startDate = startDay?.date ?? null
|
const startDate = startDay?.date ?? null
|
||||||
const endDate = (endDay ?? startDay)?.date ?? null
|
const endDate = (endDay ?? startDay)?.date ?? null
|
||||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||||
if (form.type === 'flight') {
|
if (form.type === 'flight') {
|
||||||
flightWps.forEach((w, i) => {
|
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
||||||
const isFirst = i === 0
|
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
||||||
const isLast = i === flightWps.length - 1
|
|
||||||
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
|
||||||
const dId = isLast ? w.arrDayId : w.depDayId
|
|
||||||
const time = isLast ? w.arrTime : w.depTime
|
|
||||||
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), time || null))
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flights derive their span from the first/last waypoint; other transports
|
|
||||||
// keep using the single departure/arrival form fields unchanged.
|
|
||||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
|
||||||
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : null
|
|
||||||
const payload = {
|
const payload = {
|
||||||
title: form.title,
|
title: form.title,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
||||||
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
||||||
reservation_time: form.type === 'flight'
|
reservation_time: buildTime(startDay, form.departure_time),
|
||||||
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
||||||
: buildTime(startDay, form.departure_time),
|
|
||||||
reservation_end_time: form.type === 'flight'
|
|
||||||
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
|
||||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
|
||||||
location: null,
|
location: null,
|
||||||
confirmation_number: form.confirmation_number || null,
|
confirmation_number: form.confirmation_number || null,
|
||||||
notes: form.notes || null,
|
notes: form.notes || null,
|
||||||
@@ -328,6 +235,11 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
endpoints,
|
endpoints,
|
||||||
needs_review: false,
|
needs_review: false,
|
||||||
}
|
}
|
||||||
|
if (isBudgetEnabled) {
|
||||||
|
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||||
|
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||||
|
: { total_price: 0 }
|
||||||
|
}
|
||||||
const saved = await onSave(payload)
|
const saved = await onSave(payload)
|
||||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||||
for (const file of pendingFiles) {
|
for (const file of pendingFiles) {
|
||||||
@@ -338,14 +250,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
await onFileUpload(fd)
|
await onFileUpload(fd)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The user asked to create/edit the linked expense — open the Costs editor
|
|
||||||
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
|
||||||
const intent = expenseIntentRef.current
|
|
||||||
expenseIntentRef.current = null
|
|
||||||
if (intent && onOpenExpense && saved?.id) {
|
|
||||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
|
||||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -353,12 +257,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
|
||||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
|
||||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
|
||||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0]
|
const file = e.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
@@ -450,130 +348,100 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{form.type === 'flight' ? (
|
{/* From / To endpoints */}
|
||||||
/* ── Flight route: ordered airports (origin · stops · destination) ── */
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
<div>
|
||||||
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
||||||
{waypoints.map((wp, i) => {
|
{form.type === 'flight' ? (
|
||||||
const isFirst = i === 0
|
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
||||||
const isLast = i === waypoints.length - 1
|
) : (
|
||||||
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||||
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
)}
|
||||||
return (
|
|
||||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span className="text-content-faint" style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
|
|
||||||
</div>
|
|
||||||
{!isFirst && !isLast && (
|
|
||||||
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isFirst && (
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
|
||||||
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
|
||||||
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
|
||||||
</div>
|
|
||||||
{wp.airport && (
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
|
||||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!isLast && (
|
|
||||||
<>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
|
||||||
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
|
||||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
|
||||||
</div>
|
|
||||||
{wp.airport && (
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
|
||||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
|
||||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
|
||||||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={labelClass}>{t('reservations.meta.seat')}</label>
|
|
||||||
<input type="text" value={wp.seat} onChange={e => updateWp({ seat: e.target.value })} placeholder="12A" className={inputClass} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isLast && (
|
|
||||||
<button type="button" onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
|
||||||
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit' }}>
|
|
||||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div>
|
||||||
<>
|
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||||
{/* From / To endpoints (non-flight) */}
|
{form.type === 'flight' ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
||||||
<div>
|
) : (
|
||||||
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
|
||||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Departure row */}
|
{/* Departure row */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
<label className={labelClass}>
|
||||||
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
|
||||||
</div>
|
</label>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<CustomSelect
|
||||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
value={form.start_day_id}
|
||||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
onChange={value => set('start_day_id', value)}
|
||||||
|
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||||
|
options={dayOptions}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>
|
||||||
|
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
|
||||||
|
</label>
|
||||||
|
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||||
|
</div>
|
||||||
|
{form.type === 'flight' && fromPick.airport && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
||||||
|
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||||
|
{fromPick.airport.tz}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Arrival row */}
|
{/* Arrival row */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
<label className={labelClass}>
|
||||||
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
||||||
</div>
|
</label>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<CustomSelect
|
||||||
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
value={form.end_day_id}
|
||||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
onChange={value => set('end_day_id', value)}
|
||||||
|
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||||
|
options={dayOptions}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>
|
||||||
|
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
|
||||||
|
</label>
|
||||||
|
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||||
|
</div>
|
||||||
|
{form.type === 'flight' && toPick.airport && (
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||||
|
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||||
|
{toPick.airport.tz}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Flight-specific fields */}
|
||||||
|
{form.type === 'flight' && (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||||
|
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||||
|
placeholder="Lufthansa" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||||
|
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||||
|
placeholder="LH 123" className={inputClass} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Train-specific fields */}
|
{/* Train-specific fields */}
|
||||||
@@ -715,14 +583,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Costs — create / view the expense linked to this booking */}
|
{/* Price + Budget Category */}
|
||||||
{isBudgetEnabled && (
|
{isBudgetEnabled && (
|
||||||
<BookingCostsSection
|
<>
|
||||||
reservationId={reservation?.id ?? null}
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
onCreate={handleCreateExpense}
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
onEdit={handleEditExpense}
|
<label className={labelClass}>{t('reservations.price')}</label>
|
||||||
onRemove={handleRemoveExpense}
|
<input type="text" inputMode="decimal" value={form.price}
|
||||||
/>
|
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||||
|
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||||
|
placeholder="0.00"
|
||||||
|
className={inputClass} />
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={form.budget_category}
|
||||||
|
onChange={v => set('budget_category', v)}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||||
|
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||||
|
]}
|
||||||
|
placeholder={t('reservations.budgetCategoryAuto')}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{form.price && parseFloat(form.price) > 0 && (
|
||||||
|
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||||
|
{t('reservations.budgetHint')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useContextMenu } from '../shared/ContextMenu'
|
|||||||
import { placesApi } from '../../api/client'
|
import { placesApi } from '../../api/client'
|
||||||
import { useTripStore } from '../../store/tripStore'
|
import { useTripStore } from '../../store/tripStore'
|
||||||
import { useCanDo } from '../../store/permissionsStore'
|
import { useCanDo } from '../../store/permissionsStore'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
|
||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
|
|
||||||
export interface PlacesSidebarProps {
|
export interface PlacesSidebarProps {
|
||||||
@@ -50,8 +49,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const loadTrip = useTripStore((s) => s.loadTrip)
|
const loadTrip = useTripStore((s) => s.loadTrip)
|
||||||
const can = useCanDo()
|
const can = useCanDo()
|
||||||
const canEditPlaces = can('place_edit', trip)
|
const canEditPlaces = can('place_edit', trip)
|
||||||
// Places-API enrichment (#886) needs a Google Maps key; gate the toggle on it.
|
|
||||||
const canEnrichImport = useAuthStore((s) => s.hasMapsKey)
|
|
||||||
const isNaverListImportEnabled = true
|
const isNaverListImportEnabled = true
|
||||||
|
|
||||||
const [fileImportOpen, setFileImportOpen] = useState(false)
|
const [fileImportOpen, setFileImportOpen] = useState(false)
|
||||||
@@ -97,7 +94,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
const [listImportUrl, setListImportUrl] = useState('')
|
const [listImportUrl, setListImportUrl] = useState('')
|
||||||
const [listImportLoading, setListImportLoading] = useState(false)
|
const [listImportLoading, setListImportLoading] = useState(false)
|
||||||
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
const [listImportProvider, setListImportProvider] = useState<'google' | 'naver'>('google')
|
||||||
const [listImportEnrich, setListImportEnrich] = useState(false)
|
|
||||||
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
const availableListImportProviders: Array<'google' | 'naver'> = isNaverListImportEnabled ? ['google', 'naver'] : ['google']
|
||||||
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
const hasMultipleListImportProviders = availableListImportProviders.length > 1
|
||||||
|
|
||||||
@@ -112,10 +108,9 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
setListImportLoading(true)
|
setListImportLoading(true)
|
||||||
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
const provider = listImportProvider === 'naver' && isNaverListImportEnabled ? 'naver' : 'google'
|
||||||
try {
|
try {
|
||||||
const enrich = listImportEnrich && canEnrichImport
|
|
||||||
const result = provider === 'google'
|
const result = provider === 'google'
|
||||||
? await placesApi.importGoogleList(tripId, listImportUrl.trim(), enrich)
|
? await placesApi.importGoogleList(tripId, listImportUrl.trim())
|
||||||
: await placesApi.importNaverList(tripId, listImportUrl.trim(), enrich)
|
: await placesApi.importNaverList(tripId, listImportUrl.trim())
|
||||||
await loadTrip(tripId)
|
await loadTrip(tripId)
|
||||||
if (result.count === 0 && result.skipped > 0) {
|
if (result.count === 0 && result.skipped > 0) {
|
||||||
toast.warning(t('places.importAllSkipped'))
|
toast.warning(t('places.importAllSkipped'))
|
||||||
@@ -228,7 +223,6 @@ export function usePlacesSidebar(props: PlacesSidebarProps) {
|
|||||||
scrollContainerRef, onScrollTopChange,
|
scrollContainerRef, onScrollTopChange,
|
||||||
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
listImportOpen, setListImportOpen, listImportUrl, setListImportUrl,
|
||||||
listImportLoading, listImportProvider, setListImportProvider,
|
listImportLoading, listImportProvider, setListImportProvider,
|
||||||
listImportEnrich, setListImportEnrich, canEnrichImport,
|
|
||||||
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
availableListImportProviders, hasMultipleListImportProviders, handleListImport,
|
||||||
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
search, setSearch, filter, setFilter, categoryFilters, setCategoryFiltersLocal,
|
||||||
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
selectMode, setSelectMode, selectedIds, setSelectedIds, pendingDeleteIds, setPendingDeleteIds,
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react'
|
|
||||||
import { Plane, Save } from 'lucide-react'
|
|
||||||
import { useTranslation } from '../../i18n'
|
|
||||||
import { useToast } from '../shared/Toast'
|
|
||||||
import { airtrailApi } from '../../api/client'
|
|
||||||
import Section from './Section'
|
|
||||||
import ToggleSwitch from './ToggleSwitch'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Settings → Integrations → AirTrail. Per-user connection to a self-hosted
|
|
||||||
* AirTrail instance (URL + Bearer API key). Mirrors the photo-provider (Immich)
|
|
||||||
* connection layout: stacked fields, a toggle, then Save / Test-connection with
|
|
||||||
* a status badge. The key is stored encrypted and never prefilled.
|
|
||||||
*/
|
|
||||||
export default function AirTrailConnectionSection(): React.ReactElement {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const [url, setUrl] = useState('')
|
|
||||||
const [apiKey, setApiKey] = useState('')
|
|
||||||
const [allowInsecureTls, setAllowInsecureTls] = useState(false)
|
|
||||||
const [writeEnabled, setWriteEnabled] = useState(false)
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [testing, setTesting] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
airtrailApi
|
|
||||||
.getSettings()
|
|
||||||
.then(d => {
|
|
||||||
setUrl(d.url || '')
|
|
||||||
setAllowInsecureTls(!!d.allowInsecureTls)
|
|
||||||
setWriteEnabled(!!d.writeEnabled)
|
|
||||||
setConnected(!!d.connected)
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false))
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// Send the key only when the user typed a new one — never prefilled, so a blank
|
|
||||||
// field means "keep the stored key".
|
|
||||||
const keyPayload = (): { apiKey?: string } => {
|
|
||||||
const k = apiKey.trim()
|
|
||||||
return k ? { apiKey: k } : {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setSaving(true)
|
|
||||||
try {
|
|
||||||
const d = await airtrailApi.saveSettings({ url: url.trim(), allowInsecureTls, writeEnabled, ...keyPayload() })
|
|
||||||
const status = await airtrailApi.status().catch(() => ({ connected: false }))
|
|
||||||
setConnected(!!status.connected)
|
|
||||||
setApiKey('')
|
|
||||||
if (d?.warning) toast.warning(d.warning)
|
|
||||||
else toast.success(t('settings.airtrail.toast.saved'))
|
|
||||||
} catch (err: any) {
|
|
||||||
toast.error(err?.response?.data?.error || t('settings.airtrail.toast.saveError'))
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTest = async () => {
|
|
||||||
setTesting(true)
|
|
||||||
try {
|
|
||||||
const d = await airtrailApi.test({ url: url.trim(), allowInsecureTls, ...keyPayload() })
|
|
||||||
setConnected(!!d.connected)
|
|
||||||
if (d.connected) toast.success(t('settings.airtrail.test.success', { count: d.flightCount ?? 0 }))
|
|
||||||
else toast.error(d.error || t('settings.airtrail.test.failed'))
|
|
||||||
} catch {
|
|
||||||
toast.error(t('settings.airtrail.test.failed'))
|
|
||||||
} finally {
|
|
||||||
setTesting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSave = !!url.trim() && (connected || !!apiKey.trim())
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Section title={t('settings.airtrail.title')} icon={Plane}>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.url')}</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
value={url}
|
|
||||||
onChange={e => setUrl(e.target.value)}
|
|
||||||
placeholder="https://airtrail.example.com"
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.airtrail.apiKey')}</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={apiKey}
|
|
||||||
onChange={e => setApiKey(e.target.value)}
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={connected && !apiKey ? '••••••••' : t('settings.airtrail.apiKeyPlaceholder')}
|
|
||||||
className="w-full px-3 py-2.5 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-slate-300"
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.apiKeyHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ToggleSwitch on={allowInsecureTls} onToggle={() => setAllowInsecureTls(v => !v)} />
|
|
||||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.allowInsecureTls')}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<ToggleSwitch on={writeEnabled} onToggle={() => setWriteEnabled(v => !v)} />
|
|
||||||
<span className="text-sm font-medium text-slate-700">{t('settings.airtrail.writeBack')}</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">{t('settings.airtrail.writeBackHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || loading || !canSave}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-sm hover:bg-slate-700 disabled:bg-slate-400"
|
|
||||||
>
|
|
||||||
<Save className="w-4 h-4" /> {t('common.save')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleTest}
|
|
||||||
disabled={testing || loading || !url.trim()}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg text-sm hover:bg-slate-50"
|
|
||||||
>
|
|
||||||
{testing ? (
|
|
||||||
<div className="w-4 h-4 border-2 border-slate-300 border-t-slate-700 rounded-full animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Plane className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t('settings.airtrail.test.button')}
|
|
||||||
</button>
|
|
||||||
{connected ? (
|
|
||||||
<span className="basis-full sm:basis-auto text-xs font-medium text-green-600 flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
|
||||||
{t('settings.airtrail.connected')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="basis-full sm:basis-auto text-xs font-medium text-slate-400 flex items-center gap-1">
|
|
||||||
<span className="w-2 h-2 bg-slate-300 rounded-full" />
|
|
||||||
{t('settings.airtrail.notConnected')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-500">{t('settings.airtrail.hint')}</p>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -262,37 +262,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
|
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Explore places on the map (POI category pill) */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.mapPoiPill')}</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{[
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
].map(opt => (
|
|
||||||
<button
|
|
||||||
key={String(opt.value)}
|
|
||||||
onClick={async () => {
|
|
||||||
try { await updateSetting('map_poi_pill_enabled', opt.value) }
|
|
||||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
|
||||||
border: (settings.map_poi_pill_enabled !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
|
||||||
background: (settings.map_poi_pill_enabled !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapPoiPillHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Blur Booking Codes */}
|
{/* Blur Booking Codes */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
|
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
|
||||||
@@ -322,37 +291,6 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Optimize route from accommodation */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.optimizeFromAccommodation')}</label>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
{[
|
|
||||||
{ value: true, label: t('settings.on') || 'On' },
|
|
||||||
{ value: false, label: t('settings.off') || 'Off' },
|
|
||||||
].map(opt => (
|
|
||||||
<button
|
|
||||||
key={String(opt.value)}
|
|
||||||
onClick={async () => {
|
|
||||||
try { await updateSetting('optimize_from_accommodation', opt.value) }
|
|
||||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 8,
|
|
||||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
|
||||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
|
||||||
border: (settings.optimize_from_accommodation !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
|
||||||
background: (settings.optimize_from_accommodation !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
|
||||||
color: 'var(--text-primary)',
|
|
||||||
transition: 'all 0.15s',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs mt-1 text-content-faint">{t('settings.optimizeFromAccommodationHint')}</p>
|
|
||||||
</div>
|
|
||||||
</Section>
|
</Section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Trash2, Copy, Terminal, Plus, Check, KeyRound, ChevronDown, ChevronRigh
|
|||||||
import { authApi, oauthApi } from '../../api/client'
|
import { authApi, oauthApi } from '../../api/client'
|
||||||
import { useAddonStore } from '../../store/addonStore'
|
import { useAddonStore } from '../../store/addonStore'
|
||||||
import PhotoProvidersSection from './PhotoProvidersSection'
|
import PhotoProvidersSection from './PhotoProvidersSection'
|
||||||
import AirTrailConnectionSection from './AirTrailConnectionSection'
|
|
||||||
import { ALL_SCOPES } from '../../api/oauthScopes'
|
import { ALL_SCOPES } from '../../api/oauthScopes'
|
||||||
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
import ScopeGroupPicker from '../OAuth/ScopeGroupPicker'
|
||||||
|
|
||||||
@@ -98,7 +97,6 @@ export default function IntegrationsTab(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PhotoProvidersSection />
|
<PhotoProvidersSection />
|
||||||
{S.airtrailEnabled && <AirTrailConnectionSection />}
|
|
||||||
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
{S.mcpEnabled && <IntegrationsMcpSection {...S} />}
|
||||||
<McpTokenModals {...S} />
|
<McpTokenModals {...S} />
|
||||||
<OAuthClientModals {...S} />
|
<OAuthClientModals {...S} />
|
||||||
@@ -111,7 +109,6 @@ function useIntegrations() {
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
const { isEnabled: addonEnabled, loadAddons } = useAddonStore()
|
||||||
const mcpEnabled = addonEnabled('mcp')
|
const mcpEnabled = addonEnabled('mcp')
|
||||||
const airtrailEnabled = addonEnabled('airtrail')
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAddons()
|
loadAddons()
|
||||||
@@ -292,7 +289,7 @@ function useIntegrations() {
|
|||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
t, locale, toast, mcpEnabled, airtrailEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
t, locale, toast, mcpEnabled, oauthClients, setOauthClients, oauthSessions, setOauthSessions, oauthCreateOpen, setOauthCreateOpen, oauthNewName, setOauthNewName, oauthNewUris, setOauthNewUris, oauthNewScopes, setOauthNewScopes, oauthCreating, oauthCreatedClient, setOauthCreatedClient, oauthDeleteId, setOauthDeleteId, oauthRevokeId, setOauthRevokeId, oauthRotateId, setOauthRotateId, oauthRotatedSecret, setOauthRotatedSecret, oauthRotating, oauthScopesExpanded, setOauthScopesExpanded, oauthIsMachine, setOauthIsMachine, activeMcpTab, setActiveMcpTab, configOpenOAuth, setConfigOpenOAuth, configOpenToken, setConfigOpenToken, mcpTokens, setMcpTokens, mcpModalOpen, setMcpModalOpen, mcpNewName, setMcpNewName, mcpCreatedToken, setMcpCreatedToken, mcpCreating, mcpDeleteId, setMcpDeleteId, copiedKey, mcpEndpoint, mcpJsonConfigOAuth, mcpJsonConfig, handleCreateMcpToken, handleDeleteMcpToken, handleCopy, handleCreateOAuthClient, handleDeleteOAuthClient, handleRotateSecret, handleRevokeSession,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface CachedTripRow {
|
|||||||
export default function OfflineTab(): React.ReactElement {
|
export default function OfflineTab(): React.ReactElement {
|
||||||
const [rows, setRows] = useState<CachedTripRow[]>([])
|
const [rows, setRows] = useState<CachedTripRow[]>([])
|
||||||
const [pendingCount, setPendingCount] = useState(0)
|
const [pendingCount, setPendingCount] = useState(0)
|
||||||
const [failedCount, setFailedCount] = useState(0)
|
|
||||||
const [syncing, setSyncing] = useState(false)
|
const [syncing, setSyncing] = useState(false)
|
||||||
const [clearing, setClearing] = useState(false)
|
const [clearing, setClearing] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -29,13 +28,11 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const [metas, pending, failed] = await Promise.all([
|
const [metas, pending] = await Promise.all([
|
||||||
offlineDb.syncMeta.toArray(),
|
offlineDb.syncMeta.toArray(),
|
||||||
mutationQueue.pendingCount(),
|
mutationQueue.pendingCount(),
|
||||||
mutationQueue.failedCount(),
|
|
||||||
])
|
])
|
||||||
setPendingCount(pending)
|
setPendingCount(pending)
|
||||||
setFailedCount(failed)
|
|
||||||
|
|
||||||
const result: CachedTripRow[] = []
|
const result: CachedTripRow[] = []
|
||||||
for (const meta of metas) {
|
for (const meta of metas) {
|
||||||
@@ -88,7 +85,6 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
<Stat label="Cached trips" value={rows.length} />
|
<Stat label="Cached trips" value={rows.length} />
|
||||||
<Stat label="Pending changes" value={pendingCount} />
|
<Stat label="Pending changes" value={pendingCount} />
|
||||||
{failedCount > 0 && <Stat label="Failed changes" value={failedCount} danger />}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -169,14 +165,13 @@ export default function OfflineTab(): React.ReactElement {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Stat({ label, value, danger }: { label: string; value: number; danger?: boolean }) {
|
function Stat({ label, value }: { label: string; value: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="border border-edge bg-surface-secondary" style={{
|
<div className="border border-edge bg-surface-secondary" style={{
|
||||||
padding: '8px 14px', borderRadius: 8,
|
padding: '8px 14px', borderRadius: 8,
|
||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
}}>
|
}}>
|
||||||
<div style={{ fontSize: 20, fontWeight: 700, color: danger ? '#ef4444' : undefined }}
|
<div className="text-content" style={{ fontSize: 20, fontWeight: 700 }}>{value}</div>
|
||||||
className={danger ? undefined : 'text-content'}>{value}</div>
|
|
||||||
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
<div className="text-content-muted" style={{ fontSize: 11 }}>{label}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
export default function ToggleSwitch({ on, onToggle, label }: { on: boolean; onToggle: () => void; label?: string }) {
|
export default function ToggleSwitch({ on, onToggle }: { on: boolean; onToggle: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onToggle} aria-pressed={on} aria-label={label}
|
<button type="button" onClick={onToggle}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
position: 'relative', width: 44, height: 24, minWidth: 44, flexShrink: 0,
|
||||||
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
borderRadius: 12, border: 'none', padding: 0, cursor: 'pointer',
|
||||||
|
|||||||
@@ -277,7 +277,6 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
|||||||
const [desc, setDesc] = useState(item.description || '')
|
const [desc, setDesc] = useState(item.description || '')
|
||||||
const [dueDate, setDueDate] = useState(item.due_date || '')
|
const [dueDate, setDueDate] = useState(item.due_date || '')
|
||||||
const [category, setCategory] = useState(item.category || '')
|
const [category, setCategory] = useState(item.category || '')
|
||||||
const [addingCategory, setAddingCategoryInline] = useState(false)
|
|
||||||
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
const [assignedUserId, setAssignedUserId] = useState<number | null>(item.assigned_user_id)
|
||||||
const [priority, setPriority] = useState(item.priority || 0)
|
const [priority, setPriority] = useState(item.priority || 0)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -379,52 +378,21 @@ function DetailPane({ item, tripId, categories, members, onClose }: {
|
|||||||
{/* Category */}
|
{/* Category */}
|
||||||
<div>
|
<div>
|
||||||
<label className={labelClass}>{t('todo.detail.category')}</label>
|
<label className={labelClass}>{t('todo.detail.category')}</label>
|
||||||
{addingCategory ? (
|
<CustomSelect
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
value={category}
|
||||||
<input
|
onChange={v => setCategory(String(v))}
|
||||||
autoFocus
|
options={[
|
||||||
value={category}
|
{ value: '', label: t('todo.noCategory') },
|
||||||
onChange={e => setCategory(e.target.value)}
|
...categories.map(c => ({
|
||||||
onKeyDown={e => { if (e.key === 'Enter') setAddingCategoryInline(false); if (e.key === 'Escape') { setCategory(''); setAddingCategoryInline(false) } }}
|
value: c,
|
||||||
placeholder={t('todo.newCategory')}
|
label: c,
|
||||||
style={{ flex: 1, fontSize: 13, padding: '8px 10px', border: '1px solid var(--border-primary)', borderRadius: 8, background: 'var(--bg-primary)', color: 'var(--text-primary)', fontFamily: 'inherit', outline: 'none' }}
|
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
||||||
/>
|
})),
|
||||||
<button type="button" onClick={() => setAddingCategoryInline(false)}
|
]}
|
||||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-primary)' }}>
|
placeholder={t('todo.noCategory')}
|
||||||
<Check size={14} />
|
size="sm"
|
||||||
</button>
|
disabled={!canEdit}
|
||||||
</div>
|
/>
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<CustomSelect
|
|
||||||
value={category}
|
|
||||||
onChange={v => setCategory(String(v))}
|
|
||||||
options={[
|
|
||||||
{ value: '', label: t('todo.noCategory') },
|
|
||||||
...categories.map(c => ({
|
|
||||||
value: c, label: c,
|
|
||||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: katColor(c, categories), display: 'inline-block' }} />,
|
|
||||||
})),
|
|
||||||
...(category && !categories.includes(category) ? [{
|
|
||||||
value: category, label: `${category} (${t('todo.newCategoryLabel') || 'new'})`,
|
|
||||||
icon: <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#9ca3af', display: 'inline-block' }} />,
|
|
||||||
}] : []),
|
|
||||||
]}
|
|
||||||
placeholder={t('todo.noCategory')}
|
|
||||||
size="sm"
|
|
||||||
disabled={!canEdit}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{canEdit && (
|
|
||||||
<button type="button" onClick={() => { setCategory(''); setAddingCategoryInline(true) }}
|
|
||||||
title={t('todo.newCategory')}
|
|
||||||
style={{ background: 'var(--bg-hover)', border: '1px solid var(--border-primary)', borderRadius: 8, padding: '0 10px', cursor: 'pointer', color: 'var(--text-muted)', fontFamily: 'inherit' }}>
|
|
||||||
<Plus size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Due date */}
|
{/* Due date */}
|
||||||
|
|||||||
@@ -288,26 +288,4 @@ describe('TripFormModal', () => {
|
|||||||
await user.click(submitBtn.closest('button')!);
|
await user.click(submitBtn.closest('button')!);
|
||||||
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Saving...')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-COMP-TRIPFORM-029: clearing the day count leaves the field empty (no snap to 1)', () => {
|
|
||||||
render(<TripFormModal {...defaultProps} trip={null} />);
|
|
||||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
|
||||||
expect(dayInput).toBeInTheDocument();
|
|
||||||
expect(dayInput.value).toBe('7');
|
|
||||||
fireEvent.change(dayInput, { target: { value: '' } });
|
|
||||||
expect(dayInput.value).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('FE-COMP-TRIPFORM-030: empty day count blocks submit with an error', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onSave = vi.fn();
|
|
||||||
render(<TripFormModal {...defaultProps} trip={null} onSave={onSave} />);
|
|
||||||
await user.type(screen.getByPlaceholderText(/Summer in Japan/i), 'No-date Trip');
|
|
||||||
const dayInput = document.querySelector('input[max="365"]') as HTMLInputElement;
|
|
||||||
fireEvent.change(dayInput, { target: { value: '' } });
|
|
||||||
const submitBtn = screen.getAllByText('Create New Trip').find(el => el.closest('button'))!;
|
|
||||||
await user.click(submitBtn.closest('button')!);
|
|
||||||
await screen.findByText('Number of days is required');
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: '',
|
start_date: '',
|
||||||
end_date: '',
|
end_date: '',
|
||||||
reminder_days: 0 as number,
|
reminder_days: 0 as number,
|
||||||
day_count: 7 as number | '',
|
day_count: 7,
|
||||||
})
|
})
|
||||||
const [customReminder, setCustomReminder] = useState(false)
|
const [customReminder, setCustomReminder] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
@@ -100,12 +100,6 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
if (formData.start_date && formData.end_date && new Date(formData.end_date) < new Date(formData.start_date)) {
|
||||||
setError(t('dashboard.endDateError')); return
|
setError(t('dashboard.endDateError')); return
|
||||||
}
|
}
|
||||||
if (!formData.start_date && !formData.end_date) {
|
|
||||||
const dc = Number(formData.day_count)
|
|
||||||
if (formData.day_count === '' || !Number.isInteger(dc) || dc < 1 || dc > 365) {
|
|
||||||
setError(t('dashboard.dayCountRequired')); return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
const result = await onSave({
|
const result = await onSave({
|
||||||
@@ -114,7 +108,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
start_date: formData.start_date || null,
|
start_date: formData.start_date || null,
|
||||||
end_date: formData.end_date || null,
|
end_date: formData.end_date || null,
|
||||||
reminder_days: formData.reminder_days,
|
reminder_days: formData.reminder_days,
|
||||||
...(!formData.start_date && !formData.end_date ? { day_count: Number(formData.day_count) } : {}),
|
...(!formData.start_date && !formData.end_date ? { day_count: formData.day_count } : {}),
|
||||||
})
|
})
|
||||||
const createdTrip = result ? result.trip : undefined
|
const createdTrip = result ? result.trip : undefined
|
||||||
// Add selected members for newly created trips
|
// Add selected members for newly created trips
|
||||||
@@ -326,12 +320,7 @@ export default function TripFormModal({ isOpen, onClose, onSave, trip, onCoverUp
|
|||||||
{t('dashboard.dayCount')}
|
{t('dashboard.dayCount')}
|
||||||
</label>
|
</label>
|
||||||
<input type="number" min={1} max={365} value={formData.day_count}
|
<input type="number" min={1} max={365} value={formData.day_count}
|
||||||
onChange={e => {
|
onChange={e => update('day_count', Math.max(1, Math.min(365, Number(e.target.value) || 1)))}
|
||||||
const raw = e.target.value
|
|
||||||
if (raw === '') { update('day_count', ''); return }
|
|
||||||
const n = Math.floor(Number(raw))
|
|
||||||
if (Number.isFinite(n)) update('day_count', Math.min(365, Math.max(1, n)))
|
|
||||||
}}
|
|
||||||
className={inputCls} />
|
className={inputCls} />
|
||||||
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
<p className="text-xs text-slate-400 mt-1.5">{t('dashboard.dayCountHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
||||||
{Array.from({ length: 12 }, (_, i) => (
|
{Array.from({ length: 12 }, (_, i) => (
|
||||||
<VacayMonthCard
|
<VacayMonthCard
|
||||||
key={i}
|
key={i}
|
||||||
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
|
{/* Floating toolbar */}
|
||||||
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
|
<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 bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
<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 bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCompanyMode(false)}
|
onClick={() => setCompanyMode(false)}
|
||||||
|
|||||||
+3
-155
@@ -27,12 +27,6 @@ export interface QueuedMutation {
|
|||||||
tempId?: number;
|
tempId?: number;
|
||||||
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
/** For DELETE mutations: the entity id to remove from Dexie on flush */
|
||||||
entityId?: number;
|
entityId?: number;
|
||||||
/**
|
|
||||||
* For PUT/DELETE enqueued offline against a still-unsynced (negative-id) entity:
|
|
||||||
* the temp id of the target. The url carries an `{id}` placeholder that the
|
|
||||||
* mutation queue rewrites to the real server id once the dependent CREATE flushes.
|
|
||||||
*/
|
|
||||||
tempEntityId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncMeta {
|
export interface SyncMeta {
|
||||||
@@ -47,48 +41,13 @@ export interface SyncMeta {
|
|||||||
export interface BlobCacheEntry {
|
export interface BlobCacheEntry {
|
||||||
/** Relative URL, e.g. "/api/files/42/download" */
|
/** Relative URL, e.g. "/api/files/42/download" */
|
||||||
url: string;
|
url: string;
|
||||||
/**
|
|
||||||
* Trip this blob belongs to, so it is evicted together with the trip in
|
|
||||||
* clearTripData. Legacy rows cached before v3 carry the sentinel -1.
|
|
||||||
*/
|
|
||||||
tripId: number;
|
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
/** Byte size captured at insert time — Blob.size is not reliably preserved
|
|
||||||
* across IndexedDB round-trips, so the LRU budget reads this instead. */
|
|
||||||
bytes: number;
|
|
||||||
mime: string;
|
mime: string;
|
||||||
cachedAt: number;
|
cachedAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dexie class ────────────────────────────────────────────────────────────────
|
// ── Dexie class ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* The offline DB is scoped per user so that one account can never read another
|
|
||||||
* account's cached data on a shared device. Anonymous (logged-out) state uses
|
|
||||||
* the base name; a logged-in user uses `trek-offline-u<userId>`.
|
|
||||||
*/
|
|
||||||
const ANON_DB_NAME = 'trek-offline';
|
|
||||||
|
|
||||||
function userDbName(userId: number | string): string {
|
|
||||||
return `trek-offline-u${userId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Best-effort read of the persisted auth snapshot so the very first DB opened on
|
|
||||||
* app load (before loadUser resolves) is already the correct per-user one — the
|
|
||||||
* PWA can render cached data offline without leaking across users.
|
|
||||||
*/
|
|
||||||
function initialDbName(): string {
|
|
||||||
try {
|
|
||||||
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem('trek_auth_snapshot') : null;
|
|
||||||
if (!raw) return ANON_DB_NAME;
|
|
||||||
const id = JSON.parse(raw)?.state?.user?.id;
|
|
||||||
return id != null ? userDbName(id) : ANON_DB_NAME;
|
|
||||||
} catch {
|
|
||||||
return ANON_DB_NAME;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class TrekOfflineDb extends Dexie {
|
class TrekOfflineDb extends Dexie {
|
||||||
trips!: Table<Trip, number>;
|
trips!: Table<Trip, number>;
|
||||||
days!: Table<Day, number>;
|
days!: Table<Day, number>;
|
||||||
@@ -106,8 +65,8 @@ class TrekOfflineDb extends Dexie {
|
|||||||
syncMeta!: Table<SyncMeta, number>;
|
syncMeta!: Table<SyncMeta, number>;
|
||||||
blobCache!: Table<BlobCacheEntry, string>;
|
blobCache!: Table<BlobCacheEntry, string>;
|
||||||
|
|
||||||
constructor(name: string = ANON_DB_NAME) {
|
constructor() {
|
||||||
super(name);
|
super('trek-offline');
|
||||||
|
|
||||||
this.version(1).stores({
|
this.version(1).stores({
|
||||||
trips: 'id',
|
trips: 'id',
|
||||||
@@ -129,67 +88,10 @@ class TrekOfflineDb extends Dexie {
|
|||||||
tags: 'id',
|
tags: 'id',
|
||||||
categories: 'id',
|
categories: 'id',
|
||||||
});
|
});
|
||||||
|
|
||||||
// v3: scope the blob cache by trip so it can be evicted with the trip and
|
|
||||||
// bounded by an LRU budget (see enforceBlobBudget).
|
|
||||||
this.version(3).stores({
|
|
||||||
blobCache: 'url, cachedAt, tripId',
|
|
||||||
}).upgrade(async (tx) => {
|
|
||||||
await tx.table('blobCache').toCollection().modify((row: Partial<BlobCacheEntry>) => {
|
|
||||||
if (row.tripId == null) row.tripId = -1;
|
|
||||||
if (row.bytes == null) row.bytes = row.blob?.size ?? 0;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The live instance is swapped on login/logout via reopenForUser/reopenAnonymous.
|
export const offlineDb = new TrekOfflineDb();
|
||||||
// A Proxy keeps the exported `offlineDb` binding stable for the ~19 modules that
|
|
||||||
// import it directly, while every access forwards to the current connection.
|
|
||||||
let _db = new TrekOfflineDb(initialDbName());
|
|
||||||
|
|
||||||
export const offlineDb = new Proxy({} as TrekOfflineDb, {
|
|
||||||
get(_target, prop) {
|
|
||||||
const value = (_db as unknown as Record<string | symbol, unknown>)[prop];
|
|
||||||
return typeof value === 'function' ? (value as (...args: unknown[]) => unknown).bind(_db) : value;
|
|
||||||
},
|
|
||||||
set(_target, prop, value) {
|
|
||||||
(_db as unknown as Record<string | symbol, unknown>)[prop] = value;
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
}) as TrekOfflineDb;
|
|
||||||
|
|
||||||
async function switchTo(name: string): Promise<void> {
|
|
||||||
if (_db.name === name) {
|
|
||||||
if (!_db.isOpen()) await _db.open();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (_db.isOpen()) _db.close();
|
|
||||||
_db = new TrekOfflineDb(name);
|
|
||||||
await _db.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Point the offline DB at a specific user's scoped database (call on login). */
|
|
||||||
export async function reopenForUser(userId: number | string): Promise<void> {
|
|
||||||
await switchTo(userDbName(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Point the offline DB at the anonymous database (call on logout). */
|
|
||||||
export async function reopenAnonymous(): Promise<void> {
|
|
||||||
await switchTo(ANON_DB_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete the current user's scoped database entirely and return to the anonymous
|
|
||||||
* DB. Used on logout so no trace of the account's data remains on the device.
|
|
||||||
*/
|
|
||||||
export async function deleteCurrentUserDb(): Promise<void> {
|
|
||||||
if (_db.name !== ANON_DB_NAME) {
|
|
||||||
try { await _db.delete(); } catch { /* ignore — fall through to anon */ }
|
|
||||||
}
|
|
||||||
_db = new TrekOfflineDb(ANON_DB_NAME);
|
|
||||||
await _db.open();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
// ── Bulk upsert helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -246,58 +148,6 @@ export async function upsertSyncMeta(meta: SyncMeta): Promise<void> {
|
|||||||
await offlineDb.syncMeta.put(meta);
|
await offlineDb.syncMeta.put(meta);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read a pre-downloaded file blob for offline use. Returns null when the file
|
|
||||||
* was never cached (or on any read error). The stored MIME is reapplied so the
|
|
||||||
* caller's inline-vs-download decision stays correct even if the persisted Blob
|
|
||||||
* lost its type.
|
|
||||||
*/
|
|
||||||
export async function getCachedBlob(url: string): Promise<Blob | null> {
|
|
||||||
try {
|
|
||||||
const entry = await offlineDb.blobCache.get(url);
|
|
||||||
if (!entry) return null;
|
|
||||||
return entry.blob.type
|
|
||||||
? entry.blob
|
|
||||||
: new Blob([entry.blob], { type: entry.mime || 'application/octet-stream' });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Blob-cache budget ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upper bounds for the offline file-blob cache. Kept conservative so trip
|
|
||||||
* documents never starve the map-tile cache (sized at MAX_TILES in
|
|
||||||
* tilePrefetcher.ts) for the origin's storage quota.
|
|
||||||
*/
|
|
||||||
export const BLOB_CACHE_MAX_ENTRIES = 200;
|
|
||||||
export const BLOB_CACHE_MAX_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Evict oldest-by-cachedAt blobs until the cache is under both the entry-count
|
|
||||||
* and byte budget. Call after inserting new blobs. LRU on insertion time, which
|
|
||||||
* is a reasonable proxy for access for write-once document blobs.
|
|
||||||
*/
|
|
||||||
export async function enforceBlobBudget(
|
|
||||||
maxCount = BLOB_CACHE_MAX_ENTRIES,
|
|
||||||
maxBytes = BLOB_CACHE_MAX_BYTES,
|
|
||||||
): Promise<void> {
|
|
||||||
const entries = await offlineDb.blobCache.orderBy('cachedAt').toArray();
|
|
||||||
let count = entries.length;
|
|
||||||
let totalBytes = entries.reduce((sum, e) => sum + (e.bytes ?? 0), 0);
|
|
||||||
if (count <= maxCount && totalBytes <= maxBytes) return;
|
|
||||||
|
|
||||||
const toDelete: string[] = [];
|
|
||||||
for (const e of entries) {
|
|
||||||
if (count <= maxCount && totalBytes <= maxBytes) break;
|
|
||||||
toDelete.push(e.url);
|
|
||||||
totalBytes -= e.bytes ?? 0;
|
|
||||||
count -= 1;
|
|
||||||
}
|
|
||||||
if (toDelete.length) await offlineDb.blobCache.bulkDelete(toDelete);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
// ── Eviction / cleanup ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Delete all cached data for one trip (eviction or explicit clear). */
|
/** Delete all cached data for one trip (eviction or explicit clear). */
|
||||||
@@ -316,7 +166,6 @@ export async function clearTripData(tripId: number): Promise<void> {
|
|||||||
offlineDb.tripMembers,
|
offlineDb.tripMembers,
|
||||||
offlineDb.mutationQueue,
|
offlineDb.mutationQueue,
|
||||||
offlineDb.syncMeta,
|
offlineDb.syncMeta,
|
||||||
offlineDb.blobCache,
|
|
||||||
],
|
],
|
||||||
async () => {
|
async () => {
|
||||||
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
await offlineDb.days.where('trip_id').equals(tripId).delete();
|
||||||
@@ -330,7 +179,6 @@ export async function clearTripData(tripId: number): Promise<void> {
|
|||||||
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
await offlineDb.tripMembers.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
await offlineDb.mutationQueue.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
await offlineDb.syncMeta.where('tripId').equals(tripId).delete();
|
||||||
await offlineDb.blobCache.where('tripId').equals(tripId).delete();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// Remove the trip row itself outside the transaction since it's a separate table
|
// Remove the trip row itself outside the transaction since it's a separate table
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { airtrailApi } from '../api/client'
|
|
||||||
import { useAddonStore } from '../store/addonStore'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves whether the current user can use AirTrail in a trip: the addon must
|
|
||||||
* be enabled globally AND the user must have a working connection. Drives the
|
|
||||||
* "AirTrail Import/Sync" button visibility in the Transport panel.
|
|
||||||
*/
|
|
||||||
export function useAirtrailConnection() {
|
|
||||||
const airtrailEnabled = useAddonStore(s => s.isEnabled('airtrail'))
|
|
||||||
const [connected, setConnected] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!airtrailEnabled) {
|
|
||||||
setConnected(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let cancelled = false
|
|
||||||
setLoading(true)
|
|
||||||
airtrailApi
|
|
||||||
.status()
|
|
||||||
.then(d => { if (!cancelled) setConnected(!!d.connected) })
|
|
||||||
.catch(() => { if (!cancelled) setConnected(false) })
|
|
||||||
.finally(() => { if (!cancelled) setLoading(false) })
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [airtrailEnabled])
|
|
||||||
|
|
||||||
return { airtrailEnabled, connected, available: airtrailEnabled && connected, loading }
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
* Live FX rates for the Costs panel, used to convert every amount into the user's
|
||||||
* display currency. Fetches api.frankfurter.dev (no key, already CSP-allowlisted
|
* display currency. Fetches exchangerate-api.com (no key, already CSP-allowlisted
|
||||||
* for the dashboard widget) for the given base and caches per base in memory +
|
* for the dashboard widget) for the given base and caches per base in memory +
|
||||||
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
* localStorage for a few hours. rates[X] = units of X per 1 base, so an amount in
|
||||||
* currency C converts to base as `amount / rates[C]`.
|
* currency C converts to base as `amount / rates[C]`.
|
||||||
@@ -33,19 +33,14 @@ export function useExchangeRates(base: string) {
|
|||||||
if (cached) setRates(cached.rates)
|
if (cached) setRates(cached.rates)
|
||||||
if (cached && Date.now() - cached.ts < TTL_MS) return
|
if (cached && Date.now() - cached.ts < TTL_MS) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
fetch(`https://api.frankfurter.dev/v2/rates?base=${encodeURIComponent(upper)}`)
|
fetch(`https://api.exchangerate-api.com/v4/latest/${encodeURIComponent(upper)}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((d: Array<{ quote?: string; rate?: number }>) => {
|
.then((d: { rates?: Record<string, number> }) => {
|
||||||
if (cancelled || !Array.isArray(d)) return
|
if (cancelled || !d?.rates) return
|
||||||
// Frankfurter omits the base's own self-rate, so seed it with `base = 1`.
|
const entry = { rates: d.rates, ts: Date.now() }
|
||||||
const rates: Record<string, number> = { [upper]: 1 }
|
|
||||||
for (const r of d) {
|
|
||||||
if (r && typeof r.quote === 'string' && typeof r.rate === 'number') rates[r.quote] = r.rate
|
|
||||||
}
|
|
||||||
const entry = { rates, ts: Date.now() }
|
|
||||||
mem.set(upper, entry)
|
mem.set(upper, entry)
|
||||||
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
try { localStorage.setItem('trek_fx_' + upper, JSON.stringify(entry)) } catch { /* ignore */ }
|
||||||
setRates(rates)
|
setRates(d.rates)
|
||||||
})
|
})
|
||||||
.catch(() => { /* offline → keep cached/identity */ })
|
.catch(() => { /* offline → keep cached/identity */ })
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||||
import { useTripStore } from '../store/tripStore'
|
import { useTripStore } from '../store/tripStore'
|
||||||
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
import { calculateRouteWithLegs } from '../components/Map/RouteCalculator'
|
||||||
import { getTransportRouteEndpoints } from '../utils/dayMerge'
|
|
||||||
import type { TripStoreState } from '../store/tripStore'
|
import type { TripStoreState } from '../store/tripStore'
|
||||||
import type { RouteSegment, RouteResult } from '../types'
|
import type { RouteSegment, RouteResult } from '../types'
|
||||||
|
|
||||||
@@ -54,41 +53,29 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
return pos != null
|
return pos != null
|
||||||
})
|
})
|
||||||
|
|
||||||
// Build a unified list of places + transports sorted by effective position.
|
// Build a unified list of places + transports sorted by effective position,
|
||||||
type Entry =
|
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
|
||||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
|
||||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
|
const entries: (Entry & { pos: number })[] = [
|
||||||
const entries: Entry[] = [
|
|
||||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||||
})),
|
})),
|
||||||
...dayTransports.map(r => {
|
...dayTransports.map(r => ({
|
||||||
const { from, to } = getTransportRouteEndpoints(r, dayId)
|
kind: 'transport' as const,
|
||||||
return {
|
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||||
kind: 'transport' as const,
|
})),
|
||||||
from,
|
|
||||||
to,
|
|
||||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
].sort((a, b) => a.pos - b.pos)
|
].sort((a, b) => a.pos - b.pos)
|
||||||
|
|
||||||
// Group located places into driving runs.
|
// Group consecutive located places into runs, resetting whenever a transport
|
||||||
// - A transport WITH a location anchors the route to its departure point (you
|
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
||||||
// travel there), then breaks the run (you don't drive the flight/train); its
|
|
||||||
// arrival point starts the next run.
|
|
||||||
// - A transport WITHOUT a location is ignored entirely — the places around it
|
|
||||||
// connect directly, as if the booking weren't there.
|
|
||||||
const runs: { lat: number; lng: number }[][] = []
|
const runs: { lat: number; lng: number }[][] = []
|
||||||
let currentRun: { lat: number; lng: number }[] = []
|
let currentRun: { lat: number; lng: number }[] = []
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (entry.kind === 'place') {
|
if (entry.kind === 'place') {
|
||||||
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||||
} else if (entry.from || entry.to) {
|
} else {
|
||||||
if (entry.from) currentRun.push(entry.from)
|
|
||||||
if (currentRun.length >= 2) runs.push(currentRun)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
currentRun = []
|
currentRun = []
|
||||||
if (entry.to) currentRun.push(entry.to)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (currentRun.length >= 2) runs.push(currentRun)
|
if (currentRun.length >= 2) runs.push(currentRun)
|
||||||
@@ -133,9 +120,7 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
|||||||
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
||||||
.map(r => {
|
.map(r => {
|
||||||
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
||||||
// Include endpoints so adding/moving a departure/arrival location re-routes.
|
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
|
||||||
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
|
|
||||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
|
|
||||||
})
|
})
|
||||||
.sort()
|
.sort()
|
||||||
.join('|')
|
.join('|')
|
||||||
|
|||||||
@@ -35,23 +35,6 @@ body { height: 100%; overflow: auto; overscroll-behavior: none; -webkit-overflow
|
|||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mapbox GL hover popup — the name/category/address card on marker hover.
|
|
||||||
Matches the Leaflet map's white hover tooltip. pointer-events:none so moving
|
|
||||||
onto the popup never steals the marker's mouseleave and causes flicker. */
|
|
||||||
.trek-map-popup { pointer-events: none; }
|
|
||||||
.trek-map-popup .mapboxgl-popup-content {
|
|
||||||
padding: 7px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.16);
|
|
||||||
}
|
|
||||||
.trek-map-popup .mapboxgl-popup-tip {
|
|
||||||
border-top-color: #fff;
|
|
||||||
border-bottom-color: #fff;
|
|
||||||
border-left-color: #fff;
|
|
||||||
border-right-color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.atlas-tooltip {
|
.atlas-tooltip {
|
||||||
background: rgba(10, 10, 20, 0.6) !important;
|
background: rgba(10, 10, 20, 0.6) !important;
|
||||||
backdrop-filter: blur(20px) saturate(180%) !important;
|
backdrop-filter: blur(20px) saturate(180%) !important;
|
||||||
|
|||||||
@@ -15,11 +15,8 @@ import '@fontsource/geist-sans/500.css'
|
|||||||
import '@fontsource/geist-sans/600.css'
|
import '@fontsource/geist-sans/600.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { startConnectivityProbe } from './sync/connectivity'
|
import { startConnectivityProbe } from './sync/connectivity'
|
||||||
import { requestPersistentStorage } from './sync/persistentStorage'
|
|
||||||
|
|
||||||
startConnectivityProbe()
|
startConnectivityProbe()
|
||||||
// Keep offline data (map tiles, file blobs, IndexedDB) exempt from eviction.
|
|
||||||
requestPersistentStorage()
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -175,9 +175,6 @@ function useDefaultAtlasHandlers() {
|
|||||||
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
|
http.get('/api/addons/atlas/stats', () => HttpResponse.json(atlasStatsResponse)),
|
||||||
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
|
http.get('/api/addons/atlas/bucket-list', () => HttpResponse.json({ items: [] })),
|
||||||
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })),
|
http.get('/api/addons/atlas/regions', () => HttpResponse.json({ regions: {} })),
|
||||||
// Country-border GeoJSON (admin-0) — served by the API now. Tests that need real
|
|
||||||
// country features override this handler via server.use(...).
|
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json({ type: 'FeatureCollection', features: [] })),
|
|
||||||
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
|
// Handler for region GeoJSON fetch (triggered by loadRegionsForViewport when intersects=true)
|
||||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||||
);
|
);
|
||||||
@@ -190,6 +187,18 @@ beforeEach(() => {
|
|||||||
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
seedStore(useAuthStore, { isAuthenticated: true, user: buildUser() });
|
||||||
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: false }) });
|
||||||
|
|
||||||
|
// Stub the external GeoJSON fetch (GitHub raw URL) to avoid real network calls
|
||||||
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
|
const urlStr = String(url);
|
||||||
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve({ type: 'FeatureCollection', features: [] }),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
useDefaultAtlasHandlers();
|
useDefaultAtlasHandlers();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -460,9 +469,16 @@ describe('AtlasPage', () => {
|
|||||||
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
|
describe('FE-PAGE-ATLAS-017: country search filters options from GeoJSON', () => {
|
||||||
it('typing in search updates the input value', async () => {
|
it('typing in search updates the input value', async () => {
|
||||||
// Override fetch to return GeoJSON with FR feature
|
// Override fetch to return GeoJSON with FR feature
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -503,9 +519,16 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => {
|
describe('FE-PAGE-ATLAS-019: confirm popup shows via Enter on search with GeoJSON', () => {
|
||||||
it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
|
it('pressing Enter in search with matching GeoJSON result triggers confirm popup', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -577,9 +600,16 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-022: confirm popup for bucket type shows month/year selects', () => {
|
describe('FE-PAGE-ATLAS-022: confirm popup for bucket type shows month/year selects', () => {
|
||||||
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
|
it('selecting Add to bucket list in confirm popup shows month/year pickers', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -612,9 +642,16 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => {
|
describe('FE-PAGE-ATLAS-031: confirm popup opens and mark-visited action works', () => {
|
||||||
it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
|
it('opens confirm popup via search and clicking Mark as visited closes it', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -673,9 +710,16 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-032: confirm popup Add to Bucket opens bucket type', () => {
|
describe('FE-PAGE-ATLAS-032: confirm popup Add to Bucket opens bucket type', () => {
|
||||||
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
|
it('clicking Add to bucket list in choose popup switches to bucket type', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -807,9 +851,16 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => {
|
describe('FE-PAGE-ATLAS-029: confirm popup opens via search dropdown click', () => {
|
||||||
it('clicking a country in the search dropdown opens the confirm action popup', async () => {
|
it('clicking a country in the search dropdown opens the confirm action popup', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -863,9 +914,16 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
|
describe('FE-PAGE-ATLAS-030: confirm popup overlay click closes it', () => {
|
||||||
it('clicking the overlay backdrop closes the confirm popup', async () => {
|
it('clicking the overlay backdrop closes the confirm popup', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(geoJsonWithFR),
|
||||||
|
} as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -942,9 +1000,13 @@ describe('AtlasPage', () => {
|
|||||||
{ type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
|
{ type: 'Feature', properties: { ISO_A2: 'DE', ADM0_A3: 'DEU', ISO_A3: 'DEU', NAME: 'Germany', ADMIN: 'Germany' }, geometry: null },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandDE)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandDE) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
|
|
||||||
@@ -961,9 +1023,13 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
|
describe('FE-PAGE-ATLAS-034: dropdown button click + mouse events', () => {
|
||||||
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
|
it('clicking France dropdown button covers onClick and mouse event handlers', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -1034,9 +1100,13 @@ describe('AtlasPage', () => {
|
|||||||
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
|
http.get('/api/addons/atlas/stats', () => HttpResponse.json(emptyAtlasResponse)),
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
);
|
);
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -1088,9 +1158,13 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
|
describe('FE-PAGE-ATLAS-036: bucket popup submit action', () => {
|
||||||
it('submits a bucket list item from the confirm popup', async () => {
|
it('submits a bucket list item from the confirm popup', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/bucket-list', () =>
|
http.post('/api/addons/atlas/bucket-list', () =>
|
||||||
@@ -1247,9 +1321,13 @@ describe('AtlasPage', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithXK)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithXK) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
|
|
||||||
@@ -1267,9 +1345,13 @@ describe('AtlasPage', () => {
|
|||||||
{ a3: 'FRA', name: 'France', query: 'france' },
|
{ a3: 'FRA', name: 'France', query: 'france' },
|
||||||
{ a3: 'NOR', name: 'Norway', query: 'norway' },
|
{ a3: 'NOR', name: 'Norway', query: 'norway' },
|
||||||
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
|
])('returns $name in search results when GeoJSON provides ADM0_A3=$a3 but ISO_A2 is -99', async ({ a3, name, query }) => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(makeGeoJsonWithA3Fallback(a3, name))),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(makeGeoJsonWithA3Fallback(a3, name)) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
@@ -1377,9 +1459,13 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => {
|
describe('FE-PAGE-ATLAS-044: direct France dropdown button click', () => {
|
||||||
it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
|
it('directly finds and clicks the France button in the dropdown to cover onClick', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
http.post('/api/addons/atlas/country/:code/mark', () => HttpResponse.json({ success: true })),
|
||||||
@@ -1431,9 +1517,13 @@ describe('AtlasPage', () => {
|
|||||||
|
|
||||||
describe('FE-PAGE-ATLAS-045: dark mode toggle covers map re-init + loadRegionsForViewport', () => {
|
describe('FE-PAGE-ATLAS-045: dark mode toggle covers map re-init + loadRegionsForViewport', () => {
|
||||||
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
|
it('switching to dark mode re-initializes map and covers region loading code path', async () => {
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonWithFR)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonWithFR) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
http.get('/api/addons/atlas/regions/geo', () => HttpResponse.json({ features: [] })),
|
||||||
@@ -1546,9 +1636,13 @@ describe('AtlasPage', () => {
|
|||||||
{ type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
|
{ type: 'Feature', properties: { ISO_A2: 'IT', ADM0_A3: 'ITA', ISO_A3: 'ITA', NAME: 'Italy', ADMIN: 'Italy' }, geometry: null },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
server.use(
|
vi.spyOn(global, 'fetch').mockImplementation((url) => {
|
||||||
http.get('/api/addons/atlas/countries/geo', () => HttpResponse.json(geoJsonFRandIT)),
|
const urlStr = String(url);
|
||||||
);
|
if (urlStr.includes('geojson') || urlStr.includes('githubusercontent')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve(geoJsonFRandIT) } as Response);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(`Unmocked fetch: ${urlStr}`));
|
||||||
|
});
|
||||||
|
|
||||||
render(<AtlasPage />);
|
render(<AtlasPage />);
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import CustomSelect from '../components/shared/CustomSelect'
|
|||||||
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
import { Globe, MapPin, Briefcase, Calendar, Flag, PanelLeftOpen, PanelLeftClose, X, Star, Plus, Trash2, Search } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../types'
|
import type { TranslationFn } from '../types'
|
||||||
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
import { A2_TO_A3, countryCodeToFlag, type AtlasCountry, type AtlasStats, type AtlasData, type CountryDetail } from './atlas/atlasModel'
|
||||||
import { continentForCountry } from '@trek/shared'
|
|
||||||
import { useAtlas } from './atlas/useAtlas'
|
import { useAtlas } from './atlas/useAtlas'
|
||||||
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
import AtlasCountrySearch from './atlas/AtlasCountrySearch'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -213,8 +212,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
await apiClient.post(`/addons/atlas/country/${confirmAction.code}/mark`)
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
if (!prev || prev.countries.find(c => c.code === confirmAction.code)) return prev
|
||||||
const cont = continentForCountry(confirmAction.code)
|
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||||
return { ...prev, countries: [...prev.countries, { code: confirmAction.code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
@@ -262,8 +260,7 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
})
|
})
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
if (!prev || prev.countries.find(c => c.code === countryCode)) return prev
|
||||||
const cont = continentForCountry(countryCode)
|
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 } }
|
||||||
return { ...prev, countries: [...prev.countries, { code: countryCode, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }], stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 }, continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 } }
|
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(getApiErrorMessage(err, t('common.error')))
|
toast.error(getApiErrorMessage(err, t('common.error')))
|
||||||
@@ -342,12 +339,10 @@ export default function AtlasPage(): React.ReactElement {
|
|||||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
const remainingRegions = (visitedRegions[countryCode] || []).filter(r => r.code !== rCode && r.manuallyMarked)
|
||||||
if (remainingRegions.length > 0) return prev
|
if (remainingRegions.length > 0) return prev
|
||||||
const cont = continentForCountry(countryCode)
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: prev.countries.filter(c => c.code !== countryCode),
|
countries: prev.countries.filter(c => c.code !== countryCode),
|
||||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -20,11 +20,8 @@ beforeEach(() => {
|
|||||||
} as any);
|
} as any);
|
||||||
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
// Intercept CurrencyWidget's external fetch so it resolves before teardown
|
||||||
server.use(
|
server.use(
|
||||||
http.get('https://api.frankfurter.dev/v2/rates', () => {
|
http.get('https://api.exchangerate-api.com/v4/latest/:currency', () => {
|
||||||
return HttpResponse.json([
|
return HttpResponse.json({ rates: { USD: 1.08, EUR: 1, CHF: 0.97 } });
|
||||||
{ date: '2026-06-16', base: 'EUR', quote: 'USD', rate: 1.08 },
|
|
||||||
{ date: '2026-06-16', base: 'EUR', quote: 'CHF', rate: 0.97 },
|
|
||||||
]);
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -229,7 +226,7 @@ describe('DashboardPage', () => {
|
|||||||
await user.click(archiveButtons[0]);
|
await user.click(archiveButtons[0]);
|
||||||
|
|
||||||
// Switch to the archive filter segment
|
// Switch to the archive filter segment
|
||||||
await user.click(screen.getByText('Archived'));
|
await user.click(screen.getByText('Archive'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('Paris Adventure')[0]).toBeInTheDocument();
|
||||||
@@ -296,7 +293,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Switch to the archive filter
|
// Switch to the archive filter
|
||||||
await user.click(screen.getByText('Archived'));
|
await user.click(screen.getByText('Archive'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||||
@@ -445,7 +442,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Switch to the archive filter
|
// Switch to the archive filter
|
||||||
await user.click(screen.getByText('Archived'));
|
await user.click(screen.getByText('Archive'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Rome Trip')).toBeInTheDocument();
|
||||||
@@ -647,7 +644,7 @@ describe('DashboardPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Archive filter reveals the archived trip
|
// Archive filter reveals the archived trip
|
||||||
await user.click(screen.getByText('Archived'));
|
await user.click(screen.getByText('Archive'));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
expect(screen.getByText('Old Archived Trip')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -690,7 +687,7 @@ describe('DashboardPage', () => {
|
|||||||
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
expect(screen.getAllByText('My Active Trip')[0]).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
await user.click(screen.getByText('Archived'));
|
await user.click(screen.getByText('Archive'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
expect(screen.getByText('Restored Trip')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -16,10 +16,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
Plus, Edit2, Trash2, Archive, Copy, ArrowRight, MapPin,
|
||||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||||
LayoutGrid, List, Ticket, X,
|
LayoutGrid, List, SlidersHorizontal, Ticket, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
|
||||||
import '../styles/dashboard.css'
|
import '../styles/dashboard.css'
|
||||||
|
|
||||||
const GRADIENTS = [
|
const GRADIENTS = [
|
||||||
@@ -38,7 +36,6 @@ function tripGradient(id: number): string { return GRADIENTS[id % GRADIENTS.leng
|
|||||||
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
function splitDate(dateStr: string | null | undefined, locale: string): { d: string; m: string } | null {
|
||||||
if (!dateStr) return null
|
if (!dateStr) return null
|
||||||
const date = new Date(dateStr + 'T00:00:00Z')
|
const date = new Date(dateStr + 'T00:00:00Z')
|
||||||
if (isNaN(date.getTime())) return null // malformed date — render a dash, never crash
|
|
||||||
return {
|
return {
|
||||||
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
d: date.toLocaleDateString(locale, { day: 'numeric', timeZone: 'UTC' }),
|
||||||
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
m: date.toLocaleDateString(locale, { month: 'short', timeZone: 'UTC' }),
|
||||||
@@ -123,12 +120,15 @@ export default function DashboardPage(): React.ReactElement {
|
|||||||
<div className="sec-tools">
|
<div className="sec-tools">
|
||||||
<div className="seg">
|
<div className="seg">
|
||||||
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
<button className={tripFilter === 'planned' ? 'on' : ''} onClick={() => setTripFilter('planned')}>{t('dashboard.filter.planned')}</button>
|
||||||
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archived')}</button>
|
<button className={tripFilter === 'archive' ? 'on' : ''} onClick={() => setTripFilter('archive')}>{t('dashboard.archive')}</button>
|
||||||
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
<button className={tripFilter === 'completed' ? 'on' : ''} onClick={() => setTripFilter('completed')}>{t('dashboard.mobile.completed')}</button>
|
||||||
</div>
|
</div>
|
||||||
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
<button className="tool-action" aria-label={t('dashboard.aria.toggleView')} onClick={toggleViewMode} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||||
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
{viewMode === 'grid' ? <List size={17} /> : <LayoutGrid size={17} />}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="tool-action" aria-label={t('dashboard.aria.filter')} style={{ width: 38, height: 38, borderRadius: 11 }}>
|
||||||
|
<SlidersHorizontal size={17} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -464,15 +464,9 @@ function CurrencyTool(): React.ReactElement {
|
|||||||
const [rates, setRates] = useState<Record<string, number> | null>(null)
|
const [rates, setRates] = useState<Record<string, number> | null>(null)
|
||||||
|
|
||||||
const fetchRate = React.useCallback(() => {
|
const fetchRate = React.useCallback(() => {
|
||||||
fetch(`https://api.frankfurter.dev/v2/rates?base=${from}`)
|
fetch(`https://api.exchangerate-api.com/v4/latest/${from}`)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then((d: Array<{ quote: string; rate: number }>) => {
|
.then(d => setRates(d.rates ?? null))
|
||||||
if (!Array.isArray(d)) { setRates(null); return }
|
|
||||||
// Frankfurter omits the base's own self-rate; seed it so `from` stays selectable.
|
|
||||||
const map: Record<string, number> = { [from]: 1 }
|
|
||||||
for (const r of d) map[r.quote] = r.rate
|
|
||||||
setRates(map)
|
|
||||||
})
|
|
||||||
.catch(() => setRates(null))
|
.catch(() => setRates(null))
|
||||||
}, [from])
|
}, [from])
|
||||||
|
|
||||||
@@ -605,7 +599,6 @@ function UpcomingTool({ items, locale, onOpen }: {
|
|||||||
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
||||||
}): React.ReactElement {
|
}): React.ReactElement {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const timeFormat = useSettingsStore(s => s.settings.time_format)
|
|
||||||
return (
|
return (
|
||||||
<div className="tool">
|
<div className="tool">
|
||||||
<div className="tool-head">
|
<div className="tool-head">
|
||||||
@@ -616,13 +609,10 @@ function UpcomingTool({ items, locale, onOpen }: {
|
|||||||
) : (
|
) : (
|
||||||
<div className="upc-list">
|
<div className="upc-list">
|
||||||
{items.map(r => {
|
{items.map(r => {
|
||||||
// Read the date/time straight from the stored string parts. Going through
|
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
|
||||||
// new Date(...).toISOString() reinterprets the naive local time as UTC and
|
const d = when ? new Date(when) : null
|
||||||
// can roll the displayed day forward/back in non-UTC timezones.
|
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
|
||||||
const parsed = splitReservationDateTime(r.reservation_time)
|
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
|
||||||
const datePart = parsed.date || r.day_date || null
|
|
||||||
const dateStr = datePart ? splitDate(datePart, locale) : null
|
|
||||||
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
|
|
||||||
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
||||||
return (
|
return (
|
||||||
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
||||||
|
|||||||
@@ -103,38 +103,6 @@ describe('LoginPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-LOGIN-007: Remember me sends remember_me to the API', () => {
|
|
||||||
it('renders an off toggle and forwards remember_me: true when toggled on', async () => {
|
|
||||||
let capturedBody: Record<string, unknown> | null = null;
|
|
||||||
server.use(
|
|
||||||
http.post('/api/auth/login', async ({ request }) => {
|
|
||||||
capturedBody = (await request.json()) as Record<string, unknown>;
|
|
||||||
return HttpResponse.json({ user: { id: 1, username: 'test', email: 'test@example.com', role: 'user' } });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<LoginPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText(EMAIL_PLACEHOLDER)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const toggle = screen.getByRole('button', { name: /remember me/i });
|
|
||||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
|
||||||
|
|
||||||
await user.type(screen.getByPlaceholderText(EMAIL_PLACEHOLDER), 'user@example.com');
|
|
||||||
await user.type(screen.getByPlaceholderText(PASSWORD_PLACEHOLDER), 'password123');
|
|
||||||
await user.click(toggle);
|
|
||||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
|
||||||
await user.click(screen.getByRole('button', { name: /sign in/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(capturedBody).toEqual(expect.objectContaining({ remember_me: true }));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
describe('FE-PAGE-LOGIN-005: Registration toggle visible', () => {
|
||||||
it('shows a Register button to switch to registration mode', async () => {
|
it('shows a Register button to switch to registration mode', async () => {
|
||||||
// Default appConfig has allow_registration: true, has_users: true
|
// Default appConfig has allow_registration: true, has_users: true
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React from 'react'
|
|||||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n'
|
||||||
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
import { Plane, Eye, EyeOff, Mail, Lock, MapPin, Calendar, Package, User, Globe, Zap, Users, Wallet, Map, CheckSquare, BookMarked, FolderOpen, Route, Shield, KeyRound, ChevronDown, Fingerprint } from 'lucide-react'
|
||||||
import { useLogin } from './login/useLogin'
|
import { useLogin } from './login/useLogin'
|
||||||
import ToggleSwitch from '../components/Settings/ToggleSwitch'
|
|
||||||
|
|
||||||
export default function LoginPage(): React.ReactElement {
|
export default function LoginPage(): React.ReactElement {
|
||||||
const { t, language } = useTranslation()
|
const { t, language } = useTranslation()
|
||||||
@@ -10,7 +9,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
const {
|
const {
|
||||||
navigate,
|
navigate,
|
||||||
mode, setMode,
|
mode, setMode,
|
||||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||||
isLoading, error, setError, appConfig, inviteToken,
|
isLoading, error, setError, appConfig, inviteToken,
|
||||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
@@ -492,7 +491,6 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMfaCode(e.target.value.toUpperCase().slice(0, 24))}
|
||||||
placeholder="000000 or XXXX-XXXX"
|
placeholder="000000 or XXXX-XXXX"
|
||||||
required
|
required
|
||||||
autoFocus
|
|
||||||
style={inputBase}
|
style={inputBase}
|
||||||
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
onFocus={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#111827'}
|
||||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
onBlur={(e: React.FocusEvent<HTMLInputElement>) => e.target.style.borderColor = '#e5e7eb'}
|
||||||
@@ -573,16 +571,7 @@ export default function LoginPage(): React.ReactElement {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{mode === 'login' && (
|
{mode === 'login' && (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginTop: 8 }}>
|
<div style={{ textAlign: 'right', marginTop: 6 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<ToggleSwitch on={rememberMe} onToggle={() => setRememberMe(!rememberMe)} label={t('login.rememberMe')} />
|
|
||||||
<span
|
|
||||||
onClick={() => setRememberMe(!rememberMe)}
|
|
||||||
style={{ cursor: 'pointer', color: '#374151', fontSize: 12.5, fontWeight: 500, userSelect: 'none' }}
|
|
||||||
>
|
|
||||||
{t('login.rememberMe')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
<button type="button" onClick={() => navigate('/forgot-password')} style={{
|
||||||
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||||
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
color: '#6b7280', fontSize: 12.5, fontWeight: 500, fontFamily: 'inherit',
|
||||||
|
|||||||
@@ -405,79 +405,4 @@ describe('SharedTripPage', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-SHARED-017: Multi-leg flight shows each leg in the Day Plan', () => {
|
|
||||||
const day = { id: 101, trip_id: 1, day_number: 1, date: '2026-07-01', title: 'Day One', notes: null };
|
|
||||||
const multiLegFlight = {
|
|
||||||
id: 9, trip_id: 1, title: 'Flight', type: 'flight', status: 'confirmed',
|
|
||||||
day_id: 101, end_day_id: 101,
|
|
||||||
reservation_time: '2026-07-01T08:00:00', reservation_end_time: '2026-07-01T20:00:00',
|
|
||||||
metadata: JSON.stringify({
|
|
||||||
legs: [
|
|
||||||
{ from: 'FRA', to: 'BER', airline: 'Lufthansa', flight_number: 'LH1', dep_day_id: 101, dep_time: '08:00', arr_day_id: 101, arr_time: '09:00' },
|
|
||||||
{ from: 'BER', to: 'HND', airline: 'Lufthansa', flight_number: 'LH2', dep_day_id: 101, dep_time: '10:00', arr_day_id: 101, arr_time: '20:00' },
|
|
||||||
],
|
|
||||||
departure_airport: 'FRA', arrival_airport: 'HND', airline: 'Lufthansa', flight_number: 'LH1',
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
|
|
||||||
function serveMultiLeg(token: string) {
|
|
||||||
server.use(
|
|
||||||
http.get('/api/shared/:token', ({ params }) => {
|
|
||||||
if (params.token !== token) return;
|
|
||||||
return HttpResponse.json({
|
|
||||||
trip: { id: 1, title: 'Shared Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
|
|
||||||
days: [day],
|
|
||||||
assignments: {},
|
|
||||||
dayNotes: {},
|
|
||||||
places: [],
|
|
||||||
reservations: [multiLegFlight],
|
|
||||||
accommodations: [],
|
|
||||||
packing: [],
|
|
||||||
budget: [],
|
|
||||||
categories: [],
|
|
||||||
permissions: { share_bookings: true, share_packing: false, share_budget: false, share_collab: false },
|
|
||||||
collab: [],
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('renders each leg with its own route, not the overall start/end', async () => {
|
|
||||||
serveMultiLeg('multileg-token');
|
|
||||||
renderSharedTrip('multileg-token');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Expand the day to reveal the timeline
|
|
||||||
fireEvent.click(screen.getByText('Day One'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/FRA → BER/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
// Second leg shows its OWN route + flight number (the bug showed the overall route here)
|
|
||||||
expect(screen.getByText(/BER → HND/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
|
||||||
// The overall start→end must NOT appear on any leg
|
|
||||||
expect(screen.queryByText(/FRA → HND/)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists each leg flight number in the Bookings tab', async () => {
|
|
||||||
serveMultiLeg('multileg-bookings-token');
|
|
||||||
renderSharedTrip('multileg-bookings-token');
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Shared Paris Trip')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: /bookings/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/LH1/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
expect(screen.getByText(/LH2/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
|
|||||||
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
import { Clock, MapPin, FileText, Train, Plane, Bus, Car, Ship, Ticket, Hotel, Map, Luggage, Wallet, MessageCircle } from 'lucide-react'
|
||||||
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
import { isDayInAccommodationRange } from '../utils/dayOrder'
|
||||||
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
import { getTransportForDay, getMergedItems } from '../utils/dayMerge'
|
||||||
import { getFlightLegs } from '../utils/flightLegs'
|
|
||||||
import { splitReservationDateTime } from '../utils/formatters'
|
import { splitReservationDateTime } from '../utils/formatters'
|
||||||
|
|
||||||
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
|
||||||
@@ -215,24 +214,16 @@ export default function SharedTripPage() {
|
|||||||
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
const TIcon = TRANSPORT_ICONS[r.type] || Ticket
|
||||||
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
const meta = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {})
|
||||||
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
const time = splitReservationDateTime(r.reservation_time).time ?? ''
|
||||||
const endTime = splitReservationDateTime(r.reservation_end_time).time ?? ''
|
|
||||||
let sub = ''
|
let sub = ''
|
||||||
if (r.type === 'flight') {
|
if (r.type === 'flight') sub = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||||
if (r.__leg) {
|
|
||||||
// One leg of a multi-leg flight — show this segment's own route/flight number.
|
|
||||||
sub = [r.__leg.airline, r.__leg.flight_number, (r.__leg.from || r.__leg.to) ? [r.__leg.from, r.__leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' · ')
|
|
||||||
} else {
|
|
||||||
sub = [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') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
else if (r.type === 'train') sub = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : ''].filter(Boolean).join(' · ')
|
||||||
return (
|
return (
|
||||||
<div key={r.__leg ? `t-${r.id}-leg${r.__leg.index}` : `t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
<div key={`t-${r.id}`} className="bg-[rgba(59,130,246,0.06)]" style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px', borderRadius: 6, border: '1px solid rgba(59,130,246,0.15)' }}>
|
||||||
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
<div className="bg-[rgba(59,130,246,0.12)]" style={{ width: 24, height: 24, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
||||||
<TIcon size={12} color="#3b82f6" />
|
<TIcon size={12} color="#3b82f6" />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}${endTime ? `–${endTime}` : ''}` : ''}</div>
|
<div className="text-[#111827]" style={{ fontSize: 12, fontWeight: 500 }}>{r.title}{time ? ` · ${time}` : ''}</div>
|
||||||
{sub && <div className="text-[#6b7280]" style={{ fontSize: 10 }}>{sub}</div>}
|
{sub && <div className="text-[#6b7280]" style={{ fontSize: 10 }}>{sub}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,11 +284,7 @@ export default function SharedTripPage() {
|
|||||||
{date && <span>{date}</span>}
|
{date && <span>{date}</span>}
|
||||||
{time && <span>{time}</span>}
|
{time && <span>{time}</span>}
|
||||||
{r.location && <span>{r.location}</span>}
|
{r.location && <span>{r.location}</span>}
|
||||||
{r.type === 'flight'
|
{meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
||||||
? getFlightLegs(r).map((leg, i) => (
|
|
||||||
<span key={i}>{[leg.airline, leg.flight_number, (leg.from || leg.to) ? [leg.from, leg.to].filter(Boolean).join(' → ') : ''].filter(Boolean).join(' ')}</span>
|
|
||||||
))
|
|
||||||
: meta.airline && <span>{meta.airline} {meta.flight_number || ''}</span>}
|
|
||||||
{meta.train_number && <span>{meta.train_number}</span>}
|
{meta.train_number && <span>{meta.train_number}</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1160,13 +1160,10 @@ describe('TripPlannerPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
describe('FE-PAGE-PLANNER-041: handleSaveReservation edit path covers update reservation', () => {
|
||||||
it('does not force a day_id on edit so the server keeps/derives it (#1237)', async () => {
|
it('calls onEdit then onSave on ReservationModal to exercise the edit-reservation handler', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
seedTripStore({ id: 42 });
|
seedTripStore({ id: 42 });
|
||||||
// Capture the update payload — tripActions is a snapshot of the store at mount.
|
|
||||||
const updateReservationSpy = vi.fn().mockResolvedValue({ id: 1, day_id: 7 });
|
|
||||||
seedStore(useTripStore, { updateReservation: updateReservationSpy } as any);
|
|
||||||
|
|
||||||
renderPlannerPage(42);
|
renderPlannerPage(42);
|
||||||
|
|
||||||
@@ -1182,24 +1179,20 @@ describe('TripPlannerPage', () => {
|
|||||||
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
expect(screen.getByTestId('reservations-panel')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Edit a reservation that lives on day 7 (no day is selected — Book tab).
|
// Set editingReservation via captured onEdit prop (inline lambda in JSX)
|
||||||
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'other', status: 'confirmed', day_id: 7 };
|
const fakeReservation = { id: 1, trip_id: 42, name: 'Test', type: 'restaurant', status: 'confirmed' };
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
capturedReservationsPanelProps.current.onEdit?.(fakeReservation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Call onSave — now takes edit path (editingReservation is set)
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await capturedReservationModalProps.current.onSave?.({
|
await capturedReservationModalProps.current.onSave?.({
|
||||||
name: 'Updated Booking',
|
name: 'Updated Booking',
|
||||||
type: 'tour',
|
type: 'restaurant',
|
||||||
status: 'confirmed',
|
status: 'confirmed',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// The client must NOT send a day_id (no forcing to the selected day, no
|
|
||||||
// stale value) — the server keeps/derives it from the booking's date.
|
|
||||||
expect(updateReservationSpy).toHaveBeenCalled();
|
|
||||||
expect(updateReservationSpy.mock.calls[0][2]).not.toHaveProperty('day_id');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useTripStore } from '../store/tripStore'
|
|||||||
import { useCanDo } from '../store/permissionsStore'
|
import { useCanDo } from '../store/permissionsStore'
|
||||||
import { useSettingsStore } from '../store/settingsStore'
|
import { useSettingsStore } from '../store/settingsStore'
|
||||||
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
|
||||||
import { MapCompassPill } from '../components/Map/MapCompassPill'
|
|
||||||
import { getCached, fetchPhoto } from '../services/photoService'
|
import { getCached, fetchPhoto } from '../services/photoService'
|
||||||
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
import DayPlanSidebar from '../components/Planner/DayPlanSidebar'
|
||||||
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
import PlacesSidebar from '../components/Planner/PlacesSidebar'
|
||||||
@@ -18,16 +17,13 @@ import TripMembersModal from '../components/Trips/TripMembersModal'
|
|||||||
import { ReservationModal } from '../components/Planner/ReservationModal'
|
import { ReservationModal } from '../components/Planner/ReservationModal'
|
||||||
import { TransportModal } from '../components/Planner/TransportModal'
|
import { TransportModal } from '../components/Planner/TransportModal'
|
||||||
import BookingImportModal from '../components/Planner/BookingImportModal'
|
import BookingImportModal from '../components/Planner/BookingImportModal'
|
||||||
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
|
|
||||||
// MemoriesPanel moved to Journey addon
|
// MemoriesPanel moved to Journey addon
|
||||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||||
import FileManager from '../components/Files/FileManager'
|
import FileManager from '../components/Files/FileManager'
|
||||||
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
|
import CostsPanel from '../components/Budget/CostsPanel'
|
||||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
|
||||||
import type { BudgetItem } from '../types'
|
|
||||||
import CollabPanel from '../components/Collab/CollabPanel'
|
import CollabPanel from '../components/Collab/CollabPanel'
|
||||||
import Navbar from '../components/Layout/Navbar'
|
import Navbar from '../components/Layout/Navbar'
|
||||||
import { useToast } from '../components/shared/Toast'
|
import { useToast } from '../components/shared/Toast'
|
||||||
@@ -46,8 +42,6 @@ import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
|||||||
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
||||||
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
|
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
|
||||||
import { useTripPlanner } from './tripPlanner/useTripPlanner'
|
import { useTripPlanner } from './tripPlanner/useTripPlanner'
|
||||||
import { usePoiExplore } from '../components/Map/usePoiExplore'
|
|
||||||
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
|
|
||||||
|
|
||||||
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
||||||
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
||||||
@@ -59,7 +53,6 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
|||||||
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
|
||||||
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
const [addTodoSignal, setAddTodoSignal] = useState(0)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const isAdmin = useAuthStore(s => s.user?.role === 'admin')
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
|
{ id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
|
||||||
@@ -128,7 +121,7 @@ function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; p
|
|||||||
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
||||||
style={sharedBtnStyle}
|
style={sharedBtnStyle}
|
||||||
/>
|
/>
|
||||||
{isAdmin && packingItems.length > 0 && (
|
{packingItems.length > 0 && (
|
||||||
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
|
<button onClick={() => setSaveTemplateSignal(s => s + 1)}
|
||||||
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
className={`${sharedBtnClass} bg-accent text-accent-text`}
|
||||||
style={sharedBtnStyle}
|
style={sharedBtnStyle}
|
||||||
@@ -191,7 +184,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||||
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
|
||||||
bookingForAssignmentId, setBookingForAssignmentId,
|
bookingForAssignmentId, setBookingForAssignmentId,
|
||||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||||
transportModalDayId, setTransportModalDayId,
|
transportModalDayId, setTransportModalDayId,
|
||||||
@@ -202,30 +194,14 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
|
||||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
|
||||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||||
selectedPlace, dayOrderMap, dayPlaces,
|
selectedPlace, dayOrderMap, dayPlaces,
|
||||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||||
} = useTripPlanner()
|
} = useTripPlanner()
|
||||||
|
|
||||||
const poi = usePoiExplore()
|
|
||||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
|
||||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
|
||||||
|
|
||||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
|
||||||
// page level so it has tripMembers / base currency / current user available.
|
|
||||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
|
||||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
|
||||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
|
||||||
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
|
||||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
|
||||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
|
||||||
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
|
||||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading || !splashDone) {
|
if (isLoading || !splashDone) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface" style={{
|
<div className="bg-surface" style={{
|
||||||
@@ -323,20 +299,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
const r = reservations.find(x => x.id === rid)
|
const r = reservations.find(x => x.id === rid)
|
||||||
if (r) setMapTransportDetail(r)
|
if (r) setMapTransportDetail(r)
|
||||||
}}
|
}}
|
||||||
pois={poi.pois}
|
|
||||||
onPoiClick={openAddPlaceFromPoi}
|
|
||||||
onViewportChange={poi.onViewportChange}
|
|
||||||
onMapReady={setGlMap}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(poiPillEnabled || glMap) && (
|
|
||||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none', alignItems: 'flex-start', gap: 8 }}>
|
|
||||||
{poiPillEnabled && (
|
|
||||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} errorKeys={poi.errorKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
|
||||||
)}
|
|
||||||
{glMap && <MapCompassPill map={glMap} />}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||||
@@ -377,8 +341,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onSelectDay={handleSelectDay}
|
onSelectDay={handleSelectDay}
|
||||||
onPlaceClick={handlePlaceClick}
|
onPlaceClick={handlePlaceClick}
|
||||||
onReorder={handleReorder}
|
onReorder={handleReorder}
|
||||||
onReorderDays={handleReorderDays}
|
|
||||||
onAddDay={handleAddDay}
|
|
||||||
onUpdateDayTitle={handleUpdateDayTitle}
|
onUpdateDayTitle={handleUpdateDayTitle}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
|
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
|
||||||
@@ -465,7 +427,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
onPlaceClick={handlePlaceClick}
|
onPlaceClick={handlePlaceClick}
|
||||||
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true) }}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onEditPlace={(place) => openPlaceEditor(place)}
|
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||||
onCategoryFilterChange={setMapCategoryFilter}
|
onCategoryFilterChange={setMapCategoryFilter}
|
||||||
@@ -531,7 +493,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)}
|
onEdit={() => {
|
||||||
|
if (selectedAssignmentId) {
|
||||||
|
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||||
|
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||||
|
setEditingPlace(placeWithAssignmentTimes)
|
||||||
|
} else {
|
||||||
|
setEditingPlace(selectedPlace)
|
||||||
|
}
|
||||||
|
setEditingAssignmentId(selectedAssignmentId || null)
|
||||||
|
setShowPlaceForm(true)
|
||||||
|
}}
|
||||||
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
onDelete={() => handleDeletePlace(selectedPlace.id)}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -569,7 +541,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
reservations={reservations}
|
reservations={reservations}
|
||||||
onClose={() => setSelectedPlaceId(null)}
|
onClose={() => setSelectedPlaceId(null)}
|
||||||
onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }}
|
onEdit={() => {
|
||||||
|
if (selectedAssignmentId) {
|
||||||
|
const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId)
|
||||||
|
const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace
|
||||||
|
setEditingPlace(placeWithAssignmentTimes)
|
||||||
|
} else {
|
||||||
|
setEditingPlace(selectedPlace)
|
||||||
|
}
|
||||||
|
setEditingAssignmentId(selectedAssignmentId || null)
|
||||||
|
setShowPlaceForm(true)
|
||||||
|
setSelectedPlaceId(null)
|
||||||
|
}}
|
||||||
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }}
|
||||||
onAssignToDay={handleAssignToDay}
|
onAssignToDay={handleAssignToDay}
|
||||||
onRemoveAssignment={handleRemoveAssignment}
|
onRemoveAssignment={handleRemoveAssignment}
|
||||||
@@ -609,8 +592,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{mobileSidebarOpen === 'left'
|
{mobileSidebarOpen === 'left'
|
||||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded />
|
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -629,10 +612,6 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
assignments={assignments}
|
assignments={assignments}
|
||||||
files={files}
|
files={files}
|
||||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||||
onImport={() => setShowBookingImport(true)}
|
|
||||||
bookingImportAvailable={bookingImportAvailable}
|
|
||||||
onAirTrailImport={() => setShowAirTrailImport(true)}
|
|
||||||
airTrailAvailable={airTrailAvailable}
|
|
||||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||||
onDelete={handleDeleteReservation}
|
onDelete={handleDeleteReservation}
|
||||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||||
@@ -696,25 +675,12 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||||
{bookingExpense && (
|
|
||||||
<ExpenseModal
|
|
||||||
tripId={tripId}
|
|
||||||
base={costsBase}
|
|
||||||
people={tripMembers}
|
|
||||||
me={meId}
|
|
||||||
editing={bookingExpense.editing}
|
|
||||||
prefill={bookingExpense.prefill}
|
|
||||||
onClose={() => setBookingExpense(null)}
|
|
||||||
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
isOpen={!!deletePlaceId}
|
isOpen={!!deletePlaceId}
|
||||||
onClose={() => setDeletePlaceId(null)}
|
onClose={() => setDeletePlaceId(null)}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react'
|
|||||||
import { adminApi } from '../../api/client'
|
import { adminApi } from '../../api/client'
|
||||||
import Modal from '../../components/shared/Modal'
|
import Modal from '../../components/shared/Modal'
|
||||||
import CustomSelect from '../../components/shared/CustomSelect'
|
import CustomSelect from '../../components/shared/CustomSelect'
|
||||||
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint, Eye, EyeOff } from 'lucide-react'
|
import { CheckCircle, ArrowUpCircle, ExternalLink, RefreshCw, AlertTriangle, Fingerprint } from 'lucide-react'
|
||||||
import type { TranslationFn } from '../../types'
|
import type { TranslationFn } from '../../types'
|
||||||
import type { useAdmin } from './useAdmin'
|
import type { useAdmin } from './useAdmin'
|
||||||
|
|
||||||
@@ -22,8 +22,6 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
showRotateJwtModal, setShowRotateJwtModal, rotatingJwt, setRotatingJwt,
|
||||||
handleCreateUser, handleSaveUser,
|
handleCreateUser, handleSaveUser,
|
||||||
} = admin
|
} = admin
|
||||||
const [showCreatePw, setShowCreatePw] = React.useState(false)
|
|
||||||
const [showEditPw, setShowEditPw] = React.useState(false)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -73,24 +71,13 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('common.password')} *</label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showCreatePw ? 'text' : 'password'}
|
value={createForm.password}
|
||||||
value={createForm.password}
|
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
||||||
onChange={e => setCreateForm(f => ({ ...f, password: e.target.value }))}
|
placeholder={t('common.password')}
|
||||||
placeholder={t('common.password')}
|
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreatePw(v => !v)}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label="Show or hide password"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
{showCreatePw ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||||
@@ -151,24 +138,13 @@ export default function AdminUserModals({ admin, t }: AdminUserModalsProps): Rea
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('admin.newPassword')} <span className="text-slate-400 font-normal">({t('admin.newPasswordHint')})</span></label>
|
||||||
<div className="relative">
|
<input
|
||||||
<input
|
type="password"
|
||||||
type={showEditPw ? 'text' : 'password'}
|
value={editForm.password}
|
||||||
value={editForm.password}
|
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
||||||
onChange={e => setEditForm(f => ({ ...f, password: e.target.value }))}
|
placeholder={t('admin.newPasswordPlaceholder')}
|
||||||
placeholder={t('admin.newPasswordPlaceholder')}
|
className="w-full px-3 py-2.5 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
||||||
className="w-full px-3 py-2.5 pr-10 border border-slate-300 rounded-lg text-slate-900 focus:ring-2 focus:ring-slate-400 focus:border-transparent text-sm"
|
/>
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowEditPw(v => !v)}
|
|
||||||
tabIndex={-1}
|
|
||||||
aria-label="Show or hide password"
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600"
|
|
||||||
>
|
|
||||||
{showEditPw ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1.5">{t('settings.role')}</label>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface AdminUsersTabProps {
|
|||||||
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
// create-invite modal. Pure layout around the useAdmin hook — no logic of its own.
|
||||||
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps): React.ReactElement {
|
||||||
const {
|
const {
|
||||||
hour12, currentUser,
|
serverTimezone, hour12, currentUser,
|
||||||
users, isLoading,
|
users, isLoading,
|
||||||
setShowCreateUser,
|
setShowCreateUser,
|
||||||
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
invites, showCreateInvite, setShowCreateInvite, inviteForm, setInviteForm,
|
||||||
@@ -92,10 +92,10 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{new Date(u.created_at).toLocaleDateString(locale)}
|
{new Date(u.created_at).toLocaleDateString(locale, { timeZone: serverTimezone })}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3 text-sm text-slate-500">
|
<td className="px-5 py-3 text-sm text-slate-500">
|
||||||
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12 }) : '—'}
|
{u.last_login ? new Date(u.last_login).toLocaleDateString(locale, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', hour12, timeZone: serverTimezone }) : '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-5 py-3">
|
<td className="px-5 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
@@ -162,7 +162,7 @@ export default function AdminUsersTab({ admin, t, locale }: AdminUsersTabProps):
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-slate-400 mt-0.5">
|
<div className="text-xs text-slate-400 mt-0.5">
|
||||||
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
{inv.used_count}/{inv.max_uses === 0 ? '∞' : inv.max_uses} {t('admin.invite.uses')}
|
||||||
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale)}`}
|
{inv.expires_at && ` · ${t('admin.invite.expiresAt')} ${new Date(inv.expires_at).toLocaleDateString(locale, { timeZone: serverTimezone })}`}
|
||||||
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
{` · ${t('admin.invite.createdBy')} ${inv.created_by_name}`}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import apiClient, { mapsApi } from '../../api/client'
|
|||||||
import L from 'leaflet'
|
import L from 'leaflet'
|
||||||
import type { GeoJsonFeatureCollection } from '../../types'
|
import type { GeoJsonFeatureCollection } from '../../types'
|
||||||
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
import { A2_TO_A3, type AtlasData, type CountryDetail, type BucketItem } from './atlasModel'
|
||||||
import { continentForCountry } from '@trek/shared'
|
|
||||||
|
|
||||||
function useCountryNames(language: string): (code: string) => string {
|
function useCountryNames(language: string): (code: string) => string {
|
||||||
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
const [resolver, setResolver] = useState<(code: string) => string>(() => (code: string) => code)
|
||||||
@@ -133,19 +132,18 @@ export function useAtlas() {
|
|||||||
}).catch(() => setLoading(false))
|
}).catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Load country-border GeoJSON from our API (geoBoundaries, served server-side —
|
// Load GeoJSON world data (direct GeoJSON, no conversion needed)
|
||||||
// no third-party fetch from the browser).
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.get('/addons/atlas/countries/geo')
|
fetch('https://raw.githubusercontent.com/nvkelso/natural-earth-vector/master/geojson/ne_50m_admin_0_countries.geojson')
|
||||||
.then(res => {
|
.then(r => r.json())
|
||||||
const geo = res.data
|
.then(geo => {
|
||||||
// Dynamically build A2→A3 mapping from GeoJSON
|
// Dynamically build A2→A3 mapping from GeoJSON
|
||||||
for (const f of geo.features) {
|
for (const f of geo.features) {
|
||||||
const a2 = f.properties?.ISO_A2
|
const a2 = f.properties?.ISO_A2
|
||||||
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
const a3 = f.properties?.ADM0_A3 || f.properties?.ISO_A3
|
||||||
// Only accept clean 2-letter ISO codes and never overwrite an existing
|
// Only real 2-letter ISO codes: natural-earth uses subdivision-style
|
||||||
// mapping: some datasets carry subdivision-style values like "CN-TW" for
|
// values like "CN-TW" for Taiwan, which would otherwise overwrite the
|
||||||
// Taiwan, which would clobber the legitimate TWN->TW entry (#1049).
|
// legitimate TWN->TW reverse mapping and break the country (#1049).
|
||||||
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
|
if (a2 && a3 && a2.length === 2 && a2 !== '-99' && a3 !== '-99' && !A2_TO_A3[a2]) {
|
||||||
A2_TO_A3[a2] = a3
|
A2_TO_A3[a2] = a3
|
||||||
}
|
}
|
||||||
@@ -341,10 +339,7 @@ export function useAtlas() {
|
|||||||
</div>
|
</div>
|
||||||
</div>`
|
</div>`
|
||||||
layer.bindTooltip(tooltipHtml, {
|
layer.bindTooltip(tooltipHtml, {
|
||||||
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
|
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
// bounds centre, which for countries with overseas territories (e.g. France) lands
|
|
||||||
// far out in the ocean instead of over the area being hovered.
|
|
||||||
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
|
||||||
})
|
})
|
||||||
layer.on('click', () => {
|
layer.on('click', () => {
|
||||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||||
@@ -367,7 +362,7 @@ export function useAtlas() {
|
|||||||
country_layer_by_a2_ref.current[countryCode] = layer
|
country_layer_by_a2_ref.current[countryCode] = layer
|
||||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||||
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||||
})
|
})
|
||||||
layer.on('click', () => handleMarkCountry(countryCode, name))
|
layer.on('click', () => handleMarkCountry(countryCode, name))
|
||||||
layer.on('mouseover', (e) => {
|
layer.on('mouseover', (e) => {
|
||||||
@@ -556,20 +551,6 @@ export function useAtlas() {
|
|||||||
} catch (e ) {
|
} catch (e ) {
|
||||||
console.error('Error fitting bounds', e)
|
console.error('Error fitting bounds', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror the map-click behaviour so an already-visited country can be removed
|
|
||||||
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
|
|
||||||
// hit on the map, so search was the only way in — but it always opened the
|
|
||||||
// "Mark / Bucket" dialog with no Remove option.
|
|
||||||
const visited = data?.countries.find(c => c.code === country_code)
|
|
||||||
if (visited) {
|
|
||||||
if (visited.placeCount === 0 && visited.tripCount === 0) {
|
|
||||||
handleUnmarkCountry(country_code)
|
|
||||||
} else {
|
|
||||||
loadCountryDetailRef.current(country_code)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -583,12 +564,10 @@ export function useAtlas() {
|
|||||||
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
apiClient.post(`/addons/atlas/country/${code}/mark`).catch(() => {})
|
||||||
setData(prev => {
|
setData(prev => {
|
||||||
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
if (!prev || prev.countries.find(c => c.code === code)) return prev
|
||||||
const cont = continentForCountry(code)
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
countries: [...prev.countries, { code, placeCount: 0, tripCount: 0, firstVisit: null, lastVisit: null }],
|
||||||
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
stats: { ...prev.stats, totalCountries: prev.stats.totalCountries + 1 },
|
||||||
continents: { ...prev.continents, [cont]: (prev.continents?.[cont] || 0) + 1 },
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
@@ -599,12 +578,10 @@ export function useAtlas() {
|
|||||||
if (!prev) return prev
|
if (!prev) return prev
|
||||||
const c = prev.countries.find(c => c.code === code)
|
const c = prev.countries.find(c => c.code === code)
|
||||||
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
if (!c || c.placeCount > 0 || c.tripCount > 0) return prev
|
||||||
const cont = continentForCountry(code)
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
countries: prev.countries.filter(c => c.code !== code),
|
countries: prev.countries.filter(c => c.code !== code),
|
||||||
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
stats: { ...prev.stats, totalCountries: Math.max(0, prev.stats.totalCountries - 1) },
|
||||||
continents: { ...prev.continents, [cont]: Math.max(0, (prev.continents?.[cont] || 0) - 1) },
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setVisitedRegions(prev => {
|
setVisitedRegions(prev => {
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export function useLogin() {
|
|||||||
const [username, setUsername] = useState<string>('')
|
const [username, setUsername] = useState<string>('')
|
||||||
const [email, setEmail] = useState<string>('')
|
const [email, setEmail] = useState<string>('')
|
||||||
const [password, setPassword] = useState<string>('')
|
const [password, setPassword] = useState<string>('')
|
||||||
const [rememberMe, setRememberMe] = useState<boolean>(false)
|
|
||||||
const [showPassword, setShowPassword] = useState<boolean>(false)
|
const [showPassword, setShowPassword] = useState<boolean>(false)
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||||
const [error, setError] = useState<string>('')
|
const [error, setError] = useState<string>('')
|
||||||
@@ -243,7 +242,7 @@ export function useLogin() {
|
|||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const mfaResult = await completeMfaLogin(mfaToken, mfaCode, rememberMe)
|
const mfaResult = await completeMfaLogin(mfaToken, mfaCode)
|
||||||
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
if ('user' in mfaResult && mfaResult.user?.must_change_password) {
|
||||||
setSavedLoginPassword(password)
|
setSavedLoginPassword(password)
|
||||||
setPasswordChangeStep(true)
|
setPasswordChangeStep(true)
|
||||||
@@ -259,7 +258,7 @@ export function useLogin() {
|
|||||||
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
if (password.length < 8) { setError(t('login.passwordMinLength')); setIsLoading(false); return }
|
||||||
await register(username, email, password, inviteToken || undefined)
|
await register(username, email, password, inviteToken || undefined)
|
||||||
} else {
|
} else {
|
||||||
const result = await login(email, password, rememberMe)
|
const result = await login(email, password)
|
||||||
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
if ('mfa_required' in result && result.mfa_required && 'mfa_token' in result) {
|
||||||
setMfaToken(result.mfa_token)
|
setMfaToken(result.mfa_token)
|
||||||
setMfaStep(true)
|
setMfaStep(true)
|
||||||
@@ -290,7 +289,7 @@ export function useLogin() {
|
|||||||
return {
|
return {
|
||||||
navigate,
|
navigate,
|
||||||
mode, setMode,
|
mode, setMode,
|
||||||
username, setUsername, email, setEmail, password, setPassword, rememberMe, setRememberMe, showPassword, setShowPassword,
|
username, setUsername, email, setEmail, password, setPassword, showPassword, setShowPassword,
|
||||||
isLoading, error, setError, appConfig, inviteToken,
|
isLoading, error, setError, appConfig, inviteToken,
|
||||||
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
langDropdownOpen, setLangDropdownOpen, setLanguageLocal,
|
||||||
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
showTakeoff, mfaStep, setMfaStep, mfaToken, setMfaToken, mfaCode, setMfaCode,
|
||||||
|
|||||||
@@ -16,8 +16,7 @@ export function useSettings() {
|
|||||||
|
|
||||||
const memoriesEnabled = addonEnabled('memories')
|
const memoriesEnabled = addonEnabled('memories')
|
||||||
const mcpEnabled = addonEnabled('mcp')
|
const mcpEnabled = addonEnabled('mcp')
|
||||||
const airtrailEnabled = addonEnabled('airtrail')
|
const hasIntegrations = memoriesEnabled || mcpEnabled
|
||||||
const hasIntegrations = memoriesEnabled || mcpEnabled || airtrailEnabled
|
|
||||||
|
|
||||||
const [appVersion, setAppVersion] = useState<string | null>(null)
|
const [appVersion, setAppVersion] = useState<string | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState('display')
|
const [activeTab, setActiveTab] = useState('display')
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest'
|
|
||||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
|
||||||
import { buildAssignment, buildPlace } from '../../../tests/helpers/factories'
|
|
||||||
|
|
||||||
describe('resolvePoolAssignmentId', () => {
|
|
||||||
it('returns the lone assignment id when the place is assigned to exactly one day', () => {
|
|
||||||
const place = buildPlace({ id: 7 })
|
|
||||||
const assignment = buildAssignment({ id: 42, day_id: 3, place })
|
|
||||||
const assignments = { 3: [assignment], 4: [buildAssignment({ id: 99, day_id: 4 })] }
|
|
||||||
expect(resolvePoolAssignmentId(assignments, 7)).toBe(42)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when the place is not assigned to any day', () => {
|
|
||||||
const assignments = { 3: [buildAssignment({ id: 99, day_id: 3 })] }
|
|
||||||
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns null when the place is assigned to multiple days (ambiguous time)', () => {
|
|
||||||
const assignments = {
|
|
||||||
3: [buildAssignment({ id: 1, day_id: 3, place: buildPlace({ id: 7 }) })],
|
|
||||||
4: [buildAssignment({ id: 2, day_id: 4, place: buildPlace({ id: 7 }) })],
|
|
||||||
}
|
|
||||||
expect(resolvePoolAssignmentId(assignments, 7)).toBeNull()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
/**
|
|
||||||
* Trip planner pure helpers — React/IO-free logic shared by the data hook
|
|
||||||
* (useTripPlanner) and kept here so it can be unit-tested in isolation. Part of
|
|
||||||
* the FE "page = wiring container + data hook" convention (see PATTERN.md).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Assignment } from '../../types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the day-assignment to use when a place is edited from the Places pool,
|
|
||||||
* where no day is in context. Times live per day-assignment (#1247), so we can
|
|
||||||
* only hydrate/persist a place's time when it is assigned to exactly one day.
|
|
||||||
* Returns that assignment's id, or null when the place has 0 or 2+ assignments
|
|
||||||
* (ambiguous — the modal then hides the time fields).
|
|
||||||
*/
|
|
||||||
export function resolvePoolAssignmentId(
|
|
||||||
assignments: Record<string | number, Assignment[]>,
|
|
||||||
placeId: number,
|
|
||||||
): number | null {
|
|
||||||
const matches = Object.values(assignments)
|
|
||||||
.flat()
|
|
||||||
.filter((a) => a.place?.id === placeId)
|
|
||||||
return matches.length === 1 ? matches[0].id : null
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@ import { getCached, fetchPhoto } from '../../services/photoService'
|
|||||||
import { useToast } from '../../components/shared/Toast'
|
import { useToast } from '../../components/shared/Toast'
|
||||||
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
import { Map, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi, airtrailApi } from '../../api/client'
|
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, healthApi } from '../../api/client'
|
||||||
import { accommodationRepo } from '../../repo/accommodationRepo'
|
import { accommodationRepo } from '../../repo/accommodationRepo'
|
||||||
import { offlineDb } from '../../db/offlineDb'
|
import { offlineDb } from '../../db/offlineDb'
|
||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
@@ -16,9 +16,7 @@ import { useTripWebSocket } from '../../hooks/useTripWebSocket'
|
|||||||
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
import { useRouteCalculation } from '../../hooks/useRouteCalculation'
|
||||||
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
import { usePlaceSelection } from '../../hooks/usePlaceSelection'
|
||||||
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
import { usePlannerHistory } from '../../hooks/usePlannerHistory'
|
||||||
import { useAirtrailConnection } from '../../hooks/useAirtrailConnection'
|
|
||||||
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
import type { Accommodation, TripMember, Day, Place, Reservation } from '../../types'
|
||||||
import { resolvePoolAssignmentId } from './tripPlannerModel'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
* Trip planner page logic — the big one. Owns the trip store wiring, addon
|
||||||
@@ -125,7 +123,7 @@ export function useTripPlanner() {
|
|||||||
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
||||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null>(null)
|
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
|
||||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||||
const [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
|
||||||
@@ -142,18 +140,6 @@ export function useTripPlanner() {
|
|||||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||||
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
const [showBookingImport, setShowBookingImport] = useState<boolean>(false)
|
||||||
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
const [bookingImportAvailable, setBookingImportAvailable] = useState<boolean>(false)
|
||||||
const { available: airTrailAvailable } = useAirtrailConnection()
|
|
||||||
const [showAirTrailImport, setShowAirTrailImport] = useState<boolean>(false)
|
|
||||||
// Pull this user's AirTrail edits as soon as they open the trip, so changes
|
|
||||||
// made in AirTrail show up without waiting for the background poll.
|
|
||||||
const airtrailSyncedRef = useRef<number | null>(null)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!airTrailAvailable || !tripId || airtrailSyncedRef.current === tripId) return
|
|
||||||
airtrailSyncedRef.current = tripId
|
|
||||||
airtrailApi.sync()
|
|
||||||
.then(r => { if (r && r.changed > 0) tripActions.loadReservations(tripId) })
|
|
||||||
.catch(() => {})
|
|
||||||
}, [airTrailAvailable, tripId, tripActions])
|
|
||||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||||
@@ -222,12 +208,11 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [isLoading, places])
|
}, [isLoading, places])
|
||||||
|
|
||||||
// Load the trip. loadTrip hydrates every trip-scoped slice (days, places,
|
// Load trip + files (needed for place inspector file section)
|
||||||
// packing, todo, budget, reservations, files) so offline hydration is uniform
|
|
||||||
// and there's no cross-trip bleed; members/accommodations load alongside.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (tripId) {
|
if (tripId) {
|
||||||
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
tripActions.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') })
|
||||||
|
tripActions.loadFiles(tripId)
|
||||||
loadAccommodations()
|
loadAccommodations()
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
offlineDb.tripMembers.where('tripId').equals(Number(tripId)).toArray()
|
||||||
@@ -242,6 +227,13 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [tripId])
|
}, [tripId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tripId) {
|
||||||
|
tripActions.loadReservations(tripId)
|
||||||
|
tripActions.loadBudgetItems?.(tripId)
|
||||||
|
}
|
||||||
|
}, [tripId])
|
||||||
|
|
||||||
useTripWebSocket(tripId)
|
useTripWebSocket(tripId)
|
||||||
|
|
||||||
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
const [mapCategoryFilter, setMapCategoryFilter] = useState<Set<string>>(new Set())
|
||||||
@@ -364,24 +356,6 @@ export function useTripPlanner() {
|
|||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
}, [language])
|
}, [language])
|
||||||
|
|
||||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the
|
|
||||||
// data already comes from the POI, so no reverse-geocode is needed.
|
|
||||||
const openAddPlaceFromPoi = useCallback((poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string }) => {
|
|
||||||
if (!can('place_edit', trip)) return
|
|
||||||
setPrefillCoords({
|
|
||||||
lat: poi.lat,
|
|
||||||
lng: poi.lng,
|
|
||||||
name: poi.name,
|
|
||||||
address: poi.address || '',
|
|
||||||
website: poi.website || undefined,
|
|
||||||
phone: poi.phone || undefined,
|
|
||||||
osm_id: poi.osm_id,
|
|
||||||
})
|
|
||||||
setEditingPlace(null)
|
|
||||||
setEditingAssignmentId(null)
|
|
||||||
setShowPlaceForm(true)
|
|
||||||
}, [trip])
|
|
||||||
|
|
||||||
const handleSavePlace = useCallback(async (data) => {
|
const handleSavePlace = useCallback(async (data) => {
|
||||||
const pendingFiles = data._pendingFiles
|
const pendingFiles = data._pendingFiles
|
||||||
delete data._pendingFiles
|
delete data._pendingFiles
|
||||||
@@ -424,16 +398,6 @@ export function useTripPlanner() {
|
|||||||
}
|
}
|
||||||
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
}, [editingPlace, editingAssignmentId, tripId, toast, pushUndo])
|
||||||
|
|
||||||
// Open the place editor from any entry point (Places pool, inspector, map).
|
|
||||||
// Times live per day-assignment, so when no day is in context resolve the
|
|
||||||
// place's lone assignment to hydrate & persist its times; with 0 or 2+
|
|
||||||
// assignments the time is ambiguous and the modal hides the fields (#1247).
|
|
||||||
const openPlaceEditor = useCallback((place: Place, preferredAssignmentId: number | null = null) => {
|
|
||||||
setEditingPlace(place)
|
|
||||||
setEditingAssignmentId(preferredAssignmentId ?? resolvePoolAssignmentId(assignments, place.id))
|
|
||||||
setShowPlaceForm(true)
|
|
||||||
}, [assignments])
|
|
||||||
|
|
||||||
const handleDeletePlace = useCallback((placeId) => {
|
const handleDeletePlace = useCallback((placeId) => {
|
||||||
setDeletePlaceId(placeId)
|
setDeletePlaceId(placeId)
|
||||||
}, [])
|
}, [])
|
||||||
@@ -559,32 +523,10 @@ export function useTripPlanner() {
|
|||||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||||
}, [tripId, toast])
|
}, [tripId, toast])
|
||||||
|
|
||||||
const handleReorderDays = useCallback((orderedIds: number[]) => {
|
|
||||||
const prevIds = (useTripStore.getState().days || [])
|
|
||||||
.slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
|
|
||||||
tripActions.reorderDays(tripId, orderedIds)
|
|
||||||
.then(() => {
|
|
||||||
pushUndo(t('dayplan.reorderUndo'), async () => {
|
|
||||||
await tripActions.reorderDays(tripId, prevIds)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
|
|
||||||
}, [tripId, toast, pushUndo])
|
|
||||||
|
|
||||||
const handleAddDay = useCallback((position?: number) => {
|
|
||||||
tripActions.insertDay(tripId, position)
|
|
||||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
|
|
||||||
}, [tripId, toast])
|
|
||||||
|
|
||||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||||
try {
|
try {
|
||||||
if (editingReservation) {
|
if (editingReservation) {
|
||||||
// Don't force a day here. The old code pinned it to the (often empty)
|
const r = await tripActions.updateReservation(tripId, editingReservation.id, { ...data, day_id: selectedDayId || null })
|
||||||
// selected day, which dropped the booking out of the Plan; preserving the
|
|
||||||
// old day_id instead left it stale when the date changed. Omitting it lets
|
|
||||||
// the server derive the day from the booking's date, or keep the current
|
|
||||||
// one when there is no date.
|
|
||||||
const r = await tripActions.updateReservation(tripId, editingReservation.id, data)
|
|
||||||
toast.success(t('trip.toast.reservationUpdated'))
|
toast.success(t('trip.toast.reservationUpdated'))
|
||||||
setShowReservationModal(false)
|
setShowReservationModal(false)
|
||||||
setEditingReservation(null)
|
setEditingReservation(null)
|
||||||
@@ -689,7 +631,6 @@ export function useTripPlanner() {
|
|||||||
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
showTripForm, setShowTripForm, showMembersModal, setShowMembersModal,
|
||||||
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
showReservationModal, setShowReservationModal, editingReservation, setEditingReservation,
|
||||||
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
showBookingImport, setShowBookingImport, bookingImportAvailable,
|
||||||
airTrailAvailable, showAirTrailImport, setShowAirTrailImport,
|
|
||||||
bookingForAssignmentId, setBookingForAssignmentId,
|
bookingForAssignmentId, setBookingForAssignmentId,
|
||||||
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
showTransportModal, setShowTransportModal, editingTransport, setEditingTransport,
|
||||||
transportModalDayId, setTransportModalDayId,
|
transportModalDayId, setTransportModalDayId,
|
||||||
@@ -700,9 +641,9 @@ export function useTripPlanner() {
|
|||||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
|
||||||
handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
|
||||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||||
selectedPlace, dayOrderMap, dayPlaces,
|
selectedPlace, dayOrderMap, dayPlaces,
|
||||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import { accommodationsApi } from '../api/client'
|
import { accommodationsApi } from '../api/client'
|
||||||
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
import { offlineDb, upsertAccommodations } from '../db/offlineDb'
|
||||||
import { onlineThenCache } from './withOfflineFallback'
|
|
||||||
import type { Accommodation } from '../types'
|
import type { Accommodation } from '../types'
|
||||||
|
|
||||||
export const accommodationRepo = {
|
export const accommodationRepo = {
|
||||||
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
async list(tripId: number | string): Promise<{ accommodations: Accommodation[] }> {
|
||||||
return onlineThenCache(
|
if (!navigator.onLine) {
|
||||||
async () => {
|
const accommodations = await offlineDb.accommodations
|
||||||
const result = await accommodationsApi.list(tripId)
|
.where('trip_id').equals(Number(tripId)).toArray()
|
||||||
upsertAccommodations(result.accommodations || []).catch(() => {})
|
return { accommodations }
|
||||||
return result
|
}
|
||||||
},
|
const result = await accommodationsApi.list(tripId)
|
||||||
async () => ({
|
upsertAccommodations(result.accommodations || []).catch(() => {})
|
||||||
accommodations: await offlineDb.accommodations
|
return result
|
||||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { budgetApi } from '../api/client'
|
import { budgetApi } from '../api/client'
|
||||||
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
import { offlineDb, upsertBudgetItems } from '../db/offlineDb'
|
||||||
import { onlineThenCache } from './withOfflineFallback'
|
|
||||||
import type { BudgetItem } from '../types'
|
import type { BudgetItem } from '../types'
|
||||||
|
|
||||||
export const budgetRepo = {
|
export const budgetRepo = {
|
||||||
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
async list(tripId: number | string): Promise<{ items: BudgetItem[] }> {
|
||||||
return onlineThenCache(
|
if (!navigator.onLine) {
|
||||||
async () => {
|
const cached = await offlineDb.budgetItems
|
||||||
const result = await budgetApi.list(tripId)
|
.where('trip_id')
|
||||||
upsertBudgetItems(result.items)
|
.equals(Number(tripId))
|
||||||
return result
|
.toArray()
|
||||||
},
|
return { items: cached }
|
||||||
async () => ({
|
}
|
||||||
items: await offlineDb.budgetItems
|
const result = await budgetApi.list(tripId)
|
||||||
.where('trip_id').equals(Number(tripId)).toArray(),
|
upsertBudgetItems(result.items)
|
||||||
}),
|
return result
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-14
@@ -1,22 +1,18 @@
|
|||||||
import { daysApi } from '../api/client'
|
import { daysApi } from '../api/client'
|
||||||
import { offlineDb, upsertDays } from '../db/offlineDb'
|
import { offlineDb, upsertDays } from '../db/offlineDb'
|
||||||
import { onlineThenCache } from './withOfflineFallback'
|
|
||||||
import type { Day } from '../types'
|
import type { Day } from '../types'
|
||||||
|
|
||||||
export const dayRepo = {
|
export const dayRepo = {
|
||||||
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
async list(tripId: number | string): Promise<{ days: Day[] }> {
|
||||||
return onlineThenCache(
|
if (!navigator.onLine) {
|
||||||
async () => {
|
const cached = await offlineDb.days
|
||||||
const result = await daysApi.list(tripId)
|
.where('trip_id')
|
||||||
upsertDays(result.days)
|
.equals(Number(tripId))
|
||||||
return result
|
.sortBy('day_number' as keyof Day)
|
||||||
},
|
return { days: cached as Day[] }
|
||||||
async () => ({
|
}
|
||||||
days: (await offlineDb.days
|
const result = await daysApi.list(tripId)
|
||||||
.where('trip_id')
|
upsertDays(result.days)
|
||||||
.equals(Number(tripId))
|
return result
|
||||||
.sortBy('day_number' as keyof Day)) as Day[],
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user