diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx
index c185b45aa..31758fc4a 100644
--- a/client/src/pages/TripPlannerPage.tsx
+++ b/client/src/pages/TripPlannerPage.tsx
@@ -2,7 +2,6 @@ import React, { useState, Suspense } from 'react'
import { createPortal } from 'react-dom'
import { useParams, useNavigate, useSearchParams } from 'react-router'
import { useTripStore } from '../store/tripStore'
-import { useCanDo } from '../store/permissionsStore'
import { useSettingsStore } from '../store/settingsStore'
import { MapViewAuto as MapView } from '../components/Map/MapViewAuto'
import { MapCompassPill, type CompassMap } from '../components/Map/MapCompassPill'
@@ -28,8 +27,6 @@ import { BookingDetailPopup } from '../components/Planner/bookings/BookingDetail
import BookingImportModal from '../components/Planner/BookingImportModal'
import AirTrailImportModal from '../components/Planner/AirTrailImportModal'
// MemoriesPanel moved to Journey addon
-import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
-import PackingExportMenu from '../components/Packing/PackingExportMenu'
import type { ExpensePrefill } from '../components/Budget/CostsPanel'
import { expenseEditorFor } from '../components/Budget/CostsPanel.helpers'
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
@@ -42,8 +39,7 @@ import Navbar from '../components/Layout/Navbar'
import HelpAnchor from '../components/Help/HelpAnchor'
import { getHelpContext } from '../help/registry'
import { useToast } from '../components/shared/Toast'
-import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, PackageCheck, Wallet, FolderOpen, Users, Train } from 'lucide-react'
-import { useTranslation } from '../i18n'
+import { Map, X, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Ticket, Wallet, FolderOpen, Users, Train } from 'lucide-react'
import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client'
import { accommodationRepo } from '../repo/accommodationRepo'
import { useAuthStore } from '../store/authStore'
@@ -53,161 +49,20 @@ import { useTripWebSocket } from '../hooks/useTripWebSocket'
import { useRouteCalculation } from '../hooks/useRouteCalculation'
import { usePlaceSelection } from '../hooks/usePlaceSelection'
import { usePlannerHistory } from '../hooks/usePlannerHistory'
-import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
-import { ListTodo, ListPlus, Download, Plus, FolderPlus } from 'lucide-react'
+import type { Accommodation, TripMember, Day, Place, Reservation } from '../types'
import { useTripPlannerPage } from './tripPlanner/useTripPlannerPage'
import {
- ReservationsPanel, PackingListPanel, TodoListPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
+ ReservationsPanel, FileManager, CostsPanel, ExpenseModal, CollabPanel,
RoadtripSidebar, RoadtripCorridorPanel, RoadtripLimitsCard, RoadtripStopPopup, RoadtripStayModal, RoadtripTrackModal, RoadtripAlternativesBar,
TourPlannerRail, TourPlannerToursRail, TransportModal,
} from './tripPlanner/plannerLazy'
import { LazyPanel } from './tripPlanner/LazyPanel'
+import { ListsContainer } from './tripPlanner/ListsContainer'
import { useMergedMapPois } from '../components/Map/useMergedMapPois'
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
import { useTouchDragBridge } from '../hooks/useTouchDragBridge'
import PanelResizeHandle from '../components/Planner/PanelResizeHandle'
-function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
- const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
- return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
- })
- const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
- const [importPackingSignal, setImportPackingSignal] = useState(0)
- const [addCategorySignal, setAddCategorySignal] = useState(0)
- const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
- const [addTodoSignal, setAddTodoSignal] = useState(0)
- const [packingView, setPackingView] = useState<'common' | 'personal'>('common')
- const { t } = useTranslation()
- const isAdmin = useAuthStore(s => s.user?.role === 'admin')
- const trip = useTripStore(s => s.trip)
- const canEditPacking = useCanDo()('packing_edit', trip)
-
- const tabs = [
- { id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
- { id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length },
- ]
-
- // The to-do view fills what is left under the bar, so its list and detail pane
- // scroll inside the screen and the pane's buttons stay in sight.
- const fill = subTab === 'todo'
- return (
-
-
-
-
- {t('trip.tabs.lists')}
-
-
-
- {tabs.map(tab => {
- const active = subTab === tab.id
- const Icon = tab.icon
- return (
-
- )
- })}
-
-
- {subTab === 'packing' && (() => {
- const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]'
- // Export and Import carry only their icon, with the name as tooltip and label.
- const iconBtnClass = 'inline-flex items-center justify-center px-2.5 py-[7px] sm:py-[9px] hover:opacity-[0.88] bg-accent text-accent-text'
- const sharedBtnStyle: React.CSSProperties = {
- appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
- borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
- }
- return (
-
- {canEditPacking && (
-
- )}
-
- {isAdmin && packingItems.length > 0 && (
-
- )}
-
-
-
- )
- })()}
- {subTab === 'todo' && (
-
- )}
-
-
-
- {subTab === 'packing' && (
-
-
-
- )}
- {subTab === 'todo' && (
-
-
-
- )}
-
-
- )
-}
-
/** The tab ids are historical; the help screens carry the names the tabs show. */
const TRIP_TAB_HELP: Record = {
transports: 'transports', buchungen: 'bookings', listen: 'lists', finanzplan: 'costs', dateien: 'files', collab: 'collab', roadtrip: 'roadtrip',
diff --git a/client/src/pages/tripPlanner/ListsContainer.test.tsx b/client/src/pages/tripPlanner/ListsContainer.test.tsx
new file mode 100644
index 000000000..f0d892c5e
--- /dev/null
+++ b/client/src/pages/tripPlanner/ListsContainer.test.tsx
@@ -0,0 +1,123 @@
+// FE-PAGE-PLANNER-LISTS-001 to FE-PAGE-PLANNER-LISTS-006
+import { render, screen, fireEvent, act } from '../../../tests/helpers/render'
+import { resetAllStores, seedStore } from '../../../tests/helpers/store'
+import { buildUser, buildTrip, buildPackingItem, buildTodoItem } from '../../../tests/helpers/factories'
+import { useAuthStore } from '../../store/authStore'
+import { useTripStore } from '../../store/tripStore'
+import { usePermissionsStore, type PermissionLevel } from '../../store/permissionsStore'
+import { ListsContainer } from './ListsContainer'
+
+// The panels and header tools are stubs that record what the container hands them.
+const captured = vi.hoisted(() => ({} as Record>))
+const stub = vi.hoisted(() => (name: string) => (props: Record) => {
+ captured[name] = props
+ return null
+})
+vi.mock('../../components/Packing/PackingListPanel', () => ({ default: stub('packingPanel') }))
+vi.mock('../../components/Todo/TodoListPanel', () => ({ default: stub('todoPanel') }))
+vi.mock('../../components/Packing/ApplyTemplateButton', () => ({ default: stub('applyTemplate') }))
+vi.mock('../../components/Packing/PackingExportMenu', () => ({ default: stub('exportMenu') }))
+
+/** The props a lazily loaded panel was last rendered with, once its chunk is in. */
+function loaded>(name: string): Promise {
+ return vi.waitFor(() => {
+ if (!captured[name]) throw new Error(`${name} has not rendered yet`)
+ return captured[name] as T
+ })
+}
+
+const TRIP_ID = 31
+
+function seed({ role = 'user', packingPermission }: { role?: 'user' | 'admin'; packingPermission?: PermissionLevel } = {}) {
+ seedStore(useAuthStore, { user: buildUser({ id: 9, role }) })
+ // A member, not the owner: owner-only rights then really are withheld.
+ seedStore(useTripStore, { trip: buildTrip({ id: TRIP_ID, user_id: 1 }) })
+ seedStore(usePermissionsStore, { permissions: packingPermission ? { packing_edit: packingPermission } : {} })
+}
+
+function renderLists(packing = [buildPackingItem(), buildPackingItem()], todos = [buildTodoItem()]) {
+ return render()
+}
+
+beforeEach(() => {
+ resetAllStores()
+ sessionStorage.clear()
+ for (const key of Object.keys(captured)) delete captured[key]
+})
+
+describe('ListsContainer', () => {
+ it('FE-PAGE-PLANNER-LISTS-001: opens on the packing list and counts both lists', async () => {
+ seed()
+ renderLists()
+
+ expect(screen.getByRole('heading', { name: 'Lists' })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Packing List\s*2/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /To-Do\s*1/ })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Add list' })).toBeInTheDocument()
+ expect(await loaded('packingPanel')).toMatchObject({ tripId: TRIP_ID, inlineHeader: false, view: 'common' })
+ expect(captured.todoPanel).toBeUndefined()
+ })
+
+ it('FE-PAGE-PLANNER-LISTS-002: the chosen subtab is remembered per trip for the session', async () => {
+ seed()
+ sessionStorage.setItem('trip-lists-subtab-99', 'todo')
+ renderLists()
+ // Another trip's choice does not carry over.
+ expect(screen.queryByRole('button', { name: 'Add new task' })).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: /To-Do/ }))
+ expect(sessionStorage.getItem(`trip-lists-subtab-${TRIP_ID}`)).toBe('todo')
+ expect(await loaded('todoPanel')).toMatchObject({ tripId: TRIP_ID, addItemSignal: 0 })
+ expect(screen.queryByRole('button', { name: 'Add list' })).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: /Packing List/ }))
+ expect(sessionStorage.getItem(`trip-lists-subtab-${TRIP_ID}`)).toBe('packing')
+ })
+
+ it('FE-PAGE-PLANNER-LISTS-003: each press of Add new task raises the to-do signal once more', async () => {
+ seed()
+ sessionStorage.setItem(`trip-lists-subtab-${TRIP_ID}`, 'todo')
+ renderLists()
+
+ const add = screen.getByRole('button', { name: 'Add new task' })
+ fireEvent.click(add)
+ fireEvent.click(add)
+ expect(await loaded('todoPanel')).toMatchObject({ addItemSignal: 2 })
+ })
+
+ it('FE-PAGE-PLANNER-LISTS-004: without the packing right there is no Add list, the other tools stay', async () => {
+ seed({ packingPermission: 'trip_owner' })
+ renderLists()
+
+ expect(screen.queryByRole('button', { name: 'Add list' })).toBeNull()
+ expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument()
+ await loaded('packingPanel')
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }))
+ expect(captured.packingPanel).toMatchObject({ openImportSignal: 1, addCategorySignal: 0 })
+ })
+
+ it('FE-PAGE-PLANNER-LISTS-005: an admin saves a template only when the list has items', async () => {
+ seed({ role: 'admin' })
+ const { unmount } = renderLists([])
+ expect(screen.queryByRole('button', { name: 'Save as template' })).toBeNull()
+ unmount()
+
+ renderLists()
+ fireEvent.click(screen.getByRole('button', { name: 'Save as template' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Add list' }))
+ expect(await loaded('packingPanel')).toMatchObject({ saveTemplateSignal: 1, addCategorySignal: 1 })
+ })
+
+ it('FE-PAGE-PLANNER-LISTS-006: the panel switches the view the template and export tools work on', async () => {
+ seed()
+ renderLists()
+ const panel = await loaded<{ onViewChange: (v: string) => void }>('packingPanel')
+ expect(captured.applyTemplate).toMatchObject({ tripId: TRIP_ID, visibility: 'common' })
+ expect(captured.exportMenu).toMatchObject({ tripId: TRIP_ID, view: 'common' })
+
+ act(() => { panel.onViewChange('personal') })
+ expect(captured.applyTemplate).toMatchObject({ visibility: 'personal' })
+ expect(captured.exportMenu).toMatchObject({ view: 'personal' })
+ expect(captured.packingPanel).toMatchObject({ view: 'personal' })
+ })
+})
diff --git a/client/src/pages/tripPlanner/ListsContainer.tsx b/client/src/pages/tripPlanner/ListsContainer.tsx
new file mode 100644
index 000000000..3ca7ceed8
--- /dev/null
+++ b/client/src/pages/tripPlanner/ListsContainer.tsx
@@ -0,0 +1,152 @@
+import React, { useState } from 'react'
+import { PackageCheck, ListTodo, ListPlus, Download, Plus, FolderPlus } from 'lucide-react'
+import ApplyTemplateButton from '../../components/Packing/ApplyTemplateButton'
+import PackingExportMenu from '../../components/Packing/PackingExportMenu'
+import { useTranslation } from '../../i18n'
+import { useAuthStore } from '../../store/authStore'
+import { useTripStore } from '../../store/tripStore'
+import { useCanDo } from '../../store/permissionsStore'
+import type { PackingItem, TodoItem } from '../../types'
+import { PackingListPanel, TodoListPanel } from './plannerLazy'
+import { LazyPanel } from './LazyPanel'
+
+export function ListsContainer({ tripId, packingItems, todoItems }: Readonly<{ tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }>) {
+ const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
+ return (sessionStorage.getItem(`trip-lists-subtab-${tripId}`) as 'packing' | 'todo') || 'packing'
+ })
+ const setSubTabPersist = (tab: 'packing' | 'todo') => { setSubTab(tab); sessionStorage.setItem(`trip-lists-subtab-${tripId}`, tab) }
+ const [importPackingSignal, setImportPackingSignal] = useState(0)
+ const [addCategorySignal, setAddCategorySignal] = useState(0)
+ const [saveTemplateSignal, setSaveTemplateSignal] = useState(0)
+ const [addTodoSignal, setAddTodoSignal] = useState(0)
+ const [packingView, setPackingView] = useState<'common' | 'personal'>('common')
+ const { t } = useTranslation()
+ const isAdmin = useAuthStore(s => s.user?.role === 'admin')
+ const trip = useTripStore(s => s.trip)
+ const canEditPacking = useCanDo()('packing_edit', trip)
+
+ const tabs = [
+ { id: 'packing' as const, label: t('todo.subtab.packing'), icon: PackageCheck, count: packingItems.length },
+ { id: 'todo' as const, label: t('todo.subtab.todo'), icon: ListTodo, count: todoItems.length },
+ ]
+
+ // The to-do view fills what is left under the bar, so its list and detail pane
+ // scroll inside the screen and the pane's buttons stay in sight.
+ const fill = subTab === 'todo'
+ return (
+
+
+
+
+ {t('trip.tabs.lists')}
+
+
+
+ {tabs.map(tab => {
+ const active = subTab === tab.id
+ const Icon = tab.icon
+ return (
+
+ )
+ })}
+
+
+ {subTab === 'packing' && (() => {
+ const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]'
+ // Export and Import carry only their icon, with the name as tooltip and label.
+ const iconBtnClass = 'inline-flex items-center justify-center px-2.5 py-[7px] sm:py-[9px] hover:opacity-[0.88] bg-accent text-accent-text'
+ const sharedBtnStyle: React.CSSProperties = {
+ appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
+ borderRadius: 10, fontSize: 'calc(13px * var(--fs-scale-body, 1))', fontWeight: 500,
+ }
+ return (
+
+ {canEditPacking && (
+
+ )}
+
+ {isAdmin && packingItems.length > 0 && (
+
+ )}
+
+
+
+ )
+ })()}
+ {subTab === 'todo' && (
+
+ )}
+
+
+
+ {subTab === 'packing' && (
+
+
+
+ )}
+ {subTab === 'todo' && (
+
+
+
+ )}
+
+
+ )
+}