import React, { useState, useEffect, useCallback, useMemo } from 'react' import ReactDOM from 'react-dom' import { useParams, useNavigate } from 'react-router-dom' import { useTripStore } from '../store/tripStore' import { useSettingsStore } from '../store/settingsStore' import { MapView } from '../components/Map/MapView' 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 TripMembersModal from '../components/Trips/TripMembersModal' import { ReservationModal } from '../components/Planner/ReservationModal' import ReservationsPanel from '../components/Planner/ReservationsPanel' import PackingListPanel from '../components/Packing/PackingListPanel' import FileManager from '../components/Files/FileManager' import BudgetPanel from '../components/Budget/BudgetPanel' 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 } from 'lucide-react' import { useTranslation } from '../i18n' import { addonsApi, accommodationsApi, authApi, tripsApi, assignmentsApi } from '../api/client' 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 type { Accommodation, TripMember, Day, Place, Reservation } from '../types' export default function TripPlannerPage(): React.ReactElement | null { const { id: tripId } = useParams<{ id: string }>() const navigate = useNavigate() const toast = useToast() const { t } = useTranslation() const { settings } = useSettingsStore() const tripStore = useTripStore() const { trip, days, places, assignments, packingItems, categories, reservations, budgetItems, files, selectedDayId, isLoading } = tripStore const [enabledAddons, setEnabledAddons] = useState>({ packing: true, budget: true, documents: true }) const [tripAccommodations, setTripAccommodations] = useState([]) const [allowedFileTypes, setAllowedFileTypes] = useState(null) const [tripMembers, setTripMembers] = useState([]) const loadAccommodations = useCallback(() => { if (tripId) accommodationsApi.list(tripId).then(d => setTripAccommodations(d.accommodations || [])).catch(() => {}) }, [tripId]) useEffect(() => { addonsApi.enabled().then(data => { const map = {} data.addons.forEach(a => { map[a.id] = true }) setEnabledAddons({ packing: !!map.packing, budget: !!map.budget, documents: !!map.documents, collab: !!map.collab }) }).catch(() => {}) authApi.getAppConfig().then(config => { if (config.allowed_file_types) setAllowedFileTypes(config.allowed_file_types) }).catch(() => {}) }, []) const TRIP_TABS = [ { id: 'plan', label: t('trip.tabs.plan') }, { id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort') }, ...(enabledAddons.packing ? [{ id: 'packliste', label: t('trip.tabs.packing'), shortLabel: t('trip.tabs.packingShort') }] : []), ...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget') }] : []), ...(enabledAddons.documents ? [{ id: 'dateien', label: t('trip.tabs.files') }] : []), ...(enabledAddons.collab ? [{ id: 'collab', label: 'Collab' }] : []), ] const [activeTab, setActiveTab] = useState(() => { const saved = sessionStorage.getItem(`trip-tab-${tripId}`) return saved || 'plan' }) const handleTabChange = (tabId: string): void => { setActiveTab(tabId) sessionStorage.setItem(`trip-tab-${tripId}`, tabId) if (tabId === 'finanzplan') tripStore.loadBudgetItems?.(tripId) if (tabId === 'dateien' && (!files || files.length === 0)) tripStore.loadFiles?.(tripId) } const { leftWidth, rightWidth, leftCollapsed, rightCollapsed, setLeftCollapsed, setRightCollapsed, startResizeLeft, startResizeRight } = useResizablePanels() const { selectedPlaceId, selectedAssignmentId, setSelectedPlaceId, selectAssignment } = usePlaceSelection() const [showDayDetail, setShowDayDetail] = useState(null) const [showPlaceForm, setShowPlaceForm] = useState(false) const [editingPlace, setEditingPlace] = useState(null) const [editingAssignmentId, setEditingAssignmentId] = useState(null) const [showTripForm, setShowTripForm] = useState(false) const [showMembersModal, setShowMembersModal] = useState(false) const [showReservationModal, setShowReservationModal] = useState(false) const [editingReservation, setEditingReservation] = useState(null) const [fitKey, setFitKey] = useState(0) const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null) const [deletePlaceId, setDeletePlaceId] = useState(null) // Load trip + files (needed for place inspector file section) useEffect(() => { if (tripId) { tripStore.loadTrip(tripId).catch(() => { toast.error(t('trip.toast.loadError')); navigate('/dashboard') }) tripStore.loadFiles(tripId) loadAccommodations() tripsApi.getMembers(tripId).then(d => { // Combine owner + members into one list const all = [d.owner, ...(d.members || [])].filter(Boolean) setTripMembers(all) }).catch(() => {}) } }, [tripId]) useEffect(() => { if (tripId) tripStore.loadReservations(tripId) }, [tripId]) useTripWebSocket(tripId) const mapPlaces = useCallback(() => { return places.filter(p => p.lat && p.lng) }, [places]) const { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay } = useRouteCalculation(tripStore, selectedDayId) const handleSelectDay = useCallback((dayId, skipFit) => { const changed = dayId !== selectedDayId tripStore.setSelectedDay(dayId) if (changed && !skipFit) setFitKey(k => k + 1) setMobileSidebarOpen(null) updateRouteForDay(dayId) }, [tripStore, updateRouteForDay, selectedDayId]) const handlePlaceClick = useCallback((placeId, assignmentId) => { if (assignmentId) { selectAssignment(assignmentId, placeId) } else { setSelectedPlaceId(placeId) } if (placeId) { setShowDayDetail(null); setLeftCollapsed(false); setRightCollapsed(false) } }, [selectAssignment, setSelectedPlaceId]) const handleMarkerClick = useCallback((placeId) => { const opening = placeId !== undefined setSelectedPlaceId(prev => prev === placeId ? null : placeId) if (opening) { setLeftCollapsed(false); setRightCollapsed(false) } }, []) const handleMapClick = useCallback(() => { setSelectedPlaceId(null) }, []) const handleSavePlace = useCallback(async (data) => { const pendingFiles = data._pendingFiles delete data._pendingFiles if (editingPlace) { // Always strip time fields from place update — time is per-assignment only const { place_time, end_time, ...placeData } = data await tripStore.updatePlace(tripId, editingPlace.id, placeData) // If editing from assignment context, save time per-assignment if (editingAssignmentId) { await assignmentsApi.updateTime(tripId, editingAssignmentId, { place_time: place_time || null, end_time: end_time || null }) await tripStore.refreshDays(tripId) } // Upload pending files with place_id if (pendingFiles?.length > 0) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) fd.append('place_id', editingPlace.id) try { await tripStore.addFile(tripId, fd) } catch {} } } toast.success(t('trip.toast.placeUpdated')) } else { const place = await tripStore.addPlace(tripId, data) if (pendingFiles?.length > 0 && place?.id) { for (const file of pendingFiles) { const fd = new FormData() fd.append('file', file) fd.append('place_id', place.id) try { await tripStore.addFile(tripId, fd) } catch {} } } toast.success(t('trip.toast.placeAdded')) } }, [editingPlace, editingAssignmentId, tripId, tripStore, toast]) const handleDeletePlace = useCallback((placeId) => { setDeletePlaceId(placeId) }, []) const confirmDeletePlace = useCallback(async () => { if (!deletePlaceId) return try { await tripStore.deletePlace(tripId, deletePlaceId) if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null) toast.success(t('trip.toast.placeDeleted')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }, [deletePlaceId, tripId, tripStore, toast, selectedPlaceId]) const handleAssignToDay = useCallback(async (placeId, dayId, position) => { const target = dayId || selectedDayId if (!target) { toast.error(t('trip.toast.selectDay')); return } try { await tripStore.assignPlaceToDay(tripId, target, placeId, position) toast.success(t('trip.toast.assignedToDay')) updateRouteForDay(target) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }, [selectedDayId, tripId, tripStore, toast, updateRouteForDay]) const handleRemoveAssignment = useCallback(async (dayId, assignmentId) => { try { await tripStore.removeAssignment(tripId, dayId, assignmentId) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }, [tripId, tripStore, toast, updateRouteForDay]) const handleReorder = useCallback((dayId, orderedIds) => { try { tripStore.reorderAssignments(tripId, dayId, orderedIds).catch(() => {}) // Update route immediately from orderedIds const dayItems = tripStore.assignments[String(dayId)] || [] const ordered = orderedIds.map(id => dayItems.find(a => a.id === id)).filter(Boolean) const waypoints = ordered.map(a => a.place).filter(p => p?.lat && p?.lng) if (waypoints.length >= 2) setRoute(waypoints.map(p => [p.lat, p.lng])) else setRoute(null) setRouteInfo(null) } catch { toast.error(t('trip.toast.reorderError')) } }, [tripId, tripStore, toast]) const handleUpdateDayTitle = useCallback(async (dayId, title) => { try { await tripStore.updateDayTitle(tripId, dayId, title) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }, [tripId, tripStore, toast]) const handleSaveReservation = async (data) => { try { if (editingReservation) { const r = await tripStore.updateReservation(tripId, editingReservation.id, data) toast.success(t('trip.toast.reservationUpdated')) setShowReservationModal(false) return r } else { const r = await tripStore.addReservation(tripId, { ...data, day_id: selectedDayId || null }) toast.success(t('trip.toast.reservationAdded')) setShowReservationModal(false) return r } } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } const handleDeleteReservation = async (id) => { try { await tripStore.deleteReservation(tripId, id); toast.success(t('trip.toast.deleted')) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } } const selectedPlace = selectedPlaceId ? places.find(p => p.id === selectedPlaceId) : null // Build placeId → order-number map from the selected day's assignments const dayOrderMap = useMemo(() => { if (!selectedDayId) return {} const da = assignments[String(selectedDayId)] || [] const sorted = [...da].sort((a, b) => a.order_index - b.order_index) const map = {} sorted.forEach((a, i) => { if (!a.place?.id) return if (!map[a.place.id]) map[a.place.id] = [] map[a.place.id].push(i + 1) }) return map }, [selectedDayId, assignments]) // Places assigned to selected day (with coords) — used for map fitting const dayPlaces = useMemo(() => { if (!selectedDayId) return [] const da = assignments[String(selectedDayId)] || [] return da.map(a => a.place).filter(p => p?.lat && p?.lng) }, [selectedDayId, assignments]) const mapTileUrl = settings.map_tile_url || 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' const defaultCenter = [settings.default_lat || 48.8566, settings.default_lng || 2.3522] const defaultZoom = settings.default_zoom || 10 const fontStyle = { fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif" } if (isLoading) { return (
{t('trip.loading')}
) } if (!trip) return null return (
navigate('/dashboard')} onShare={() => setShowMembersModal(true)} />
{TRIP_TABS.map(tab => { const isActive = activeTab === tab.id return ( ) })}
{/* Offset by navbar + tab bar (44px) */}
{activeTab === 'plan' && (
{ if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} /> {!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) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} />
{/* 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)} onAccommodationChange={loadAccommodations} /> ) })()} {selectedPlace && ( setSelectedPlaceId(null)} onEdit={() => { // When editing from assignment context, use assignment-level times if (selectedAssignmentId) { const assignmentObj = Object.values(assignments).flat().find(a => a.id === selectedAssignmentId) const placeWithAssignmentTimes = assignmentObj?.place ? { ...selectedPlace, place_time: assignmentObj.place.place_time, end_time: assignmentObj.place.end_time } : selectedPlace setEditingPlace(placeWithAssignmentTimes) } else { setEditingPlace(selectedPlace) } setEditingAssignmentId(selectedAssignmentId || null) setShowPlaceForm(true) }} onDelete={() => handleDeletePlace(selectedPlace.id)} onAssignToDay={handleAssignToDay} onRemoveAssignment={handleRemoveAssignment} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} 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 {} }} onUpdatePlace={async (placeId, data) => { try { await tripStore.updatePlace(tripId, placeId, data) } catch (err: unknown) { toast.error(err instanceof Error ? err.message : 'Unknown error') } }} /> )} {mobileSidebarOpen && ReactDOM.createPortal(
setMobileSidebarOpen(null)}>
e.stopPropagation()}>
{mobileSidebarOpen === 'left' ? t('trip.mobilePlan') : t('trip.mobilePlaces')}
{mobileSidebarOpen === 'left' ? { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={handlePlaceClick} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText }) } }} reservations={reservations} onAddReservation={(dayId) => { setEditingReservation(null); tripStore.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); setSelectedAssignmentId(null); setMobileSidebarOpen(null) }} accommodations={tripAccommodations} /> : { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} days={days} isMobile /> }
, document.body )}
)} {activeTab === 'buchungen' && (
{ setEditingReservation(null); setShowReservationModal(true) }} onEdit={(r) => { setEditingReservation(r); setShowReservationModal(true) }} onDelete={handleDeleteReservation} onNavigateToFiles={() => handleTabChange('dateien')} />
)} {activeTab === 'packliste' && (
)} {activeTab === 'finanzplan' && (
)} {activeTab === 'dateien' && (
tripStore.addFile(tripId, fd)} onDelete={(id) => tripStore.deleteFile(tripId, id)} onUpdate={null} places={places} reservations={reservations} tripId={tripId} allowedFileTypes={allowedFileTypes} />
)} {activeTab === 'collab' && (
)}
{ setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null) }} onSave={handleSavePlace} place={editingPlace} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripStore.addCategory?.(cat)} /> setShowTripForm(false)} onSave={async (data) => { await tripStore.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> { setShowReservationModal(false); setEditingReservation(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={(fd) => tripStore.addFile(tripId, fd)} onFileDelete={(id) => tripStore.deleteFile(tripId, id)} /> setDeletePlaceId(null)} onConfirm={confirmDeletePlace} title={t('common.delete')} message={t('trip.confirm.deletePlace')} />
) }