Compare commits

..

6 Commits

Author SHA1 Message Date
github-actions[bot] 4ae4e0c676 chore: bump version to 3.0.13 [skip ci] 2026-04-30 23:43:49 +00:00
Julien G. 51ab30f436 Bug fixes - April 30th 2026 (#936)
* fix: hotel day-range clamping in ReservationModal + stale assignment_id on accommodation clear (issues #929, #934)

* ReservationModal hotel start/end pickers now use findIndex-based
  positional clamping instead of raw ID arithmetic, matching the fix
  applied to DayDetailPanel in 8e05ba7. Prevents inverted
  start_day_id/end_day_id on trips with non-monotonic day IDs.

* Clearing accommodation_id on a hotel reservation now forces
  assignment_id to null in the save payload, removing the stale
  day-assignment link that had no UI path to clear.

* Migration: swaps inverted start_day_id/end_day_id pairs in
  day_accommodations where start.day_number > end.day_number,
  recovering existing corrupt rows from the pre-fix picker bug.

* Tests FE-PLANNER-RESMODAL-050/051/052 cover both fixes.

* fix: preserve line breaks and wrap long URLs in notes fields (#930)

Add remark-breaks to all reservation/place notes markdown renderers so
single newlines render as <br>, and add wordBreak/overflowWrap styles
so long unbroken URLs (e.g. booking.com tracking links) wrap correctly.

* fix: delete linked budget item when accommodation or reservation is deleted (#933)

Deleting an accommodation or reservation now removes any budget item
linked via reservation_id, preventing orphan entries in the Budget page.
Also fixes a pre-existing payload-shape bug where budget:deleted was
broadcast with {id} instead of {itemId}, breaking live updates for
collaborators when a reservation price was cleared.

Tests added: ACCOM-006, RESV-009b, BUDGET-004b.

* fix: restore scroll position in mobile Plan and Places sidebars on reopen (issue #932)

Both DayPlanSidebar and PlacesSidebar have their own internal scroll
containers (overflowY: auto). Scroll events don't bubble, so previous
attempts that tracked scrollTop on the outer portal div never fired.

Each sidebar now accepts initialScrollTop and onScrollTopChange props.
The internal scroll container saves its scrollTop via onScrollTopChange
on every scroll event, and restores it via useLayoutEffect on mount
(before the browser paints, so no visible flash).

TripPlannerPage holds the saved values in refs (mobilePlanScrollTopRef,
mobilePlacesScrollTopRef) and passes them through on each portal mount.

* fix(map): prevent auto zoom-out when opening/closing place inspector (issue #921)

Both Leaflet and Mapbox GL renderers now gate fitBounds strictly on fitKey
increments from the parent. Selecting or dismissing a place inspector changes
paddingOpts (via hasInspector) but no longer triggers a re-fit that zoomed
the map out to the full trip extent when no day was selected.

Also removes the zoom-12 visibility gate on Leaflet route info pills so they
render at all zoom levels when a route is active.

* fix: translate mobile bottom-nav tab labels (issue #931)

Replaced hardcoded English labels in BottomNav with t() lookups using the same translation keys as the desktop navbar (nav.myTrips, admin.addons.catalog.*.name).
2026-05-01 01:43:19 +02:00
github-actions[bot] 8b53948231 chore: bump version to 3.0.12 [skip ci] 2026-04-28 22:17:13 +00:00
Julien G. 78d6f2ba77 Bug fixes - April 28th 2026 (#915)
* fix: replace raw day-ID range checks with position-based helper (issue #889 follow-up)

Commit 8e05ba7 fixed the accommodation date-range pickers, but the
post-save state filters in DayDetailPanel and several other consumers
still compared `day.id >= start_day_id && day.id <= end_day_id`. With
non-monotonic ID layouts (day_number 1-9 → IDs 17-25, day_number 10-16
→ IDs 1-7) this made the just-saved accommodation immediately invisible
— matching the regression reported in the last comment of #889.

Introduces `isDayInAccommodationRange` in `client/src/utils/dayOrder.ts`
which compares positional order (`day_number` with `indexOf` fallback)
rather than raw IDs. Falls back to the old numeric comparison when
endpoint days are absent from the loaded array (sparse test data or
partial loads) so existing tests are unaffected.

Fixed call sites:
- DayDetailPanel.tsx (initial load, post-create, post-delete, post-edit-save)
- DayPlanSidebar.tsx (daily badge renderer)
- SharedTripPage.tsx (public share view)
- TripPDF.tsx (PDF export filter + sort)

Also declares `day_number?: number` on the client `Day` type (already
returned by the server but previously untyped).

Adds regression tests FE-PLANNER-DAYDETAIL-060/061/062 covering the
edit-save, create-save, and initial-load paths with the reporter's exact
non-monotonic ID layout.

* fix: non-transport reservations no longer appear as transports in day planner (issue #914)

getTransportForDay now uses TRANSPORT_TYPES allowlist instead of only excluding hotels,
and the click handler dispatches to onEditReservation for non-transport types instead of
always opening TransportModal, preventing silent type coercion to 'flight'.

* feat: add file attachment support to TransportModal (issue #918)

Transports (flight/train/car/cruise) now support file attachments identical to the reservation modal — upload on create/edit, link existing files, and unlink. The Files tab and Assign File modal now differentiate between bookings and transports with separate sections and type-specific icons. Translations added for all 15 locales.
2026-04-29 00:16:56 +02:00
jubnl bb89d70a94 docs: document required permissions for Immich and Synology photo providers
Co-authored-by: Ben Haas <ben@benhaas.io>
2026-04-28 05:32:39 +02:00
jubnl ad9f3887d8 docs: add wiki guide for adding places to day itinerary with GIFs
Co-authored-by: Tranko <tranko@gmail.com>
2026-04-28 05:32:35 +02:00
57 changed files with 1374 additions and 150 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
apiVersion: v2
name: trek
version: 3.0.11
version: 3.0.13
description: Minimal Helm chart for TREK app
appVersion: "3.0.11"
appVersion: "3.0.13"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-client",
"version": "3.0.11",
"version": "3.0.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-client",
"version": "3.0.11",
"version": "3.0.13",
"dependencies": {
"@react-pdf/renderer": "^4.3.2",
"axios": "^1.6.7",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-client",
"version": "3.0.11",
"version": "3.0.13",
"private": true,
"type": "module",
"scripts": {
+73 -46
View File
@@ -1,7 +1,7 @@
import ReactDOM from 'react-dom'
import { useState, useCallback, useRef, useEffect } from 'react'
import { useDropzone } from 'react-dropzone'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight } from 'lucide-react'
import { Upload, Trash2, ExternalLink, Download, X, FileText, FileImage, File, MapPin, Ticket, StickyNote, Star, RotateCcw, Pencil, Check, ChevronLeft, ChevronRight, Plane, Train, Car, Ship } from 'lucide-react'
import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n'
import { filesApi } from '../../api/client'
@@ -236,6 +236,15 @@ function AvatarChip({ name, avatarUrl, size = 20 }: { name: string; avatarUrl?:
)
}
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise'])
function transportIcon(type: string) {
if (type === 'train') return Train
if (type === 'car') return Car
if (type === 'cruise') return Ship
return Plane
}
interface FileManagerProps {
files?: TripFile[]
onUpload: (fd: FormData) => Promise<any>
@@ -490,7 +499,9 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
<SourceBadge key={p.id} icon={MapPin} label={`${t('files.sourcePlan')} · ${p.name}`} />
))}
{linkedReservations.map(r => (
<SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
TRANSPORT_TYPES.has(r.type)
? <SourceBadge key={r.id} icon={transportIcon(r.type)} label={`${t('files.sourceTransport')} · ${r.title || t('files.sourceTransport')}`} />
: <SourceBadge key={r.id} icon={Ticket} label={`${t('files.sourceBooking')} · ${r.title || t('files.sourceBooking')}`} />
))}
{file.note_id && (
<SourceBadge icon={StickyNote} label={t('files.sourceCollab') || 'Collab Notes'} />
@@ -673,52 +684,68 @@ export default function FileManager({ files = [], onUpload, onDelete, onUpdate,
</div>
)
const bookingReservations = reservations.filter(r => !TRANSPORT_TYPES.has(r.type))
const transportReservations = reservations.filter(r => TRANSPORT_TYPES.has(r.type))
const reservationBtn = (r: Reservation) => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
const Icon = TRANSPORT_TYPES.has(r.type) ? transportIcon(r.type) : Ticket
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Icon size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
}
const bookingsSection = reservations.length > 0 && (
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')}
</div>
{reservations.map(r => {
const isLinked = file.reservation_id === r.id || (file.linked_reservation_ids || []).includes(r.id)
return (
<button key={r.id} onClick={async () => {
if (isLinked) {
// Unlink: if primary reservation_id, clear it; if via file_links, remove link
if (file.reservation_id === r.id) {
await handleAssign(file.id, { reservation_id: null })
} else {
try {
const linksRes = await filesApi.getLinks(tripId, file.id)
const link = (linksRes.links || []).find((l: any) => l.reservation_id === r.id)
if (link) await filesApi.removeLink(tripId, file.id, link.id)
refreshFiles()
} catch {}
}
} else {
// Link: if no primary, set it; otherwise use file_links
if (!file.reservation_id) {
await handleAssign(file.id, { reservation_id: r.id })
} else {
try {
await filesApi.addLink(tripId, file.id, { reservation_id: r.id })
refreshFiles()
} catch {}
}
}
}} style={{
width: '100%', textAlign: 'left', padding: '6px 10px 6px 20px', background: isLinked ? 'var(--bg-hover)' : 'none',
border: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--text-primary)',
borderRadius: 8, fontFamily: 'inherit', fontWeight: isLinked ? 600 : 400,
display: 'flex', alignItems: 'center', gap: 6,
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = isLinked ? 'var(--bg-hover)' : 'transparent'}>
<Ticket size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{r.title || r.name}</span>
{isLinked && <Check size={14} style={{ marginLeft: 'auto', flexShrink: 0, color: 'var(--accent)' }} />}
</button>
)
})}
{bookingReservations.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5 }}>
{t('files.assignBooking')}
</div>
{bookingReservations.map(reservationBtn)}
</>
)}
{transportReservations.length > 0 && (
<>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-faint)', padding: '8px 10px 4px', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: bookingReservations.length > 0 ? 4 : 0 }}>
{t('files.assignTransport')}
</div>
{transportReservations.map(reservationBtn)}
</>
)}
</div>
)
@@ -19,8 +19,10 @@ vi.mock('react-router-dom', async () => {
import { render, screen, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { useAuthStore } from '../../store/authStore';
import { useSettingsStore } from '../../store/settingsStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import { buildUser } from '../../../tests/helpers/factories';
import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import BottomNav from './BottomNav';
const currentUser = buildUser({ id: 1, username: 'testuser', email: 'test@example.com' });
@@ -39,7 +41,7 @@ describe('BottomNav', () => {
it('FE-COMP-BOTTOMNAV-002: shows Trips nav link', () => {
render(<BottomNav />);
expect(screen.getByText('Trips')).toBeInTheDocument();
expect(screen.getByText('My Trips')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-003: shows Profile button', () => {
@@ -99,4 +101,39 @@ describe('BottomNav', () => {
// Sheet should be closed — username no longer visible (only the nav Profile text remains)
expect(screen.queryByText('testuser')).not.toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-010: Trips label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Mes voyages')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-011: Profile label translates when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
render(<BottomNav />);
expect(screen.getByText('Profil')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-012: addon labels translate when language is fr', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'fr' }) });
seedStore(useAddonStore, {
addons: [
{ id: 'vacay', name: 'Vacay', type: 'global', icon: 'calendar', enabled: true },
{ id: 'atlas', name: 'Atlas', type: 'global', icon: 'globe', enabled: true },
{ id: 'journey', name: 'Journey', type: 'global', icon: 'compass', enabled: true },
],
});
render(<BottomNav />);
expect(screen.getByText('Vacances')).toBeInTheDocument();
expect(screen.getByText('Atlas')).toBeInTheDocument();
expect(screen.getByText('Journal de voyage')).toBeInTheDocument();
});
it('FE-COMP-BOTTOMNAV-013: unknown addon id is not rendered', () => {
seedStore(useAddonStore, {
addons: [{ id: 'foo', name: 'Foo Addon', type: 'global', icon: 'star', enabled: true }],
});
render(<BottomNav />);
expect(screen.queryByText('Foo Addon')).not.toBeInTheDocument();
});
});
+11 -13
View File
@@ -7,14 +7,10 @@ import { useTranslation } from '../../i18n'
import { Plane, CalendarDays, Globe, Compass, User, Settings, Shield, LogOut, X } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
const BASE_ITEMS: { to: string; label: string; icon: LucideIcon; addonId?: string }[] = [
{ to: '/trips', label: 'Trips', icon: Plane },
]
const ADDON_NAV: Record<string, { to: string; label: string; icon: LucideIcon }> = {
vacay: { to: '/vacay', label: 'Vacay', icon: CalendarDays },
atlas: { to: '/atlas', label: 'Atlas', icon: Globe },
journey: { to: '/journey', label: 'Journey', icon: Compass },
const ADDON_NAV: Record<string, { icon: LucideIcon; labelKey: string }> = {
vacay: { icon: CalendarDays, labelKey: 'admin.addons.catalog.vacay.name' },
atlas: { icon: Globe, labelKey: 'admin.addons.catalog.atlas.name' },
journey: { icon: Compass, labelKey: 'admin.addons.catalog.journey.name' },
}
export default function BottomNav() {
@@ -25,11 +21,13 @@ export default function BottomNav() {
const globalAddons = addons.filter(a => a.type === 'global' && a.enabled)
const [showProfile, setShowProfile] = useState(false)
const items = [...BASE_ITEMS]
for (const addon of globalAddons) {
const nav = ADDON_NAV[addon.id]
if (nav) items.push(nav)
}
const items: { to: string; label: string; icon: LucideIcon }[] = [
{ to: '/trips', label: t('nav.myTrips'), icon: Plane },
...globalAddons.flatMap(addon => {
const nav = ADDON_NAV[addon.id]
return nav ? [{ to: `/${addon.id}`, label: t(nav.labelKey), icon: nav.icon }] : []
}),
]
return (
<>
+41 -9
View File
@@ -7,6 +7,16 @@ import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import * as photoService from '../../services/photoService'
const mapMock = vi.hoisted(() => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}))
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: any) => <div data-testid="map-container">{children}</div>,
TileLayer: () => <div data-testid="tile-layer" />,
@@ -27,15 +37,7 @@ vi.mock('react-leaflet', () => ({
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
CircleMarker: () => <div data-testid="circle-marker" />,
Circle: () => <div data-testid="circle" />,
useMap: () => ({
panTo: vi.fn(),
setView: vi.fn(),
fitBounds: vi.fn(),
getZoom: () => 10,
on: vi.fn(),
off: vi.fn(),
panBy: vi.fn(),
}),
useMap: () => mapMock,
useMapEvents: () => ({}),
}))
@@ -79,6 +81,7 @@ function buildMapPlace(overrides: Record<string, any> = {}) {
}
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
@@ -216,4 +219,33 @@ describe('MapView', () => {
render(<MapView places={places} selectedPlaceId={5} />)
expect(screen.getByTestId('marker')).toBeTruthy()
})
it('FE-COMP-MAPVIEW-018: changing selectedPlaceId/hasInspector does not refit bounds (issue #921)', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
const initialCount = mapMock.fitBounds.mock.calls.length
// Toggle selectedPlaceId on — mimics opening place inspector (hasInspector flips,
// paddingOpts memo creates new object). fitBounds must NOT fire again.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
// Toggle selectedPlaceId off — mimics closing inspector via X button.
rerender(<MapView places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />)
expect(mapMock.fitBounds).toHaveBeenCalledTimes(initialCount)
})
it('FE-COMP-MAPVIEW-019: bumping fitKey triggers a new fitBounds call', () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapView places={places} fitKey={1} />)
const afterFirst = mapMock.fitBounds.mock.calls.length
rerender(<MapView places={places} fitKey={2} />)
expect(mapMock.fitBounds.mock.calls.length).toBeGreaterThan(afterFirst)
})
})
+2 -13
View File
@@ -186,7 +186,7 @@ function BoundsController({ places, fitKey, paddingOpts, hasDayDetail }: BoundsC
}
}
} catch {}
}, [fitKey, places, paddingOpts, map, hasDayDetail])
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
return null
}
@@ -233,18 +233,7 @@ interface RouteLabelProps {
}
function RouteLabel({ midpoint, walkingText, drivingText }: RouteLabelProps) {
const map = useMap()
const [visible, setVisible] = useState(map ? map.getZoom() >= 12 : false)
useEffect(() => {
if (!map) return
const check = () => setVisible(map.getZoom() >= 12)
check()
map.on('zoomend', check)
return () => map.off('zoomend', check)
}, [map])
if (!visible || !midpoint) return null
if (!midpoint) return null
const icon = L.divIcon({
className: 'route-info-pill',
@@ -0,0 +1,164 @@
import React from 'react'
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render } from '../../../tests/helpers/render'
import { act } from '@testing-library/react'
import { resetAllStores } from '../../../tests/helpers/store'
import { buildPlace } from '../../../tests/helpers/factories'
import { useSettingsStore } from '../../store/settingsStore'
// Stable fake map so fitBounds call counts survive re-renders.
const glMap = vi.hoisted(() => ({
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
loaded: vi.fn().mockReturnValue(true),
fitBounds: vi.fn(),
flyTo: vi.fn(),
jumpTo: vi.fn(),
getZoom: vi.fn().mockReturnValue(10),
addControl: vi.fn(),
removeControl: vi.fn(),
remove: vi.fn(),
addSource: vi.fn(),
getSource: vi.fn().mockReturnValue(null),
addLayer: vi.fn(),
setLayoutProperty: vi.fn(),
getStyle: vi.fn().mockReturnValue({ layers: [] }),
isStyleLoaded: vi.fn().mockReturnValue(true),
getCanvasContainer: vi.fn(() => document.createElement('div')),
}))
vi.mock('mapbox-gl', () => ({
default: {
accessToken: '',
Map: vi.fn(() => glMap),
Marker: vi.fn(() => ({
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
})),
LngLatBounds: vi.fn(() => ({ extend: vi.fn().mockReturnThis() })),
NavigationControl: vi.fn(),
},
}))
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
vi.mock('./mapboxSetup', () => ({
isStandardFamily: vi.fn(() => false),
supportsCustom3d: vi.fn(() => false),
wantsTerrain: vi.fn(() => false),
addCustom3dBuildings: vi.fn(),
addTerrainAndSky: vi.fn(),
}))
vi.mock('./locationMarkerMapbox', () => ({
attachLocationMarker: vi.fn(() => ({ update: vi.fn() })),
}))
vi.mock('./reservationsMapbox', () => ({
ReservationMapboxOverlay: vi.fn().mockImplementation(() => ({ update: vi.fn() })),
}))
vi.mock('../../hooks/useGeolocation', () => ({
useGeolocation: vi.fn(() => ({
position: null,
mode: 'off',
error: null,
cycleMode: vi.fn(),
setMode: vi.fn(),
})),
}))
vi.mock('../../services/photoService', () => ({
getCached: vi.fn(() => null),
isLoading: vi.fn(() => false),
fetchPhoto: vi.fn(),
onThumbReady: vi.fn(() => () => {}),
getAllThumbs: vi.fn(() => ({})),
}))
import { MapViewGL } from './MapViewGL'
function buildMapPlace(overrides: Record<string, any> = {}) {
return {
...buildPlace(),
category_name: null,
category_color: null,
category_icon: null,
...overrides,
} as any
}
beforeEach(() => {
useSettingsStore.setState({
settings: {
...useSettingsStore.getState().settings,
map_provider: 'mapbox-gl',
mapbox_access_token: 'pk.test_token',
mapbox_style: 'mapbox://styles/mapbox/streets-v12',
mapbox_3d_enabled: false,
},
} as any)
})
afterEach(() => {
vi.clearAllMocks()
resetAllStores()
})
describe('MapViewGL', () => {
it('FE-COMP-MAPVIEWGL-001: opening place inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
buildMapPlace({ id: 2, lat: 48.86, lng: 2.337 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Selecting a place flips hasInspector → paddingOpts memo changes.
// fitBounds must NOT fire again (this was the bug).
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-002: closing inspector does not refit bounds (issue #921)', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(
<MapViewGL places={places} fitKey={1} selectedPlaceId={1} hasInspector={true} />,
)
await act(async () => {})
const after_initial = glMap.fitBounds.mock.calls.length
// Closing inspector (X button) clears selectedPlaceId → hasInspector=false → new paddingOpts.
rerender(
<MapViewGL places={places} fitKey={1} selectedPlaceId={null} hasInspector={false} />,
)
await act(async () => {})
expect(glMap.fitBounds).toHaveBeenCalledTimes(after_initial)
})
it('FE-COMP-MAPVIEWGL-003: bumping fitKey triggers a new fitBounds call', async () => {
const places = [
buildMapPlace({ id: 1, lat: 48.8584, lng: 2.2945 }),
]
const { rerender } = render(<MapViewGL places={places} fitKey={1} />)
await act(async () => {})
const after_first = glMap.fitBounds.mock.calls.length
rerender(<MapViewGL places={places} fitKey={2} />)
await act(async () => {})
expect(glMap.fitBounds.mock.calls.length).toBeGreaterThan(after_first)
})
})
+4 -7
View File
@@ -507,13 +507,10 @@ export function MapViewGL({
return { top, right: rightWidth + 40, bottom, left: leftWidth + 40 }
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
// Also fit when the places collection changes so the initial render
// zooms to the trip instead of the default center.
const placeBoundsKey = useMemo(
() => places.filter(p => p.lat && p.lng).map(p => `${p.id}:${p.lat}:${p.lng}`).join('|'),
[places]
)
const prevFitKey = useRef(-1)
useEffect(() => {
if (fitKey === prevFitKey.current) return
prevFitKey.current = fitKey
const map = mapRef.current
if (!map) return
const target = dayPlaces.length > 0 ? dayPlaces : places
@@ -533,7 +530,7 @@ export function MapViewGL({
}
if (map.loaded()) run()
else map.once('load', run)
}, [fitKey, placeBoundsKey, paddingOpts, mapbox3d]) // eslint-disable-line react-hooks/exhaustive-deps
}, [fitKey]) // eslint-disable-line react-hooks/exhaustive-deps
// flyTo selected place
useEffect(() => {
+7 -2
View File
@@ -4,6 +4,7 @@ import { getCategoryIcon } from '../shared/categoryIcons'
import { FileText, Info, Clock, MapPin, Navigation, Train, Plane, Bus, Car, Ship, Coffee, Ticket, Star, Heart, Camera, Flag, Lightbulb, AlertTriangle, ShoppingBag, Bookmark, Hotel, LogIn, LogOut, KeyRound, BedDouble, Utensils, Users, LucideIcon } from 'lucide-react'
import { accommodationsApi, mapsApi } from '../../api/client'
import type { Trip, Day, Place, Category, AssignmentsMap, DayNotesMap } from '../../types'
import { isDayInAccommodationRange, getDayOrder } from '../../utils/dayOrder'
function renderLucideIcon(icon:LucideIcon, props = {}) {
if (!_renderToStaticMarkup) return ''
@@ -285,8 +286,12 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
}).join('')
const accommodationsForDay = (accommodations.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
).sort((a, b) => a.start_day_id - b.start_day_id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
).sort((a, b) => {
const startA = days.find(d => d.id === a.start_day_id)
const startB = days.find(d => d.id === b.start_day_id)
return (startA ? getDayOrder(startA, days) : 0) - (startB ? getDayOrder(startB, days) : 0)
})
const accommodationDetails = accommodationsForDay.map(item => {
const isCheckIn = day.id === item.start_day_id
@@ -1069,6 +1069,100 @@ describe('DayDetailPanel', () => {
});
});
// ── Post-save state filter — non-monotonic IDs (issue #889 follow-up) ────────
it('FE-PLANNER-DAYDETAIL-060: non-monotonic IDs — hotel stays visible after edit-save (issue #889 regression)', async () => {
const days = buildNonMonotonicDays();
let getCallCount = 0;
server.use(
http.get('/api/trips/1/accommodations', () => {
getCallCount++;
const acc = getCallCount === 1
// Initial load: single-day so old filter (17>=17 && 17<=17) passes — hotel visible, edit possible
? { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 17, check_in: null, check_out: null, confirmation: null }
// Post-save relist: full span — old filter (17>=17 && 17<=7) would drop it, new code keeps it
: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null, start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null };
return HttpResponse.json({ accommodations: [acc] });
}),
http.put('/api/trips/1/accommodations/1', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 1, place_id: 50, place_name: 'Span Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Span Hotel');
// Pencil = 3rd button (index 2): collapse, close, pencil, remove
const allButtons = screen.getAllByRole('button');
await userEvent.click(allButtons[2]);
// Extend end picker to Day 16 (id=7)
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 17>=17 && 17<=7 → false (hotel vanishes). New code: position 0 in [0,15] → visible.
await waitFor(() => {
expect(screen.getByText('Span Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-061: non-monotonic IDs — hotel appears after create-save on intermediate day', async () => {
const days = buildNonMonotonicDays();
const place = buildPlace({ id: 55, name: 'Created Hotel' });
// Current day: days[5] = id 22, position 5 (within any full-span range)
const currentDay = days[5];
server.use(
http.post('/api/trips/1/accommodations', async ({ request }) => {
const body = await request.json() as any;
return HttpResponse.json({
accommodation: { id: 200, place_id: 55, place_name: 'Created Hotel', place_address: null,
start_day_id: body.start_day_id, end_day_id: body.end_day_id,
check_in: null, check_out: null, confirmation: null },
});
}),
);
render(<DayDetailPanel {...defaultProps} day={currentDay} days={days} places={[place]} />);
await userEvent.click(await screen.findByText(/Add accommodation/i));
await userEvent.click(await screen.findByRole('button', { name: /Created Hotel/i }));
// Extend end to Day 16 (id=7) — start stays at current day id=22
await userEvent.click(getDayPickerTriggers()[1]);
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Save$/i }));
// Old code: 22>=22 && 22<=7 → false (hotel vanishes). New code: position 5 in [5,15] → visible.
await waitFor(() => {
expect(screen.getByText('Created Hotel')).toBeInTheDocument();
});
});
it('FE-PLANNER-DAYDETAIL-062: non-monotonic IDs — hotel shown on initial load when it spans the full trip', async () => {
const days = buildNonMonotonicDays();
server.use(
http.get('/api/trips/1/accommodations', () =>
HttpResponse.json({
accommodations: [{ id: 1, place_id: 60, place_name: 'Full Trip Hotel', place_address: null,
start_day_id: 17, end_day_id: 7, check_in: null, check_out: null, confirmation: null }],
})
),
);
// Day 1 (id=17): old filter: 17>=17 && 17<=7 → false. New: position 0 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[0]} days={days} />);
await screen.findByText('Full Trip Hotel');
// Intermediate day (id=1, position 9): old filter: 1>=17 → false. New: 9 in [0,15] → visible.
render(<DayDetailPanel {...defaultProps} day={days[9]} days={days} />);
await screen.findByText('Full Trip Hotel');
});
it('FE-PLANNER-DAYDETAIL-040: 12h time format renders reservation time with AM/PM', async () => {
seedStore(useSettingsStore, {
settings: { time_format: '12h', temperature_unit: 'celsius', blur_booking_codes: false },
@@ -12,6 +12,7 @@ import CustomTimePicker from '../shared/CustomTimePicker'
import { useSettingsStore } from '../../store/settingsStore'
import { getLocaleForLanguage, useTranslation } from '../../i18n'
import type { Day, Place, Category, Reservation, AssignmentsMap } from '../../types'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
const WEATHER_ICON_MAP = {
Clear: Sun, Clouds: Cloud, Rain: CloudRain, Drizzle: CloudDrizzle,
@@ -99,7 +100,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
.then(data => {
setAccommodations(data.accommodations || [])
const allForDay = (data.accommodations || []).filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
)
setDayAccommodations(allForDay)
setAccommodation(allForDay[0] || null)
@@ -130,7 +131,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
setAccommodations(updated)
setAccommodation(newAcc)
setDayAccommodations(updated.filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setShowHotelPicker(false)
setHotelForm({ check_in: '', check_in_end: '', check_out: '', confirmation: '', place_id: null })
@@ -154,7 +155,7 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const updated = accommodations.filter(a => a.id !== accommodation.id)
setAccommodations(updated)
setDayAccommodations(updated.filter(a =>
days.some(d => d.id >= a.start_day_id && d.id <= a.end_day_id && d.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
setAccommodation(null)
onAccommodationChange?.()
@@ -598,9 +599,9 @@ export default function DayDetailPanel({ day, days, places, categories = [], tri
const all = d.accommodations || []
setAccommodations(all)
setDayAccommodations(all.filter(a =>
days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id)
day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false
))
const acc = all.find(a => days.some(dd => dd.id >= a.start_day_id && dd.id <= a.end_day_id && dd.id === day?.id))
const acc = all.find(a => day ? isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days) : false)
setAccommodation(acc || null)
})
onAccommodationChange?.()
@@ -2,7 +2,7 @@
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; reservationId?: string; fromDayId?: string; phase?: 'single' | 'start' | 'middle' | 'end' }
declare global { interface Window { __dragData: DragDataPayload | null } }
import React, { useState, useEffect, useRef, useMemo } from 'react'
import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'
import ReactDOM from 'react-dom'
import { ChevronDown, ChevronRight, ChevronUp, ChevronsDownUp, ChevronsUpDown, Navigation, RotateCcw, ExternalLink, Clock, Pencil, GripVertical, Ticket, Plus, FileText, Check, Trash2, Info, MapPin, Star, Heart, Camera, Lightbulb, Flag, Bookmark, Train, Bus, Plane, Car, Ship, Coffee, ShoppingBag, AlertTriangle, FileDown, Lock, Hotel, Utensils, Users, Undo2, X, Route as RouteIcon } from 'lucide-react'
@@ -14,6 +14,7 @@ import PlaceAvatar from '../shared/PlaceAvatar'
import { useContextMenu, ContextMenu } from '../shared/ContextMenu'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import WeatherWidget from '../Weather/WeatherWidget'
import { useToast } from '../shared/Toast'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -21,6 +22,7 @@ import { useTripStore } from '../../store/tripStore'
import { useCanDo } from '../../store/permissionsStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { isDayInAccommodationRange } from '../../utils/dayOrder'
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
import { useDayNotes } from '../../hooks/useDayNotes'
import Tooltip from '../shared/Tooltip'
@@ -189,6 +191,8 @@ interface DayPlanSidebarProps {
onEditTransport?: (reservation: Reservation) => void
onEditReservation?: (reservation: Reservation) => void
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
}
const DayPlanSidebar = React.memo(function DayPlanSidebar({
@@ -217,6 +221,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
onEditTransport,
onEditReservation,
onAddBookingToAssignment,
initialScrollTop,
onScrollTopChange,
}: DayPlanSidebarProps) {
const toast = useToast()
const { t, language, locale } = useTranslation()
@@ -269,6 +275,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
} | null>(null)
const inputRef = useRef(null)
const dragDataRef = useRef(null)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const initedTransportIds = useRef(new Set<number>()) // Speichert Drag-Daten als Backup (dataTransfer geht bei Re-Render verloren)
// Remember which assignment we last auto-scrolled into view so we don't
// keep yanking the user back whenever they scroll away while the same
@@ -397,7 +409,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
const getTransportForDay = (dayId: number) => {
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
return reservations.filter(r => {
if (r.type === 'hotel') return false
if (!TRANSPORT_TYPES.has(r.type)) return false
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
const startDayId = r.day_id
@@ -1116,7 +1128,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</div>
{/* Tagesliste */}
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className={`scroll-container${draggingId ? '' : ' trek-stagger'}`} style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{days.map((day, index) => {
const isSelected = selectedDayId === day.id
const isExpanded = expandedDays.has(day.id)
@@ -1214,7 +1226,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
</Tooltip>
)}
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
const dayAccs = accommodations.filter(a => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, days))
// Sort: check-out first, then ongoing stays, then check-in last
.sort((a, b) => {
const aIsOut = a.end_day_id === day.id && a.start_day_id !== day.id
@@ -1725,7 +1737,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
return (
<React.Fragment key={`transport-${res.id}-${day.id}`}>
<div
onClick={() => canEditDays && onEditTransport?.(res)}
onClick={() => {
if (!canEditDays) return
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
else onEditReservation?.(res)
}}
onDragOver={e => {
e.preventDefault(); e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
@@ -2223,7 +2239,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
{res.notes && (
<div style={{ padding: '8px 10px', background: 'var(--bg-tertiary)', borderRadius: 8 }}>
<div style={{ fontSize: 9, fontWeight: 600, color: 'var(--text-faint)', textTransform: 'uppercase', letterSpacing: '0.03em', marginBottom: 3 }}>{t('reservations.notes')}</div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>
<div className="collab-note-md" style={{ fontSize: 12, color: 'var(--text-primary)', wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>
</div>
)}
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import { X, Clock, MapPin, ExternalLink, Phone, Euro, Edit2, Trash2, Plus, Minus, ChevronDown, ChevronUp, FileText, Upload, File, FileImage, Star, Navigation, Users, Mountain, TrendingUp } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { mapsApi } from '../../api/client'
@@ -349,8 +350,8 @@ export default function PlaceInspector({
{/* Notes */}
{place.notes && (
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px' }}>
<Markdown remarkPlugins={[remarkGfm]}>{place.notes}</Markdown>
<div className="collab-note-md" style={{ background: 'var(--bg-hover)', borderRadius: 10, overflow: 'hidden', fontSize: 12, color: 'var(--text-muted)', lineHeight: '1.5', padding: '8px 12px', wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{place.notes}</Markdown>
</div>
)}
@@ -399,7 +400,7 @@ export default function PlaceInspector({
</div>
)}
</div>
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4 }}><Markdown remarkPlugins={[remarkGfm]}>{res.notes}</Markdown></div>}
{res.notes && <div className="collab-note-md" style={{ padding: '0 10px 6px', fontSize: 10, color: 'var(--text-faint)', lineHeight: 1.4, wordBreak: 'break-word', overflowWrap: 'anywhere' }}><Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{res.notes}</Markdown></div>}
{(() => {
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
if (!meta || Object.keys(meta).length === 0) return null
@@ -1,6 +1,6 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye, Route } from 'lucide-react'
import PlaceAvatar from '../shared/PlaceAvatar'
import { getCategoryIcon } from '../shared/categoryIcons'
@@ -34,6 +34,8 @@ interface PlacesSidebarProps {
onCategoryFilterChange?: (categoryIds: Set<string>) => void
onPlacesFilterChange?: (filter: string) => void
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
initialScrollTop?: number
onScrollTopChange?: (top: number) => void
}
interface MemoPlaceRowProps {
@@ -145,6 +147,7 @@ const MemoPlaceRow = React.memo(function MemoPlaceRow({
const PlacesSidebar = React.memo(function PlacesSidebar({
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
initialScrollTop, onScrollTopChange,
}: PlacesSidebarProps) {
const { t } = useTranslation()
const toast = useToast()
@@ -159,6 +162,12 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
const [sidebarDropFile, setSidebarDropFile] = useState<File | null>(null)
const [sidebarDragOver, setSidebarDragOver] = useState(false)
const sidebarDragCounter = useRef(0)
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
useLayoutEffect(() => {
if (scrollContainerRef.current && initialScrollTop) {
scrollContainerRef.current.scrollTop = initialScrollTop
}
}, [])
const handleSidebarDragEnter = (e: React.DragEvent) => {
if (!canEditPlaces) return
@@ -636,7 +645,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
)}
{/* Liste */}
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
<div className="trek-stagger" style={{ flex: 1, overflowY: 'auto', minHeight: 0 }} ref={scrollContainerRef} onScroll={(e) => onScrollTopChange?.((e.currentTarget as HTMLElement).scrollTop)}>
{filtered.length === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 16px', gap: 8 }}>
<span style={{ fontSize: 13, color: 'var(--text-faint)' }}>
@@ -1,4 +1,4 @@
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-035
// FE-PLANNER-RESMODAL-001 to FE-PLANNER-RESMODAL-052
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -723,4 +723,103 @@ describe('ReservationModal', () => {
expect.objectContaining({ type: 'hotel' })
);
});
// ── Hotel day-range picker — non-monotonic IDs (issue #929) ───────────────
// Mirrors DayDetailPanel-056/057 for the ReservationModal path.
// ID layout: day_number 1-9 → IDs 17-25, day_number 10-16 → IDs 1-7.
function buildNonMonotonicDaysRM() {
return [
buildDay({ id: 17, trip_id: 1, date: '2026-04-30', day_number: 1 }),
buildDay({ id: 18, trip_id: 1, date: '2026-05-01', day_number: 2 }),
buildDay({ id: 19, trip_id: 1, date: '2026-05-02', day_number: 3 }),
buildDay({ id: 20, trip_id: 1, date: '2026-05-03', day_number: 4 }),
buildDay({ id: 21, trip_id: 1, date: '2026-05-04', day_number: 5 }),
buildDay({ id: 22, trip_id: 1, date: '2026-05-05', day_number: 6 }),
buildDay({ id: 23, trip_id: 1, date: '2026-05-06', day_number: 7 }),
buildDay({ id: 24, trip_id: 1, date: '2026-05-07', day_number: 8 }),
buildDay({ id: 25, trip_id: 1, date: '2026-05-08', day_number: 9 }),
buildDay({ id: 1, trip_id: 1, date: '2026-05-09', day_number: 10 }),
buildDay({ id: 2, trip_id: 1, date: '2026-05-10', day_number: 11 }),
buildDay({ id: 3, trip_id: 1, date: '2026-05-11', day_number: 12 }),
buildDay({ id: 4, trip_id: 1, date: '2026-05-12', day_number: 13 }),
buildDay({ id: 5, trip_id: 1, date: '2026-05-13', day_number: 14 }),
buildDay({ id: 6, trip_id: 1, date: '2026-05-14', day_number: 15 }),
buildDay({ id: 7, trip_id: 1, date: '2026-05-15', day_number: 16 }),
] as any[];
}
it('FE-PLANNER-RESMODAL-050: non-monotonic IDs — end picker with low ID does not clobber start', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
// Switch to hotel type
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Overlap Hotel');
// Open start picker (first "Select day" trigger) and select Day 1 (id=17)
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || b.textContent?.startsWith('Day '))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 1') && !b.textContent?.startsWith('Day 1 ') || b.textContent?.trim() === 'Day 1')!);
// Open end picker and select Day 16 (id=7, low ID but last positionally)
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
// start must stay id=17 (Day 1) — old Math.max would clobber it to id=7
expect(saved.create_accommodation?.start_day_id).toBe(17);
expect(saved.create_accommodation?.end_day_id).toBe(7);
});
it('FE-PLANNER-RESMODAL-051: non-monotonic IDs — start picker does not collapse end when start has high ID', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
const days = buildNonMonotonicDaysRM();
render(<ReservationModal {...defaultProps} onSave={onSave} days={days} />);
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Span Hotel');
// Set end to Day 16 (id=7) first
const endTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[1];
await userEvent.click(endTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 16'))!);
// Set start to Day 9 (id=25, high ID but earlier by position than Day 16)
// Old code: Math.max(25, 7) = 25 → end collapses to Day 9.
// New code: position(id=25)=8 < position(id=7)=15 → end stays id=7.
const startTrigger = () => screen.getAllByRole('button').filter(b => b.textContent?.includes('Select day') || /^Day \d+/.test(b.textContent?.trim() ?? ''))[0];
await userEvent.click(startTrigger());
await userEvent.click(screen.getAllByRole('button').find(b => b.textContent?.startsWith('Day 9'))!);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
const saved = onSave.mock.calls[0][0];
expect(saved.create_accommodation?.start_day_id).toBe(25); // Day 9
expect(saved.create_accommodation?.end_day_id).toBe(7); // Day 16 — must NOT have collapsed
});
it('FE-PLANNER-RESMODAL-052: hotel with no accommodation_id sends assignment_id as null (issue #934)', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
// Hotel reservation with assignment_id set but no accommodation
const res = buildReservation({
id: 10, title: 'Stale Hotel', type: 'hotel', status: 'confirmed',
accommodation_id: null, assignment_id: 99,
} as any);
render(<ReservationModal {...defaultProps} onSave={onSave} reservation={res} />);
await userEvent.click(screen.getByRole('button', { name: /^Update$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave.mock.calls[0][0].assignment_id).toBeNull();
});
});
@@ -196,7 +196,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
reservation_end_time: form.type === 'hotel' ? null : (combinedEndTime || null),
location: form.location, confirmation_number: form.confirmation_number,
notes: form.notes,
assignment_id: form.assignment_id || null,
assignment_id: (form.type === 'hotel' && !form.accommodation_id) ? null : (form.assignment_id || null),
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
metadata: Object.keys(metadata).length > 0 ? metadata : null,
endpoints: [],
@@ -459,7 +459,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.fromDay')}</label>
<CustomSelect
value={form.hotel_start_day}
onChange={value => set('hotel_start_day', value)}
onChange={value => setForm(prev => ({
...prev,
hotel_start_day: value,
hotel_end_day: days.findIndex(d => d.id === value) > days.findIndex(d => d.id === prev.hotel_end_day)
? value : prev.hotel_end_day,
}))}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
@@ -477,7 +482,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
<label style={labelStyle}>{t('reservations.meta.toDay')}</label>
<CustomSelect
value={form.hotel_end_day}
onChange={value => set('hotel_end_day', value)}
onChange={value => setForm(prev => ({
...prev,
hotel_start_day: days.findIndex(d => d.id === value) < days.findIndex(d => d.id === prev.hotel_start_day)
? value : prev.hotel_start_day,
hotel_end_day: value,
}))}
placeholder={t('reservations.meta.selectDay')}
options={days.map(d => {
const dateBadge = d.date ? (formatDate(d.date, locale) ?? undefined) : undefined
@@ -11,6 +11,9 @@ import {
ExternalLink, BookMarked, Lightbulb, Link2, Clock, ArrowRight, AlertCircle,
} from 'lucide-react'
import { openFile } from '../../utils/fileDownload'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkBreaks from 'remark-breaks'
import type { Reservation, Day, TripFile, AssignmentsMap } from '../../types'
interface AssignmentLookupEntry {
@@ -364,7 +367,9 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
{r.notes && (
<div>
<div style={fieldLabelStyle}>{t('reservations.notes')}</div>
<div style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5 }}>{r.notes}</div>
<div className="collab-note-md" style={{ ...fieldValueStyle, fontWeight: 400, lineHeight: 1.5, wordBreak: 'break-word', overflowWrap: 'anywhere' }}>
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]}>{r.notes}</Markdown>
</div>
</div>
)}
@@ -0,0 +1,324 @@
// FE-PLANNER-TRANSMODAL-001 to FE-PLANNER-TRANSMODAL-021
import { render, screen, waitFor, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
import { useAuthStore } from '../../store/authStore';
import { useTripStore } from '../../store/tripStore';
import { useAddonStore } from '../../store/addonStore';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
import {
buildUser,
buildTrip,
buildDay,
buildReservation,
buildTripFile,
} from '../../../tests/helpers/factories';
import { TransportModal } from './TransportModal';
vi.mock('react-router-dom', async (importActual) => {
const actual = await importActual<typeof import('react-router-dom')>();
return { ...actual, useParams: () => ({ id: '1' }) };
});
vi.mock('../shared/CustomTimePicker', () => ({
default: ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
<input data-testid="time-picker" type="text" value={value} onChange={e => onChange(e.target.value)} />
),
}));
vi.mock('./AirportSelect', () => ({
default: ({ onChange }: { onChange: (a: any) => void }) => (
<input data-testid="airport-select" type="text" onChange={e => onChange({ iata: e.target.value, name: e.target.value, city: '', country: '', lat: 0, lng: 0, tz: 'UTC', icao: null })} />
),
}));
vi.mock('./LocationSelect', () => ({
default: ({ onChange }: { onChange: (l: any) => void }) => (
<input data-testid="location-select" type="text" onChange={e => onChange({ name: e.target.value, lat: 0, lng: 0, address: null })} />
),
}));
const defaultProps = {
isOpen: true,
onClose: vi.fn(),
onSave: vi.fn().mockResolvedValue(undefined),
reservation: null,
days: [],
selectedDayId: null,
files: [],
onFileUpload: vi.fn().mockResolvedValue(undefined),
onFileDelete: vi.fn().mockResolvedValue(undefined),
};
beforeEach(() => {
resetAllStores();
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1 }), budgetItems: [] });
vi.clearAllMocks();
});
describe('TransportModal', () => {
// ── Rendering ──────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-001: renders without crashing', () => {
render(<TransportModal {...defaultProps} />);
expect(document.body).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-002: shows "Add transport" title for new transport', () => {
render(<TransportModal {...defaultProps} reservation={null} />);
expect(screen.getByText(/Add transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-003: shows "Edit transport" title when editing', () => {
const res = buildReservation({ title: 'Paris Flight', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByText(/Edit transport/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-004: title input is required — onSave not called with empty title', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
expect(onSave).not.toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-005: all 4 transport type buttons are visible', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /^Flight$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Train$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /^Cruise$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-006: editing pre-fills title', () => {
const res = buildReservation({ title: 'LH123 Frankfurt', type: 'flight' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByDisplayValue('LH123 Frankfurt')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-007: edit mode save button shows "Update"', () => {
const res = buildReservation({ title: 'My Train', type: 'train' });
render(<TransportModal {...defaultProps} reservation={res} />);
expect(screen.getByRole('button', { name: /^Update$/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-008: Cancel button calls onClose', async () => {
const onClose = vi.fn();
render(<TransportModal {...defaultProps} onClose={onClose} />);
await userEvent.click(screen.getByRole('button', { name: /Cancel/i }));
expect(onClose).toHaveBeenCalled();
});
it('FE-PLANNER-TRANSMODAL-009: submitting valid flight calls onSave with correct type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH456');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ title: 'LH456', type: 'flight' }));
});
it('FE-PLANNER-TRANSMODAL-010: switching to train type calls onSave with train type', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.click(screen.getByRole('button', { name: /^Train$/i }));
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'train' }));
});
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: 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(<TransportModal {...defaultProps} onSave={onSave} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
);
});
// ── File attachment ───────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-014: attach file button rendered when onFileUpload provided', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.getByRole('button', { name: /Attach file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-015: attach file button absent when onFileUpload is undefined', () => {
render(<TransportModal {...defaultProps} onFileUpload={undefined} />);
expect(screen.queryByRole('button', { name: /Attach file/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-016: attached files shown for existing transport', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const file = buildTripFile({ id: 1, trip_id: 1, original_name: 'boarding-pass.pdf' });
(file as any).reservation_id = 5;
render(<TransportModal {...defaultProps} reservation={res} files={[file]} />);
expect(screen.getByText('boarding-pass.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-017: pending file added for new transport on file input change', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'itinerary.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('itinerary.pdf')).toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-018: file upload to existing transport calls onFileUpload with correct FormData', async () => {
const onFileUpload = vi.fn().mockResolvedValue(undefined);
const res = buildReservation({ id: 10, type: 'train', title: 'Eurostar' });
render(<TransportModal {...defaultProps} reservation={res} onFileUpload={onFileUpload} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'ticket.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('file')).toBeTruthy();
expect(fd.get('reservation_id')).toBe('10');
});
it('FE-PLANNER-TRANSMODAL-019: link existing file button appears when unattached files exist', () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-020: clicking "link existing file" shows file picker dropdown', async () => {
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
expect(screen.getByText('invoice.pdf')).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-021: clicking file in picker links it and closes picker', async () => {
server.use(
http.post('/api/trips/1/files/99/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 5, type: 'flight' });
const unattachedFile = buildTripFile({ id: 99, original_name: 'invoice.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[unattachedFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await userEvent.click(screen.getByText('invoice.pdf'));
await waitFor(() => {
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-022: removing pending file removes it from list', async () => {
render(<TransportModal {...defaultProps} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'draft.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('draft.pdf')).toBeInTheDocument());
const pendingFileRow = screen.getByText('draft.pdf').closest('div')!;
const removeBtn = pendingFileRow.querySelector('button')!;
await userEvent.click(removeBtn);
await waitFor(() => expect(screen.queryByText('draft.pdf')).not.toBeInTheDocument());
});
it('FE-PLANNER-TRANSMODAL-023: clicking attach file button triggers file input click', async () => {
render(<TransportModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click').mockImplementation(() => {});
await userEvent.click(attachBtn);
expect(clickSpy).toHaveBeenCalled();
clickSpy.mockRestore();
});
it('FE-PLANNER-TRANSMODAL-024: unlinking a linked file removes it from attached list', async () => {
server.use(
http.post('/api/trips/1/files/42/link', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files/42/links', () => HttpResponse.json({ links: [{ id: 1, reservation_id: 7 }] })),
http.delete('/api/trips/1/files/42/link/1', () => HttpResponse.json({ success: true })),
http.get('/api/trips/1/files', () => HttpResponse.json({ files: [] })),
);
const res = buildReservation({ id: 7, type: 'car' });
const looseFile = buildTripFile({ id: 42, original_name: 'rental-agreement.pdf' });
render(<TransportModal {...defaultProps} reservation={res} files={[looseFile]} />);
await userEvent.click(screen.getByRole('button', { name: /Link existing file/i }));
await waitFor(() => expect(screen.getByText('rental-agreement.pdf')).toBeInTheDocument());
await userEvent.click(screen.getByText('rental-agreement.pdf'));
await waitFor(() =>
expect(screen.queryByRole('button', { name: /Link existing file/i })).not.toBeInTheDocument()
);
const fileRow = screen.getByText('rental-agreement.pdf').closest('div')!;
const unlinkBtn = fileRow.querySelector('button[type="button"]')!;
await userEvent.click(unlinkBtn);
await waitFor(() => {
expect(screen.getByRole('button', { name: /Link existing file/i })).toBeInTheDocument();
});
});
it('FE-PLANNER-TRANSMODAL-025: pending files flushed after saving new transport', async () => {
const savedReservation = buildReservation({ id: 99, type: 'flight' });
const onSave = vi.fn().mockResolvedValue(savedReservation);
const onFileUpload = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} onFileUpload={onFileUpload} reservation={null} />);
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const testFile = new File(['content'], 'boarding.pdf', { type: 'application/pdf' });
fireEvent.change(fileInput, { target: { files: [testFile] } });
await waitFor(() => expect(screen.getByText('boarding.pdf')).toBeInTheDocument());
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'LH001');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onFileUpload).toHaveBeenCalled());
const [fd] = onFileUpload.mock.calls[0] as [FormData];
expect(fd.get('reservation_id')).toBe('99');
expect(fd.get('file')).toBeTruthy();
});
});
@@ -1,5 +1,6 @@
import { useState, useEffect, useMemo } from 'react'
import { Plane, Train, Car, Ship } from 'lucide-react'
import { useState, useEffect, useMemo, useRef } from 'react'
import { useParams } from 'react-router-dom'
import { Plane, Train, Car, Ship, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import CustomTimePicker from '../shared/CustomTimePicker'
@@ -10,7 +11,9 @@ import { useToast } from '../shared/Toast'
import { useTripStore } from '../../store/tripStore'
import { useAddonStore } from '../../store/addonStore'
import { formatDate } from '../../utils/formatters'
import type { Day, Reservation, ReservationEndpoint } from '../../types'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
@@ -89,26 +92,36 @@ const defaultForm = {
interface TransportModalProps {
isOpen: boolean
onClose: () => void
onSave: (data: Record<string, any>) => Promise<void>
onSave: (data: Record<string, any>) => Promise<Reservation | undefined>
reservation: Reservation | null
days: Day[]
selectedDayId: number | null
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<void>
onFileDelete?: (fileId: number) => Promise<void>
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
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 [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
const [toPick, setToPick] = useState<EndpointPick>({})
const [uploadingFile, setUploadingFile] = useState(false)
const [pendingFiles, setPendingFiles] = useState<File[]>([])
const [showFilePicker, setShowFilePicker] = useState(false)
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (!isOpen) return
@@ -222,7 +235,16 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
await onSave(payload)
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(saved.id))
fd.append('description', form.title)
await onFileUpload(fd)
}
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
@@ -230,6 +252,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
if (reservation?.id) {
setUploadingFile(true)
try {
const fd = new FormData()
fd.append('file', file)
fd.append('reservation_id', String(reservation.id))
fd.append('description', reservation.title)
await onFileUpload!(fd)
toast.success(t('reservations.toast.fileUploaded'))
} catch {
toast.error(t('reservations.toast.uploadError'))
} finally {
setUploadingFile(false)
e.target.value = ''
}
} else {
setPendingFiles(prev => [...prev, file])
e.target.value = ''
}
}
const attachedFiles = reservation?.id
? files.filter(f =>
f.reservation_id === reservation.id ||
linkedFileIds.includes(f.id) ||
(f.linked_reservation_ids && f.linked_reservation_ids.includes(reservation.id))
)
: []
const inputStyle = {
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
@@ -444,6 +498,94 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
</div>
{/* Files */}
<div>
<label style={labelStyle}>{t('files.title')}</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{attachedFiles.map(f => (
<div key={f.id} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
<button type="button" onClick={async () => {
if (f.reservation_id === reservation?.id) {
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
}
try {
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
if (link) await apiClient.delete(`/trips/${tripId}/files/${f.id}/link/${link.id}`)
} catch {}
setLinkedFileIds(prev => prev.filter(id => id !== f.id))
if (tripId) loadFiles(tripId)
}} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
{pendingFiles.map((f, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px', background: 'var(--bg-secondary)', borderRadius: 8 }}>
<FileText size={12} style={{ color: 'var(--text-muted)', flexShrink: 0 }} />
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.name}</span>
<button type="button" onClick={() => setPendingFiles(prev => prev.filter((_, j) => j !== i))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-faint)', display: 'flex', padding: 0, flexShrink: 0 }}>
<X size={11} />
</button>
</div>
))}
<input ref={fileInputRef} type="file" accept=".pdf,.doc,.docx,.txt,image/*" style={{ display: 'none' }} onChange={handleFileChange} />
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{onFileUpload && <button type="button" onClick={() => fileInputRef.current?.click()} disabled={uploadingFile} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: uploadingFile ? 'default' : 'pointer', fontFamily: 'inherit',
}}>
<Paperclip size={11} />
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
</button>}
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
<div style={{ position: 'relative' }}>
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '6px 10px',
border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none',
fontSize: 11, color: 'var(--text-faint)', cursor: 'pointer', fontFamily: 'inherit',
}}>
<Link2 size={11} /> {t('reservations.linkExisting')}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4, zIndex: 50,
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 220, maxHeight: 200, overflowY: 'auto',
}}>
{files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).map(f => (
<button key={f.id} type="button" onClick={async () => {
try {
await apiClient.post(`/trips/${tripId}/files/${f.id}/link`, { reservation_id: reservation.id })
setLinkedFileIds(prev => [...prev, f.id])
setShowFilePicker(false)
if (tripId) loadFiles(tripId)
} catch {}
}}
style={{
display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '6px 10px',
background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontFamily: 'inherit',
color: 'var(--text-secondary)', borderRadius: 7, textAlign: 'left',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-tertiary)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}>
<FileText size={12} style={{ color: 'var(--text-faint)', flexShrink: 0 }} />
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
</button>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Price + Budget Category */}
{isBudgetEnabled && (
<>
+2
View File
@@ -1249,6 +1249,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'فشل حذف الملف',
'files.sourcePlan': 'خطة اليوم',
'files.sourceBooking': 'الحجز',
'files.sourceTransport': 'النقل',
'files.attach': 'إرفاق',
'files.pasteHint': 'يمكنك أيضًا لصق الصور من الحافظة (Ctrl+V)',
'files.trash': 'سلة المهملات',
@@ -1261,6 +1262,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'إسناد ملف',
'files.assignPlace': 'المكان',
'files.assignBooking': 'الحجز',
'files.assignTransport': 'النقل',
'files.unassigned': 'غير مسند',
'files.unlink': 'إزالة الرابط',
'files.toast.trashed': 'تم النقل إلى سلة المهملات',
+2
View File
@@ -1218,6 +1218,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Falha ao excluir arquivo',
'files.sourcePlan': 'Plano do dia',
'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Anexar',
'files.pasteHint': 'Você também pode colar imagens da área de transferência (Ctrl+V)',
'files.trash': 'Lixeira',
@@ -1230,6 +1231,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Atribuir arquivo',
'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Não atribuído',
'files.unlink': 'Remover vínculo',
'files.toast.trashed': 'Movido para a lixeira',
+2
View File
@@ -1247,6 +1247,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nepodařilo se smazat soubor',
'files.sourcePlan': 'Denní plán',
'files.sourceBooking': 'Rezervace',
'files.sourceTransport': 'Doprava',
'files.attach': 'Přiložit',
'files.pasteHint': 'Můžete také vložit obrázek ze schránky (Ctrl+V)',
'files.trash': 'Koš',
@@ -1259,6 +1260,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Přiřadit soubor',
'files.assignPlace': 'Místo',
'files.assignBooking': 'Rezervace',
'files.assignTransport': 'Doprava',
'files.unassigned': 'Nepřiřazeno',
'files.unlink': 'Zrušit propojení',
'files.toast.trashed': 'Přesunuto do koše',
+2
View File
@@ -1251,6 +1251,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Fehler beim Löschen der Datei',
'files.sourcePlan': 'Tagesplan',
'files.sourceBooking': 'Buchung',
'files.sourceTransport': 'Transport',
'files.attach': 'Anhängen',
'files.pasteHint': 'Du kannst auch Bilder aus der Zwischenablage einfügen (Strg+V)',
'files.trash': 'Papierkorb',
@@ -1263,6 +1264,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Datei zuweisen',
'files.assignPlace': 'Ort',
'files.assignBooking': 'Buchung',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nicht zugewiesen',
'files.unlink': 'Verknüpfung entfernen',
'files.toast.trashed': 'In den Papierkorb verschoben',
+2
View File
@@ -1322,6 +1322,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Failed to delete file',
'files.sourcePlan': 'Day Plan',
'files.sourceBooking': 'Booking',
'files.sourceTransport': 'Transport',
'files.attach': 'Attach',
'files.pasteHint': 'You can also paste images from clipboard (Ctrl+V)',
'files.trash': 'Trash',
@@ -1334,6 +1335,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assign File',
'files.assignPlace': 'Place',
'files.assignBooking': 'Booking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Unassigned',
'files.unlink': 'Remove link',
'files.toast.trashed': 'Moved to trash',
+2
View File
@@ -1195,6 +1195,7 @@ const es: Record<string, string> = {
'files.toast.deleteError': 'No se pudo eliminar el archivo',
'files.sourcePlan': 'Plan diario',
'files.sourceBooking': 'Reserva',
'files.sourceTransport': 'Transporte',
'files.attach': 'Adjuntar',
'files.pasteHint': 'También puedes pegar imágenes desde el portapapeles (Ctrl+V)',
@@ -1682,6 +1683,7 @@ const es: Record<string, string> = {
'files.assignTitle': 'Asignar archivo',
'files.assignPlace': 'Lugar',
'files.assignBooking': 'Reserva',
'files.assignTransport': 'Transporte',
'files.unassigned': 'Sin asignar',
'files.unlink': 'Eliminar vínculo',
'files.noteLabel': 'Nota',
+2
View File
@@ -1245,6 +1245,7 @@ const fr: Record<string, string> = {
'files.toast.deleteError': 'Impossible de supprimer le fichier',
'files.sourcePlan': 'Plan du jour',
'files.sourceBooking': 'Réservation',
'files.sourceTransport': 'Transport',
'files.attach': 'Joindre',
'files.pasteHint': 'Vous pouvez aussi coller des images depuis le presse-papiers (Ctrl+V)',
'files.trash': 'Corbeille',
@@ -1257,6 +1258,7 @@ const fr: Record<string, string> = {
'files.assignTitle': 'Assigner le fichier',
'files.assignPlace': 'Lieu',
'files.assignBooking': 'Réservation',
'files.assignTransport': 'Transport',
'files.unassigned': 'Non attribué',
'files.unlink': 'Supprimer le lien',
'files.toast.trashed': 'Déplacé dans la corbeille',
+2
View File
@@ -1246,6 +1246,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nem sikerült törölni a fájlt',
'files.sourcePlan': 'Napi terv',
'files.sourceBooking': 'Foglalás',
'files.sourceTransport': 'Közlekedés',
'files.attach': 'Csatolás',
'files.pasteHint': 'Képeket a vágólapról is beillesztheted (Ctrl+V)',
'files.trash': 'Kuka',
@@ -1258,6 +1259,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Fájl hozzárendelése',
'files.assignPlace': 'Hely',
'files.assignBooking': 'Foglalás',
'files.assignTransport': 'Közlekedés',
'files.unassigned': 'Nincs hozzárendelve',
'files.unlink': 'Kapcsolat eltávolítása',
'files.toast.trashed': 'Kukába helyezve',
+2
View File
@@ -1306,6 +1306,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Gagal menghapus file',
'files.sourcePlan': 'Rencana Harian',
'files.sourceBooking': 'Pemesanan',
'files.sourceTransport': 'Transportasi',
'files.attach': 'Lampirkan',
'files.pasteHint': 'Kamu juga bisa menempel gambar dari clipboard (Ctrl+V)',
'files.trash': 'Sampah',
@@ -1318,6 +1319,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Tugaskan File',
'files.assignPlace': 'Tempat',
'files.assignBooking': 'Pemesanan',
'files.assignTransport': 'Transportasi',
'files.unassigned': 'Tidak ditugaskan',
'files.unlink': 'Hapus tautan',
'files.toast.trashed': 'Dipindahkan ke sampah',
+2
View File
@@ -1246,6 +1246,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Impossibile eliminare il file',
'files.sourcePlan': 'Programma giornaliero',
'files.sourceBooking': 'Prenotazione',
'files.sourceTransport': 'Trasporto',
'files.attach': 'Allega',
'files.pasteHint': 'Puoi anche incollare immagini dagli appunti (Ctrl+V)',
'files.trash': 'Cestino',
@@ -1258,6 +1259,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Assegna file',
'files.assignPlace': 'Luogo',
'files.assignBooking': 'Prenotazione',
'files.assignTransport': 'Trasporto',
'files.unassigned': 'Non assegnato',
'files.unlink': 'Rimuovi collegamento',
'files.toast.trashed': 'Spostato nel cestino',
+2
View File
@@ -1245,6 +1245,7 @@ const nl: Record<string, string> = {
'files.toast.deleteError': 'Bestand verwijderen mislukt',
'files.sourcePlan': 'Dagplan',
'files.sourceBooking': 'Boeking',
'files.sourceTransport': 'Transport',
'files.attach': 'Bijvoegen',
'files.pasteHint': 'Je kunt ook afbeeldingen plakken vanuit het klembord (Ctrl+V)',
'files.trash': 'Prullenbak',
@@ -1257,6 +1258,7 @@ const nl: Record<string, string> = {
'files.assignTitle': 'Bestand toewijzen',
'files.assignPlace': 'Plaats',
'files.assignBooking': 'Boeking',
'files.assignTransport': 'Transport',
'files.unassigned': 'Niet toegewezen',
'files.unlink': 'Koppeling verwijderen',
'files.toast.trashed': 'Naar prullenbak verplaatst',
+2
View File
@@ -1197,6 +1197,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.toast.deleteError': 'Nie udało się usunąć pliku',
'files.sourcePlan': 'Plan dni',
'files.sourceBooking': 'Rezerwacje',
'files.sourceTransport': 'Transport',
'files.attach': 'Załącz',
'files.pasteHint': 'Możesz również wkleić obrazki ze schowka (Ctrl+V)',
'files.trash': 'Kosz',
@@ -1209,6 +1210,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
'files.assignTitle': 'Przypisz plik',
'files.assignPlace': 'Miejsce',
'files.assignBooking': 'Rezerwacja',
'files.assignTransport': 'Transport',
'files.unassigned': 'Nieprzypisane',
'files.unlink': 'Usuń link',
'files.toast.trashed': 'Przeniesiono do kosza',
+2
View File
@@ -1245,6 +1245,7 @@ const ru: Record<string, string> = {
'files.toast.deleteError': 'Не удалось удалить файл',
'files.sourcePlan': 'План дня',
'files.sourceBooking': 'Бронирование',
'files.sourceTransport': 'Транспорт',
'files.attach': 'Прикрепить',
'files.pasteHint': 'Также можно вставить изображения из буфера обмена (Ctrl+V)',
'files.trash': 'Корзина',
@@ -1257,6 +1258,7 @@ const ru: Record<string, string> = {
'files.assignTitle': 'Назначить файл',
'files.assignPlace': 'Место',
'files.assignBooking': 'Бронирование',
'files.assignTransport': 'Транспорт',
'files.unassigned': 'Не назначен',
'files.unlink': 'Удалить связь',
'files.toast.trashed': 'Перемещено в корзину',
+2
View File
@@ -1245,6 +1245,7 @@ const zh: Record<string, string> = {
'files.toast.deleteError': '删除文件失败',
'files.sourcePlan': '日程计划',
'files.sourceBooking': '预订',
'files.sourceTransport': '交通',
'files.attach': '附加',
'files.pasteHint': '也可以从剪贴板粘贴图片 (Ctrl+V)',
'files.trash': '回收站',
@@ -1257,6 +1258,7 @@ const zh: Record<string, string> = {
'files.assignTitle': '分配文件',
'files.assignPlace': '地点',
'files.assignBooking': '预订',
'files.assignTransport': '交通',
'files.unassigned': '未分配',
'files.unlink': '移除关联',
'files.toast.trashed': '已移至回收站',
+2
View File
@@ -1305,6 +1305,7 @@ const zhTw: Record<string, string> = {
'files.toast.deleteError': '刪除檔案失敗',
'files.sourcePlan': '日程計劃',
'files.sourceBooking': '預訂',
'files.sourceTransport': '交通',
'files.attach': '附加',
'files.pasteHint': '也可以從剪貼簿貼上圖片 (Ctrl+V)',
'files.trash': '回收站',
@@ -1317,6 +1318,7 @@ const zhTw: Record<string, string> = {
'files.assignTitle': '分配檔案',
'files.assignPlace': '地點',
'files.assignBooking': '預訂',
'files.assignTransport': '交通',
'files.unassigned': '未分配',
'files.unlink': '移除關聯',
'files.toast.trashed': '已移至回收站',
+1 -1
View File
@@ -807,7 +807,7 @@ img[alt="TREK"] {
.collab-note-md code, .collab-note-md-full code { font-size: 0.9em; padding: 1px 5px; border-radius: 4px; background: var(--bg-secondary); }
.collab-note-md-full pre { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); overflow-x: auto; margin: 0.5em 0; }
.collab-note-md-full pre code { padding: 0; background: none; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; }
.collab-note-md a, .collab-note-md-full a { color: #3b82f6; text-decoration: underline; word-break: break-all; }
.collab-note-md blockquote, .collab-note-md-full blockquote { border-left: 3px solid var(--border-primary); padding-left: 12px; margin: 0.5em 0; color: var(--text-muted); }
.collab-note-md-full table { border-collapse: collapse; width: 100%; margin: 0.5em 0; }
.collab-note-md-full th, .collab-note-md-full td { border: 1px solid var(--border-primary); padding: 4px 8px; font-size: 0.9em; }
+2 -1
View File
@@ -10,6 +10,7 @@ import { getCategoryIcon } from '../components/shared/categoryIcons'
import { createElement } from 'react'
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 { isDayInAccommodationRange } from '../utils/dayOrder'
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
const TRANSPORT_ICONS = { flight: Plane, train: Train, bus: Bus, car: Car, cruise: Ship }
@@ -184,7 +185,7 @@ export default function SharedTripPage() {
const da = assignments[String(day.id)] || []
const notes = (dayNotes[String(day.id)] || [])
const dayTransport = (reservations || []).filter((r: any) => TRANSPORT_TYPES.has(r.type) && r.reservation_time?.split('T')[0] === day.date)
const dayAccs = (accommodations || []).filter((a: any) => day.id >= a.start_day_id && day.id <= a.end_day_id)
const dayAccs = (accommodations || []).filter((a: any) => isDayInAccommodationRange(day, a.start_day_id, a.end_day_id, sortedDays))
const merged = [
...da.map((a: any) => ({ type: 'place', k: a.order_index, data: a })),
+50
View File
@@ -1474,6 +1474,56 @@ describe('TripPlannerPage', () => {
});
});
describe('FE-PAGE-PLANNER-051: Mobile Plan sidebar stays mounted after onPlaceClick (issue #932)', () => {
it('does not unmount the mobile Plan portal when a place is tapped, preserving scroll position', async () => {
vi.useFakeTimers();
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 });
const place = buildPlace({ id: 1, trip_id: 42, lat: 48.8566, lng: 2.3522 });
const assignment = buildAssignment({ id: 10, day_id: 99, place, order_index: 0 });
seedTripStore({ id: 42 });
seedStore(useTripStore, {
places: [place],
assignments: { '99': [assignment] },
} as any);
renderPlannerPage(42);
act(() => { vi.runAllTimers(); });
vi.useRealTimers();
await waitFor(() => {
expect(screen.getByTestId('day-plan-sidebar')).toBeInTheDocument();
});
// Open the mobile Plan portal via the bottom-nav Plan button (selector mirrors FE-PAGE-PLANNER-049).
const mobilePlanBtn = Array.from(document.body.querySelectorAll('button')).find(
b => b.textContent === 'Plan' && !b.getAttribute('title'),
);
expect(mobilePlanBtn).toBeTruthy();
await act(async () => { fireEvent.click(mobilePlanBtn!); });
await waitFor(() => {
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
});
// The mock factory overwrites capturedDayPlanSidebarProps on each mount,
// so current holds the mobile portal instance's props.
const mobileOnPlaceClick = capturedDayPlanSidebarProps.current.onPlaceClick;
expect(typeof mobileOnPlaceClick).toBe('function');
await act(async () => {
mobileOnPlaceClick(place.id, assignment.id);
});
// Invariant: portal must NOT unmount — both instances persist.
// Pre-fix: collapses to 1 (setMobileSidebarOpen(null) destroyed scroll container).
// Post-fix: stays at 2, browser preserves scrollTop on the living DOM node.
expect(screen.getAllByTestId('day-plan-sidebar').length).toBe(2);
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 1024 });
});
});
describe('FE-PAGE-PLANNER-037: onExpandedDaysChange covers mapPlaces hidden logic', () => {
it('calls onExpandedDaysChange to trigger mapPlaces hidden set computation', async () => {
vi.useFakeTimers();
+15 -8
View File
@@ -272,6 +272,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [fitKey, setFitKey] = useState<number>(0)
const initialFitTripId = useRef<number | null>(null)
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
const mobilePlanScrollTopRef = useRef<number>(0)
const mobilePlacesScrollTopRef = useRef<number>(0)
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
@@ -666,15 +668,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
const handleSaveTransport = async (data) => {
try {
if (editingTransport) {
await tripActions.updateReservation(tripId, editingTransport.id, data)
const r = await tripActions.updateReservation(tripId, editingTransport.id, data)
toast.success(t('trip.toast.reservationUpdated'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
} else {
await tripActions.addReservation(tripId, data)
const r = await tripActions.addReservation(tripId, data)
toast.success(t('trip.toast.reservationAdded'))
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
return r
}
setShowTransportModal(false)
setEditingTransport(null)
setTransportModalDayId(null)
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
}
@@ -1109,8 +1116,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{mobileSidebarOpen === 'left'
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId); setMobileSidebarOpen(null) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} 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} />
: <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} />
? <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({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} 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) => { 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>
@@ -1194,7 +1201,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
<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} />
<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} />}
{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)} />}
<ConfirmDialog
isOpen={!!deletePlaceId}
onClose={() => setDeletePlaceId(null)}
+1
View File
@@ -31,6 +31,7 @@ export interface Trip {
export interface Day {
id: number
trip_id: number
day_number?: number
date: string
title: string | null
notes: string | null
+23
View File
@@ -0,0 +1,23 @@
import type { Day } from '../types'
export const getDayOrder = (day: Day, days: Day[]): number =>
day.day_number ?? days.indexOf(day)
export const isDayInAccommodationRange = (
day: Day,
startDayId: number,
endDayId: number,
days: Day[],
): boolean => {
const startDay = days.find(d => d.id === startDayId)
const endDay = days.find(d => d.id === endDayId)
if (!startDay || !endDay) {
// Endpoint days not in the loaded array (e.g. sparse test data or partial load).
// Fall back to numeric ID range — acceptable since non-monotonic IDs only arise when
// both endpoints are present in a fully-loaded trip's days list.
return day.id >= Math.min(startDayId, endDayId) && day.id <= Math.max(startDayId, endDayId)
}
const lo = Math.min(getDayOrder(startDay, days), getDayOrder(endDay, days))
const hi = Math.max(getDayOrder(startDay, days), getDayOrder(endDay, days))
return getDayOrder(day, days) >= lo && getDayOrder(day, days) <= hi
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "trek-server",
"version": "3.0.11",
"version": "3.0.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "trek-server",
"version": "3.0.11",
"version": "3.0.13",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.28.0",
"archiver": "^6.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trek-server",
"version": "3.0.11",
"version": "3.0.13",
"main": "src/index.ts",
"scripts": {
"start": "node --import tsx src/index.ts",
+11
View File
@@ -2130,6 +2130,17 @@ function runMigrations(db: Database.Database): void {
'ON journey_entries(journey_id, entry_date, sort_order)'
);
},
// Swap inverted start_day_id/end_day_id pairs in day_accommodations caused
// by the old Math.min/Math.max picker bug (pre-8e05ba7) which used raw IDs
// instead of positional order on trips with non-monotonic day ID layouts.
() => {
db.exec(`
UPDATE day_accommodations
SET start_day_id = end_day_id, end_day_id = start_day_id
WHERE (SELECT day_number FROM days WHERE id = start_day_id)
> (SELECT day_number FROM days WHERE id = end_day_id)
`);
},
];
if (currentVersion < migrations.length) {
+4 -1
View File
@@ -117,10 +117,13 @@ accommodationsRouter.delete('/:id', authenticate, requireTripAccess, (req: Reque
if (!dayService.getAccommodation(id, tripId)) return res.status(404).json({ error: 'Accommodation not found' });
const { linkedReservationId } = dayService.deleteAccommodation(id);
const { linkedReservationId, deletedBudgetItemId } = dayService.deleteAccommodation(id);
if (linkedReservationId) {
broadcast(tripId, 'reservation:deleted', { reservationId: linkedReservationId }, req.headers['x-socket-id'] as string);
}
if (deletedBudgetItemId) {
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
}
res.json({ success: true });
broadcast(tripId, 'accommodation:deleted', { accommodationId: Number(id) }, req.headers['x-socket-id'] as string);
+5 -2
View File
@@ -129,7 +129,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linked) {
deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { id: linked.id }, req.headers['x-socket-id'] as string);
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, req.headers['x-socket-id'] as string);
}
}
@@ -179,12 +179,15 @@ router.delete('/:id', authenticate, (req: Request, res: Response) => {
if (!checkPermission('reservation_edit', authReq.user.role, trip.user_id, authReq.user.id, trip.user_id !== authReq.user.id))
return res.status(403).json({ error: 'No permission' });
const { deleted: reservation, accommodationDeleted } = deleteReservation(id, tripId);
const { deleted: reservation, accommodationDeleted, deletedBudgetItemId } = deleteReservation(id, tripId);
if (!reservation) return res.status(404).json({ error: 'Reservation not found' });
if (accommodationDeleted) {
broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }, req.headers['x-socket-id'] as string);
}
if (deletedBudgetItemId) {
broadcast(tripId, 'budget:deleted', { itemId: deletedBudgetItemId }, req.headers['x-socket-id'] as string);
}
res.json({ success: true });
broadcast(tripId, 'reservation:deleted', { reservationId: Number(id) }, req.headers['x-socket-id'] as string);
+9 -4
View File
@@ -292,14 +292,19 @@ export function updateAccommodation(id: string | number, existing: DayAccommodat
return getAccommodationWithPlace(Number(id));
}
/** Delete accommodation and its linked reservation. Returns the linked reservation id if one existed. */
export function deleteAccommodation(id: string | number): { linkedReservationId: number | null } {
// Delete linked reservation
/** Delete accommodation and its linked reservation (and any linked budget item). */
export function deleteAccommodation(id: string | number): { linkedReservationId: number | null; deletedBudgetItemId: number | null } {
const linkedRes = db.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number } | undefined;
let deletedBudgetItemId: number | null = null;
if (linkedRes) {
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE reservation_id = ?').get(linkedRes.id) as { id: number } | undefined;
if (linkedBudget) {
db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id);
deletedBudgetItemId = linkedBudget.id;
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(linkedRes.id);
}
db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(id);
return { linkedReservationId: linkedRes ? linkedRes.id : null };
return { linkedReservationId: linkedRes ? linkedRes.id : null, deletedBudgetItemId };
}
+8 -3
View File
@@ -418,9 +418,9 @@ export function updateReservation(id: string | number, tripId: string | number,
return { reservation, accommodationChanged };
}
export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean } {
export function deleteReservation(id: string | number, tripId: string | number): { deleted: { id: number; title: string; type: string; accommodation_id: number | null } | undefined; accommodationDeleted: boolean; deletedBudgetItemId: number | null } {
const reservation = db.prepare('SELECT id, title, type, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(id, tripId) as { id: number; title: string; type: string; accommodation_id: number | null } | undefined;
if (!reservation) return { deleted: undefined, accommodationDeleted: false };
if (!reservation) return { deleted: undefined, accommodationDeleted: false, deletedBudgetItemId: null };
let accommodationDeleted = false;
if (reservation.accommodation_id) {
@@ -428,6 +428,11 @@ export function deleteReservation(id: string | number, tripId: string | number):
accommodationDeleted = true;
}
const linkedBudget = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linkedBudget) {
db.prepare('DELETE FROM budget_items WHERE id = ?').run(linkedBudget.id);
}
db.prepare('DELETE FROM reservations WHERE id = ?').run(id);
return { deleted: reservation, accommodationDeleted };
return { deleted: reservation, accommodationDeleted, deletedBudgetItemId: linkedBudget ? linkedBudget.id : null };
}
+19
View File
@@ -189,6 +189,25 @@ describe('Delete budget item', () => {
.set('Cookie', authCookie(user.id));
expect(list.body.items).toHaveLength(0);
});
it('BUDGET-004b — DELETE budget item does NOT delete its linked reservation', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
const result = testDb.prepare(
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
).run(trip.id, 'Hotel Cost', 'Accommodation', 250, reservation.id);
const itemId = result.lastInsertRowid as number;
const del = await request(app)
.delete(`/api/trips/${trip.id}/budget/${itemId}`)
.set('Cookie', authCookie(user.id));
expect(del.status).toBe(200);
const reservationAfter = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
expect(reservationAfter).toBeDefined();
});
});
// ─────────────────────────────────────────────────────────────────────────────
+42
View File
@@ -502,4 +502,46 @@ describe('Accommodations', () => {
).get(reservationBefore.id);
expect(reservationAfter).toBeUndefined();
});
it('ACCOM-006 — DELETE accommodation also removes its linked budget item (issue #933)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Hotel Budget Trip' });
const day1 = createDay(testDb, trip.id, { date: '2026-11-01' });
const day2 = createDay(testDb, trip.id, { date: '2026-11-03' });
const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' });
// Create a hotel reservation that creates an accommodation and a linked budget item
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Grand Hotel Stay',
type: 'hotel',
day_id: day1.id,
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
create_budget_entry: { total_price: 450, category: 'Accommodation' },
});
expect(createRes.status).toBe(201);
const accommodationId = testDb.prepare(
'SELECT id FROM day_accommodations WHERE trip_id = ?'
).get(trip.id) as any;
expect(accommodationId).toBeDefined();
const budgetBefore = testDb.prepare(
'SELECT id FROM budget_items WHERE trip_id = ?'
).get(trip.id);
expect(budgetBefore).toBeDefined();
// Delete via the accommodation endpoint (the primary bug path)
const delRes = await request(app)
.delete(`/api/trips/${trip.id}/accommodations/${accommodationId.id}`)
.set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200);
const budgetAfter = testDb.prepare(
'SELECT id FROM budget_items WHERE trip_id = ?'
).get(trip.id);
expect(budgetAfter).toBeUndefined();
});
});
@@ -452,4 +452,41 @@ describe('Reservation accommodation delete', () => {
).get(accom.id);
expect(accomAfter).toBeUndefined();
});
it('RESV-009b — DELETE reservation linked to accommodation also removes its linked budget item (issue #933)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const day1 = createDay(testDb, trip.id, { date: '2025-08-01' });
const day2 = createDay(testDb, trip.id, { date: '2025-08-03' });
const place = createPlace(testDb, trip.id, { name: 'Seaside Resort' });
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Seaside Resort Stay',
type: 'hotel',
day_id: day1.id,
create_accommodation: { place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
create_budget_entry: { total_price: 320, category: 'Accommodation' },
});
expect(createRes.status).toBe(201);
const reservationId = createRes.body.reservation.id;
const budgetBefore = testDb.prepare(
'SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?'
).get(trip.id, reservationId);
expect(budgetBefore).toBeDefined();
// Delete via the reservation endpoint
const delRes = await request(app)
.delete(`/api/trips/${trip.id}/reservations/${reservationId}`)
.set('Cookie', authCookie(user.id));
expect(delRes.status).toBe(200);
const budgetAfter = testDb.prepare(
'SELECT id FROM budget_items WHERE trip_id = ?'
).get(trip.id);
expect(budgetAfter).toBeUndefined();
});
});
+12 -1
View File
@@ -23,10 +23,21 @@ Items are sorted by their time or position index.
## Assigning places to a day
- **Drag and drop** — drag a place from the right-hand Places sidebar and drop it onto a day section or between existing items.
![Adding a place by dragging](assets/DayItineraryAddPlaceDragging.gif)
- **Add button** — click the **+** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
![Adding a place by button](assets/DayItineraryAddPlaceByButton.gif)
- **Mobile** — tap the **Add Place** button inside an expanded day section to open an inline search panel; find the place and tap it to assign.
You can also reorder places within a day, or move them to a different day, by dragging and dropping inside the sidebar.
To remove a place from a day, click the **X** button next to the place in the day timeline.
![Removing a place by button](assets/DayItineraryRemovePlaceByButton.gif)
## Multi-day reservations
A reservation that spans multiple days appears in each relevant day with a phase label:
@@ -70,4 +81,4 @@ At the top of the Day Plan sidebar:
Route calculation controls (optimize order, open in Google Maps) appear inside each expanded day section after the place list.
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
**See also:** [Places-and-Search](Places-and-Search) · [Map-Features](Map-Features) · [Route-Optimization](Route-Optimization) · [Weather-Forecasts](Weather-Forecasts) · [Reservations-and-Bookings](Reservations-and-Bookings)
+28 -2
View File
@@ -29,10 +29,25 @@ Go to **Settings → Integrations → Photo Providers**. Each enabled provider s
|-------|----------|-------|
| Server URL | Yes | Full URL of your Immich instance, e.g. `https://immich.example.com` |
| API Key | Yes | Stored encrypted; never returned to the browser after saving |
| Auto-upload to Immich | No | Checkbox; when enabled, photos you upload in TREK are also pushed to your Immich library |
| Mirror journey photos to Immich on upload | No | Checkbox; when enabled, photos you upload in TREK are also pushed to your Immich library |
Enter the full URL of your Immich instance and an Immich API key. The API key is stored encrypted on the TREK server and is never returned to the browser after it is saved.
#### Required API key permissions
When generating the API key in Immich (**Account Settings → API Keys**), grant only the scopes TREK actually uses:
| Permission | Why TREK needs it |
|------------|-------------------|
| `user.read` | Verify the API key and identify the connected account |
| `timeline.read` | Browse photos by date |
| `asset.read` | Read photo metadata and search results |
| `asset.view` | Load thumbnails and preview images |
| `album.read` | List owned + shared albums and their contents |
| `asset.upload` | *Only if you enable "Mirror journey photos to Immich on upload"* — push TREK uploads back to your library |
TREK never modifies or deletes anything in Immich, so no `update`, `delete`, or admin scopes are needed.
### Synology Photos
| Field | Required | Notes |
@@ -43,6 +58,17 @@ Enter the full URL of your Immich instance and an Immich API key. The API key is
| OTP code | No | One-time password for 2FA; only needed on first connection or when re-authenticating |
| Skip SSL verification | No | Checkbox; disable TLS certificate validation for self-signed certificates |
#### Required DSM account permissions
Synology Photos doesn't use API keys — TREK signs in with a regular DSM user account. To minimize blast radius, create a **dedicated low-privilege DSM user** for TREK rather than reusing your admin account:
- A standard (non-admin) DSM user account is sufficient.
- The account must have access to the **Synology Photos** package (DSM → **Control Panel → User & Group → [user] → Applications**, allow Synology Photos).
- The account must be able to log in to DSM (not disabled, not IP-blocked).
- Network access to DSM (typically port `5000` HTTP / `5001` HTTPS, or your reverse-proxy host).
- 2FA is supported — enter the OTP at first connection; TREK stores the resulting device token so you won't be re-prompted on subsequent saves.
- Read-only access is enough — TREK only lists albums, lists items, runs searches, and fetches thumbnails. It never writes, uploads, or deletes.
---
## Testing the connection
@@ -68,4 +94,4 @@ Once a provider is connected, you can browse and attach photos to your trips. Se
## See also
- [Admin-Addons](Admin-Addons)
- [Internal-Network-Access](Internal-Network-Access)
- [Internal-Network-Access](Internal-Network-Access)
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 MiB