mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ae4e0c676 | |||
| 51ab30f436 |
@@ -1,5 +1,5 @@
|
||||
apiVersion: v2
|
||||
name: trek
|
||||
version: 3.0.12
|
||||
version: 3.0.13
|
||||
description: Minimal Helm chart for TREK app
|
||||
appVersion: "3.0.12"
|
||||
appVersion: "3.0.13"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.12",
|
||||
"version": "3.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-client",
|
||||
"version": "3.0.12",
|
||||
"version": "3.0.13",
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.3.2",
|
||||
"axios": "^1.6.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-client",
|
||||
"version": "3.0.12",
|
||||
"version": "3.0.13",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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'
|
||||
@@ -190,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({
|
||||
@@ -218,6 +221,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
initialScrollTop,
|
||||
onScrollTopChange,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -270,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
|
||||
@@ -1117,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)
|
||||
@@ -2228,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1114,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>
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.12",
|
||||
"version": "3.0.13",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "trek-server",
|
||||
"version": "3.0.12",
|
||||
"version": "3.0.13",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.28.0",
|
||||
"archiver": "^6.0.1",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "trek-server",
|
||||
"version": "3.0.12",
|
||||
"version": "3.0.13",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"start": "node --import tsx src/index.ts",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user