import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import ReactDOM from 'react-dom' import { useParams, useNavigate, useSearchParams } from 'react-router-dom' 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 } from '../components/Map/MapCompassPill' import { getCached, fetchPhoto } from '../services/photoService' import DayPlanSidebar from '../components/Planner/DayPlanSidebar' import PlacesSidebar from '../components/Planner/PlacesSidebar' import PlaceInspector from '../components/Planner/PlaceInspector' import DayDetailPanel from '../components/Planner/DayDetailPanel' import PlaceFormModal from '../components/Planner/PlaceFormModal' import TripFormModal from '../components/Trips/TripFormModal' import SlidingTabs from '../components/shared/SlidingTabs' import TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import { TransportModal } from '../components/Planner/TransportModal' import BookingImportModal from '../components/Planner/BookingImportModal' import AirTrailImportModal from '../components/Planner/AirTrailImportModal' // MemoriesPanel moved to Journey addon import ReservationsPanel from '../components/Planner/ReservationsPanel' import PackingListPanel from '../components/Packing/PackingListPanel' import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton' import TodoListPanel from '../components/Todo/TodoListPanel' import FileManager from '../components/Files/FileManager' import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel' import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types' import type { BudgetItem } from '../types' import CollabPanel from '../components/Collab/CollabPanel' import Navbar from '../components/Layout/Navbar' 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 { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi, mapsApi } from '../api/client' import { accommodationRepo } from '../repo/accommodationRepo' import { offlineDb } from '../db/offlineDb' import { useAuthStore } from '../store/authStore' import ConfirmDialog from '../components/shared/ConfirmDialog' import { useResizablePanels } from '../hooks/useResizablePanels' 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, Upload, Plus, Trash2, FolderPlus } from 'lucide-react' import { useTripPlanner } from './tripPlanner/useTripPlanner' import { usePoiExplore } from '../components/Map/usePoiExplore' import PoiCategoryPill from '../components/Map/PoiCategoryPill' function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) { 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 [clearCheckedSignal, setClearCheckedSignal] = useState(0) const [saveTemplateSignal, setSaveTemplateSignal] = useState(0) const [addTodoSignal, setAddTodoSignal] = useState(0) const { t } = useTranslation() const isAdmin = useAuthStore(s => s.user?.role === 'admin') 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 }, ] return (

{t('trip.tabs.lists')}

{tabs.map(tab => { const active = subTab === tab.id const Icon = tab.icon return ( ) })}
{subTab === 'packing' && (() => { const packingAbgehakt = packingItems.filter(i => i.checked).length const sharedBtnClass = 'inline-flex items-center gap-1.5 px-2.5 sm:px-[14px] py-[7px] sm:py-[9px] hover:opacity-[0.88]' const sharedBtnStyle: React.CSSProperties = { appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit', borderRadius: 10, fontSize: 13, fontWeight: 500, } return (
{packingAbgehakt > 0 && ( )} {isAdmin && packingItems.length > 0 && ( )}
) })()} {subTab === 'todo' && ( )}
{subTab === 'packing' && } {subTab === 'todo' && }
) } export default function TripPlannerPage(): React.ReactElement | null { // Page = wiring container: the entire planner state machine (store, tabs, // selection, CRUD handlers with undo, map filters, splash) lives in the hook. const { tripId, navigate, toast, t, language, settings, placesPhotosEnabled, trip, days, places, assignments, packingItems, todoItems, categories, reservations, budgetItems, files, selectedDayId, isLoading, tripActions, can, canUploadFiles, pushUndo, undo, canUndo, lastActionLabel, handleUndo, enabledAddons, collabFeatures, tripAccommodations, setTripAccommodations, allowedFileTypes, tripMembers, setTripMembers, loadAccommodations, TRANSPORT_TYPES, TRIP_TABS, activeTab, setActiveTab, handleTabChange, leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight, selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment, showDayDetail, setShowDayDetail, dayDetailCollapsed, setDayDetailCollapsed, showPlaceForm, setShowPlaceForm, editingPlace, setEditingPlace, prefillCoords, setPrefillCoords, editingAssignmentId, setEditingAssignmentId, showTripForm, setShowTripForm, showMembersModal, setShowMembersModal, showReservationModal, setShowReservationModal, editingReservation, setEditingReservation, showBookingImport, setShowBookingImport, bookingImportAvailable, airTrailAvailable, showAirTrailImport, setShowAirTrailImport, bookingForAssignmentId, setBookingForAssignmentId, showTransportModal, setShowTransportModal, editingTransport, setEditingTransport, transportModalDayId, setTransportModalDayId, routeShown, setRouteShown, routeProfile, setRouteProfile, fitKey, setFitKey, mobileSidebarOpen, setMobileSidebarOpen, mobilePlanScrollTopRef, mobilePlacesScrollTopRef, deletePlaceId, setDeletePlaceId, deletePlaceIds, setDeletePlaceIds, visibleConnections, setVisibleConnections, toggleConnection, mapTransportDetail, setMapTransportDetail, isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter, expandedDayIds, setExpandedDayIds, mapPlaces, route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay, handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi, handleSavePlace, openPlaceEditor, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces, handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle, handleSaveReservation, handleSaveTransport, handleDeleteReservation, selectedPlace, dayOrderMap, dayPlaces, mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone, } = useTripPlanner() const poi = usePoiExplore() const [glMap, setGlMap] = useState(null) const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false // Costs expense editor opened from a booking modal (save-then-open). Lives at the // page level so it has tripMembers / base currency / current user available. const meId = useAuthStore(s => s.user?.id ?? -1) const displayCurrency = useSettingsStore(s => s.settings.default_currency) const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase() const loadBudgetItems = useTripStore(s => s.loadBudgetItems) const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null) const openBookingExpense = (req: BookingExpenseRequest) => { if (req.editItem) setBookingExpense({ editing: req.editItem }) else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill }) } if (isLoading || !splashDone) { return (
Loading
{trip?.title || 'TREK'}
{t('trip.loadingPhotos')}
{[0, 1, 2].map(i => (
))}
) } if (!trip) return null return (
navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
({ id: tab.id, label: {tab.shortLabel || tab.label}, title: tab.label, icon: tab.icon, }))} activeTab={activeTab} onChange={handleTabChange} />
{/* Offset by navbar + tab bar (44px) */}
{activeTab === 'plan' && (
{ const r = reservations.find(x => x.id === rid) if (r) setMapTransportDetail(r) }} pois={poi.pois} onPoiClick={openAddPlaceFromPoi} onViewportChange={poi.onViewportChange} onMapReady={setGlMap} /> {(poiPillEnabled || glMap) && (
{poiPillEnabled && ( )} {glMap && }
)}
{ if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} externalTransportDetail={mapTransportDetail} onExternalTransportDetailHandled={() => setMapTransportDetail(null)} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true) } : undefined} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true) } : undefined} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => handleTabChange('dateien')} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onRouteRefresh={() => { if (selectedDayId) updateRouteForDay(selectedDayId) }} onAddBookingToAssignment={can('day_edit', trip) ? (dayId, assignmentId) => { tripActions.setSelectedDay(dayId); setBookingForAssignmentId(assignmentId); setEditingReservation(null); setShowReservationModal(true) } : undefined} /> {!leftCollapsed && (
e.currentTarget.style.background = 'rgba(0,0,0,0.08)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} /> )}
{!rightCollapsed && (
e.currentTarget.style.background = 'rgba(0,0,0,0.08)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} /> )}
{ setEditingPlace(null); setShowPlaceForm(true) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => openPlaceEditor(place)} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} days={days} isMobile={false} />
{/* Mobile sidebar buttons — portal to body to escape Leaflet touch handling */} {activeTab === 'plan' && !mobileSidebarOpen && !showPlaceForm && !showMembersModal && !showReservationModal && ReactDOM.createPortal(
, document.body )} {showDayDetail && !selectedPlace && (() => { const currentDay = days.find(d => d.id === showDayDetail.id) || showDayDetail const dayAssignments = assignments[String(currentDay.id)] || [] const geoPlace = dayAssignments.find(a => a.place?.lat && a.place?.lng)?.place || places.find(p => p.lat && p.lng) return ( { setShowDayDetail(null); handleSelectDay(null) }} onAccommodationChange={loadAccommodations} leftWidth={isMobile ? 0 : (leftCollapsed ? 0 : leftWidth)} rightWidth={isMobile ? 0 : (rightCollapsed ? 0 : rightWidth)} collapsed={dayDetailCollapsed} onToggleCollapse={() => setDayDetailCollapsed(c => !c)} mobile={isMobile} /> ) })()} {selectedPlace && !isMobile && ( setSelectedPlaceId(null)} onEdit={() => openPlaceEditor(selectedPlace, selectedAssignmentId)} onDelete={() => handleDeletePlace(selectedPlace.id)} onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} onSetParticipants={async (assignmentId, dayId, userIds) => { try { const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) useTripStore.setState(state => ({ assignments: { ...state.assignments, [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => a.id === assignmentId ? { ...a, participants: data.participants } : a ), } })) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} leftWidth={(isMobile || window.innerWidth < 900) ? 0 : (leftCollapsed ? 0 : leftWidth)} rightWidth={(isMobile || window.innerWidth < 900) ? 0 : (rightCollapsed ? 0 : rightWidth)} /> )} {selectedPlace && isMobile && ReactDOM.createPortal(
setSelectedPlaceId(null)}>
e.stopPropagation()}> setSelectedPlaceId(null)} onEdit={() => { openPlaceEditor(selectedPlace, selectedAssignmentId); setSelectedPlaceId(null) }} onDelete={() => { handleDeletePlace(selectedPlace.id); setSelectedPlaceId(null) }} onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} tripMembers={tripMembers} onSetParticipants={async (assignmentId, dayId, userIds) => { try { const data = await assignmentsApi.setParticipants(tripId, assignmentId, userIds) useTripStore.setState(state => ({ assignments: { ...state.assignments, [String(dayId)]: (state.assignments[String(dayId)] || []).map(a => a.id === assignmentId ? { ...a, participants: data.participants } : a ), } })) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} onUpdatePlace={async (placeId, data) => { try { await tripActions.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) } }} leftWidth={0} rightWidth={0} />
, document.body )} {mobileSidebarOpen && ReactDOM.createPortal(
setMobileSidebarOpen(null)}>
e.stopPropagation()}>
{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}
{mobileSidebarOpen === 'left' ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} showRouteToolsWhenExpanded /> : { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { openPlaceEditor(place); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} /> }
, document.body )}
)} {activeTab === 'transports' && (
TRANSPORT_TYPES.has(r.type))} days={days} assignments={assignments} files={files} onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }} onImport={() => setShowBookingImport(true)} bookingImportAvailable={bookingImportAvailable} onAirTrailImport={() => setShowAirTrailImport(true)} airTrailAvailable={airTrailAvailable} onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} titleKey="transport.title" addManualKey="transport.addManual" />
)} {activeTab === 'buchungen' && (
!TRANSPORT_TYPES.has(r.type))} days={days} assignments={assignments} files={files} onAdd={() => { setEditingReservation(null); setShowReservationModal(true) }} onImport={() => setShowBookingImport(true)} bookingImportAvailable={bookingImportAvailable} onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} />
)} {activeTab === 'listen' && (
)} {activeTab === 'finanzplan' && (
)} {activeTab === 'dateien' && (
tripActions.addFile(tripId, fd)} onDelete={(id) => tripActions.deleteFile(tripId, id)} onUpdate={(id, data) => tripActions.loadFiles(tripId)} places={places} days={days} assignments={assignments} reservations={reservations} tripId={tripId} allowedFileTypes={allowedFileTypes} />
)} {activeTab === 'collab' && (
)}
{ setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingPlace ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} /> setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} /> {showTransportModal && { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />} {bookingExpense && ( setBookingExpense(null)} onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }} /> )} setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} /> setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} /> setDeletePlaceId(null)} onConfirm={confirmDeletePlace} title={t('common.delete')} message={t('trip.confirm.deletePlace')} /> setDeletePlaceIds(null)} onConfirm={confirmDeletePlaces} title={t('common.delete')} message={t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })} />
) }