mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #714 from mauriceboe/feat/selective-file-import-perf
feat(import): selective GPX/KML element import and performance improvements
This commit is contained in:
@@ -190,18 +190,27 @@ export const placesApi = {
|
||||
update: (tripId: number | string, id: number | string, data: Record<string, unknown>) => apiClient.put(`/trips/${tripId}/places/${id}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, id: number | string) => apiClient.delete(`/trips/${tripId}/places/${id}`).then(r => r.data),
|
||||
searchImage: (tripId: number | string, id: number | string) => apiClient.get(`/trips/${tripId}/places/${id}/image`).then(r => r.data),
|
||||
importGpx: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
importGpx: (tripId: number | string, file: File, opts?: { waypoints?: boolean; routes?: boolean; tracks?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.waypoints !== undefined) fd.append('importWaypoints', String(opts.waypoints))
|
||||
if (opts?.routes !== undefined) fd.append('importRoutes', String(opts.routes))
|
||||
if (opts?.tracks !== undefined) fd.append('importTracks', String(opts.tracks))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/gpx`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importMapFile: (tripId: number | string, file: File) => {
|
||||
const fd = new FormData(); fd.append('file', file)
|
||||
importMapFile: (tripId: number | string, file: File, opts?: { points?: boolean; paths?: boolean }) => {
|
||||
const fd = new FormData()
|
||||
fd.append('file', file)
|
||||
if (opts?.points !== undefined) fd.append('importPoints', String(opts.points))
|
||||
if (opts?.paths !== undefined) fd.append('importPaths', String(opts.paths))
|
||||
return apiClient.post(`/trips/${tripId}/places/import/map`, fd, { headers: { 'Content-Type': 'multipart/form-data' } }).then(r => r.data)
|
||||
},
|
||||
importGoogleList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/google-list`, { url }).then(r => r.data),
|
||||
importNaverList: (tripId: number | string, url: string) =>
|
||||
apiClient.post(`/trips/${tripId}/places/import/naver-list`, { url }).then(r => r.data),
|
||||
bulkDelete: (tripId: number | string, ids: number[]) =>
|
||||
apiClient.post(`/trips/${tripId}/places/bulk-delete`, { ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export const assignmentsApi = {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
import { render, screen } from '../../../tests/helpers/render'
|
||||
import { fireEvent } from '@testing-library/react'
|
||||
import { fireEvent, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { resetAllStores } from '../../../tests/helpers/store'
|
||||
import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import * as photoService from '../../services/photoService'
|
||||
@@ -16,10 +17,13 @@ vi.mock('react-leaflet', () => ({
|
||||
data-lng={position[1]}
|
||||
onClick={() => eventHandlers?.click?.()}
|
||||
>
|
||||
<button
|
||||
data-testid="marker-hover-trigger"
|
||||
onClick={() => eventHandlers?.mouseover?.({ originalEvent: { clientX: 100, clientY: 100 } })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Tooltip: ({ children }: any) => <div data-testid="tooltip">{children}</div>,
|
||||
Polyline: ({ positions }: any) => <div data-testid="polyline" data-points={JSON.stringify(positions)} />,
|
||||
CircleMarker: () => <div data-testid="circle-marker" />,
|
||||
Circle: () => <div data-testid="circle" />,
|
||||
@@ -101,22 +105,26 @@ describe('MapView', () => {
|
||||
expect(onMarkerClick).toHaveBeenCalledWith(42)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', () => {
|
||||
it('FE-COMP-MAPVIEW-004: tooltip shows place name', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945 })]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Eiffel Tower')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', () => {
|
||||
it('FE-COMP-MAPVIEW-005: tooltip shows category name when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Louvre', lat: 48.86, lng: 2.337, category_name: 'Museum', category_icon: null }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('Museum')
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-006: renders polyline when route has 2+ points', () => {
|
||||
render(<MapView route={[[48.0, 2.0], [49.0, 3.0]]} />)
|
||||
render(<MapView route={[[[48.0, 2.0], [49.0, 3.0]]]} />)
|
||||
expect(screen.getByTestId('polyline')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -126,7 +134,7 @@ describe('MapView', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-008: does not render polyline for single-point route', () => {
|
||||
render(<MapView route={[[48.0, 2.0]]} />)
|
||||
render(<MapView route={[[[48.0, 2.0]]]} />)
|
||||
expect(screen.queryByTestId('polyline')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -145,7 +153,7 @@ describe('MapView', () => {
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-011: renders RouteLabel marker when routeSegments provided with route', () => {
|
||||
const route = [[48.0, 2.0], [49.0, 3.0]] as [number, number][]
|
||||
const route = [[[48.0, 2.0], [49.0, 3.0]]] as [number, number][][][]
|
||||
const routeSegments = [
|
||||
{ mid: [48.5, 2.5] as [number, number], from: 0, to: 1, walkingText: '10 min', drivingText: '3 min' },
|
||||
]
|
||||
@@ -191,11 +199,13 @@ describe('MapView', () => {
|
||||
vi.mocked(photoService.getCached).mockReturnValue(null)
|
||||
})
|
||||
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', () => {
|
||||
it('FE-COMP-MAPVIEW-016: tooltip shows address when present', async () => {
|
||||
const user = userEvent.setup()
|
||||
const places = [
|
||||
buildMapPlace({ name: 'Eiffel Tower', lat: 48.8584, lng: 2.2945, address: '5 Av. Anatole France' }),
|
||||
]
|
||||
render(<MapView places={places} />)
|
||||
await user.click(screen.getByTestId('marker-hover-trigger'))
|
||||
expect(screen.getByTestId('tooltip').textContent).toContain('5 Av. Anatole France')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, useMemo, useCallback, createElement, memo } from 'react'
|
||||
import DOM from 'react-dom'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { MapContainer, TileLayer, Marker, Tooltip, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
@@ -369,6 +369,35 @@ function LocationTracker() {
|
||||
)
|
||||
}
|
||||
|
||||
interface MemoMarkerProps {
|
||||
place: any
|
||||
isSelected: boolean
|
||||
orderNumbers: number[] | null
|
||||
photoUrl: string | null
|
||||
onClickPlace: (id: number) => void
|
||||
onHover: (place: any, x: number, y: number) => void
|
||||
onHoverOut: () => void
|
||||
}
|
||||
|
||||
const MemoMarker = memo(function MemoMarker({
|
||||
place, isSelected, orderNumbers, photoUrl, onClickPlace, onHover, onHoverOut,
|
||||
}: MemoMarkerProps) {
|
||||
const icon = createPlaceIcon({ ...place, image_url: photoUrl }, orderNumbers, isSelected)
|
||||
return (
|
||||
<Marker
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onClickPlace(place.id),
|
||||
mouseover: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mousemove: (e: any) => onHover(place, e.originalEvent.clientX, e.originalEvent.clientY),
|
||||
mouseout: onHoverOut,
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
export const MapView = memo(function MapView({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -408,18 +437,48 @@ export const MapView = memo(function MapView({
|
||||
return { paddingTopLeft: [left, top], paddingBottomRight: [right, bottom] }
|
||||
}, [leftWidth, rightWidth, hasInspector, hasDayDetail])
|
||||
|
||||
// Hover state for the single tooltip overlay (replaces per-marker <Tooltip>)
|
||||
const [hoveredPlace, setHoveredPlace] = useState<any>(null)
|
||||
const [tooltipPos, setTooltipPos] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const handleMarkerHover = useCallback((place: any, x: number, y: number) => {
|
||||
setHoveredPlace(place)
|
||||
setTooltipPos({ x, y })
|
||||
}, [])
|
||||
|
||||
const handleMarkerHoverOut = useCallback(() => {
|
||||
setHoveredPlace(null)
|
||||
}, [])
|
||||
|
||||
const handleMarkerClick = useCallback((id: number) => {
|
||||
onMarkerClick?.(id)
|
||||
}, [onMarkerClick])
|
||||
|
||||
// photoUrls: only base64 thumbs for smooth map zoom
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>(getAllThumbs)
|
||||
const placesPhotosEnabled = useAuthStore(s => s.placesPhotosEnabled)
|
||||
// Batch photo state updates through a RAF so N simultaneous photo loads
|
||||
// collapse into a single re-render instead of N separate renders.
|
||||
const pendingThumbsRef = useRef<Record<string, string>>({})
|
||||
const thumbRafRef = useRef<number | null>(null)
|
||||
|
||||
// Fetch photos via shared service — subscribe to thumb (base64) availability
|
||||
const placeIds = useMemo(() => places.map(p => p.id).join(','), [places])
|
||||
useEffect(() => {
|
||||
if (!places || places.length === 0 || !placesPhotosEnabled) return
|
||||
const cleanups: (() => void)[] = []
|
||||
|
||||
const setThumb = (cacheKey: string, thumb: string) => {
|
||||
setPhotoUrls(prev => prev[cacheKey] === thumb ? prev : { ...prev, [cacheKey]: thumb })
|
||||
pendingThumbsRef.current[cacheKey] = thumb
|
||||
if (thumbRafRef.current !== null) return
|
||||
thumbRafRef.current = requestAnimationFrame(() => {
|
||||
thumbRafRef.current = null
|
||||
const pending = pendingThumbsRef.current
|
||||
pendingThumbsRef.current = {}
|
||||
setPhotoUrls(prev => {
|
||||
const hasChange = Object.entries(pending).some(([k, v]) => prev[k] !== v)
|
||||
return hasChange ? { ...prev, ...pending } : prev
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
for (const place of places) {
|
||||
@@ -432,11 +491,9 @@ export const MapView = memo(function MapView({
|
||||
continue
|
||||
}
|
||||
|
||||
// Subscribe for when thumb becomes available
|
||||
cleanups.push(onThumbReady(cacheKey, thumb => setThumb(cacheKey, thumb)))
|
||||
|
||||
if (!cached && !isLoading(cacheKey)) {
|
||||
// Use the persisted proxy URL as photoId so photoService generates a base64 thumb from it
|
||||
const photoId = place.image_url || place.google_place_id || place.osm_id
|
||||
if (photoId || (place.lat && place.lng)) {
|
||||
fetchPhoto(cacheKey, photoId || `coords:${place.lat}:${place.lng}`, place.lat, place.lng, place.name)
|
||||
@@ -444,7 +501,13 @@ export const MapView = memo(function MapView({
|
||||
}
|
||||
}
|
||||
|
||||
return () => cleanups.forEach(fn => fn())
|
||||
return () => {
|
||||
cleanups.forEach(fn => fn())
|
||||
if (thumbRafRef.current !== null) {
|
||||
cancelAnimationFrame(thumbRafRef.current)
|
||||
thumbRafRef.current = null
|
||||
}
|
||||
}
|
||||
}, [placeIds, placesPhotosEnabled])
|
||||
|
||||
const clusterIconCreateFunction = useCallback((cluster) => {
|
||||
@@ -457,57 +520,49 @@ export const MapView = memo(function MapView({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const isTouchDevice = typeof window !== 'undefined' && ('ontouchstart' in window || navigator.maxTouchPoints > 0)
|
||||
const isTouchDevice = typeof window !== 'undefined' && navigator.maxTouchPoints > 0
|
||||
|
||||
const markers = useMemo(() => places.map((place) => {
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const pck = place.google_place_id || place.osm_id || `${place.lat},${place.lng}`
|
||||
const resolvedPhoto = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const photoUrl = (pck && photoUrls[pck]) || place.image_url || null
|
||||
const orderNumbers = dayOrderMap[place.id] ?? null
|
||||
const icon = createPlaceIcon({ ...place, image_url: resolvedPhoto }, orderNumbers, isSelected)
|
||||
|
||||
return (
|
||||
<Marker
|
||||
<MemoMarker
|
||||
key={place.id}
|
||||
position={[place.lat, place.lng]}
|
||||
icon={icon}
|
||||
eventHandlers={{
|
||||
click: () => onMarkerClick && onMarkerClick(place.id),
|
||||
}}
|
||||
zIndexOffset={isSelected ? 1000 : 0}
|
||||
>
|
||||
<Tooltip
|
||||
direction="right"
|
||||
offset={[0, 0]}
|
||||
opacity={1}
|
||||
className="map-tooltip"
|
||||
permanent={isTouchDevice && isSelected}
|
||||
>
|
||||
<div style={{ fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif" }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: 'var(--text-primary)', whiteSpace: 'nowrap' }}>
|
||||
{place.name}
|
||||
</div>
|
||||
{place.category_name && (() => {
|
||||
const CatIcon = getCategoryIcon(place.category_icon)
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: place.category_color || 'var(--text-muted)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: 'var(--text-muted)' }}>{place.category_name}</span>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{place.address && (
|
||||
<div style={{ fontSize: 11, color: 'var(--text-faint)', marginTop: 2, maxWidth: 180, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{place.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
place={place}
|
||||
isSelected={isSelected}
|
||||
orderNumbers={orderNumbers}
|
||||
photoUrl={photoUrl}
|
||||
onClickPlace={handleMarkerClick}
|
||||
onHover={handleMarkerHover}
|
||||
onHoverOut={handleMarkerHoverOut}
|
||||
/>
|
||||
)
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, onMarkerClick, isTouchDevice])
|
||||
}), [places, selectedPlaceId, dayOrderMap, photoUrls, handleMarkerClick, handleMarkerHover, handleMarkerHoverOut])
|
||||
|
||||
const gpxPolylines = useMemo(() => places.flatMap(place => {
|
||||
if (!place.route_geometry) return []
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return []
|
||||
return [(
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)]
|
||||
} catch { return [] }
|
||||
}), [places])
|
||||
|
||||
const TooltipOverlay = hoveredPlace && tooltipPos && !isTouchDevice
|
||||
const CatIcon = TooltipOverlay ? getCategoryIcon(hoveredPlace.category_icon) : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<MapContainer
|
||||
id="trek-map"
|
||||
center={center}
|
||||
@@ -548,15 +603,18 @@ export const MapView = memo(function MapView({
|
||||
{markers}
|
||||
</MarkerClusterGroup>
|
||||
|
||||
{route && route.length > 1 && (
|
||||
{route && route.length > 0 && (
|
||||
<>
|
||||
<Polyline
|
||||
positions={route}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
{route.map((seg, i) => seg.length > 1 && (
|
||||
<Polyline
|
||||
key={i}
|
||||
positions={seg}
|
||||
color="#111827"
|
||||
weight={3}
|
||||
opacity={0.9}
|
||||
dashArray="6, 5"
|
||||
/>
|
||||
))}
|
||||
{routeSegments.map((seg, i) => (
|
||||
<RouteLabel key={i} midpoint={seg.mid} from={seg.from} to={seg.to} walkingText={seg.walkingText} drivingText={seg.drivingText} />
|
||||
))}
|
||||
@@ -564,22 +622,7 @@ export const MapView = memo(function MapView({
|
||||
)}
|
||||
|
||||
{/* GPX imported route geometries */}
|
||||
{places.map((place) => {
|
||||
if (!place.route_geometry) return null
|
||||
try {
|
||||
const coords = JSON.parse(place.route_geometry) as [number, number][]
|
||||
if (!coords || coords.length < 2) return null
|
||||
return (
|
||||
<Polyline
|
||||
key={`gpx-${place.id}`}
|
||||
positions={coords}
|
||||
color={place.category_color || '#3b82f6'}
|
||||
weight={3.5}
|
||||
opacity={0.75}
|
||||
/>
|
||||
)
|
||||
} catch { return null }
|
||||
})}
|
||||
{gpxPolylines}
|
||||
|
||||
<ReservationOverlay
|
||||
reservations={visibleReservations}
|
||||
@@ -588,5 +631,38 @@ export const MapView = memo(function MapView({
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
</MapContainer>
|
||||
|
||||
{TooltipOverlay && (
|
||||
<div data-testid="tooltip" style={{
|
||||
position: 'fixed',
|
||||
left: tooltipPos.x + 14,
|
||||
top: tooltipPos.y - 10,
|
||||
zIndex: 9999,
|
||||
pointerEvents: 'none',
|
||||
background: 'white',
|
||||
borderRadius: 8,
|
||||
boxShadow: '0 2px 10px rgba(0,0,0,0.15)',
|
||||
padding: '6px 10px',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||
maxWidth: 220,
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
<div style={{ fontWeight: 600, fontSize: 12, color: '#111827', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.name}
|
||||
</div>
|
||||
{hoveredPlace.category_name && CatIcon && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 1 }}>
|
||||
<CatIcon size={10} style={{ color: hoveredPlace.category_color || '#6b7280', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 11, color: '#6b7280' }}>{hoveredPlace.category_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{hoveredPlace.address && (
|
||||
<div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{hoveredPlace.address}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -440,26 +440,27 @@ describe('DayPlanSidebar', () => {
|
||||
type: 'flight',
|
||||
title: 'Paris to London',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
|
||||
expect(screen.getByText('Paris to London')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-031: clicking transport item shows detail modal', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-031: clicking transport item calls onEditTransport', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel Day' })
|
||||
const reservation = buildReservation({
|
||||
id: 200,
|
||||
type: 'flight',
|
||||
title: 'Air France 123',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation] })} />)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation], onEditTransport })} />)
|
||||
await user.click(screen.getByText('Air France 123'))
|
||||
// Detail modal should appear (shows the title again in the modal)
|
||||
await waitFor(() => {
|
||||
const titles = screen.getAllByText('Air France 123')
|
||||
expect(titles.length).toBeGreaterThan(1)
|
||||
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 200 }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -664,6 +665,7 @@ describe('DayPlanSidebar', () => {
|
||||
const reservation = buildReservation({
|
||||
id: 200, type: 'flight', title: 'CDG to LHR',
|
||||
reservation_time: '2025-06-01T08:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day],
|
||||
@@ -684,6 +686,8 @@ describe('DayPlanSidebar', () => {
|
||||
id: 201, type: 'flight', title: 'Transatlantic',
|
||||
reservation_time: '2025-06-01T22:00:00',
|
||||
reservation_end_time: '2025-06-02T06:00:00',
|
||||
day_id: 10,
|
||||
end_day_id: 11,
|
||||
} as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day1, day2],
|
||||
@@ -704,6 +708,8 @@ describe('DayPlanSidebar', () => {
|
||||
id: 300, type: 'car', title: 'Renault Rental',
|
||||
reservation_time: '2025-06-01T09:00:00',
|
||||
reservation_end_time: '2025-06-03T17:00:00',
|
||||
day_id: 10,
|
||||
end_day_id: 12,
|
||||
} as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day1, day2, day3],
|
||||
@@ -786,20 +792,22 @@ describe('DayPlanSidebar', () => {
|
||||
|
||||
// ── Transport detail modal with metadata ───────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-051: transport detail modal shows flight metadata', async () => {
|
||||
it('FE-PLANNER-DAYPLAN-051: clicking flight transport calls onEditTransport with reservation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEditTransport = vi.fn()
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Travel' })
|
||||
const reservation = {
|
||||
...buildReservation({
|
||||
id: 202, type: 'flight', title: 'Paris to Berlin',
|
||||
reservation_time: '2025-06-01T07:30:00',
|
||||
day_id: 10,
|
||||
}),
|
||||
metadata: JSON.stringify({ airline: 'Lufthansa', flight_number: 'LH1234', departure_airport: 'CDG', arrival_airport: 'BER' }),
|
||||
}
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any] })} />)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days: [day], reservations: [reservation as any], onEditTransport })} />)
|
||||
await user.click(screen.getByText('Paris to Berlin'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Lufthansa')).toBeInTheDocument()
|
||||
expect(onEditTransport).toHaveBeenCalledWith(expect.objectContaining({ id: 202, type: 'flight' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1124,6 +1132,7 @@ describe('DayPlanSidebar', () => {
|
||||
const flight = buildReservation({
|
||||
id: 201, type: 'flight', title: 'Afternoon Flight',
|
||||
reservation_time: '2025-06-01T14:00:00',
|
||||
day_id: 10,
|
||||
})
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [flight],
|
||||
@@ -1683,4 +1692,42 @@ describe('DayPlanSidebar', () => {
|
||||
// Optimize button should not be visible when no day is selected
|
||||
expect(screen.queryByRole('button', { name: /optimize/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Edit reservation pencil button ───────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-097: pencil button on non-transport reservation calls onEditReservation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Hotel du Lac' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 77, trip_id: 1, type: 'hotel', status: 'pending', assignment_id: 99 } as any)
|
||||
const onEditReservation = vi.fn()
|
||||
const onEditTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditReservation).toHaveBeenCalledWith(res)
|
||||
expect(onEditTransport).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-098: pencil button on transport reservation calls onEditTransport', async () => {
|
||||
const user = userEvent.setup()
|
||||
const place = buildPlace({ id: 1, name: 'Geneva Airport' })
|
||||
const day = buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' })
|
||||
const assignment = buildAssignment({ id: 99, day_id: 10, order_index: 0, place })
|
||||
const res = buildReservation({ id: 88, trip_id: 1, type: 'flight', status: 'pending', assignment_id: 99 } as any)
|
||||
const onEditReservation = vi.fn()
|
||||
const onEditTransport = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({
|
||||
days: [day], places: [place], assignments: { '10': [assignment] }, reservations: [res],
|
||||
onEditReservation, onEditTransport,
|
||||
})} />)
|
||||
const pencil = screen.getByTitle(/edit/i)
|
||||
await user.click(pencil)
|
||||
expect(onEditTransport).toHaveBeenCalledWith(res)
|
||||
expect(onEditReservation).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable @typescript-eslint/no-unnecessary-type-assertion */
|
||||
interface DragDataPayload { placeId?: string; assignmentId?: string; noteId?: string; fromDayId?: string }
|
||||
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'
|
||||
@@ -183,6 +183,11 @@ interface DayPlanSidebarProps {
|
||||
canUndo?: boolean
|
||||
lastActionLabel?: string | null
|
||||
onUndo?: () => void
|
||||
onRouteRefresh?: () => void
|
||||
onAddTransport?: (dayId: number) => void
|
||||
onEditTransport?: (reservation: Reservation) => void
|
||||
onEditReservation?: (reservation: Reservation) => void
|
||||
onAddBookingToAssignment?: (dayId: number, assignmentId: number) => void
|
||||
}
|
||||
|
||||
const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
@@ -206,6 +211,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
canUndo = false,
|
||||
lastActionLabel = null,
|
||||
onUndo,
|
||||
onRouteRefresh,
|
||||
onAddTransport,
|
||||
onEditTransport,
|
||||
onEditReservation,
|
||||
onAddBookingToAssignment,
|
||||
}: DayPlanSidebarProps) {
|
||||
const toast = useToast()
|
||||
const { t, language, locale } = useTranslation()
|
||||
@@ -235,11 +245,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const [undoHover, setUndoHover] = useState(false)
|
||||
const [pdfHover, setPdfHover] = useState(false)
|
||||
const [icsHover, setIcsHover] = useState(false)
|
||||
const [hoveredAssignmentId, setHoveredAssignmentId] = useState<number | null>(null)
|
||||
const [dropTargetKey, _setDropTargetKey] = useState(null)
|
||||
const dropTargetRef = useRef(null)
|
||||
const setDropTargetKey = (key) => { dropTargetRef.current = key; _setDropTargetKey(key) }
|
||||
const [dragOverDayId, setDragOverDayId] = useState(null)
|
||||
const [hoveredId, setHoveredId] = useState(null)
|
||||
const [transportDetail, setTransportDetail] = useState(null)
|
||||
const [transportPosVersion, setTransportPosVersion] = useState(0)
|
||||
|
||||
@@ -265,19 +275,21 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// Drag-Daten aus dataTransfer, Ref oder window lesen (dataTransfer geht bei Re-Render verloren)
|
||||
const getDragData = (e) => {
|
||||
const dt = e?.dataTransfer
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId gesetzt)
|
||||
// Interner Drag hat Vorrang (Ref wird nur bei assignmentId/noteId/reservationId gesetzt)
|
||||
if (dragDataRef.current) {
|
||||
return {
|
||||
placeId: '',
|
||||
assignmentId: dragDataRef.current.assignmentId || '',
|
||||
noteId: dragDataRef.current.noteId || '',
|
||||
reservationId: dragDataRef.current.reservationId || '',
|
||||
fromDayId: parseInt(dragDataRef.current.fromDayId) || 0,
|
||||
phase: (dragDataRef.current.phase || 'single') as 'single' | 'start' | 'middle' | 'end',
|
||||
}
|
||||
}
|
||||
// Externer Drag (aus PlacesSidebar)
|
||||
const ext = window.__dragData || {}
|
||||
const placeId = dt?.getData('placeId') || ext.placeId || ''
|
||||
return { placeId, assignmentId: '', noteId: '', fromDayId: 0 }
|
||||
return { placeId, assignmentId: '', noteId: '', reservationId: '', fromDayId: 0, phase: 'single' as const }
|
||||
}
|
||||
|
||||
// Only auto-expand genuinely new days (not on initial load from storage)
|
||||
@@ -324,26 +336,19 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
|
||||
// Determine if a reservation's end_time represents a different date (multi-day)
|
||||
const getEndDate = (r: Reservation) => {
|
||||
const endStr = r.reservation_end_time || ''
|
||||
return endStr.includes('T') ? endStr.split('T')[0] : null
|
||||
}
|
||||
|
||||
// Get span phase: how a reservation relates to a specific day's date
|
||||
const getSpanPhase = (r: Reservation, dayDate: string): 'single' | 'start' | 'middle' | 'end' => {
|
||||
if (!r.reservation_time) return 'single'
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r) || startDate
|
||||
if (startDate === endDate) return 'single'
|
||||
if (dayDate === startDate) return 'start'
|
||||
if (dayDate === endDate) return 'end'
|
||||
// Get span phase: how a reservation relates to a specific day (by id)
|
||||
const getSpanPhase = (r: Reservation, dayId: number): 'single' | 'start' | 'middle' | 'end' => {
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
if (!startDayId || startDayId === endDayId) return 'single'
|
||||
if (dayId === startDayId) return 'start'
|
||||
if (dayId === endDayId) return 'end'
|
||||
return 'middle'
|
||||
}
|
||||
|
||||
// Get the appropriate display time for a reservation on a specific day
|
||||
const getDisplayTimeForDay = (r: Reservation, dayDate: string): string | null => {
|
||||
const phase = getSpanPhase(r, dayDate)
|
||||
const getDisplayTimeForDay = (r: Reservation, dayId: number): string | null => {
|
||||
const phase = getSpanPhase(r, dayId)
|
||||
if (phase === 'end') return r.reservation_end_time || null
|
||||
if (phase === 'middle') return null
|
||||
return r.reservation_time || null
|
||||
@@ -357,36 +362,56 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return t(`reservations.span.${phase === 'start' ? 'start' : phase === 'end' ? 'end' : 'ongoing'}`)
|
||||
}
|
||||
|
||||
const getDayOrder = (day: (typeof days)[number]) => (day as any).day_number ?? days.indexOf(day)
|
||||
|
||||
const computeMultiDayMove = (r: Reservation, targetDayId: number, phase: 'single' | 'start' | 'middle' | 'end') => {
|
||||
const startId = r.day_id ?? targetDayId
|
||||
const endId = r.end_day_id ?? startId
|
||||
const order = (id: number) => { const d = days.find(x => x.id === id); return d ? getDayOrder(d) : 0 }
|
||||
if (phase === 'single' || startId === endId) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
if (phase === 'start') {
|
||||
if (order(targetDayId) > order(endId)) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
return { day_id: targetDayId, end_day_id: endId }
|
||||
}
|
||||
// phase === 'end'
|
||||
if (order(targetDayId) < order(startId)) return { day_id: targetDayId, end_day_id: targetDayId }
|
||||
return { day_id: startId, end_day_id: targetDayId }
|
||||
}
|
||||
|
||||
const getTransportForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
const dayAssignmentIds = (assignments[String(dayId)] || []).map(a => a.id)
|
||||
return reservations.filter(r => {
|
||||
if (!r.reservation_time || r.type === 'hotel') return false
|
||||
if (r.type === 'hotel') return false
|
||||
if (r.assignment_id && dayAssignmentIds.includes(r.assignment_id)) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
|
||||
if (endDate && endDate !== startDate) {
|
||||
// Multi-day: show on any day in range (car middle handled elsewhere)
|
||||
return day.date >= startDate && day.date <= endDate
|
||||
} else {
|
||||
// Single-day: show all non-hotel reservations that match this day's date
|
||||
return startDate === day.date
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id ?? startDayId
|
||||
|
||||
if (startDayId == null) return false
|
||||
|
||||
if (endDayId !== startDayId) {
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) >= getDayOrder(startDay) && getDayOrder(thisDay) <= getDayOrder(endDay)
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}
|
||||
|
||||
// Get car rentals that are in "active" (middle) phase for a day — shown in day header, not timeline
|
||||
const getActiveRentalsForDay = (dayId: number) => {
|
||||
const day = days.find(d => d.id === dayId)
|
||||
if (!day?.date) return []
|
||||
return reservations.filter(r => {
|
||||
if (r.type !== 'car' || !r.reservation_time) return false
|
||||
const startDate = r.reservation_time.split('T')[0]
|
||||
const endDate = getEndDate(r)
|
||||
if (!endDate || endDate === startDate) return false
|
||||
return day.date > startDate && day.date < endDate
|
||||
if (r.type !== 'car') return false
|
||||
const startDayId = r.day_id
|
||||
const endDayId = r.end_day_id
|
||||
if (!startDayId || !endDayId || endDayId === startDayId) return false
|
||||
const startDay = days.find(d => d.id === startDayId)
|
||||
const endDay = days.find(d => d.id === endDayId)
|
||||
const thisDay = days.find(d => d.id === dayId)
|
||||
if (!startDay || !endDay || !thisDay) return false
|
||||
return getDayOrder(thisDay) > getDayOrder(startDay) && getDayOrder(thisDay) < getDayOrder(endDay)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -435,11 +460,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
day_plan_position: computeTransportPosition(r, da) + idx * 0.01,
|
||||
}))
|
||||
// Mark as initialized immediately to prevent re-entry
|
||||
for (const p of positions) {
|
||||
initedTransportIds.current.add(p.id)
|
||||
const res = reservations.find(x => x.id === p.id)
|
||||
if (res) res.day_plan_position = p.day_plan_position
|
||||
}
|
||||
for (const p of positions) initedTransportIds.current.add(p.id)
|
||||
// Update store so subscribers see the new positions
|
||||
useTripStore.setState(state => ({
|
||||
reservations: state.reservations.map(r => {
|
||||
const p = positions.find(x => x.id === r.id)
|
||||
if (!p) return r
|
||||
return { ...r, day_plan_position: p.day_plan_position }
|
||||
})
|
||||
}))
|
||||
// Persist to server (fire and forget)
|
||||
reservationsApi.updatePositions(tripId, positions).catch(() => {})
|
||||
}
|
||||
@@ -448,7 +477,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const da = getDayAssignments(dayId)
|
||||
const dn = (dayNotes[String(dayId)] || []).slice().sort((a, b) => a.sort_order - b.sort_order)
|
||||
const transport = getTransportForDay(dayId)
|
||||
const dayDate = days.find(d => d.id === dayId)?.date || ''
|
||||
|
||||
// Initialize positions for transports that don't have one yet
|
||||
if (transport.some(r => r.day_plan_position == null)) {
|
||||
@@ -465,7 +493,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const timedTransports = transport.map(r => ({
|
||||
type: 'transport' as const,
|
||||
data: r,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayDate)) ?? 0,
|
||||
minutes: parseTimeToMinutes(getDisplayTimeForDay(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
@@ -607,23 +635,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
|
||||
try {
|
||||
// Update transport positions in store FIRST so the useEffect triggered by
|
||||
// onReorder's optimistic assignment update reads the correct positions.
|
||||
if (transportUpdates.length) {
|
||||
useTripStore.setState(state => ({
|
||||
reservations: state.reservations.map(r => {
|
||||
const tu = transportUpdates.find(u => u.id === r.id)
|
||||
if (!tu) return r
|
||||
const day_positions = { ...(r.day_positions || {}), [dayId]: tu.day_plan_position }
|
||||
return { ...r, day_plan_position: tu.day_plan_position, day_positions }
|
||||
})
|
||||
}))
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
if (transportUpdates.length) {
|
||||
onRouteRefresh?.()
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||
}
|
||||
for (const n of noteUpdates) {
|
||||
await tripActions.updateDayNote(tripId, dayId, n.id, { sort_order: n.sort_order })
|
||||
}
|
||||
if (transportUpdates.length) {
|
||||
for (const tu of transportUpdates) {
|
||||
const res = reservations.find(r => r.id === tu.id)
|
||||
if (res) {
|
||||
res.day_plan_position = tu.day_plan_position
|
||||
// Update per-day position for multi-day reservations
|
||||
if (!res.day_positions) res.day_positions = {}
|
||||
res.day_positions[dayId] = tu.day_plan_position
|
||||
}
|
||||
}
|
||||
setTransportPosVersion(v => v + 1)
|
||||
await reservationsApi.updatePositions(tripId, transportUpdates, dayId)
|
||||
}
|
||||
if (prevAssignmentIds.length) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPrevIds = prevAssignmentIds
|
||||
@@ -635,13 +667,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
// Transport bookings themselves cannot be dragged
|
||||
if (fromType === 'transport') {
|
||||
toast.error(t('dayplan.cannotReorderTransport'))
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
|
||||
const m = getMergedItems(dayId)
|
||||
|
||||
// Check if a timed place is being moved → would it break chronological order?
|
||||
@@ -854,7 +879,12 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragOverDayId(null)
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== dayId) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, dayId, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||
}
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), dayId)
|
||||
} else if (assignmentId && fromDayId !== dayId) {
|
||||
@@ -1112,6 +1142,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
>
|
||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>}
|
||||
{canEditDays && onAddTransport && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||
title={t('transport.addTransport')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.45,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.opacity = '1' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.opacity = '0.45' }}
|
||||
>
|
||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||
</button>
|
||||
)}
|
||||
{(() => {
|
||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||
// Sort: check-out first, then ongoing stays, then check-in last
|
||||
@@ -1191,7 +1242,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
// Drop on transport card (detected via dropTargetRef for sync accuracy)
|
||||
if (dropTargetRef.current?.startsWith('transport-')) {
|
||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||
@@ -1200,6 +1251,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter)
|
||||
} else if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (assignmentId) {
|
||||
@@ -1213,6 +1269,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
return
|
||||
}
|
||||
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId && !placeId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
@@ -1259,7 +1320,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isPlaceSelected = selectedAssignmentId ? assignment.id === selectedAssignmentId : place.id === selectedPlaceId
|
||||
const isDraggingThis = draggingId === assignment.id
|
||||
const isHovered = hoveredId === assignment.id
|
||||
const placeIdx = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
|
||||
const arrowMove = (direction: 'up' | 'down') => {
|
||||
@@ -1312,11 +1372,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); setDragOverDayId(null); if (dropTargetKey !== `place-${assignment.id}`) setDropTargetKey(`place-${assignment.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
const pos = placeItems.findIndex(i => i.data.id === assignment.id)
|
||||
onAssignToDay?.(parseInt(placeId), day.id, pos >= 0 ? pos : undefined)
|
||||
setDropTargetKey(null); window.__dragData = null
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'place', assignment.id)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
const toIdx = getDayAssignments(day.id).findIndex(a => a.id === assignment.id)
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id, toIdx).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
@@ -1343,15 +1409,27 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
canEditDays && onDeletePlace && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
onMouseEnter={() => setHoveredId(assignment.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'var(--bg-hover)'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
setHoveredAssignmentId(assignment.id)
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
if (!isPlaceSelected && !lockedIds.has(assignment.id))
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
setHoveredAssignmentId(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
cursor: 'pointer',
|
||||
background: lockedIds.has(assignment.id)
|
||||
? 'rgba(220,38,38,0.08)'
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : (isHovered ? 'var(--bg-hover)' : 'transparent'),
|
||||
: isPlaceSelected ? 'var(--bg-hover)' : 'transparent',
|
||||
borderLeft: lockedIds.has(assignment.id)
|
||||
? '3px solid #dc2626'
|
||||
: '3px solid transparent',
|
||||
@@ -1359,7 +1437,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
opacity: isDraggingThis ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div
|
||||
@@ -1420,26 +1498,74 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const res = reservations.find(r => r.assignment_id === assignment.id)
|
||||
if (!res) return null
|
||||
const confirmed = res.status === 'confirmed'
|
||||
const hasEndpoints = onToggleConnection && (res.endpoints || []).length >= 2
|
||||
const active = hasEndpoints ? visibleConnectionIds.includes(res.id) : false
|
||||
return (
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
<div style={{ marginTop: 3, display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 3, padding: '1px 6px', borderRadius: 5, fontSize: 9, fontWeight: 600,
|
||||
background: confirmed ? 'rgba(22,163,74,0.1)' : 'rgba(217,119,6,0.1)',
|
||||
color: confirmed ? '#16a34a' : '#d97706',
|
||||
}}>
|
||||
{(() => { const RI = RES_ICONS[res.type] || Ticket; return <RI size={8} /> })()}
|
||||
<span className="hidden sm:inline">{confirmed ? t('planner.resConfirmed') : t('planner.resPending')}</span>
|
||||
{res.reservation_time?.includes('T') && (
|
||||
<span style={{ fontWeight: 400 }}>
|
||||
{new Date(res.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: timeFormat === '12h' })}
|
||||
{res.reservation_end_time && ` – ${res.reservation_end_time}`}
|
||||
</span>
|
||||
)}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
})()}
|
||||
</div>
|
||||
{hasEndpoints && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); onToggleConnection!(res.id) }}
|
||||
title={t(active ? 'map.hideConnections' : 'map.showConnections')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: active ? '#3b82f6' : 'transparent',
|
||||
color: active ? '#fff' : 'var(--text-faint)',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!active) e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { if (!active) e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<RouteIcon size={11} />
|
||||
</button>
|
||||
)}
|
||||
{(() => {
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
if (!meta) return null
|
||||
if (meta.airline && meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.airline} {meta.flight_number}</span>
|
||||
if (meta.flight_number) return <span style={{ fontWeight: 400 }}>{meta.flight_number}</span>
|
||||
if (meta.train_number) return <span style={{ fontWeight: 400 }}>{meta.train_number}</span>
|
||||
return null
|
||||
{canEditDays && (() => {
|
||||
const isTransport = ['flight','train','car','cruise','bus'].includes(res.type)
|
||||
const handler = isTransport ? onEditTransport : onEditReservation
|
||||
if (!handler) return null
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={e => { e.stopPropagation(); handler(res) }}
|
||||
title={t('common.edit')}
|
||||
style={{
|
||||
flexShrink: 0, appearance: 'none',
|
||||
width: 20, height: 20, borderRadius: 4,
|
||||
display: 'grid', placeItems: 'center', cursor: 'pointer',
|
||||
border: 'none', background: 'transparent',
|
||||
color: 'var(--text-faint)',
|
||||
transition: 'all 0.12s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.color = 'var(--text-primary)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
>
|
||||
<Pencil size={11} />
|
||||
</button>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
@@ -1462,7 +1588,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={moveUp} disabled={idx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: idx === 0 ? 'default' : 'pointer', color: idx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}>
|
||||
<ChevronUp size={12} strokeWidth={2} />
|
||||
</button>
|
||||
@@ -1470,6 +1596,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<ChevronDown size={12} strokeWidth={2} />
|
||||
</button>
|
||||
</div>}
|
||||
{canEditDays && onAddBookingToAssignment && hoveredAssignmentId === assignment.id && (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation()
|
||||
onAddBookingToAssignment(day.id, assignment.id)
|
||||
}}
|
||||
title={t('reservations.addBooking')}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-primary)',
|
||||
borderRadius: 5,
|
||||
padding: '2px 6px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
fontSize: 10,
|
||||
fontWeight: 500,
|
||||
color: 'var(--text-muted)',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={11} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)
|
||||
@@ -1478,7 +1630,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
// Transport booking (flight, train, bus, car, cruise)
|
||||
if (item.type === 'transport') {
|
||||
const res = item.data
|
||||
const spanPhase = getSpanPhase(res, day.date)
|
||||
const spanPhase = getSpanPhase(res, day.id)
|
||||
|
||||
// Car "active" (middle) days are shown in the day header, skip here
|
||||
if (res.type === 'car' && spanPhase === 'middle') return null
|
||||
@@ -1486,7 +1638,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const TransportIcon = RES_ICONS[res.type] || Ticket
|
||||
const color = '#3b82f6'
|
||||
const meta = typeof res.metadata === 'string' ? JSON.parse(res.metadata || '{}') : (res.metadata || {})
|
||||
const isTransportHovered = hoveredId === `transport-${res.id}`
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
@@ -1501,13 +1652,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Multi-day span phase
|
||||
const spanLabel = getSpanLabel(res, spanPhase)
|
||||
const displayTime = getDisplayTimeForDay(res, day.date)
|
||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
{showDropLine && <div style={{ height: 2, background: 'var(--text-primary)', borderRadius: 1, margin: '2px 8px' }} />}
|
||||
<div
|
||||
onClick={() => setTransportDetail(res)}
|
||||
onClick={() => canEditDays && onEditTransport?.(res)}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
@@ -1515,13 +1666,26 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle'}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
dragDataRef.current = { reservationId: String(res.id), fromDayId: String(day.id), phase: spanPhase }
|
||||
setDraggingId(res.id)
|
||||
}}
|
||||
onDragEnd={() => { setDraggingId(null); setDragOverDayId(null); setDropTargetKey(null); dragDataRef.current = null }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const insertAfter = e.clientY > rect.top + rect.height / 2
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId: fromAssignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
} else if (fromReservationId && fromDayId !== day.id) {
|
||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r2) { const update = computeMultiDayMove(r2, day.id, phase); tripActions.updateReservation(tripId, r2.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter)
|
||||
} else if (fromAssignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (fromAssignmentId) {
|
||||
@@ -1533,20 +1697,25 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
onMouseEnter={() => setHoveredId(`transport-${res.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = `${color}12` }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = `${color}08` }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 10px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${color}33`,
|
||||
background: isTransportHovered ? `${color}12` : `${color}08`,
|
||||
cursor: 'pointer', userSelect: 'none',
|
||||
background: `${color}08`,
|
||||
cursor: canEditDays && onEditTransport ? 'pointer' : 'default', userSelect: 'none',
|
||||
transition: 'background 0.1s',
|
||||
opacity: spanPhase === 'middle' ? 0.65 : 1,
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && (
|
||||
<div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>
|
||||
)}
|
||||
<div style={{
|
||||
width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
borderRadius: '50%', background: `${color}18`,
|
||||
@@ -1616,7 +1785,6 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
|
||||
// Notizkarte
|
||||
const note = item.data
|
||||
const isNoteHovered = hoveredId === `note-${note.id}`
|
||||
const NoteIcon = getNoteIcon(note.icon)
|
||||
const noteIdx = idx
|
||||
return (
|
||||
@@ -1629,8 +1797,14 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `note-${note.id}`) setDropTargetKey(`note-${note.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, fromDayId } = getDragData(e)
|
||||
if (fromNoteId && fromDayId !== day.id) {
|
||||
const { noteId: fromNoteId, assignmentId: fromAssignmentId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
} else if (fromReservationId) {
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'note', note.id)
|
||||
} else if (fromNoteId && fromDayId !== day.id) {
|
||||
const tm = getMergedItems(day.id)
|
||||
const toIdx = tm.findIndex(i => i.type === 'note' && i.data.id === note.id)
|
||||
const so = toIdx <= 0 ? (tm[0]?.sortKey ?? 0) - 1 : (tm[toIdx - 1].sortKey + tm[toIdx].sortKey) / 2
|
||||
@@ -1653,20 +1827,30 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
{ divider: true },
|
||||
{ label: t('common.delete'), icon: Trash2, danger: true, onClick: () => deleteNote(day.id, note.id) },
|
||||
]) : undefined}
|
||||
onMouseEnter={() => setHoveredId(`note-${note.id}`)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onMouseEnter={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '1'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={e => {
|
||||
const grip = e.currentTarget.querySelector('.dp-grip') as HTMLElement | null
|
||||
if (grip) grip.style.opacity = '0.3'
|
||||
const editBtns = e.currentTarget.querySelector('.note-edit-buttons') as HTMLElement | null
|
||||
if (editBtns) editBtns.style.opacity = '0'
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '7px 8px 7px 2px',
|
||||
margin: '1px 8px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--border-faint)',
|
||||
background: isNoteHovered ? 'var(--bg-hover)' : 'var(--bg-hover)',
|
||||
background: 'var(--bg-hover)',
|
||||
opacity: draggingId === `note-${note.id}` ? 0.4 : 1,
|
||||
transition: 'background 0.1s', cursor: 'grab', userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{canEditDays && <div style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: isNoteHovered ? 1 : 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
{canEditDays && <div className="dp-grip" style={{ flexShrink: 0, color: 'var(--text-faint)', display: 'flex', alignItems: 'center', opacity: 0.3, transition: 'opacity 0.15s', cursor: 'grab' }}>
|
||||
<GripVertical size={13} strokeWidth={1.8} />
|
||||
</div>}
|
||||
<div style={{ width: 28, height: 28, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '50%', background: 'var(--bg-hover)', overflow: 'hidden' }}>
|
||||
@@ -1680,11 +1864,11 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
<div className="collab-note-md" style={{ fontSize: 10.5, fontWeight: 400, color: 'var(--text-faint)', lineHeight: '1.3', marginTop: 2, wordBreak: 'break-word' }}><Markdown remarkPlugins={[remarkGfm]}>{note.time}</Markdown></div>
|
||||
)}
|
||||
</div>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: isNoteHovered ? 1 : 0, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="note-edit-buttons" style={{ display: 'flex', gap: 1, flexShrink: 0, opacity: 0, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => openEditNote(day.id, note, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Pencil size={10} /></button>
|
||||
<button onClick={e => deleteNote(day.id, note.id, e)} style={{ background: 'none', border: 'none', padding: 2, cursor: 'pointer', color: 'var(--text-faint)', display: 'flex' }}><Trash2 size={10} /></button>
|
||||
</div>}
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, opacity: isNoteHovered ? 1 : undefined, transition: 'opacity 0.15s' }}>
|
||||
{canEditDays && <div className="reorder-buttons" style={{ flexShrink: 0, display: 'flex', gap: 1, transition: 'opacity 0.15s' }}>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'up') }} disabled={noteIdx === 0} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === 0 ? 'default' : 'pointer', color: noteIdx === 0 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronUp size={12} strokeWidth={2} /></button>
|
||||
<button onClick={e => { e.stopPropagation(); moveNote(day.id, note.id, 'down') }} disabled={noteIdx === merged.length - 1} style={{ background: 'none', border: 'none', padding: '1px 2px', cursor: noteIdx === merged.length - 1 ? 'default' : 'pointer', color: noteIdx === merged.length - 1 ? 'var(--border-primary)' : 'var(--text-faint)', display: 'flex', lineHeight: 1 }}><ChevronDown size={12} strokeWidth={2} /></button>
|
||||
</div>}
|
||||
@@ -1699,12 +1883,17 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
||||
onDragOver={e => { e.preventDefault(); e.stopPropagation(); if (dropTargetKey !== `end-${day.id}`) setDropTargetKey(`end-${day.id}`) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const { placeId, assignmentId, noteId, fromDayId } = getDragData(e)
|
||||
const { placeId, assignmentId, noteId, reservationId: fromReservationId, fromDayId, phase } = getDragData(e)
|
||||
// Neuer Ort von der Orte-Liste
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
setDropTargetKey(null); window.__dragData = null; return
|
||||
}
|
||||
if (fromReservationId && fromDayId !== day.id) {
|
||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
||||
if (r) { const update = computeMultiDayMove(r, day.id, phase); tripActions.updateReservation(tripId, r.id, update).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError'))) }
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null; return
|
||||
}
|
||||
if (!assignmentId && !noteId) { dragDataRef.current = null; window.__dragData = null; return }
|
||||
if (assignmentId && fromDayId !== day.id) {
|
||||
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
|
||||
@@ -36,6 +36,8 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [summary, setSummary] = useState<PlacesImportSummary | null>(null)
|
||||
const [gpxOpts, setGpxOpts] = useState({ waypoints: true, routes: true, tracks: true })
|
||||
const [kmlOpts, setKmlOpts] = useState({ points: true, paths: true })
|
||||
|
||||
const validateFile = (f: File): string | null => {
|
||||
const ext = f.name.toLowerCase().split('.').pop()
|
||||
@@ -127,7 +129,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
|
||||
try {
|
||||
if (ext === 'gpx') {
|
||||
const result = await placesApi.importGpx(tripId, file)
|
||||
const result = await placesApi.importGpx(tripId, file, gpxOpts)
|
||||
await loadTrip(tripId)
|
||||
if (result.count === 0 && result.skipped > 0) {
|
||||
toast.warning(t('places.importAllSkipped'))
|
||||
@@ -137,15 +139,13 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importGpx'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
handleClose()
|
||||
} else {
|
||||
const result = await placesApi.importMapFile(tripId, file)
|
||||
const result = await placesApi.importMapFile(tripId, file, kmlOpts)
|
||||
await loadTrip(tripId)
|
||||
setSummary(result.summary || null)
|
||||
if (result.count === 0 && (result.summary?.skippedCount ?? 0) > 0) {
|
||||
@@ -159,9 +159,7 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t('undo.importKeyholeMarkup'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -177,7 +175,12 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
}
|
||||
}
|
||||
|
||||
const canImport = !!file && !loading
|
||||
const fileExt = file?.name.toLowerCase().split('.').pop() ?? ''
|
||||
const isGpx = fileExt === 'gpx'
|
||||
const isKml = fileExt === 'kml' || fileExt === 'kmz'
|
||||
const gpxNoneSelected = isGpx && !gpxOpts.waypoints && !gpxOpts.routes && !gpxOpts.tracks
|
||||
const kmlNoneSelected = isKml && !kmlOpts.points && !kmlOpts.paths
|
||||
const canImport = !!file && !loading && !gpxNoneSelected && !kmlNoneSelected
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
@@ -242,6 +245,58 @@ export default function FileImportModal({ isOpen, onClose, tripId, pushUndo, ini
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isGpx && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.gpxImportTypes')}
|
||||
</div>
|
||||
{(['waypoints', 'routes', 'tracks'] as const).map(key => (
|
||||
<label key={key} onClick={() => setGpxOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: gpxOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: gpxOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{gpxOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'waypoints' ? 'places.gpxImportWaypoints' : key === 'routes' ? 'places.gpxImportRoutes' : 'places.gpxImportTracks')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{gpxNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.gpxImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isKml && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', marginBottom: 6, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{t('places.kmlImportTypes')}
|
||||
</div>
|
||||
{(['points', 'paths'] as const).map(key => (
|
||||
<label key={key} onClick={() => setKmlOpts(prev => ({ ...prev, [key]: !prev[key] }))} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', cursor: 'pointer' }}>
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: kmlOpts[key] ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: kmlOpts[key] ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{kmlOpts[key] && <svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2" stroke="white" strokeWidth="1.8" fill="none" strokeLinecap="round" strokeLinejoin="round" /></svg>}
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-primary)', userSelect: 'none' }}>
|
||||
{t(key === 'points' ? 'places.kmlImportPoints' : 'places.kmlImportPaths')}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{kmlNoneSelected && (
|
||||
<div style={{ fontSize: 11, color: '#b45309', marginTop: 4 }}>{t('places.kmlImportNoneSelected')}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{summary && (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import { useState, useMemo, useEffect, useRef } from 'react'
|
||||
import { Search, Plus, X, CalendarDays, Pencil, Trash2, ExternalLink, Navigation, Upload, ChevronDown, Check, MapPin, Eye } from 'lucide-react'
|
||||
import { useState, useMemo, useEffect, 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'
|
||||
import { useTranslation } from '../../i18n'
|
||||
@@ -12,6 +12,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||
import FileImportModal from './FileImportModal'
|
||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||
|
||||
interface PlacesSidebarProps {
|
||||
tripId: number
|
||||
@@ -25,6 +26,8 @@ interface PlacesSidebarProps {
|
||||
onAssignToDay: (placeId: number, dayId: number) => void
|
||||
onEditPlace: (place: Place) => void
|
||||
onDeletePlace: (placeId: number) => void
|
||||
onBulkDeletePlaces?: (ids: number[]) => void
|
||||
onBulkDeleteConfirm?: (ids: number[]) => void
|
||||
days: Day[]
|
||||
isMobile: boolean
|
||||
onCategoryFilterChange?: (categoryIds: Set<string>) => void
|
||||
@@ -32,9 +35,115 @@ interface PlacesSidebarProps {
|
||||
pushUndo?: (label: string, undoFn: () => Promise<void> | void) => void
|
||||
}
|
||||
|
||||
interface MemoPlaceRowProps {
|
||||
place: Place
|
||||
category: Category | undefined
|
||||
isSelected: boolean
|
||||
isPlanned: boolean
|
||||
inDay: boolean
|
||||
isChecked: boolean
|
||||
selectMode: boolean
|
||||
selectedDayId: number | null
|
||||
canEditPlaces: boolean
|
||||
isMobile: boolean
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
onPlaceClick: (id: number | null) => void
|
||||
onContextMenu: (e: React.MouseEvent, place: Place) => void
|
||||
onAssignToDay: (placeId: number, dayId?: number) => void
|
||||
toggleSelected: (id: number) => void
|
||||
setDayPickerPlace: (place: any) => void
|
||||
}
|
||||
|
||||
const MemoPlaceRow = React.memo(function MemoPlaceRow({
|
||||
place, category: cat, isSelected, isPlanned, inDay, isChecked,
|
||||
selectMode, selectedDayId, canEditPlaces, isMobile, t,
|
||||
onPlaceClick, onContextMenu, onAssignToDay, toggleSelected, setDayPickerPlace,
|
||||
}: MemoPlaceRowProps) {
|
||||
const hasGeometry = Boolean(place.route_geometry)
|
||||
return (
|
||||
<div
|
||||
key={place.id}
|
||||
draggable={!selectMode}
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (selectMode) {
|
||||
toggleSelected(place.id)
|
||||
} else if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={selectMode ? undefined : e => onContextMenu(e, place)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: selectMode ? 'pointer' : 'grab',
|
||||
background: isChecked ? 'color-mix(in srgb, var(--accent) 8%, transparent)' : isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected && !isChecked) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{selectMode && (
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 4, flexShrink: 0,
|
||||
border: isChecked ? 'none' : '1.5px solid var(--border-primary)',
|
||||
background: isChecked ? 'var(--accent)' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{isChecked && <Check size={10} strokeWidth={3} color="white" />}
|
||||
</div>
|
||||
)}
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{hasGeometry && <Route size={11} strokeWidth={2} color="var(--text-faint)" style={{ flexShrink: 0 }} title="Track / Route" />}
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!selectMode && !inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
tripId, places, categories, assignments, selectedDayId, selectedPlaceId,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
onPlaceClick, onAddPlace, onAssignToDay, onEditPlace, onDeletePlace, onBulkDeletePlaces, onBulkDeleteConfirm, days, isMobile, onCategoryFilterChange, onPlacesFilterChange, pushUndo,
|
||||
}: PlacesSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -110,9 +219,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
if (result.places?.length > 0) {
|
||||
const importedIds: number[] = result.places.map((p: { id: number }) => p.id)
|
||||
pushUndo?.(t(provider === 'google' ? 'undo.importGoogleList' : 'undo.importNaverList'), async () => {
|
||||
for (const id of importedIds) {
|
||||
try { await placesApi.delete(tripId, id) } catch {}
|
||||
}
|
||||
try { await placesApi.bulkDelete(tripId, importedIds) } catch {}
|
||||
await loadTrip(tripId)
|
||||
})
|
||||
}
|
||||
@@ -126,6 +233,28 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [search, setSearch] = useState('')
|
||||
const [filter, setFilter] = useState('all')
|
||||
const [categoryFilters, setCategoryFiltersLocal] = useState<Set<string>>(new Set())
|
||||
const [selectMode, setSelectMode] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<number[] | null>(null)
|
||||
|
||||
const exitSelectMode = () => { setSelectMode(false); setSelectedIds(new Set()) }
|
||||
|
||||
// Auto-exit when all selected places have been removed from the store (e.g. after bulk delete)
|
||||
useEffect(() => {
|
||||
if (!selectMode || selectedIds.size === 0) return
|
||||
const placeIdSet = new Set(places.map(p => p.id))
|
||||
if ([...selectedIds].every(id => !placeIdSet.has(id))) {
|
||||
setSelectMode(false)
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [places])
|
||||
|
||||
const toggleSelected = useCallback((id: number) => setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id); else next.add(id)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const toggleCategoryFilter = (catId: string) => {
|
||||
setCategoryFiltersLocal(prev => {
|
||||
@@ -140,12 +269,16 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const [mobileShowDays, setMobileShowDays] = useState(false)
|
||||
|
||||
// Alle geplanten Ort-IDs abrufen (einem Tag zugewiesen)
|
||||
const hasTracks = useMemo(() => places.some(p => p.route_geometry), [places])
|
||||
useEffect(() => { if (filter === 'tracks' && !hasTracks) setFilter('all') }, [hasTracks, filter])
|
||||
|
||||
const plannedIds = useMemo(() => new Set(
|
||||
Object.values(assignments).flatMap(da => da.map(a => a.place?.id).filter(Boolean))
|
||||
), [assignments])
|
||||
|
||||
const filtered = useMemo(() => places.filter(p => {
|
||||
if (filter === 'unplanned' && plannedIds.has(p.id)) return false
|
||||
if (filter === 'tracks' && !p.route_geometry) return false
|
||||
if (categoryFilters.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!categoryFilters.has('uncategorized')) return false
|
||||
@@ -159,6 +292,26 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
const isAssignedToSelectedDay = (placeId) =>
|
||||
selectedDayId && (assignments[String(selectedDayId)] || []).some(a => a.place?.id === placeId)
|
||||
|
||||
const selectedDayIdRef = useRef<number | null>(selectedDayId)
|
||||
useEffect(() => { selectedDayIdRef.current = selectedDayId }, [selectedDayId])
|
||||
|
||||
const inDaySet = useMemo(() => {
|
||||
if (!selectedDayId) return new Set<number>()
|
||||
return new Set<number>((assignments[String(selectedDayId)] || []).map((a: any) => a.place?.id).filter(Boolean))
|
||||
}, [assignments, selectedDayId])
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, place: Place) => {
|
||||
const selDayId = selectedDayIdRef.current
|
||||
ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${(place as any).google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + (place as any).google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])
|
||||
}, [ctxMenu.open, canEditPlaces, t, onEditPlace, onAssignToDay, onDeletePlace])
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleSidebarDragEnter}
|
||||
@@ -219,13 +372,67 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
>
|
||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
||||
padding: '5px 10px', borderRadius: 8,
|
||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
|
||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Check size={11} strokeWidth={2} /> {t('common.select')}
|
||||
</button>
|
||||
</div>
|
||||
{selectMode && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, padding: '6px 8px', borderRadius: 8, background: 'var(--bg-tertiary)', fontSize: 11 }}>
|
||||
<span style={{ flex: 1, color: 'var(--text-muted)', fontWeight: 500 }}>
|
||||
{t('places.selectionCount', { count: selectedIds.size })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === filtered.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||
}
|
||||
}}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
|
||||
>
|
||||
{selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (isMobile) {
|
||||
setPendingDeleteIds(Array.from(selectedIds))
|
||||
} else {
|
||||
onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||
}
|
||||
}}
|
||||
disabled={selectedIds.size === 0}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
|
||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default',
|
||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||
fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
<Trash2 size={11} strokeWidth={2} /> {t('places.deleteSelected')}
|
||||
</button>
|
||||
<button onClick={exitSelectMode} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 2 }}>
|
||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
|
||||
{/* Filter-Tabs */}
|
||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
||||
{[{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }].map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id) }} style={{
|
||||
{([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
|
||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
|
||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
||||
@@ -240,7 +447,7 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onChange={e => { setSearch(e.target.value); if (selectMode) setSelectedIds(new Set()) }}
|
||||
placeholder={t('places.search')}
|
||||
style={{
|
||||
width: '100%', padding: '7px 30px 7px 30px', borderRadius: 10,
|
||||
@@ -363,82 +570,29 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
filtered.map(place => {
|
||||
const cat = categories.find(c => c.id === place.category_id)
|
||||
const isSelected = place.id === selectedPlaceId
|
||||
const inDay = isAssignedToSelectedDay(place.id)
|
||||
const isPlanned = plannedIds.has(place.id)
|
||||
|
||||
const inDay = inDaySet.has(place.id)
|
||||
const isChecked = selectedIds.has(place.id)
|
||||
return (
|
||||
<div
|
||||
<MemoPlaceRow
|
||||
key={place.id}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('placeId', String(place.id))
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
// Backup in window für Cross-Component Drag (dataTransfer geht bei Re-Render verloren)
|
||||
window.__dragData = { placeId: String(place.id) }
|
||||
}}
|
||||
onClick={() => {
|
||||
if (isMobile) {
|
||||
setDayPickerPlace(place)
|
||||
} else {
|
||||
onPlaceClick(isSelected ? null : place.id)
|
||||
}
|
||||
}}
|
||||
onContextMenu={e => ctxMenu.open(e, [
|
||||
canEditPlaces && { label: t('common.edit'), icon: Pencil, onClick: () => onEditPlace(place) },
|
||||
selectedDayId && { label: t('planner.addToDay'), icon: CalendarDays, onClick: () => onAssignToDay(place.id, selectedDayId) },
|
||||
place.website && { label: t('inspector.website'), icon: ExternalLink, onClick: () => window.open(place.website, '_blank') },
|
||||
(place.lat && place.lng) && { label: 'Google Maps', icon: Navigation, onClick: () => window.open(`https://www.google.com/maps/search/?api=1&query=${place.google_place_id ? encodeURIComponent(place.name) + '&query_place_id=' + place.google_place_id : place.lat + ',' + place.lng}`, '_blank') },
|
||||
{ divider: true },
|
||||
canEditPlaces && { label: t('common.delete'), icon: Trash2, danger: true, onClick: () => onDeletePlace(place.id) },
|
||||
])}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 14px 9px 16px',
|
||||
cursor: 'grab',
|
||||
background: isSelected ? 'var(--border-faint)' : 'transparent',
|
||||
borderBottom: '1px solid var(--border-faint)',
|
||||
transition: 'background 0.1s',
|
||||
contentVisibility: 'auto',
|
||||
containIntrinsicSize: '0 52px',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isSelected) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<PlaceAvatar place={place} category={cat} size={34} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, overflow: 'hidden' }}>
|
||||
{cat && (() => {
|
||||
const CatIcon = getCategoryIcon(cat.icon)
|
||||
return <CatIcon size={11} strokeWidth={2} color={cat.color || '#6366f1'} style={{ flexShrink: 0 }} title={cat.name} />
|
||||
})()}
|
||||
<span style={{ fontSize: 13, fontWeight: 500, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', lineHeight: 1.2 }}>
|
||||
{place.name}
|
||||
</span>
|
||||
</div>
|
||||
{(place.description || place.address || cat?.name) && (
|
||||
<div style={{ marginTop: 2 }}>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-faint)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', display: 'block', lineHeight: 1.2 }}>
|
||||
{place.description || place.address || cat?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flexShrink: 0, display: 'flex', alignItems: 'center' }}>
|
||||
{!inDay && selectedDayId && (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onAssignToDay(place.id) }}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 20, height: 20, borderRadius: 6,
|
||||
background: 'var(--bg-hover)', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--text-faint)', padding: 0, transition: 'background 0.15s, color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { e.currentTarget.style.background = 'var(--accent)'; e.currentTarget.style.color = 'var(--accent-text)' }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'var(--bg-hover)'; e.currentTarget.style.color = 'var(--text-faint)' }}
|
||||
><Plus size={12} strokeWidth={2.5} /></button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
place={place}
|
||||
category={cat}
|
||||
isSelected={isSelected}
|
||||
isPlanned={isPlanned}
|
||||
inDay={inDay}
|
||||
isChecked={isChecked}
|
||||
selectMode={selectMode}
|
||||
selectedDayId={selectedDayId}
|
||||
canEditPlaces={canEditPlaces}
|
||||
isMobile={isMobile}
|
||||
t={t}
|
||||
onPlaceClick={onPlaceClick}
|
||||
onContextMenu={openContextMenu}
|
||||
onAssignToDay={onAssignToDay}
|
||||
toggleSelected={toggleSelected}
|
||||
setDayPickerPlace={setDayPickerPlace}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)}
|
||||
@@ -602,6 +756,14 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
||||
initialFile={sidebarDropFile}
|
||||
/>
|
||||
<ContextMenu menu={ctxMenu.menu} onClose={ctxMenu.close} />
|
||||
{isMobile && (
|
||||
<ConfirmDialog
|
||||
isOpen={!!pendingDeleteIds?.length}
|
||||
onClose={() => setPendingDeleteIds(null)}
|
||||
onConfirm={() => { onBulkDeleteConfirm?.(pendingDeleteIds!); setPendingDeleteIds(null) }}
|
||||
message={t('trip.confirm.deletePlaces', { count: pendingDeleteIds?.length ?? 0 })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-003: shows "Edit Reservation" title when editing', () => {
|
||||
const res = buildReservation({ title: 'Flight NY', type: 'flight' });
|
||||
const res = buildReservation({ title: 'Nice Dinner', type: 'restaurant' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} />);
|
||||
expect(screen.getByText(/Edit Reservation/i)).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,34 +101,26 @@ describe('ReservationModal', () => {
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-005: all 9 type buttons are visible', () => {
|
||||
it('FE-PLANNER-RESMODAL-005: all 5 type buttons are visible (transport types removed)', () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByRole('button', { name: /Flight/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Accommodation/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Restaurant/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Train/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /^Car$/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Cruise/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Event/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Tour/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Other/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Flight$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Train$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Car$/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /^Cruise$/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Type selection ──────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Flight type button shows flight-specific fields', async () => {
|
||||
it('FE-PLANNER-RESMODAL-006: clicking Event type button activates it', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
// Flight-specific airline field has placeholder="Lufthansa" (exact, not the title placeholder)
|
||||
expect(screen.getByPlaceholderText('Lufthansa')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-007: flight type shows airline/airport fields', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
expect(screen.getByText(/Airline/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^From$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/^To$/i)).toBeInTheDocument();
|
||||
const eventBtn = screen.getByRole('button', { name: /Event/i });
|
||||
await userEvent.click(eventBtn);
|
||||
expect(eventBtn).toHaveStyle({ background: 'var(--text-primary)' });
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-008: hotel type shows check-in/check-out time fields', async () => {
|
||||
@@ -139,12 +131,10 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText(/Check-out/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-009: train type shows train number/platform/seat fields', async () => {
|
||||
it('FE-PLANNER-RESMODAL-009: restaurant type shows location field', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Platform/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Seat/i)).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
expect(screen.getByPlaceholderText(/Address, Airport/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-010: hotel type hides assignment picker', async () => {
|
||||
@@ -183,13 +173,10 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByDisplayValue('Breakfast included')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — train type does not show flight fields', () => {
|
||||
const res = buildReservation({ type: 'train' });
|
||||
it('FE-PLANNER-RESMODAL-014: editing pre-fills type — restaurant type shows location field', () => {
|
||||
const res = buildReservation({ type: 'restaurant', location: 'Via Roma 1' });
|
||||
render(<ReservationModal {...defaultProps} reservation={res} />);
|
||||
// Flight-specific airline input has placeholder="Lufthansa" (exact) — should NOT appear for train type
|
||||
expect(screen.queryByPlaceholderText('Lufthansa')).not.toBeInTheDocument();
|
||||
// Train fields should appear
|
||||
expect(screen.getByText(/Train No\./i)).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('Via Roma 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────────────
|
||||
@@ -232,18 +219,18 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Submit flow ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-016: submitting valid flight calls onSave with correct shape', async () => {
|
||||
it('FE-PLANNER-RESMODAL-016: submitting valid restaurant booking calls onSave with correct shape', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Air France 777');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Restaurant/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Le Jules Verne');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Air France 777', type: 'flight' })
|
||||
expect.objectContaining({ title: 'Le Jules Verne', type: 'restaurant' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -439,17 +426,17 @@ describe('ReservationModal', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-031: train type — saving calls onSave with train type', async () => {
|
||||
it('FE-PLANNER-RESMODAL-031: event type — saving calls onSave with event type', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Eurostar Paris');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Event/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Louvre Museum');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ title: 'Eurostar Paris', type: 'train' })
|
||||
expect.objectContaining({ title: 'Louvre Museum', type: 'event' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -473,7 +460,7 @@ describe('ReservationModal', () => {
|
||||
|
||||
it('FE-PLANNER-RESMODAL-036: file upload to existing reservation calls onFileUpload', async () => {
|
||||
const onFileUpload = vi.fn().mockResolvedValue(undefined);
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'flight' });
|
||||
const res = buildReservation({ id: 10, title: 'My Trip', type: 'other' });
|
||||
render(
|
||||
<ReservationModal
|
||||
{...defaultProps}
|
||||
@@ -575,26 +562,18 @@ describe('ReservationModal', () => {
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-042: flight type metadata saved with airline and flight number', async () => {
|
||||
it('FE-PLANNER-RESMODAL-042: hotel type metadata saved with check-in time', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Flight/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'AF 447 CDG → JFK');
|
||||
await userEvent.type(screen.getByPlaceholderText('Lufthansa'), 'Air France');
|
||||
await userEvent.type(screen.getByPlaceholderText('LH 123'), 'AF 447');
|
||||
await userEvent.click(screen.getByRole('button', { name: /Accommodation/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Grand Hotel');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'flight',
|
||||
metadata: expect.objectContaining({
|
||||
airline: 'Air France',
|
||||
flight_number: 'AF 447',
|
||||
}),
|
||||
})
|
||||
expect.objectContaining({ title: 'Grand Hotel', type: 'hotel' })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -634,22 +613,21 @@ describe('ReservationModal', () => {
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: car type shows date/time section', async () => {
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Car$/i }));
|
||||
// Car type still shows date fields (not hotel which hides them)
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('date-picker').length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByTestId('time-picker').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-046: cruise type renders and saves correctly', async () => {
|
||||
it('FE-PLANNER-RESMODAL-046: other type renders and saves correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Cruise/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Caribbean Cruise');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Other$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Misc item');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'cruise' })));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||
@@ -730,23 +708,17 @@ describe('ReservationModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-035: flight with train number metadata saved correctly', async () => {
|
||||
it('FE-PLANNER-RESMODAL-035: hotel type saves correctly', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /Train/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE 792');
|
||||
await userEvent.type(screen.getByPlaceholderText(/ICE 123/i), 'ICE 792');
|
||||
await userEvent.type(screen.getByPlaceholderText(/^12$/i), '5');
|
||||
await userEvent.type(screen.getByPlaceholderText(/42A/i), '14B');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Accommodation$/i }));
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Test');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'train',
|
||||
metadata: expect.objectContaining({ train_number: 'ICE 792', platform: '5', seat: '14B' }),
|
||||
})
|
||||
expect.objectContaining({ type: 'hotel' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,72 +5,17 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useAddonStore } from '../../store/addonStore'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { Plane, Hotel, Utensils, Train, Car, Ship, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { Hotel, Utensils, Ticket, FileText, Users, Paperclip, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import AirportSelect, { type Airport } from './AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'cruise', 'car'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
const isTransport = (t: string): t is TransportType => (TRANSPORT_TYPES as readonly string[]).includes(t)
|
||||
|
||||
interface EndpointPick {
|
||||
airport?: Airport
|
||||
location?: LocationPoint
|
||||
}
|
||||
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||
code: a.iata,
|
||||
lat: a.lat, lng: a.lng,
|
||||
timezone: a.tz,
|
||||
local_date: date,
|
||||
local_time: time,
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: l.name,
|
||||
code: null,
|
||||
lat: l.lat, lng: l.lng,
|
||||
timezone: null,
|
||||
local_date: date,
|
||||
local_time: time,
|
||||
}
|
||||
}
|
||||
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return {
|
||||
iata: e.code, icao: null,
|
||||
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
|
||||
country: '',
|
||||
lat: e.lat, lng: e.lng,
|
||||
tz: e.timezone || '',
|
||||
}
|
||||
}
|
||||
|
||||
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
|
||||
if (!e) return null
|
||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||
}
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
{ value: 'restaurant', labelKey: 'reservations.type.restaurant', Icon: Utensils },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
{ value: 'event', labelKey: 'reservations.type.event', Icon: Ticket },
|
||||
{ value: 'tour', labelKey: 'reservations.type.tour', Icon: Users },
|
||||
{ value: 'other', labelKey: 'reservations.type.other', Icon: FileText },
|
||||
@@ -84,7 +29,6 @@ function buildAssignmentOptions(days, assignments, t, locale) {
|
||||
const dayLabel = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
const dateStr = day.date ? ` · ${formatDate(day.date, locale)}` : ''
|
||||
const groupLabel = `${dayLabel}${dateStr}`
|
||||
// Group header (non-selectable)
|
||||
options.push({ value: `_header_${day.id}`, label: groupLabel, disabled: true, isHeader: true })
|
||||
for (let i = 0; i < da.length; i++) {
|
||||
const place = da[i].place
|
||||
@@ -115,9 +59,10 @@ interface ReservationModalProps {
|
||||
onFileUpload?: (fd: FormData) => Promise<void>
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [] }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -135,22 +80,16 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
})
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
const [linkedFileIds, setLinkedFileIds] = useState<number[]>([])
|
||||
const [unlinkedFileIds, setUnlinkedFileIds] = useState<number[]>([])
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
|
||||
const assignmentOptions = useMemo(
|
||||
() => buildAssignmentOptions(days, assignments, t, locale),
|
||||
@@ -160,7 +99,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
useEffect(() => {
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string' ? JSON.parse(reservation.metadata || '{}') : (reservation.metadata || {})
|
||||
// Parse end_date from reservation_end_time if it's a full ISO datetime
|
||||
const rawEnd = reservation.reservation_end_time || ''
|
||||
let endDate = ''
|
||||
let endTime = rawEnd
|
||||
@@ -183,15 +121,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
notes: reservation.notes || '',
|
||||
assignment_id: reservation.assignment_id || '',
|
||||
accommodation_id: reservation.accommodation_id || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_departure_airport: meta.departure_airport || '',
|
||||
meta_arrival_airport: meta.arrival_airport || '',
|
||||
meta_departure_timezone: meta.departure_timezone || '',
|
||||
meta_arrival_timezone: meta.arrival_timezone || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
meta_check_in_time: meta.check_in_time || '',
|
||||
meta_check_in_end_time: meta.check_in_end_time || '',
|
||||
meta_check_out_time: meta.check_out_time || '',
|
||||
@@ -201,61 +130,26 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
|
||||
const eps = reservation.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (reservation.type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||
} else if (isTransport(reservation.type)) {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
} else {
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '', accommodation_id: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_airline: '', meta_flight_number: '', meta_departure_airport: '', meta_arrival_airport: '',
|
||||
meta_departure_timezone: '', meta_arrival_timezone: '',
|
||||
meta_train_number: '', meta_platform: '', meta_seat: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
setPendingFiles([])
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [reservation, isOpen, selectedDayId])
|
||||
}, [reservation, isOpen, selectedDayId, defaultAssignmentId])
|
||||
|
||||
const set = (field, value) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
// Validate that end datetime is after start datetime
|
||||
const isEndBeforeStart = (() => {
|
||||
if (!form.end_date || !form.reservation_time) return false
|
||||
const startDate = form.reservation_time.split('T')[0]
|
||||
const startTime = form.reservation_time.split('T')[1] || '00:00'
|
||||
const endTime = form.reservation_end_time || '00:00'
|
||||
// For flights, compare in UTC using timezone offsets
|
||||
if (form.type === 'flight') {
|
||||
const parseOffset = (tz: string): number | null => {
|
||||
if (!tz) return null
|
||||
const m = tz.trim().match(/^(?:UTC|GMT)?\s*([+-])(\d{1,2})(?::(\d{2}))?$/i)
|
||||
if (!m) return null
|
||||
const sign = m[1] === '+' ? 1 : -1
|
||||
return sign * (parseInt(m[2]) * 60 + parseInt(m[3] || '0'))
|
||||
}
|
||||
const depOffset = parseOffset(form.meta_departure_timezone)
|
||||
const arrOffset = parseOffset(form.meta_arrival_timezone)
|
||||
if (depOffset === null || arrOffset === null) return false
|
||||
const depMinutes = new Date(`${startDate}T${startTime}`).getTime() - depOffset * 60000
|
||||
const arrMinutes = new Date(`${form.end_date}T${endTime}`).getTime() - arrOffset * 60000
|
||||
return arrMinutes <= depMinutes
|
||||
}
|
||||
const startFull = `${startDate}T${startTime}`
|
||||
const endFull = `${form.end_date}T${endTime}`
|
||||
return endFull <= startFull
|
||||
@@ -268,27 +162,11 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const metadata: Record<string, string> = {}
|
||||
if (form.type === 'flight') {
|
||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (fromPick.airport) {
|
||||
metadata.departure_airport = fromPick.airport.iata
|
||||
metadata.departure_timezone = fromPick.airport.tz
|
||||
}
|
||||
if (toPick.airport) {
|
||||
metadata.arrival_airport = toPick.airport.iata
|
||||
metadata.arrival_timezone = toPick.airport.tz
|
||||
}
|
||||
} else if (form.type === 'hotel') {
|
||||
if (form.type === 'hotel') {
|
||||
if (form.meta_check_in_time) metadata.check_in_time = form.meta_check_in_time
|
||||
if (form.meta_check_in_end_time) metadata.check_in_end_time = form.meta_check_in_end_time
|
||||
if (form.meta_check_out_time) metadata.check_out_time = form.meta_check_out_time
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
// Combine end_date + end_time into reservation_end_time
|
||||
let combinedEndTime = form.reservation_end_time
|
||||
if (form.end_date) {
|
||||
combinedEndTime = form.reservation_end_time ? `${form.end_date}T${form.reservation_end_time}` : form.end_date
|
||||
@@ -297,40 +175,24 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
if (isTransport(form.type)) {
|
||||
const startDate = (form.reservation_time || '').split('T')[0] || null
|
||||
const startTime = (form.reservation_time || '').split('T')[1]?.slice(0, 5) || null
|
||||
const endDate = form.end_date || null
|
||||
const endTime = form.reservation_end_time || null
|
||||
if (form.type === 'flight') {
|
||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, startTime))
|
||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, endTime))
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, startTime))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, endTime))
|
||||
}
|
||||
}
|
||||
|
||||
const saveData: Record<string, any> = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.type === 'hotel' ? null : form.reservation_time,
|
||||
reservation_end_time: form.type === 'hotel' ? null : combinedEndTime,
|
||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||
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,
|
||||
accommodation_id: form.type === 'hotel' ? (form.accommodation_id || null) : null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints: isTransport(form.type) ? endpoints : [],
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
}
|
||||
// Auto-create/update budget entry if price is set, or signal removal if cleared
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
// If hotel with place + days, pass hotel data for auto-creation or update
|
||||
if (form.type === 'hotel' && form.hotel_place_id && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id,
|
||||
@@ -428,7 +290,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
|
||||
{/* Assignment Picker (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && assignmentOptions.length > 0 && (
|
||||
<div>
|
||||
<div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
<Link2 size={10} style={{ display: 'inline', verticalAlign: '-1px', marginRight: 3 }} />
|
||||
@@ -455,73 +317,57 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Date/Time + End Date/Time + Status (hidden for hotels) */}
|
||||
{form.type !== 'hotel' && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, t] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (t ? `${d}T${t}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, t] = (form.reservation_time || '').split('T'); return t || '' })()}
|
||||
onChange={t => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', t ? `${date}T${t}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{form.type === 'flight' && fromPick.airport && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
</div>
|
||||
<label style={labelStyle}>{t('reservations.date')}</label>
|
||||
<CustomDatePicker
|
||||
value={(() => { const [d] = (form.reservation_time || '').split('T'); return d || '' })()}
|
||||
onChange={d => {
|
||||
const [, tm] = (form.reservation_time || '').split('T')
|
||||
set('reservation_time', d ? (tm ? `${d}T${tm}` : d) : '')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && toPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{toPick.airport.tz}
|
||||
</div>
|
||||
<label style={labelStyle}>{t('reservations.startTime')}</label>
|
||||
<CustomTimePicker
|
||||
value={(() => { const [, tm] = (form.reservation_time || '').split('T'); return tm || '' })()}
|
||||
onChange={tm => {
|
||||
const [d] = (form.reservation_time || '').split('T')
|
||||
const selectedDay = days.find(dy => dy.id === selectedDayId)
|
||||
const date = d || selectedDay?.date || new Date().toISOString().split('T')[0]
|
||||
set('reservation_time', tm ? `${date}T${tm}` : date)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endDate')}</label>
|
||||
<CustomDatePicker
|
||||
value={form.end_date}
|
||||
onChange={d => set('end_date', d || '')}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.reservation_end_time} onChange={v => set('reservation_end_time', v)} />
|
||||
</div>
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
)}
|
||||
</div>
|
||||
{isEndBeforeStart && (
|
||||
<div style={{ fontSize: 11, color: '#ef4444', marginTop: -6 }}>{t('reservations.validation.endBeforeStart')}</div>
|
||||
)}
|
||||
</>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Location (own row for non-transport, non-hotel types) */}
|
||||
{!isTransport(form.type) && form.type !== 'hotel' && (
|
||||
{/* Location */}
|
||||
{form.type !== 'hotel' && (
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.locationAddress')}</label>
|
||||
<input type="text" value={form.location} onChange={e => set('location', e.target.value)}
|
||||
@@ -550,46 +396,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* From / To endpoints for transport bookings */}
|
||||
{isTransport(form.type) && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.from')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.to')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.airline') || 'Airline'}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.flightNumber') || 'Flight No.'}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hotel fields */}
|
||||
{form.type === 'hotel' && (
|
||||
<>
|
||||
{/* Hotel place + day range */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.hotelPlace')}</label>
|
||||
@@ -633,7 +442,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Check-in / check-in-until / check-out */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.checkIn')}</label>
|
||||
@@ -651,26 +459,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</>
|
||||
)}
|
||||
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.trainNumber') || 'Train No.'}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.platform') || 'Platform'}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.seat') || 'Seat'}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
@@ -689,12 +477,9 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<span style={{ flex: 1, fontSize: 12, color: 'var(--text-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.original_name}</span>
|
||||
<a href="#" onClick={(e) => { e.preventDefault(); openFile(f.url).catch(() => {}) }} style={{ color: 'var(--text-faint)', display: 'flex', flexShrink: 0, cursor: 'pointer' }}><ExternalLink size={11} /></a>
|
||||
<button type="button" onClick={async () => {
|
||||
// Always unlink, never delete the file
|
||||
// Clear primary reservation_id if it points to this reservation
|
||||
if (f.reservation_id === reservation?.id) {
|
||||
try { await apiClient.put(`/trips/${tripId}/files/${f.id}`, { reservation_id: null }) } catch {}
|
||||
}
|
||||
// Remove from file_links if linked there
|
||||
try {
|
||||
const linksRes = await apiClient.get(`/trips/${tripId}/files/${f.id}/links`)
|
||||
const link = (linksRes.data.links || []).find((l: any) => l.reservation_id === reservation?.id)
|
||||
@@ -727,7 +512,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<Paperclip size={11} />
|
||||
{uploadingFile ? t('reservations.uploading') : t('reservations.attachFile')}
|
||||
</button>}
|
||||
{/* Link existing file picker */}
|
||||
{reservation?.id && files.filter(f => !f.deleted_at && !attachedFiles.some(af => af.id === f.id)).length > 0 && (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button type="button" onClick={() => setShowFilePicker(v => !v)} style={{
|
||||
@@ -771,7 +555,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category — only shown when budget addon is enabled */}
|
||||
{/* Price + Budget Category */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
@@ -779,7 +563,7 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
<label style={labelStyle}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } set('price', t) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
style={inputStyle} />
|
||||
</div>
|
||||
|
||||
@@ -69,9 +69,10 @@ interface ReservationCardProps {
|
||||
onNavigateToFiles: () => void
|
||||
assignmentLookup: Record<number, AssignmentLookupEntry>
|
||||
canEdit: boolean
|
||||
days?: Day[]
|
||||
}
|
||||
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit }: ReservationCardProps) {
|
||||
function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateToFiles, assignmentLookup, canEdit, days = [] }: ReservationCardProps) {
|
||||
const { toggleReservationStatus } = useTripStore()
|
||||
const toast = useToast()
|
||||
const { t, locale } = useTranslation()
|
||||
@@ -109,6 +110,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
const hasCode = !!r.confirmation_number
|
||||
const dateCols = [hasDate, hasTime, hasCode].filter(Boolean).length
|
||||
|
||||
const TRANSPORT_TYPES_SET = new Set(['flight', 'train', 'bus', 'car', 'cruise'])
|
||||
const isTransportType = TRANSPORT_TYPES_SET.has(r.type)
|
||||
const startDay = r.day_id ? days.find(d => d.id === r.day_id) : undefined
|
||||
const endDay = r.end_day_id ? days.find(d => d.id === r.end_day_id) : undefined
|
||||
const dayLabel = (day: typeof startDay): string => {
|
||||
if (!day) return ''
|
||||
const base = day.title || t('dayplan.dayN', { n: day.day_number })
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00Z')
|
||||
const dateStr = d.toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })
|
||||
return `${base} · ${dateStr}`
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column',
|
||||
@@ -186,6 +202,15 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: 14, display: 'flex', flexDirection: 'column', gap: 12, flex: 1 }}>
|
||||
{/* Day label for transport reservations linked to a day */}
|
||||
{isTransportType && startDay && (
|
||||
<div>
|
||||
<div style={fieldLabelStyle}>{t('reservations.date')}</div>
|
||||
<div style={{ ...fieldValueStyle, textAlign: 'center' }}>
|
||||
{dayLabel(startDay)}{endDay && endDay.id !== startDay.id ? ` – ${dayLabel(endDay)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Date / Time row */}
|
||||
{hasDate && (
|
||||
<div style={{ display: 'grid', gap: 10, gridTemplateColumns: hasTime ? '1fr 1fr' : '1fr' }}>
|
||||
@@ -430,9 +455,11 @@ interface ReservationsPanelProps {
|
||||
onEdit: (reservation: Reservation) => void
|
||||
onDelete: (id: number) => void
|
||||
onNavigateToFiles: () => void
|
||||
titleKey?: string
|
||||
addManualKey?: string
|
||||
}
|
||||
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles }: ReservationsPanelProps) {
|
||||
export default function ReservationsPanel({ tripId, reservations, days, assignments, files = [], onAdd, onEdit, onDelete, onNavigateToFiles, titleKey = 'reservations.title', addManualKey = 'reservations.addManual' }: ReservationsPanelProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const can = useCanDo()
|
||||
const trip = useTripStore((s) => s.trip)
|
||||
@@ -483,7 +510,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap',
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600, color: 'var(--text-primary)', letterSpacing: '-0.01em', flexShrink: 0 }}>
|
||||
{t('reservations.title')}
|
||||
{t(titleKey)}
|
||||
</h2>
|
||||
|
||||
{reservations.length > 0 && (
|
||||
@@ -557,7 +584,7 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
onMouseLeave={e => e.currentTarget.style.opacity = '1'}
|
||||
>
|
||||
<Plus size={14} strokeWidth={2.5} />
|
||||
<span className="hidden sm:inline">{t('reservations.addManual')}</span>
|
||||
<span className="hidden sm:inline">{t(addManualKey)}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -579,12 +606,12 @@ export default function ReservationsPanel({ tripId, reservations, days, assignme
|
||||
<>
|
||||
{allPending.length > 0 && (
|
||||
<Section title={t('reservations.pending')} count={allPending.length} accent="gray" storageKey={`trek:bookings-pending-open:${tripId}`}>
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
{allPending.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
|
||||
</Section>
|
||||
)}
|
||||
{allConfirmed.length > 0 && (
|
||||
<Section title={t('reservations.confirmed')} count={allConfirmed.length} accent="green" storageKey={`trek:bookings-confirmed-open:${tripId}`}>
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} />)}
|
||||
{allConfirmed.map(r => <ReservationCard key={r.id} r={r} tripId={tripId} onEdit={onEdit} onDelete={onDelete} files={files} onNavigateToFiles={onNavigateToFiles} assignmentLookup={assignmentLookup} canEdit={canEdit} days={days} />)}
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plane, Train, Car, Ship } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import AirportSelect, { type Airport } from './AirportSelect'
|
||||
import LocationSelect, { type LocationPoint } from './LocationSelect'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import { formatDate } from '../../utils/formatters'
|
||||
import type { Day, Reservation, ReservationEndpoint } from '../../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
|
||||
interface EndpointPick {
|
||||
airport?: Airport
|
||||
location?: LocationPoint
|
||||
}
|
||||
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
||||
code: a.iata,
|
||||
lat: a.lat, lng: a.lng,
|
||||
timezone: a.tz,
|
||||
local_date: date,
|
||||
local_time: time,
|
||||
}
|
||||
}
|
||||
|
||||
function endpointFromLocation(l: LocationPoint, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
return {
|
||||
role, sequence,
|
||||
name: l.name,
|
||||
code: null,
|
||||
lat: l.lat, lng: l.lng,
|
||||
timezone: null,
|
||||
local_date: date,
|
||||
local_time: time,
|
||||
}
|
||||
}
|
||||
|
||||
function airportFromEndpoint(e: ReservationEndpoint | undefined): Airport | null {
|
||||
if (!e || !e.code) return null
|
||||
return {
|
||||
iata: e.code, icao: null,
|
||||
name: e.name, city: e.name.replace(/\s*\([A-Z]{3}\)\s*$/, ''),
|
||||
country: '',
|
||||
lat: e.lat, lng: e.lng,
|
||||
tz: e.timezone || '',
|
||||
}
|
||||
}
|
||||
|
||||
function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint | null {
|
||||
if (!e) return null
|
||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
{ value: 'car', labelKey: 'reservations.type.car', Icon: Car },
|
||||
{ value: 'cruise', labelKey: 'reservations.type.cruise', Icon: Ship },
|
||||
]
|
||||
|
||||
const defaultForm = {
|
||||
title: '',
|
||||
type: 'flight' as TransportType,
|
||||
status: 'pending' as 'pending' | 'confirmed',
|
||||
start_day_id: '' as string | number,
|
||||
end_day_id: '' as string | number,
|
||||
departure_time: '',
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
meta_platform: '',
|
||||
meta_seat: '',
|
||||
}
|
||||
|
||||
interface TransportModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (data: Record<string, any>) => Promise<void>
|
||||
reservation: Reservation | null
|
||||
days: Day[]
|
||||
selectedDayId: number | null
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (reservation) {
|
||||
const meta = typeof reservation.metadata === 'string'
|
||||
? JSON.parse(reservation.metadata || '{}')
|
||||
: (reservation.metadata || {})
|
||||
const eps = reservation.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
const type = (TRANSPORT_TYPES as readonly string[]).includes(reservation.type)
|
||||
? reservation.type as TransportType
|
||||
: 'flight'
|
||||
setForm({
|
||||
title: reservation.title || '',
|
||||
type,
|
||||
status: reservation.status || 'pending',
|
||||
start_day_id: reservation.day_id ?? '',
|
||||
end_day_id: reservation.end_day_id ?? '',
|
||||
departure_time: reservation.reservation_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
arrival_time: reservation.reservation_end_time?.split('T')[1]?.slice(0, 5) ?? '',
|
||||
confirmation_number: reservation.confirmation_number || '',
|
||||
notes: reservation.notes || '',
|
||||
meta_airline: meta.airline || '',
|
||||
meta_flight_number: meta.flight_number || '',
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
}
|
||||
} else {
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId])
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const startDay = days.find(d => d.id === Number(form.start_day_id))
|
||||
const endDay = days.find(d => d.id === Number(form.end_day_id))
|
||||
|
||||
const buildTime = (day: Day | undefined, time: string): string | null => {
|
||||
if (!time) return null
|
||||
return day?.date ? `${day.date}T${time}` : `T${time}`
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {}
|
||||
if (form.type === 'flight') {
|
||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
||||
if (fromPick.airport) {
|
||||
metadata.departure_airport = fromPick.airport.iata
|
||||
metadata.departure_timezone = fromPick.airport.tz
|
||||
}
|
||||
if (toPick.airport) {
|
||||
metadata.arrival_airport = toPick.airport.iata
|
||||
metadata.arrival_timezone = toPick.airport.tz
|
||||
}
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
if (form.type === 'flight') {
|
||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
||||
} else {
|
||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
||||
if (toPick.location) endpoints.push(endpointFromLocation(toPick.location, 'to', 1, endDate, form.arrival_time || null))
|
||||
}
|
||||
|
||||
const payload = {
|
||||
title: form.title,
|
||||
type: form.type,
|
||||
status: form.status,
|
||||
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
||||
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
||||
reservation_time: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : null,
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
await onSave(payload)
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
padding: '8px 12px', fontSize: 13, fontFamily: 'inherit',
|
||||
outline: 'none', boxSizing: 'border-box' as const, color: 'var(--text-primary)', background: 'var(--bg-input)',
|
||||
}
|
||||
const labelStyle = {
|
||||
display: 'block', fontSize: 11, fontWeight: 600, color: 'var(--text-faint)',
|
||||
marginBottom: 5, textTransform: 'uppercase' as const, letterSpacing: '0.03em',
|
||||
}
|
||||
|
||||
const dayOptions = [
|
||||
{ value: '', label: '—' },
|
||||
...days.map(d => ({
|
||||
value: d.id,
|
||||
label: d.title || `${t('dayplan.dayN', { n: d.day_number })}${d.date ? ` · ${formatDate(d.date, locale) ?? ''}` : ''}`,
|
||||
})),
|
||||
]
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title={reservation ? t('transport.modalTitle.edit') : t('transport.modalTitle.create')}
|
||||
size="2xl"
|
||||
>
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
|
||||
{/* Type selector */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.bookingType')}</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5 }}>
|
||||
{TYPE_OPTIONS.map(({ value, labelKey, Icon }) => (
|
||||
<button key={value} type="button" onClick={() => set('type', value)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4,
|
||||
padding: '5px 10px', borderRadius: 99, border: '1px solid',
|
||||
fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', transition: 'all 0.12s',
|
||||
background: form.type === value ? 'var(--text-primary)' : 'var(--bg-card)',
|
||||
borderColor: form.type === value ? 'var(--text-primary)' : 'var(--border-primary)',
|
||||
color: form.type === value ? 'var(--bg-primary)' : 'var(--text-muted)',
|
||||
}}>
|
||||
<Icon size={11} /> {t(labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.titleLabel')} *</label>
|
||||
<input type="text" value={form.title} onChange={e => set('title', e.target.value)} required
|
||||
placeholder={t('reservations.titlePlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
|
||||
{/* From / To endpoints */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.from')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.to')}</label>
|
||||
{form.type === 'flight' ? (
|
||||
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
||||
) : (
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.departureDate') : form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.start_day_id}
|
||||
onChange={value => set('start_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.departureTime') : form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && fromPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={form.end_day_id}
|
||||
onChange={value => set('end_day_id', value)}
|
||||
placeholder={t('dayplan.dayN', { n: '?' })}
|
||||
options={dayOptions}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>
|
||||
{form.type === 'flight' ? t('reservations.arrivalTime') : form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}
|
||||
</label>
|
||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||
</div>
|
||||
{form.type === 'flight' && toPick.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label style={labelStyle}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div style={{ ...inputStyle, padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{toPick.airport.tz}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight-specific fields */}
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={form.meta_flight_number} onChange={e => set('meta_flight_number', e.target.value)}
|
||||
placeholder="LH 123" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
{form.type === 'train' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.trainNumber')}</label>
|
||||
<input type="text" value={form.meta_train_number} onChange={e => set('meta_train_number', e.target.value)}
|
||||
placeholder="ICE 123" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.platform')}</label>
|
||||
<input type="text" value={form.meta_platform} onChange={e => set('meta_platform', e.target.value)}
|
||||
placeholder="12" style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.meta.seat')}</label>
|
||||
<input type="text" value={form.meta_seat} onChange={e => set('meta_seat', e.target.value)}
|
||||
placeholder="42A" style={inputStyle} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Booking Code + Status */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.confirmationCode')}</label>
|
||||
<input type="text" value={form.confirmation_number} onChange={e => set('confirmation_number', e.target.value)}
|
||||
placeholder={t('reservations.confirmationPlaceholder')} style={inputStyle} />
|
||||
</div>
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.status')}</label>
|
||||
<CustomSelect
|
||||
value={form.status}
|
||||
onChange={value => set('status', value)}
|
||||
options={[
|
||||
{ value: 'pending', label: t('reservations.pending') },
|
||||
{ value: 'confirmed', label: t('reservations.confirmed') },
|
||||
]}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label style={labelStyle}>{t('reservations.notes')}</label>
|
||||
<textarea value={form.notes} onChange={e => set('notes', e.target.value)} rows={2}
|
||||
placeholder={t('reservations.notesPlaceholder')}
|
||||
style={{ ...inputStyle, resize: 'none', lineHeight: 1.5 }} />
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTop: '1px solid var(--border-secondary)' }}>
|
||||
<button type="button" onClick={onClose} style={{ padding: '8px 16px', borderRadius: 10, border: '1px solid var(--border-primary)', background: 'none', fontSize: 12, cursor: 'pointer', fontFamily: 'inherit', color: 'var(--text-muted)' }}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="submit" disabled={isSaving || !form.title.trim()} style={{ padding: '8px 20px', borderRadius: 10, border: 'none', background: 'var(--text-primary)', color: 'var(--bg-primary)', fontSize: 12, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit', opacity: isSaving || !form.title.trim() ? 0.5 : 1 }}>
|
||||
{isSaving ? t('common.saving') : reservation ? t('common.update') : t('common.add')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Sun, Cloud, CloudRain, CloudSnow, CloudDrizzle, CloudLightning, Wind } from 'lucide-react'
|
||||
import { weatherApi } from '../../api/client'
|
||||
import { fetchWeather } from '../../services/weatherQueue'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
const WEATHER_ICON_MAP = {
|
||||
@@ -61,7 +61,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
// Climate data: use from cache but re-fetch in background to upgrade to forecast
|
||||
else if (cached.type === 'climate') {
|
||||
setWeather(cached)
|
||||
weatherApi.get(lat, lng, date)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (!data.error && data.temp !== undefined && data.type === 'forecast') {
|
||||
setWeatherCache(cacheKey, data)
|
||||
@@ -77,7 +77,7 @@ export default function WeatherWidget({ lat, lng, date, compact = false }: Weath
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
weatherApi.get(lat, lng, date)
|
||||
fetchWeather(lat, lng, date)
|
||||
.then(data => {
|
||||
if (data.error || data.temp === undefined) {
|
||||
setFailed(true)
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function ConfirmDialog({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center px-4"
|
||||
className="fixed inset-0 z-[10000] flex items-center justify-center px-4"
|
||||
style={{ backgroundColor: 'rgba(15, 23, 42, 0.5)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
|
||||
@@ -1,50 +1,123 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import { useTripStore } from '../store/tripStore'
|
||||
import { calculateSegments } from '../components/Map/RouteCalculator'
|
||||
import type { TripStoreState } from '../store/tripStore'
|
||||
import type { RouteSegment, RouteResult } from '../types'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'cruise']
|
||||
|
||||
/**
|
||||
* Manages route calculation state for a selected day. Extracts geo-coded waypoints from
|
||||
* day assignments, draws a straight-line route, and optionally fetches per-segment
|
||||
* driving/walking durations via OSRM. Aborts in-flight requests when the day changes.
|
||||
*/
|
||||
export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: number | null) {
|
||||
const [route, setRoute] = useState<[number, number][] | null>(null)
|
||||
const [route, setRoute] = useState<[number, number][][] | null>(null)
|
||||
const [routeInfo, setRouteInfo] = useState<RouteResult | null>(null)
|
||||
const [routeSegments, setRouteSegments] = useState<RouteSegment[]>([])
|
||||
const routeCalcEnabled = useSettingsStore((s) => s.settings.route_calculation) !== false
|
||||
const routeAbortRef = useRef<AbortController | null>(null)
|
||||
// Keep a ref to the latest tripStore so updateRouteForDay never has a stale closure
|
||||
const tripStoreRef = useRef(tripStore)
|
||||
tripStoreRef.current = tripStore
|
||||
const reservationsForSignature = useTripStore((s) => s.reservations)
|
||||
|
||||
const updateRouteForDay = useCallback(async (dayId: number | null) => {
|
||||
if (routeAbortRef.current) routeAbortRef.current.abort()
|
||||
if (!dayId) { setRoute(null); setRouteSegments([]); return }
|
||||
const currentAssignments = tripStoreRef.current.assignments || {}
|
||||
// Read directly from store (not a render-phase ref) so callers after optimistic
|
||||
// updates or non-optimistic deletes always see the latest assignments.
|
||||
const currentAssignments = useTripStore.getState().assignments || {}
|
||||
const da = (currentAssignments[String(dayId)] || []).slice().sort((a, b) => a.order_index - b.order_index)
|
||||
const waypoints = da.map((a) => a.place).filter((p) => p?.lat && p?.lng)
|
||||
if (waypoints.length < 2) { setRoute(null); setRouteSegments([]); return }
|
||||
setRoute(waypoints.map((p) => [p.lat!, p.lng!]))
|
||||
const allReservations = useTripStore.getState().reservations || []
|
||||
const allDays = useTripStore.getState().days || []
|
||||
const dayOrder = (id: number | null | undefined): number | null => {
|
||||
if (id == null) return null
|
||||
const d = allDays.find(x => x.id === id)
|
||||
return d ? ((d as any).day_number ?? allDays.indexOf(d)) : null
|
||||
}
|
||||
const thisOrder = dayOrder(dayId)
|
||||
|
||||
// Transport reservations for this day with a known position — mirrors getTransportForDay semantics
|
||||
const dayTransports = thisOrder == null ? [] : allReservations.filter(r => {
|
||||
if (!TRANSPORT_TYPES.includes(r.type)) return false
|
||||
const startId = r.day_id
|
||||
if (startId == null) return false
|
||||
const endId = r.end_day_id ?? startId
|
||||
if (startId === endId) {
|
||||
if (startId !== dayId) return false
|
||||
} else {
|
||||
const startOrder = dayOrder(startId)
|
||||
const endOrder = dayOrder(endId)
|
||||
if (startOrder == null || endOrder == null) return false
|
||||
if (thisOrder < startOrder || thisOrder > endOrder) return false
|
||||
}
|
||||
const pos = r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position
|
||||
return pos != null
|
||||
})
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position,
|
||||
// then derive segments by resetting whenever a transport appears — mirrors getMergedItems order.
|
||||
type Entry = { kind: 'place'; lat: number; lng: number } | { kind: 'transport' }
|
||||
const entries: (Entry & { pos: number })[] = [
|
||||
...da.filter(a => a.place?.lat && a.place?.lng).map(a => ({
|
||||
kind: 'place' as const, lat: a.place.lat!, lng: a.place.lng!, pos: a.order_index,
|
||||
})),
|
||||
...dayTransports.map(r => ({
|
||||
kind: 'transport' as const,
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
const segments: [number, number][][] = []
|
||||
let currentSeg: [number, number][] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
currentSeg.push([entry.lat, entry.lng])
|
||||
} else {
|
||||
if (currentSeg.length >= 2) segments.push([...currentSeg])
|
||||
currentSeg = []
|
||||
}
|
||||
}
|
||||
if (currentSeg.length >= 2) segments.push(currentSeg)
|
||||
|
||||
const geocodedWaypoints = da.map(a => a.place).filter(p => p?.lat && p?.lng) as { lat: number; lng: number }[]
|
||||
|
||||
if (segments.length === 0 && geocodedWaypoints.length < 2) {
|
||||
setRoute(null); setRouteSegments([]); return
|
||||
}
|
||||
setRoute(segments.length > 0 ? segments : null)
|
||||
if (!routeCalcEnabled) { setRouteSegments([]); return }
|
||||
const controller = new AbortController()
|
||||
routeAbortRef.current = controller
|
||||
try {
|
||||
const segments = await calculateSegments(waypoints as { lat: number; lng: number }[], { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(segments)
|
||||
const calcSegments = await calculateSegments(geocodedWaypoints, { signal: controller.signal })
|
||||
if (!controller.signal.aborted) setRouteSegments(calcSegments)
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error && err.name !== 'AbortError') setRouteSegments([])
|
||||
else if (!(err instanceof Error)) setRouteSegments([])
|
||||
}
|
||||
}, [routeCalcEnabled])
|
||||
|
||||
// Only recalculate when assignments for the SELECTED day change
|
||||
// Stable signature for transport reservations on the selected day — changes when a transport
|
||||
// is added, removed, or repositioned, ensuring route recalc fires even on transport-only reorders.
|
||||
const transportSignature = useMemo(() => {
|
||||
if (!selectedDayId) return ''
|
||||
return reservationsForSignature
|
||||
.filter(r => TRANSPORT_TYPES.includes(r.type))
|
||||
.map(r => {
|
||||
const pos = r.day_positions?.[selectedDayId] ?? r.day_positions?.[String(selectedDayId)] ?? r.day_plan_position
|
||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}`
|
||||
})
|
||||
.sort()
|
||||
.join('|')
|
||||
}, [reservationsForSignature, selectedDayId])
|
||||
|
||||
// Recalculate when assignments or transport positions for the SELECTED day change
|
||||
const selectedDayAssignments = selectedDayId ? tripStore.assignments?.[String(selectedDayId)] : null
|
||||
useEffect(() => {
|
||||
if (!selectedDayId) { setRoute(null); setRouteSegments([]); return }
|
||||
updateRouteForDay(selectedDayId)
|
||||
}, [selectedDayId, selectedDayAssignments])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedDayId, selectedDayAssignments, transportSignature])
|
||||
|
||||
return { route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'إضافة',
|
||||
'common.loading': 'جارٍ التحميل...',
|
||||
'common.import': 'استيراد',
|
||||
'common.select': 'تحديد',
|
||||
'common.selectAll': 'تحديد الكل',
|
||||
'common.deselectAll': 'إلغاء تحديد الكل',
|
||||
'common.error': 'خطأ',
|
||||
'common.unknownError': 'خطأ غير معروف',
|
||||
'common.tooManyAttempts': 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
@@ -867,6 +870,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'الخطة',
|
||||
'trip.tabs.transports': 'المواصلات',
|
||||
'trip.tabs.reservations': 'الحجوزات',
|
||||
'trip.tabs.reservationsShort': 'حجز',
|
||||
'trip.tabs.packing': 'قائمة التجهيز',
|
||||
@@ -889,6 +893,8 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'تمت إضافة الحجز',
|
||||
'trip.toast.deleted': 'تم الحذف',
|
||||
'trip.confirm.deletePlace': 'هل تريد حذف هذا المكان؟',
|
||||
'trip.confirm.deletePlaces': 'حذف {count} أماكن؟',
|
||||
'trip.toast.placesDeleted': 'تم حذف {count} أماكن',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'لا توجد أماكن مخططة لهذا اليوم',
|
||||
@@ -933,6 +939,17 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'فشل الاستيراد',
|
||||
'places.importAllSkipped': 'جميع الأماكن موجودة بالفعل في الرحلة.',
|
||||
'places.gpxImported': 'تم استيراد {count} مكان من GPX',
|
||||
'places.gpxImportTypes': 'ما الذي تريد استيراده؟',
|
||||
'places.gpxImportWaypoints': 'نقاط الطريق',
|
||||
'places.gpxImportRoutes': 'المسارات',
|
||||
'places.gpxImportTracks': 'المسارات (مع هندسة الطريق)',
|
||||
'places.gpxImportNoneSelected': 'اختر نوعاً واحداً على الأقل للاستيراد.',
|
||||
'places.kmlImportTypes': 'ما الذي تريد استيراده؟',
|
||||
'places.kmlImportPoints': 'نقاط (Placemarks)',
|
||||
'places.kmlImportPaths': 'مسارات (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'اختر نوعًا واحدًا على الأقل.',
|
||||
'places.selectionCount': '{count} محدد',
|
||||
'places.deleteSelected': 'حذف المحدد',
|
||||
'places.kmlKmzImported': 'تم استيراد {count} مكان من KMZ/KML',
|
||||
'places.urlResolved': 'تم استيراد المكان من الرابط',
|
||||
'places.importList': 'استيراد قائمة',
|
||||
@@ -949,6 +966,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'إلى أي يوم تريد الإضافة؟',
|
||||
'places.all': 'الكل',
|
||||
'places.unplanned': 'غير مخطط',
|
||||
'places.filterTracks': 'المسارات',
|
||||
'places.search': 'ابحث عن أماكن...',
|
||||
'places.allCategories': 'كل الفئات',
|
||||
'places.categoriesSelected': 'فئات',
|
||||
@@ -1121,6 +1139,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'النهاية',
|
||||
'reservations.span.ongoing': 'جارٍ',
|
||||
'reservations.validation.endBeforeStart': 'يجب أن يكون تاريخ/وقت الانتهاء بعد تاريخ/وقت البدء',
|
||||
'reservations.addBooking': 'إضافة حجز',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'الميزانية',
|
||||
@@ -1773,6 +1792,7 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'تمت إعادة ترتيب الأماكن',
|
||||
'undo.optimize': 'تم تحسين المسار',
|
||||
'undo.deletePlace': 'تم حذف المكان',
|
||||
'undo.deletePlaces': 'تم حذف الأماكن',
|
||||
'undo.moveDay': 'تم نقل المكان إلى يوم آخر',
|
||||
'undo.lock': 'تم تبديل قفل المكان',
|
||||
'undo.importGpx': 'استيراد GPX',
|
||||
@@ -2064,6 +2084,11 @@ const ar: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'كلمة شخصية مني',
|
||||
'system_notice.v3_thankyou.body': 'قبل أن تمضي — أريد أن أتوقف لحظة.\n\nبدأ TREK كمشروع جانبي بنيته لرحلاتي الخاصة. لم أتخيل يومًا أنه سيكبر ليصبح شيئًا يعتمد عليه 4,000 منكم لتخطيط مغامراتهم. كل نجمة، كل مشكلة، كل طلب ميزة — أقرأها جميعًا، وهي ما يبقيني مستمرًا في الليالي المتأخرة بين عمل بدوام كامل والجامعة.\n\nأريدكم أن تعرفوا: TREK سيبقى دائمًا مفتوح المصدر، دائمًا مستضافًا ذاتيًا، دائمًا ملككم. لا تتبع، لا اشتراكات، لا شروط خفية. مجرد أداة بناها شخص يحب السفر بقدر ما تحبونه.\n\nشكر خاص لـ [jubnl](https://github.com/jubnl) — لقد أصبحت متعاونًا رائعًا. الكثير مما يجعل الإصدار 3.0 عظيمًا يحمل بصماتك. شكرًا لإيمانك بهذا المشروع عندما كان لا يزال في بداياته.\n\nولكل واحد منكم ممن أبلغ عن خطأ، أو ترجم نصًا، أو شارك TREK مع صديق، أو ببساطة استخدمه لتخطيط رحلة — **شكرًا لكم**. أنتم السبب في وجود هذا.\n\nإلى المزيد من المغامرات معًا.\n\n— Maurice\n\n---\n\n[انضم إلى المجتمع على Discord](https://discord.gg/7Q6M6jDwzf)\n\nإذا جعل TREK رحلاتك أفضل، [فنجان قهوة صغير](https://ko-fi.com/mauriceboe) يبقي الأضواء مشتعلة.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'المواصلات',
|
||||
'transport.addManual': 'نقل يدوي',
|
||||
}
|
||||
|
||||
export default ar
|
||||
|
||||
@@ -10,6 +10,9 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Adicionar',
|
||||
'common.loading': 'Carregando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Selecionar',
|
||||
'common.selectAll': 'Selecionar tudo',
|
||||
'common.deselectAll': 'Desmarcar tudo',
|
||||
'common.error': 'Erro',
|
||||
'common.unknownError': 'Erro desconhecido',
|
||||
'common.tooManyAttempts': 'Muitas tentativas. Tente novamente mais tarde.',
|
||||
@@ -837,6 +840,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plano',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de mala',
|
||||
@@ -858,6 +862,8 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reserva adicionada',
|
||||
'trip.toast.deleted': 'Excluído',
|
||||
'trip.confirm.deletePlace': 'Tem certeza de que deseja excluir este lugar?',
|
||||
'trip.confirm.deletePlaces': 'Excluir {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares excluídos',
|
||||
'trip.loadingPhotos': 'Carregando fotos dos lugares...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
@@ -903,6 +909,17 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importação falhou',
|
||||
'places.importAllSkipped': 'Todos os lugares já estavam na viagem.',
|
||||
'places.gpxImported': '{count} lugares importados do GPX',
|
||||
'places.gpxImportTypes': 'O que deseja importar?',
|
||||
'places.gpxImportWaypoints': 'Pontos de caminho',
|
||||
'places.gpxImportRoutes': 'Rotas',
|
||||
'places.gpxImportTracks': 'Trilhas (com geometria de percurso)',
|
||||
'places.gpxImportNoneSelected': 'Selecione pelo menos um tipo para importar.',
|
||||
'places.kmlImportTypes': 'O que deseja importar?',
|
||||
'places.kmlImportPoints': 'Pontos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Caminhos (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecione pelo menos um tipo.',
|
||||
'places.selectionCount': '{count} selecionado(s)',
|
||||
'places.deleteSelected': 'Excluir seleção',
|
||||
'places.kmlKmzImported': '{count} lugares importados de KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado da URL',
|
||||
'places.importList': 'Importar lista',
|
||||
@@ -919,6 +936,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Adicionar a qual dia?',
|
||||
'places.all': 'Todos',
|
||||
'places.unplanned': 'Não planejados',
|
||||
'places.filterTracks': 'Trilhas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas as categorias',
|
||||
'places.categoriesSelected': 'categorias',
|
||||
@@ -1090,6 +1108,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Fim',
|
||||
'reservations.span.ongoing': 'Em andamento',
|
||||
'reservations.validation.endBeforeStart': 'A data/hora final deve ser posterior à data/hora inicial',
|
||||
'reservations.addBooking': 'Adicionar reserva',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Orçamento',
|
||||
@@ -1714,6 +1733,7 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Locais reordenados',
|
||||
'undo.optimize': 'Rota otimizada',
|
||||
'undo.deletePlace': 'Local excluído',
|
||||
'undo.deletePlaces': 'Lugares excluídos',
|
||||
'undo.moveDay': 'Local movido para outro dia',
|
||||
'undo.lock': 'Bloqueio do local alternado',
|
||||
'undo.importGpx': 'Importação de GPX',
|
||||
@@ -2267,6 +2287,11 @@ const br: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Uma nota pessoal minha',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir em frente — quero fazer uma pausa.\n\nO TREK começou como um projeto paralelo que criei para minhas próprias viagens. Nunca imaginei que cresceria a ponto de 4.000 de vocês confiarem nele para planejar suas aventuras. Cada estrela, cada issue, cada pedido de recurso — eu leio todos, e eles me mantêm firme nas noites longas entre um trabalho em tempo integral e a universidade.\n\nQuero que saibam: o TREK sempre será open source, sempre self-hosted, sempre de vocês. Sem rastreamento, sem assinaturas, sem pegadinhas. Apenas uma ferramenta feita por alguém que ama viajar tanto quanto vocês.\n\nAgradecimento especial ao [jubnl](https://github.com/jubnl) — você se tornou um colaborador incrível. Muito do que torna a versão 3.0 especial tem a sua marca. Obrigado por acreditar neste projeto quando ele ainda era bem cru.\n\nE a cada um de vocês que reportou um bug, traduziu uma string, compartilhou o TREK com um amigo ou simplesmente o usou para planejar uma viagem — **obrigado**. Vocês são a razão de tudo isso existir.\n\nQue venham muitas mais aventuras juntos.\n\n— Maurice\n\n---\n\n[Junte-se à comunidade no Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe o TREK torna suas viagens melhores, um [cafezinho](https://ko-fi.com/mauriceboe) sempre mantém as luzes acesas.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte Manual',
|
||||
}
|
||||
|
||||
export default br
|
||||
|
||||
@@ -10,6 +10,9 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Přidat',
|
||||
'common.loading': 'Načítání...',
|
||||
'common.import': 'Importovat',
|
||||
'common.select': 'Vybrat',
|
||||
'common.selectAll': 'Vybrat vše',
|
||||
'common.deselectAll': 'Zrušit výběr všeho',
|
||||
'common.error': 'Chyba',
|
||||
'common.unknownError': 'Neznámá chyba',
|
||||
'common.tooManyAttempts': 'Příliš mnoho pokusů. Zkuste to prosím znovu.',
|
||||
@@ -865,6 +868,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Plánovač cesty (Trip Planner)
|
||||
'trip.tabs.plan': 'Plán',
|
||||
'trip.tabs.transports': 'Doprava',
|
||||
'trip.tabs.reservations': 'Rezervace',
|
||||
'trip.tabs.reservationsShort': 'Rez.',
|
||||
'trip.tabs.packing': 'Seznam věcí',
|
||||
@@ -887,6 +891,8 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Rezervace přidána',
|
||||
'trip.toast.deleted': 'Smazáno',
|
||||
'trip.confirm.deletePlace': 'Opravdu chcete toto místo smazat?',
|
||||
'trip.confirm.deletePlaces': 'Smazat {count} míst?',
|
||||
'trip.toast.placesDeleted': '{count} míst smazáno',
|
||||
|
||||
// Denní plán (Day Plan)
|
||||
'dayplan.emptyDay': 'Na tento den nejsou naplánována žádná místa',
|
||||
@@ -931,6 +937,17 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import se nezdařil',
|
||||
'places.importAllSkipped': 'Všechna místa již byla v cestě.',
|
||||
'places.gpxImported': '{count} míst importováno z GPX',
|
||||
'places.gpxImportTypes': 'Co chcete importovat?',
|
||||
'places.gpxImportWaypoints': 'Trasové body',
|
||||
'places.gpxImportRoutes': 'Trasy',
|
||||
'places.gpxImportTracks': 'Trasy GPS (s geometrií)',
|
||||
'places.gpxImportNoneSelected': 'Vyberte alespoň jeden typ k importu.',
|
||||
'places.kmlImportTypes': 'Co chcete importovat?',
|
||||
'places.kmlImportPoints': 'Body (Placemarks)',
|
||||
'places.kmlImportPaths': 'Trasy (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Vyberte alespoň jeden typ.',
|
||||
'places.selectionCount': '{count} vybráno',
|
||||
'places.deleteSelected': 'Smazat vybrané',
|
||||
'places.kmlKmzImported': 'Importováno {count} míst z KMZ/KML',
|
||||
'places.urlResolved': 'Místo importováno z URL',
|
||||
'places.importList': 'Import seznamu',
|
||||
@@ -947,6 +964,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Přidat do kterého dne?',
|
||||
'places.all': 'Vše',
|
||||
'places.unplanned': 'Nezařazené',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.search': 'Hledat místa...',
|
||||
'places.allCategories': 'Všechny kategorie',
|
||||
'places.categoriesSelected': 'kategorií',
|
||||
@@ -1119,6 +1137,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Konec',
|
||||
'reservations.span.ongoing': 'Probíhá',
|
||||
'reservations.validation.endBeforeStart': 'Datum/čas konce musí být po datu/čase začátku',
|
||||
'reservations.addBooking': 'Přidat rezervaci',
|
||||
|
||||
// Rozpočet (Budget)
|
||||
'budget.title': 'Rozpočet',
|
||||
@@ -1717,6 +1736,7 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Místa přeseřazena',
|
||||
'undo.optimize': 'Trasa optimalizována',
|
||||
'undo.deletePlace': 'Místo smazáno',
|
||||
'undo.deletePlaces': 'Místa smazána',
|
||||
'undo.moveDay': 'Místo přesunuto na jiný den',
|
||||
'undo.lock': 'Zámek místa přepnut',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -2271,6 +2291,11 @@ const cs: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobní slovo ode mě',
|
||||
'system_notice.v3_thankyou.body': 'Než budete pokračovat — chci se na chvíli zastavit.\n\nTREK začal jako vedlejší projekt, který jsem vytvořil pro své vlastní cesty. Nikdy jsem si nepředstavoval, že vyroste v něco, čemu 4 000 z vás důvěřuje při plánování svých dobrodružství. Každou hvězdičku, každý issue, každý požadavek na funkci — všechny čtu a právě ony mě drží při životě během pozdních nocí mezi prací na plný úvazek a univerzitou.\n\nChci, abyste věděli: TREK bude vždy open source, vždy self-hosted, vždy váš. Žádné sledování, žádná předplatná, žádné háčky. Jen nástroj vytvořený někým, kdo miluje cestování stejně jako vy.\n\nZvláštní poděkování patří [jubnl](https://github.com/jubnl) — stal ses neuvěřitelným spolupracovníkem. Tolik z toho, co dělá verzi 3.0 skvělou, nese tvůj rukopis. Děkuji, že jsi věřil tomuto projektu, když byl ještě v plenkách.\n\nA každému z vás, kdo nahlásil chybu, přeložil řetězec, sdílel TREK s přítelem nebo ho jednoduše použil k plánování cesty — **děkuji**. Vy jste důvod, proč tohle existuje.\n\nNa mnoho dalších dobrodružství společně.\n\n— Maurice\n\n---\n\n[Přidej se ke komunitě na Discordu](https://discord.gg/7Q6M6jDwzf)\n\nPokud ti TREK zlepšuje cestování, [malá káva](https://ko-fi.com/mauriceboe) vždy pomůže udržet světla rozsvícená.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Doprava',
|
||||
'transport.addManual': 'Ruční doprava',
|
||||
}
|
||||
|
||||
export default cs
|
||||
|
||||
@@ -10,6 +10,9 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Hinzufügen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importieren',
|
||||
'common.select': 'Auswählen',
|
||||
'common.selectAll': 'Alle auswählen',
|
||||
'common.deselectAll': 'Alle abwählen',
|
||||
'common.error': 'Fehler',
|
||||
'common.unknownError': 'Unbekannter Fehler',
|
||||
'common.tooManyAttempts': 'Zu viele Versuche. Bitte versuchen Sie es später erneut.',
|
||||
@@ -870,6 +873,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Karte',
|
||||
'trip.tabs.transports': 'Transporte',
|
||||
'trip.tabs.reservations': 'Buchungen',
|
||||
'trip.tabs.reservationsShort': 'Buchung',
|
||||
'trip.tabs.packing': 'Liste',
|
||||
@@ -892,6 +896,8 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservierung hinzugefügt',
|
||||
'trip.toast.deleted': 'Gelöscht',
|
||||
'trip.confirm.deletePlace': 'Möchtest du diesen Ort wirklich löschen?',
|
||||
'trip.confirm.deletePlaces': '{count} Orte löschen?',
|
||||
'trip.toast.placesDeleted': '{count} Orte gelöscht',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Keine Orte für diesen Tag geplant',
|
||||
@@ -936,6 +942,17 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import fehlgeschlagen',
|
||||
'places.importAllSkipped': 'Alle Orte waren bereits in der Reise.',
|
||||
'places.gpxImported': '{count} Orte aus GPX importiert',
|
||||
'places.gpxImportTypes': 'Was soll importiert werden?',
|
||||
'places.gpxImportWaypoints': 'Wegpunkte',
|
||||
'places.gpxImportRoutes': 'Routen',
|
||||
'places.gpxImportTracks': 'Tracks (mit Streckenverlauf)',
|
||||
'places.gpxImportNoneSelected': 'Wähle mindestens einen Typ zum Importieren.',
|
||||
'places.kmlImportTypes': 'Was möchtest du importieren?',
|
||||
'places.kmlImportPoints': 'Punkte (Placemarks)',
|
||||
'places.kmlImportPaths': 'Pfade (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Wähle mindestens einen Typ aus.',
|
||||
'places.selectionCount': '{count} ausgewählt',
|
||||
'places.deleteSelected': 'Auswahl löschen',
|
||||
'places.kmlKmzImported': '{count} Orte aus KMZ/KML importiert',
|
||||
'places.urlResolved': 'Ort aus URL importiert',
|
||||
'places.importList': 'Listenimport',
|
||||
@@ -952,6 +969,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Zu welchem Tag hinzufügen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ungeplant',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Orte suchen...',
|
||||
'places.allCategories': 'Alle Kategorien',
|
||||
'places.categoriesSelected': 'Kategorien',
|
||||
@@ -1121,6 +1139,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Ende',
|
||||
'reservations.span.ongoing': 'Laufend',
|
||||
'reservations.validation.endBeforeStart': 'Enddatum/-zeit muss nach dem Startdatum/-zeit liegen',
|
||||
'reservations.addBooking': 'Buchung hinzufügen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1722,6 +1741,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Orte neu sortiert',
|
||||
'undo.optimize': 'Route optimiert',
|
||||
'undo.deletePlace': 'Ort gelöscht',
|
||||
'undo.deletePlaces': 'Orte gelöscht',
|
||||
'undo.moveDay': 'Ort zu anderem Tag verschoben',
|
||||
'undo.lock': 'Ortssperre umgeschaltet',
|
||||
'undo.importGpx': 'GPX-Import',
|
||||
@@ -2271,6 +2291,11 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — persönlicher Dank
|
||||
'system_notice.v3_thankyou.title': 'Ein persönliches Wort von mir',
|
||||
'system_notice.v3_thankyou.body': 'Bevor du weiterklickst — einen Moment noch.\n\nTREK hat als Nebenprojekt für meine eigenen Reisen angefangen. Ich hätte nie gedacht, dass es jemals so weit kommt, dass 4.000 von euch damit ihre Abenteuer planen. Jeder Stern, jedes Issue, jeder Feature-Wunsch — ich lese sie alle, und sie halten mich am Laufen durch die späten Nächte zwischen Vollzeitjob und Studium.\n\nEins will ich euch sagen: TREK wird immer Open Source bleiben, immer self-hosted, immer eures. Kein Tracking, keine Abos, keine versteckten Haken. Einfach ein Tool, gebaut von jemandem, der das Reisen genauso liebt wie ihr.\n\nBesonderer Dank an [jubnl](https://github.com/jubnl) — du bist ein unglaublicher Mitstreiter geworden. So vieles, was 3.0 großartig macht, trägt deine Handschrift. Danke, dass du an dieses Projekt geglaubt hast, als es noch holprig war.\n\nUnd an jeden einzelnen von euch, der einen Bug gemeldet, einen String übersetzt, TREK mit Freunden geteilt oder einfach damit eine Reise geplant hat — **danke**. Ihr seid der Grund, warum es das hier gibt.\n\nAuf viele weitere Abenteuer zusammen.\n\n— Maurice\n\n---\n\n[Tritt der Community auf Discord bei](https://discord.gg/7Q6M6jDwzf)\n\nWenn TREK deine Reisen besser macht, hält ein [kleiner Kaffee](https://ko-fi.com/mauriceboe) die Lichter an.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transporte',
|
||||
'transport.addManual': 'Manuelles Transportmittel',
|
||||
}
|
||||
|
||||
export default de
|
||||
|
||||
@@ -10,6 +10,9 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Add',
|
||||
'common.loading': 'Loading...',
|
||||
'common.import': 'Import',
|
||||
'common.select': 'Select',
|
||||
'common.selectAll': 'Select all',
|
||||
'common.deselectAll': 'Deselect all',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Unknown error',
|
||||
'common.tooManyAttempts': 'Too many attempts. Please try again later.',
|
||||
@@ -927,6 +930,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transports',
|
||||
'trip.tabs.reservations': 'Bookings',
|
||||
'trip.tabs.reservationsShort': 'Book',
|
||||
'trip.tabs.packing': 'Packing List',
|
||||
@@ -949,6 +953,8 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservation added',
|
||||
'trip.toast.deleted': 'Deleted',
|
||||
'trip.confirm.deletePlace': 'Are you sure you want to delete this place?',
|
||||
'trip.confirm.deletePlaces': 'Delete {count} places?',
|
||||
'trip.toast.placesDeleted': '{count} places deleted',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No places planned for this day',
|
||||
@@ -993,6 +999,17 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import failed',
|
||||
'places.importAllSkipped': 'All places were already in the trip.',
|
||||
'places.gpxImported': '{count} places imported from GPX',
|
||||
'places.gpxImportTypes': 'What do you want to import?',
|
||||
'places.gpxImportWaypoints': 'Waypoints',
|
||||
'places.gpxImportRoutes': 'Routes',
|
||||
'places.gpxImportTracks': 'Tracks (with path geometry)',
|
||||
'places.gpxImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.kmlImportTypes': 'What do you want to import?',
|
||||
'places.kmlImportPoints': 'Points (Placemarks)',
|
||||
'places.kmlImportPaths': 'Paths (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Select at least one type to import.',
|
||||
'places.selectionCount': '{count} selected',
|
||||
'places.deleteSelected': 'Delete selected',
|
||||
'places.kmlKmzImported': '{count} places imported from KMZ/KML',
|
||||
'places.urlResolved': 'Place imported from URL',
|
||||
'places.importList': 'List Import',
|
||||
@@ -1009,6 +1026,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Add to which day?',
|
||||
'places.all': 'All',
|
||||
'places.unplanned': 'Unplanned',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Search places...',
|
||||
'places.allCategories': 'All Categories',
|
||||
'places.categoriesSelected': 'categories',
|
||||
@@ -1178,6 +1196,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'End',
|
||||
'reservations.span.ongoing': 'Ongoing',
|
||||
'reservations.validation.endBeforeStart': 'End date/time must be after start date/time',
|
||||
'reservations.addBooking': 'Add booking',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1791,6 +1810,7 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Places reordered',
|
||||
'undo.optimize': 'Route optimized',
|
||||
'undo.deletePlace': 'Place deleted',
|
||||
'undo.deletePlaces': 'Places deleted',
|
||||
'undo.moveDay': 'Place moved to another day',
|
||||
'undo.lock': 'Place lock toggled',
|
||||
'undo.importGpx': 'GPX import',
|
||||
@@ -2309,6 +2329,11 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
||||
'system_notice.pager.counter': '{current} / {total}',
|
||||
'system_notice.pager.goto': 'Go to notice {n}',
|
||||
'system_notice.pager.position': 'Notice {current} of {total}',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Manual Transport',
|
||||
}
|
||||
|
||||
export default en
|
||||
|
||||
@@ -10,6 +10,9 @@ const es: Record<string, string> = {
|
||||
'common.add': 'Añadir',
|
||||
'common.loading': 'Cargando...',
|
||||
'common.import': 'Importar',
|
||||
'common.select': 'Seleccionar',
|
||||
'common.selectAll': 'Seleccionar todo',
|
||||
'common.deselectAll': 'Deseleccionar todo',
|
||||
'common.error': 'Error',
|
||||
'common.unknownError': 'Error desconocido',
|
||||
'common.tooManyAttempts': 'Demasiados intentos. Inténtelo de nuevo más tarde.',
|
||||
@@ -840,6 +843,7 @@ const es: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transportes',
|
||||
'trip.tabs.reservations': 'Reservas',
|
||||
'trip.tabs.reservationsShort': 'Reservas',
|
||||
'trip.tabs.packing': 'Lista de equipaje',
|
||||
@@ -862,6 +866,8 @@ const es: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Reserva añadida',
|
||||
'trip.toast.deleted': 'Eliminado',
|
||||
'trip.confirm.deletePlace': '¿Seguro que quieres eliminar este lugar?',
|
||||
'trip.confirm.deletePlaces': '¿Eliminar {count} lugares?',
|
||||
'trip.toast.placesDeleted': '{count} lugares eliminados',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'No hay lugares planificados para este día',
|
||||
@@ -906,6 +912,17 @@ const es: Record<string, string> = {
|
||||
'places.importFileError': 'Importación fallida',
|
||||
'places.importAllSkipped': 'Todos los lugares ya estaban en el viaje.',
|
||||
'places.gpxImported': '{count} lugares importados desde GPX',
|
||||
'places.gpxImportTypes': '¿Qué deseas importar?',
|
||||
'places.gpxImportWaypoints': 'Puntos de ruta',
|
||||
'places.gpxImportRoutes': 'Rutas',
|
||||
'places.gpxImportTracks': 'Tracks (con geometría de ruta)',
|
||||
'places.gpxImportNoneSelected': 'Selecciona al menos un tipo para importar.',
|
||||
'places.kmlImportTypes': '¿Qué deseas importar?',
|
||||
'places.kmlImportPoints': 'Puntos (Placemarks)',
|
||||
'places.kmlImportPaths': 'Rutas (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecciona al menos un tipo.',
|
||||
'places.selectionCount': '{count} seleccionado(s)',
|
||||
'places.deleteSelected': 'Eliminar selección',
|
||||
'places.kmlKmzImported': '{count} lugares importados desde KMZ/KML',
|
||||
'places.urlResolved': 'Lugar importado desde URL',
|
||||
'places.importList': 'Importar lista',
|
||||
@@ -922,6 +939,7 @@ const es: Record<string, string> = {
|
||||
'places.assignToDay': '¿A qué día añadirlo?',
|
||||
'places.all': 'Todo',
|
||||
'places.unplanned': 'Sin planificar',
|
||||
'places.filterTracks': 'Rutas',
|
||||
'places.search': 'Buscar lugares...',
|
||||
'places.allCategories': 'Todas las categorías',
|
||||
'places.categoriesSelected': 'categorías',
|
||||
@@ -1067,6 +1085,7 @@ const es: Record<string, string> = {
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En curso',
|
||||
'reservations.validation.endBeforeStart': 'La fecha/hora de fin debe ser posterior a la de inicio',
|
||||
'reservations.addBooking': 'Añadir reserva',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Presupuesto',
|
||||
@@ -1724,6 +1743,7 @@ const es: Record<string, string> = {
|
||||
'undo.reorder': 'Lugares reordenados',
|
||||
'undo.optimize': 'Ruta optimizada',
|
||||
'undo.deletePlace': 'Lugar eliminado',
|
||||
'undo.deletePlaces': 'Lugares eliminados',
|
||||
'undo.moveDay': 'Lugar movido a otro día',
|
||||
'undo.lock': 'Bloqueo de lugar activado/desactivado',
|
||||
'undo.importGpx': 'Importación GPX',
|
||||
@@ -2273,6 +2293,11 @@ const es: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personal de mi parte',
|
||||
'system_notice.v3_thankyou.body': 'Antes de seguir — quiero tomarme un momento.\n\nTREK empezó como un proyecto personal que construí para mis propios viajes. Nunca imaginé que crecería hasta convertirse en algo en lo que 4.000 de vosotros confían para planificar sus aventuras. Cada estrella, cada issue, cada solicitud de funcionalidad — los leo todos, y son lo que me mantiene en pie durante las noches largas entre un trabajo a jornada completa y la universidad.\n\nQuiero que sepáis: TREK siempre será open source, siempre self-hosted, siempre vuestro. Sin rastreo, sin suscripciones, sin letra pequeña. Solo una herramienta hecha por alguien que ama viajar tanto como vosotros.\n\nUn agradecimiento especial a [jubnl](https://github.com/jubnl) — te has convertido en un colaborador increíble. Mucho de lo que hace grande la versión 3.0 lleva tu huella. Gracias por creer en este proyecto cuando todavía era un borrador.\n\nY a cada uno de vosotros que reportó un bug, tradujo un texto, compartió TREK con un amigo o simplemente lo usó para planificar un viaje — **gracias**. Vosotros sois la razón de que esto exista.\n\nPor muchas más aventuras juntos.\n\n— Maurice\n\n---\n\n[Únete a la comunidad en Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK mejora tus viajes, un [pequeño café](https://ko-fi.com/mauriceboe) siempre mantiene las luces encendidas.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportes',
|
||||
'transport.addManual': 'Transporte manual',
|
||||
}
|
||||
|
||||
export default es
|
||||
|
||||
@@ -10,6 +10,9 @@ const fr: Record<string, string> = {
|
||||
'common.add': 'Ajouter',
|
||||
'common.loading': 'Chargement…',
|
||||
'common.import': 'Importer',
|
||||
'common.select': 'Sélectionner',
|
||||
'common.selectAll': 'Tout sélectionner',
|
||||
'common.deselectAll': 'Tout désélectionner',
|
||||
'common.error': 'Erreur',
|
||||
'common.unknownError': 'Erreur inconnue',
|
||||
'common.tooManyAttempts': 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
@@ -864,6 +867,7 @@ const fr: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transports',
|
||||
'trip.tabs.reservations': 'Réservations',
|
||||
'trip.tabs.reservationsShort': 'Résa',
|
||||
'trip.tabs.packing': 'Liste de bagages',
|
||||
@@ -886,6 +890,8 @@ const fr: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Réservation ajoutée',
|
||||
'trip.toast.deleted': 'Supprimé',
|
||||
'trip.confirm.deletePlace': 'Voulez-vous vraiment supprimer ce lieu ?',
|
||||
'trip.confirm.deletePlaces': 'Supprimer {count} lieux?',
|
||||
'trip.toast.placesDeleted': '{count} lieux supprimés',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Aucun lieu prévu pour ce jour',
|
||||
@@ -930,6 +936,17 @@ const fr: Record<string, string> = {
|
||||
'places.importFileError': 'Importation échouée',
|
||||
'places.importAllSkipped': 'Tous les lieux étaient déjà dans le voyage.',
|
||||
'places.gpxImported': '{count} lieux importés depuis GPX',
|
||||
'places.gpxImportTypes': 'Que voulez-vous importer?',
|
||||
'places.gpxImportWaypoints': 'Points de passage',
|
||||
'places.gpxImportRoutes': 'Itinéraires',
|
||||
'places.gpxImportTracks': 'Traces (avec géométrie)',
|
||||
'places.gpxImportNoneSelected': 'Sélectionnez au moins un type à importer.',
|
||||
'places.kmlImportTypes': 'Que souhaitez-vous importer ?',
|
||||
'places.kmlImportPoints': 'Points (Placemarks)',
|
||||
'places.kmlImportPaths': 'Chemins (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Sélectionnez au moins un type.',
|
||||
'places.selectionCount': '{count} sélectionné(s)',
|
||||
'places.deleteSelected': 'Supprimer la sélection',
|
||||
'places.kmlKmzImported': '{count} lieux importés depuis KMZ/KML',
|
||||
'places.urlResolved': 'Lieu importé depuis l\'URL',
|
||||
'places.importList': 'Import de liste',
|
||||
@@ -946,6 +963,7 @@ const fr: Record<string, string> = {
|
||||
'places.assignToDay': 'Ajouter à quel jour ?',
|
||||
'places.all': 'Tous',
|
||||
'places.unplanned': 'Non planifiés',
|
||||
'places.filterTracks': 'Traces',
|
||||
'places.search': 'Rechercher des lieux…',
|
||||
'places.allCategories': 'Toutes les catégories',
|
||||
'places.categoriesSelected': 'catégories',
|
||||
@@ -1117,6 +1135,7 @@ const fr: Record<string, string> = {
|
||||
'reservations.span.end': 'Fin',
|
||||
'reservations.span.ongoing': 'En cours',
|
||||
'reservations.validation.endBeforeStart': 'La date/heure de fin doit être postérieure à la date/heure de début',
|
||||
'reservations.addBooking': 'Ajouter une réservation',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1718,6 +1737,7 @@ const fr: Record<string, string> = {
|
||||
'undo.reorder': 'Lieux réorganisés',
|
||||
'undo.optimize': 'Itinéraire optimisé',
|
||||
'undo.deletePlace': 'Lieu supprimé',
|
||||
'undo.deletePlaces': 'Lieux supprimés',
|
||||
'undo.moveDay': 'Lieu déplacé vers un autre jour',
|
||||
'undo.lock': 'Verrouillage du lieu modifié',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -2267,6 +2287,11 @@ const fr: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Un mot personnel de ma part',
|
||||
'system_notice.v3_thankyou.body': 'Avant de continuer — je veux prendre un instant.\n\nTREK a commencé comme un projet perso que j\'ai construit pour mes propres voyages. Je n\'aurais jamais imaginé qu\'il grandirait au point que 4 000 d\'entre vous lui fassent confiance pour planifier vos aventures. Chaque étoile, chaque issue, chaque demande de fonctionnalité — je les lis toutes, et ce sont elles qui me font tenir pendant les nuits blanches entre un travail à temps plein et l\'université.\n\nJe veux que vous sachiez : TREK sera toujours open source, toujours auto-hébergé, toujours à vous. Pas de tracking, pas d\'abonnements, pas de conditions cachées. Juste un outil construit par quelqu\'un qui aime voyager autant que vous.\n\nUn merci tout particulier à [jubnl](https://github.com/jubnl) — tu es devenu un collaborateur incroyable. Une grande partie de ce qui rend la 3.0 géniale porte ton empreinte. Merci d\'avoir cru en ce projet quand il était encore brut.\n\nEt à chacun d\'entre vous qui a signalé un bug, traduit une chaîne, partagé TREK avec un ami ou simplement l\'a utilisé pour planifier un voyage — **merci**. Vous êtes la raison pour laquelle tout ceci existe.\n\nÀ de nombreuses autres aventures ensemble.\n\n— Maurice\n\n---\n\n[Rejoins la communauté sur Discord](https://discord.gg/7Q6M6jDwzf)\n\nSi TREK rend tes voyages meilleurs, un [petit café](https://ko-fi.com/mauriceboe) aide toujours à garder les lumières allumées.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transports',
|
||||
'transport.addManual': 'Transport manuel',
|
||||
}
|
||||
|
||||
export default fr
|
||||
|
||||
@@ -10,6 +10,9 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Hozzáadás',
|
||||
'common.loading': 'Betöltés...',
|
||||
'common.import': 'Importálás',
|
||||
'common.select': 'Kiválaszt',
|
||||
'common.selectAll': 'Mindet kiválaszt',
|
||||
'common.deselectAll': 'Összes kijelölés megszüntetése',
|
||||
'common.error': 'Hiba',
|
||||
'common.unknownError': 'Ismeretlen hiba',
|
||||
'common.tooManyAttempts': 'Túl sok próbálkozás. Kérjük, próbálja újra később.',
|
||||
@@ -865,6 +868,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Utazástervező
|
||||
'trip.tabs.plan': 'Terv',
|
||||
'trip.tabs.transports': 'Közlekedés',
|
||||
'trip.tabs.reservations': 'Foglalások',
|
||||
'trip.tabs.reservationsShort': 'Foglalás',
|
||||
'trip.tabs.packing': 'Csomagolási lista',
|
||||
@@ -886,6 +890,8 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Foglalás hozzáadva',
|
||||
'trip.toast.deleted': 'Törölve',
|
||||
'trip.confirm.deletePlace': 'Biztosan törölni szeretnéd ezt a helyet?',
|
||||
'trip.confirm.deletePlaces': '{count} helyet töröl?',
|
||||
'trip.toast.placesDeleted': '{count} hely törölve',
|
||||
'trip.loadingPhotos': 'Helyek fotóinak betöltése...',
|
||||
|
||||
// Napi terv oldalsáv
|
||||
@@ -931,6 +937,17 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importálás sikertelen',
|
||||
'places.importAllSkipped': 'Minden hely már szerepel az utazásban.',
|
||||
'places.gpxImported': '{count} hely importálva GPX-ből',
|
||||
'places.gpxImportTypes': 'Mit szeretnél importálni?',
|
||||
'places.gpxImportWaypoints': 'Útpontok',
|
||||
'places.gpxImportRoutes': 'Útvonalak',
|
||||
'places.gpxImportTracks': 'Nyomvonalak (útvonalgeometriával)',
|
||||
'places.gpxImportNoneSelected': 'Válassz legalább egy típust az importáláshoz.',
|
||||
'places.kmlImportTypes': 'Mit szeretnél importálni?',
|
||||
'places.kmlImportPoints': 'Pontok (Placemarks)',
|
||||
'places.kmlImportPaths': 'Útvonalak (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Válassz legalább egy típust.',
|
||||
'places.selectionCount': '{count} kiválasztva',
|
||||
'places.deleteSelected': 'Kijelöltek törlése',
|
||||
'places.kmlKmzImported': '{count} hely importálva KMZ/KML-ből',
|
||||
'places.urlResolved': 'Hely importálva URL-ből',
|
||||
'places.importList': 'Lista importálás',
|
||||
@@ -947,6 +964,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Melyik naphoz adod?',
|
||||
'places.all': 'Összes',
|
||||
'places.unplanned': 'Nem tervezett',
|
||||
'places.filterTracks': 'Nyomvonalak',
|
||||
'places.search': 'Helyek keresése...',
|
||||
'places.allCategories': 'Összes kategória',
|
||||
'places.categoriesSelected': 'kategória',
|
||||
@@ -1118,6 +1136,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Vége',
|
||||
'reservations.span.ongoing': 'Folyamatban',
|
||||
'reservations.validation.endBeforeStart': 'A befejezés dátuma/időpontja a kezdés utáni kell legyen',
|
||||
'reservations.addBooking': 'Foglalás hozzáadása',
|
||||
|
||||
// Költségvetés
|
||||
'budget.title': 'Költségvetés',
|
||||
@@ -1716,6 +1735,7 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Helyek átrendezve',
|
||||
'undo.optimize': 'Útvonal optimalizálva',
|
||||
'undo.deletePlace': 'Hely törölve',
|
||||
'undo.deletePlaces': 'Helyek törölve',
|
||||
'undo.moveDay': 'Hely áthelyezve másik napra',
|
||||
'undo.lock': 'Hely zárolása váltva',
|
||||
'undo.importGpx': 'GPX importálás',
|
||||
@@ -2268,6 +2288,11 @@ const hu: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Egy személyes gondolat tőlem',
|
||||
'system_notice.v3_thankyou.body': 'Mielőtt továbbmennél — szeretnék egy pillanatra megállni.\n\nA TREK egy hobbiprojektként indult, amit a saját utazásaimhoz építettem. Sosem gondoltam volna, hogy valami olyanná nő, amire 4000-en bízzátok a kalandjaitok tervezését. Minden csillagot, minden issue-t, minden funkciókérést — mindet elolvasom, és ezek tartanak életben a késő éjszakákon a teljes állás és az egyetem között.\n\nSzeretnétek, ha tudnátok: a TREK mindig nyílt forráskódú marad, mindig self-hosted, mindig a tiétek. Nincs nyomkövetés, nincs előfizetés, nincsenek rejtett feltételek. Csak egy eszköz, amit valaki épített, aki ugyanúgy szereti az utazást, mint ti.\n\nKülönleges köszönet [jubnl](https://github.com/jubnl)-nek — hihetetlen társsá váltál. A 3.0 nagyszerűségének nagy része a te kézjegyedet viseli. Köszönöm, hogy hittél ebben a projektben, amikor még nyers volt.\n\nÉs mindannyiótoknak, akik hibát jelentettetek, szöveget fordítottatok, megosztottátok a TREK-et egy baráttal, vagy egyszerűen csak egy utazást terveztetek vele — **köszönöm**. Ti vagytok az ok, amiért ez létezik.\n\nSok további közös kalandért.\n\n— Maurice\n\n---\n\n[Csatlakozz a közösséghez a Discordon](https://discord.gg/7Q6M6jDwzf)\n\nHa a TREK jobbá teszi az utazásaidat, egy [kis kávé](https://ko-fi.com/mauriceboe) mindig segít, hogy égve maradjanak a fények.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Közlekedés',
|
||||
'transport.addManual': 'Kézi közlekedés',
|
||||
}
|
||||
|
||||
export default hu
|
||||
|
||||
@@ -10,6 +10,9 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Tambah',
|
||||
'common.loading': 'Memuat...',
|
||||
'common.import': 'Impor',
|
||||
'common.select': 'Pilih',
|
||||
'common.selectAll': 'Pilih semua',
|
||||
'common.deselectAll': 'Batalkan semua pilihan',
|
||||
'common.error': 'Kesalahan',
|
||||
'common.unknownError': 'Kesalahan tidak diketahui',
|
||||
'common.tooManyAttempts': 'Terlalu banyak percobaan. Coba lagi nanti.',
|
||||
@@ -925,6 +928,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Rencana',
|
||||
'trip.tabs.transports': 'Transportasi',
|
||||
'trip.tabs.reservations': 'Pemesanan',
|
||||
'trip.tabs.reservationsShort': 'Pesan',
|
||||
'trip.tabs.packing': 'Daftar Perlengkapan',
|
||||
@@ -947,6 +951,8 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Reservasi ditambahkan',
|
||||
'trip.toast.deleted': 'Dihapus',
|
||||
'trip.confirm.deletePlace': 'Apakah kamu yakin ingin menghapus tempat ini?',
|
||||
'trip.confirm.deletePlaces': 'Hapus {count} tempat?',
|
||||
'trip.toast.placesDeleted': '{count} tempat dihapus',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Belum ada tempat yang direncanakan untuk hari ini',
|
||||
@@ -991,6 +997,17 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Impor gagal',
|
||||
'places.importAllSkipped': 'Semua tempat sudah ada di perjalanan.',
|
||||
'places.gpxImported': '{count} tempat diimpor dari GPX',
|
||||
'places.gpxImportTypes': 'Apa yang ingin diimpor?',
|
||||
'places.gpxImportWaypoints': 'Titik jalan',
|
||||
'places.gpxImportRoutes': 'Rute',
|
||||
'places.gpxImportTracks': 'Trek (dengan geometri jalur)',
|
||||
'places.gpxImportNoneSelected': 'Pilih setidaknya satu jenis untuk diimpor.',
|
||||
'places.kmlImportTypes': 'Apa yang ingin diimpor?',
|
||||
'places.kmlImportPoints': 'Titik (Placemarks)',
|
||||
'places.kmlImportPaths': 'Jalur (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Pilih setidaknya satu jenis.',
|
||||
'places.selectionCount': '{count} dipilih',
|
||||
'places.deleteSelected': 'Hapus yang dipilih',
|
||||
'places.kmlKmzImported': '{count} tempat diimpor dari KMZ/KML',
|
||||
'places.urlResolved': 'Tempat diimpor dari URL',
|
||||
'places.importList': 'Impor Daftar',
|
||||
@@ -1007,6 +1024,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Tambah ke hari mana?',
|
||||
'places.all': 'Semua',
|
||||
'places.unplanned': 'Belum direncanakan',
|
||||
'places.filterTracks': 'Trek',
|
||||
'places.search': 'Cari tempat...',
|
||||
'places.allCategories': 'Semua Kategori',
|
||||
'places.categoriesSelected': 'kategori',
|
||||
@@ -1178,6 +1196,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Selesai',
|
||||
'reservations.span.ongoing': 'Berlangsung',
|
||||
'reservations.validation.endBeforeStart': 'Tanggal/waktu selesai harus setelah tanggal/waktu mulai',
|
||||
'reservations.addBooking': 'Tambah pemesanan',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Anggaran',
|
||||
@@ -1791,6 +1810,7 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Tempat diurutkan ulang',
|
||||
'undo.optimize': 'Rute dioptimalkan',
|
||||
'undo.deletePlace': 'Tempat dihapus',
|
||||
'undo.deletePlaces': 'Tempat dihapus',
|
||||
'undo.moveDay': 'Tempat dipindah ke hari lain',
|
||||
'undo.lock': 'Kunci tempat diubah',
|
||||
'undo.importGpx': 'Impor GPX',
|
||||
@@ -2309,6 +2329,11 @@ const id: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Catatan pribadi dari saya',
|
||||
'system_notice.v3_thankyou.body': 'Sebelum kamu lanjut — saya ingin berhenti sejenak.\n\nTREK dimulai sebagai proyek sampingan yang saya buat untuk perjalanan saya sendiri. Saya tidak pernah membayangkan ia akan tumbuh menjadi sesuatu yang dipercaya oleh 4.000 dari kalian untuk merencanakan petualangan. Setiap bintang, setiap issue, setiap permintaan fitur — saya membaca semuanya, dan itulah yang membuat saya terus bertahan di malam-malam larut antara pekerjaan penuh waktu dan kuliah.\n\nSaya ingin kalian tahu: TREK akan selalu open source, selalu self-hosted, selalu milik kalian. Tanpa pelacakan, tanpa langganan, tanpa syarat tersembunyi. Hanya sebuah alat yang dibuat oleh seseorang yang mencintai traveling sama seperti kalian.\n\nTerima kasih khusus untuk [jubnl](https://github.com/jubnl) — kamu telah menjadi kolaborator yang luar biasa. Begitu banyak hal yang membuat versi 3.0 hebat memiliki jejakmu. Terima kasih telah percaya pada proyek ini ketika masih kasar.\n\nDan untuk setiap dari kalian yang melaporkan bug, menerjemahkan string, membagikan TREK kepada teman, atau sekadar menggunakannya untuk merencanakan perjalanan — **terima kasih**. Kalianlah alasan semua ini ada.\n\nUntuk lebih banyak petualangan bersama.\n\n— Maurice\n\n---\n\n[Bergabunglah dengan komunitas di Discord](https://discord.gg/7Q6M6jDwzf)\n\nJika TREK membuat perjalananmu lebih baik, [secangkir kopi kecil](https://ko-fi.com/mauriceboe) selalu membantu menjaga lampu tetap menyala.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transportasi',
|
||||
'transport.addManual': 'Transportasi Manual',
|
||||
};
|
||||
|
||||
export default id;
|
||||
|
||||
@@ -10,6 +10,9 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'common.add': 'Aggiungi',
|
||||
'common.loading': 'Caricamento...',
|
||||
'common.import': 'Importa',
|
||||
'common.select': 'Seleziona',
|
||||
'common.selectAll': 'Seleziona tutto',
|
||||
'common.deselectAll': 'Deseleziona tutto',
|
||||
'common.error': 'Errore',
|
||||
'common.unknownError': 'Errore sconosciuto',
|
||||
'common.tooManyAttempts': 'Troppi tentativi. Riprova più tardi.',
|
||||
@@ -865,6 +868,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Programma',
|
||||
'trip.tabs.transports': 'Trasporti',
|
||||
'trip.tabs.reservations': 'Prenotazioni',
|
||||
'trip.tabs.reservationsShort': 'Pren.',
|
||||
'trip.tabs.packing': 'Lista valigia',
|
||||
@@ -886,6 +890,8 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Prenotazione aggiunta',
|
||||
'trip.toast.deleted': 'Eliminato',
|
||||
'trip.confirm.deletePlace': 'Sei sicuro di voler eliminare questo luogo?',
|
||||
'trip.confirm.deletePlaces': 'Eliminare {count} luoghi?',
|
||||
'trip.toast.placesDeleted': '{count} luoghi eliminati',
|
||||
'trip.loadingPhotos': 'Caricamento foto dei luoghi...',
|
||||
|
||||
// Day Plan Sidebar
|
||||
@@ -931,6 +937,17 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Importazione non riuscita',
|
||||
'places.importAllSkipped': 'Tutti i luoghi erano già nel viaggio.',
|
||||
'places.gpxImported': '{count} luoghi importati da GPX',
|
||||
'places.gpxImportTypes': 'Cosa vuoi importare?',
|
||||
'places.gpxImportWaypoints': 'Waypoint',
|
||||
'places.gpxImportRoutes': 'Percorsi',
|
||||
'places.gpxImportTracks': 'Tracce (con geometria percorso)',
|
||||
'places.gpxImportNoneSelected': 'Seleziona almeno un tipo da importare.',
|
||||
'places.kmlImportTypes': 'Cosa vuoi importare?',
|
||||
'places.kmlImportPoints': 'Punti (Placemarks)',
|
||||
'places.kmlImportPaths': 'Percorsi (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Seleziona almeno un tipo.',
|
||||
'places.selectionCount': '{count} selezionato/i',
|
||||
'places.deleteSelected': 'Elimina selezionati',
|
||||
'places.kmlKmzImported': '{count} luoghi importati da KMZ/KML',
|
||||
'places.urlResolved': 'Luogo importato dall\'URL',
|
||||
'places.importList': 'Importa lista',
|
||||
@@ -947,6 +964,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'A quale giorno aggiungere?',
|
||||
'places.all': 'Tutti',
|
||||
'places.unplanned': 'Non pianificati',
|
||||
'places.filterTracks': 'Tracce',
|
||||
'places.search': 'Cerca luoghi...',
|
||||
'places.allCategories': 'Tutte le categorie',
|
||||
'places.categoriesSelected': 'categorie',
|
||||
@@ -1118,6 +1136,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Fine',
|
||||
'reservations.span.ongoing': 'In corso',
|
||||
'reservations.validation.endBeforeStart': 'La data/ora di fine deve essere successiva alla data/ora di inizio',
|
||||
'reservations.addBooking': 'Aggiungi prenotazione',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1720,6 +1739,7 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Luoghi riordinati',
|
||||
'undo.optimize': 'Percorso ottimizzato',
|
||||
'undo.deletePlace': 'Luogo eliminato',
|
||||
'undo.deletePlaces': 'Luoghi eliminati',
|
||||
'undo.moveDay': 'Luogo spostato in altro giorno',
|
||||
'undo.lock': 'Blocco luogo modificato',
|
||||
'undo.importGpx': 'Importazione GPX',
|
||||
@@ -2268,6 +2288,11 @@ const it: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Una nota personale da parte mia',
|
||||
'system_notice.v3_thankyou.body': 'Prima di andare avanti — voglio prendermi un momento.\n\nTREK è nato come un progetto secondario che ho costruito per i miei viaggi. Non avrei mai immaginato che sarebbe cresciuto fino a diventare qualcosa di cui 4.000 di voi si fidano per pianificare le proprie avventure. Ogni stella, ogni issue, ogni richiesta di funzionalità — le leggo tutte, e sono loro a tenermi in piedi nelle notti tarde tra un lavoro a tempo pieno e l\'università.\n\nVoglio che sappiate: TREK sarà sempre open source, sempre self-hosted, sempre vostro. Nessun tracciamento, nessun abbonamento, nessuna fregatura. Solo uno strumento creato da qualcuno che ama viaggiare tanto quanto voi.\n\nUn ringraziamento speciale a [jubnl](https://github.com/jubnl) — sei diventato un collaboratore incredibile. Molto di ciò che rende la 3.0 fantastica porta la tua impronta. Grazie per aver creduto in questo progetto quando era ancora acerbo.\n\nE a ognuno di voi che ha segnalato un bug, tradotto una stringa, condiviso TREK con un amico o semplicemente lo ha usato per pianificare un viaggio — **grazie**. Voi siete il motivo per cui tutto questo esiste.\n\nA molte altre avventure insieme.\n\n— Maurice\n\n---\n\n[Unisciti alla community su Discord](https://discord.gg/7Q6M6jDwzf)\n\nSe TREK rende i tuoi viaggi migliori, un [piccolo caffè](https://ko-fi.com/mauriceboe) aiuta sempre a tenere le luci accese.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Trasporti',
|
||||
'transport.addManual': 'Trasporto manuale',
|
||||
}
|
||||
|
||||
export default it
|
||||
|
||||
@@ -10,6 +10,9 @@ const nl: Record<string, string> = {
|
||||
'common.add': 'Toevoegen',
|
||||
'common.loading': 'Laden...',
|
||||
'common.import': 'Importeren',
|
||||
'common.select': 'Selecteren',
|
||||
'common.selectAll': 'Alles selecteren',
|
||||
'common.deselectAll': 'Alles deselecteren',
|
||||
'common.error': 'Fout',
|
||||
'common.unknownError': 'Onbekende fout',
|
||||
'common.tooManyAttempts': 'Te veel pogingen. Probeer het later opnieuw.',
|
||||
@@ -864,6 +867,7 @@ const nl: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Boekingen',
|
||||
'trip.tabs.reservationsShort': 'Boek',
|
||||
'trip.tabs.packing': 'Paklijst',
|
||||
@@ -886,6 +890,8 @@ const nl: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Reservering toegevoegd',
|
||||
'trip.toast.deleted': 'Verwijderd',
|
||||
'trip.confirm.deletePlace': 'Weet je zeker dat je deze plaats wilt verwijderen?',
|
||||
'trip.confirm.deletePlaces': '{count} plaatsen verwijderen?',
|
||||
'trip.toast.placesDeleted': '{count} plaatsen verwijderd',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Geen plaatsen gepland voor deze dag',
|
||||
@@ -930,6 +936,17 @@ const nl: Record<string, string> = {
|
||||
'places.importFileError': 'Importeren mislukt',
|
||||
'places.importAllSkipped': 'Alle plaatsen waren al in de reis.',
|
||||
'places.gpxImported': '{count} plaatsen geïmporteerd uit GPX',
|
||||
'places.gpxImportTypes': 'Wat wil je importeren?',
|
||||
'places.gpxImportWaypoints': 'Waypoints',
|
||||
'places.gpxImportRoutes': 'Routes',
|
||||
'places.gpxImportTracks': 'Tracks (met routegeometrie)',
|
||||
'places.gpxImportNoneSelected': 'Selecteer minstens één type om te importeren.',
|
||||
'places.kmlImportTypes': 'Wat wil je importeren?',
|
||||
'places.kmlImportPoints': 'Punten (Placemarks)',
|
||||
'places.kmlImportPaths': 'Paden (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Selecteer minstens één type.',
|
||||
'places.selectionCount': '{count} geselecteerd',
|
||||
'places.deleteSelected': 'Selectie verwijderen',
|
||||
'places.kmlKmzImported': '{count} plaatsen geïmporteerd uit KMZ/KML',
|
||||
'places.urlResolved': 'Plaats geïmporteerd van URL',
|
||||
'places.importList': 'Lijst importeren',
|
||||
@@ -946,6 +963,7 @@ const nl: Record<string, string> = {
|
||||
'places.assignToDay': 'Aan welke dag toevoegen?',
|
||||
'places.all': 'Alle',
|
||||
'places.unplanned': 'Ongepland',
|
||||
'places.filterTracks': 'Tracks',
|
||||
'places.search': 'Plaatsen zoeken...',
|
||||
'places.allCategories': 'Alle categorieën',
|
||||
'places.categoriesSelected': 'categorieën',
|
||||
@@ -1117,6 +1135,7 @@ const nl: Record<string, string> = {
|
||||
'reservations.span.end': 'Einde',
|
||||
'reservations.span.ongoing': 'Lopend',
|
||||
'reservations.validation.endBeforeStart': 'Einddatum/-tijd moet na de startdatum/-tijd liggen',
|
||||
'reservations.addBooking': 'Boeking toevoegen',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budget',
|
||||
@@ -1718,6 +1737,7 @@ const nl: Record<string, string> = {
|
||||
'undo.reorder': 'Locaties hergeordend',
|
||||
'undo.optimize': 'Route geoptimaliseerd',
|
||||
'undo.deletePlace': 'Locatie verwijderd',
|
||||
'undo.deletePlaces': 'Plaatsen verwijderd',
|
||||
'undo.moveDay': 'Locatie naar andere dag verplaatst',
|
||||
'undo.lock': 'Vergrendeling locatie gewijzigd',
|
||||
'undo.importGpx': 'GPX-import',
|
||||
@@ -2267,6 +2287,11 @@ const nl: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Een persoonlijk woord van mij',
|
||||
'system_notice.v3_thankyou.body': 'Voordat je verdergaat — ik wil even stilstaan.\n\nTREK begon als een zijproject dat ik bouwde voor mijn eigen reizen. Ik had nooit gedacht dat het zou uitgroeien tot iets waar 4.000 van jullie op vertrouwen om avonturen te plannen. Elke ster, elke issue, elk functieverzoek — ik lees ze allemaal, en ze houden me op de been tijdens de late avonden tussen een fulltime baan en de universiteit.\n\nIk wil dat jullie weten: TREK zal altijd open source zijn, altijd self-hosted, altijd van jullie. Geen tracking, geen abonnementen, geen addertjes. Gewoon een tool gebouwd door iemand die net zo veel van reizen houdt als jullie.\n\nSpeciale dank aan [jubnl](https://github.com/jubnl) — je bent een ongelooflijke medewerker geworden. Zo veel van wat 3.0 geweldig maakt draagt jouw vingerafdruk. Bedankt dat je in dit project geloofde toen het nog ruw was.\n\nEn aan ieder van jullie die een bug meldde, een string vertaalde, TREK deelde met een vriend of het simpelweg gebruikte om een reis te plannen — **bedankt**. Jullie zijn de reden dat dit bestaat.\n\nOp nog vele avonturen samen.\n\n— Maurice\n\n---\n\n[Sluit je aan bij de community op Discord](https://discord.gg/7Q6M6jDwzf)\n\nAls TREK je reizen beter maakt, houdt een [klein kopje koffie](https://ko-fi.com/mauriceboe) altijd de lichten aan.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Handmatig transport',
|
||||
}
|
||||
|
||||
export default nl
|
||||
|
||||
@@ -832,6 +832,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'Plan',
|
||||
'trip.tabs.transports': 'Transport',
|
||||
'trip.tabs.reservations': 'Rezerwacje',
|
||||
'trip.tabs.reservationsShort': 'Rezerwacje',
|
||||
'trip.tabs.packing': 'Lista pakowania',
|
||||
@@ -853,6 +854,8 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'trip.toast.reservationAdded': 'Rezerwacja została dodana',
|
||||
'trip.toast.deleted': 'Usunięto',
|
||||
'trip.confirm.deletePlace': 'Czy na pewno chcesz usunąć to miejsce?',
|
||||
'trip.confirm.deletePlaces': 'Usunąć {count} miejsc?',
|
||||
'trip.toast.placesDeleted': '{count} miejsc usunięto',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'Brak miejsc zaplanowanych na ten dzień',
|
||||
@@ -897,6 +900,17 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.importFileError': 'Import nie powiódł się',
|
||||
'places.importAllSkipped': 'Wszystkie miejsca były już w podróży.',
|
||||
'places.gpxImported': '{count} miejsc zaimportowanych z GPX',
|
||||
'places.gpxImportTypes': 'Co chcesz zaimportować?',
|
||||
'places.gpxImportWaypoints': 'Punkty trasy',
|
||||
'places.gpxImportRoutes': 'Trasy',
|
||||
'places.gpxImportTracks': 'Trasy GPS (ze śladem)',
|
||||
'places.gpxImportNoneSelected': 'Wybierz co najmniej jeden typ do importu.',
|
||||
'places.kmlImportTypes': 'Co chcesz zaimportować?',
|
||||
'places.kmlImportPoints': 'Punkty (Placemarks)',
|
||||
'places.kmlImportPaths': 'Ścieżki (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Wybierz co najmniej jeden typ.',
|
||||
'places.selectionCount': '{count} zaznaczono',
|
||||
'places.deleteSelected': 'Usuń wybrane',
|
||||
'places.kmlKmzImported': 'Zaimportowano {count} miejsc z KMZ/KML',
|
||||
'places.urlResolved': 'Miejsce zaimportowane z URL',
|
||||
'places.kmlKmzSummaryValues': 'Placemarks: {total} • Zaimportowano: {created} • Pominięto: {skipped}',
|
||||
@@ -904,6 +918,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'places.assignToDay': 'Do którego dnia dodać?',
|
||||
'places.all': 'Wszystkie',
|
||||
'places.unplanned': 'Niezaplanowane',
|
||||
'places.filterTracks': 'Trasy',
|
||||
'places.search': 'Szukaj miejsc...',
|
||||
'places.allCategories': 'Wszystkie kategorie',
|
||||
'places.categoriesSelected': 'kategorii',
|
||||
@@ -1074,6 +1089,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'reservations.span.end': 'Koniec',
|
||||
'reservations.span.ongoing': 'W trakcie',
|
||||
'reservations.validation.endBeforeStart': 'Data/godzina zakończenia musi być późniejsza niż data/godzina rozpoczęcia',
|
||||
'reservations.addBooking': 'Dodaj rezerwację',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Budżet',
|
||||
@@ -1605,6 +1621,9 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'collab.polls.delete': 'Usuń',
|
||||
'collab.polls.closedSection': 'Zamknięte',
|
||||
'common.import': 'Importuj',
|
||||
'common.select': 'Wybierz',
|
||||
'common.selectAll': 'Zaznacz wszystko',
|
||||
'common.deselectAll': 'Odznacz wszystko',
|
||||
'common.saved': 'Zapisano',
|
||||
'trips.reminder': 'Przypomnienie',
|
||||
'trips.reminderNone': 'Brak',
|
||||
@@ -1778,6 +1797,7 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
'undo.reorder': 'Kolejność zmieniona',
|
||||
'undo.optimize': 'Trasa zoptymalizowana',
|
||||
'undo.deletePlace': 'Miejsce usunięte',
|
||||
'undo.deletePlaces': 'Miejsca usunięte',
|
||||
'undo.moveDay': 'Miejsce przeniesione',
|
||||
'undo.lock': 'Blokada przełączona',
|
||||
'undo.importGpx': 'Import GPX',
|
||||
@@ -2260,6 +2280,11 @@ const pl: Record<string, string | { name: string; category: string }[]> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Osobiste słowo ode mnie',
|
||||
'system_notice.v3_thankyou.body': 'Zanim pójdziesz dalej — chcę się na chwilę zatrzymać.\n\nTREK zaczął się jako poboczny projekt, który zbudowałem na własne podróże. Nigdy nie wyobrażałem sobie, że wyrośnie na coś, czemu 4000 z was ufa przy planowaniu swoich przygód. Każda gwiazdka, każdy issue, każda prośba o funkcję — czytam je wszystkie i to one trzymają mnie na nogach podczas późnych nocy między pracą na pełny etat a uczelnią.\n\nChcę, żebyście wiedzieli: TREK zawsze będzie open source, zawsze self-hosted, zawsze wasz. Bez śledzenia, bez subskrypcji, bez haczyków. Po prostu narzędzie zbudowane przez kogoś, kto kocha podróżowanie tak samo jak wy.\n\nSzczególne podziękowania dla [jubnl](https://github.com/jubnl) — stałeś się niesamowitym współpracownikiem. Tak wiele z tego, co czyni wersję 3.0 wspaniałą, nosi twój ślad. Dziękuję, że uwierzyłeś w ten projekt, gdy był jeszcze surowy.\n\nI każdemu z was, kto zgłosił błąd, przetłumaczył tekst, podzielił się TREK z przyjacielem lub po prostu użył go do zaplanowania podróży — **dziękuję**. To wy jesteście powodem, dla którego to istnieje.\n\nZa wiele kolejnych wspólnych przygód.\n\n— Maurice\n\n---\n\n[Dołącz do społeczności na Discordzie](https://discord.gg/7Q6M6jDwzf)\n\nJeśli TREK sprawia, że Twoje podróże są lepsze, [mała kawa](https://ko-fi.com/mauriceboe) zawsze pomaga utrzymać światła włączone.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Transport',
|
||||
'transport.addManual': 'Ręczny transport',
|
||||
}
|
||||
|
||||
export default pl
|
||||
|
||||
@@ -10,6 +10,9 @@ const ru: Record<string, string> = {
|
||||
'common.add': 'Добавить',
|
||||
'common.loading': 'Загрузка...',
|
||||
'common.import': 'Импорт',
|
||||
'common.select': 'Выбрать',
|
||||
'common.selectAll': 'Выбрать всё',
|
||||
'common.deselectAll': 'Снять выделение со всех',
|
||||
'common.error': 'Ошибка',
|
||||
'common.unknownError': 'Неизвестная ошибка',
|
||||
'common.tooManyAttempts': 'Слишком много попыток. Попробуйте позже.',
|
||||
@@ -864,6 +867,7 @@ const ru: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': 'План',
|
||||
'trip.tabs.transports': 'Транспорт',
|
||||
'trip.tabs.reservations': 'Бронирования',
|
||||
'trip.tabs.reservationsShort': 'Брони',
|
||||
'trip.tabs.packing': 'Список вещей',
|
||||
@@ -886,6 +890,8 @@ const ru: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': 'Бронирование добавлено',
|
||||
'trip.toast.deleted': 'Удалено',
|
||||
'trip.confirm.deletePlace': 'Вы уверены, что хотите удалить это место?',
|
||||
'trip.confirm.deletePlaces': 'Удалить {count} мест?',
|
||||
'trip.toast.placesDeleted': '{count} мест удалено',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': 'На этот день мест не запланировано',
|
||||
@@ -930,6 +936,17 @@ const ru: Record<string, string> = {
|
||||
'places.importFileError': 'Ошибка импорта',
|
||||
'places.importAllSkipped': 'Все места уже были в поездке.',
|
||||
'places.gpxImported': '{count} мест импортировано из GPX',
|
||||
'places.gpxImportTypes': 'Что импортировать?',
|
||||
'places.gpxImportWaypoints': 'Путевые точки',
|
||||
'places.gpxImportRoutes': 'Маршруты',
|
||||
'places.gpxImportTracks': 'Треки (с геометрией пути)',
|
||||
'places.gpxImportNoneSelected': 'Выберите хотя бы один тип для импорта.',
|
||||
'places.kmlImportTypes': 'Что вы хотите импортировать?',
|
||||
'places.kmlImportPoints': 'Точки (Placemarks)',
|
||||
'places.kmlImportPaths': 'Маршруты (LineStrings)',
|
||||
'places.kmlImportNoneSelected': 'Выберите хотя бы один тип.',
|
||||
'places.selectionCount': '{count} выбрано',
|
||||
'places.deleteSelected': 'Удалить выбранные',
|
||||
'places.kmlKmzImported': '{count} мест импортировано из KMZ/KML',
|
||||
'places.urlResolved': 'Место импортировано из URL',
|
||||
'places.importList': 'Импорт списка',
|
||||
@@ -946,6 +963,7 @@ const ru: Record<string, string> = {
|
||||
'places.assignToDay': 'Добавить в какой день?',
|
||||
'places.all': 'Все',
|
||||
'places.unplanned': 'Незапланированные',
|
||||
'places.filterTracks': 'Треки',
|
||||
'places.search': 'Поиск мест...',
|
||||
'places.allCategories': 'Все категории',
|
||||
'places.categoriesSelected': 'категорий',
|
||||
@@ -1117,6 +1135,7 @@ const ru: Record<string, string> = {
|
||||
'reservations.span.end': 'Конец',
|
||||
'reservations.span.ongoing': 'Продолжается',
|
||||
'reservations.validation.endBeforeStart': 'Дата/время окончания должны быть позже даты/времени начала',
|
||||
'reservations.addBooking': 'Добавить бронирование',
|
||||
|
||||
// Budget
|
||||
'budget.title': 'Бюджет',
|
||||
@@ -1715,6 +1734,7 @@ const ru: Record<string, string> = {
|
||||
'undo.reorder': 'Места переупорядочены',
|
||||
'undo.optimize': 'Маршрут оптимизирован',
|
||||
'undo.deletePlace': 'Место удалено',
|
||||
'undo.deletePlaces': 'Места удалены',
|
||||
'undo.moveDay': 'Место перемещено в другой день',
|
||||
'undo.lock': 'Блокировка места изменена',
|
||||
'undo.importGpx': 'Импорт GPX',
|
||||
@@ -2267,6 +2287,11 @@ const ru: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': 'Личное слово от меня',
|
||||
'system_notice.v3_thankyou.body': 'Прежде чем продолжить — хочу остановиться на мгновение.\n\nTREK начинался как сторонний проект, который я создал для собственных поездок. Я никогда не думал, что он вырастет во что-то, чему 4 000 из вас доверяют планирование своих приключений. Каждая звёздочка, каждый issue, каждый запрос на фичу — я читаю их все, и именно они поддерживают меня в поздние ночи между основной работой и университетом.\n\nХочу, чтобы вы знали: TREK всегда будет open source, всегда self-hosted, всегда вашим. Никакого отслеживания, никаких подписок, никаких подвохов. Просто инструмент, созданный человеком, который любит путешествовать так же, как и вы.\n\nОсобая благодарность [jubnl](https://github.com/jubnl) — ты стал невероятным соратником. Многое из того, что делает версию 3.0 великолепной, несёт твой отпечаток. Спасибо, что поверил в этот проект, когда он был ещё сырым.\n\nИ каждому из вас, кто сообщил об ошибке, перевёл строку, поделился TREK с другом или просто использовал его для планирования поездки — **спасибо**. Вы — причина, по которой всё это существует.\n\nЗа множество новых приключений вместе.\n\n— Maurice\n\n---\n\n[Присоединяйся к сообществу в Discord](https://discord.gg/7Q6M6jDwzf)\n\nЕсли TREK делает твои путешествия лучше, [маленький кофе](https://ko-fi.com/mauriceboe) всегда помогает держать свет включённым.',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': 'Транспорт',
|
||||
'transport.addManual': 'Ручной транспорт',
|
||||
}
|
||||
|
||||
export default ru
|
||||
|
||||
@@ -10,6 +10,9 @@ const zh: Record<string, string> = {
|
||||
'common.add': '添加',
|
||||
'common.loading': '加载中...',
|
||||
'common.import': '导入',
|
||||
'common.select': '选择',
|
||||
'common.selectAll': '全选',
|
||||
'common.deselectAll': '取消全选',
|
||||
'common.error': '错误',
|
||||
'common.unknownError': '未知错误',
|
||||
'common.tooManyAttempts': '尝试次数过多,请稍后再试。',
|
||||
@@ -864,6 +867,7 @@ const zh: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': '计划',
|
||||
'trip.tabs.transports': '交通',
|
||||
'trip.tabs.reservations': '预订',
|
||||
'trip.tabs.reservationsShort': '预订',
|
||||
'trip.tabs.packing': '行李清单',
|
||||
@@ -886,6 +890,8 @@ const zh: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': '预订已添加',
|
||||
'trip.toast.deleted': '已删除',
|
||||
'trip.confirm.deletePlace': '确定要删除这个地点吗?',
|
||||
'trip.confirm.deletePlaces': '删除 {count} 个地点?',
|
||||
'trip.toast.placesDeleted': '已删除 {count} 个地点',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': '当天暂无计划',
|
||||
@@ -930,6 +936,17 @@ const zh: Record<string, string> = {
|
||||
'places.importFileError': '导入失败',
|
||||
'places.importAllSkipped': '所有地点已在行程中。',
|
||||
'places.gpxImported': '已从 GPX 导入 {count} 个地点',
|
||||
'places.gpxImportTypes': '要导入什么?',
|
||||
'places.gpxImportWaypoints': '路点',
|
||||
'places.gpxImportRoutes': '路线',
|
||||
'places.gpxImportTracks': '轨迹(含路径几何)',
|
||||
'places.gpxImportNoneSelected': '请至少选择一种导入类型。',
|
||||
'places.kmlImportTypes': '要导入什么?',
|
||||
'places.kmlImportPoints': '点(Placemarks)',
|
||||
'places.kmlImportPaths': '路径(LineStrings)',
|
||||
'places.kmlImportNoneSelected': '请至少选择一种类型。',
|
||||
'places.selectionCount': '已选 {count} 项',
|
||||
'places.deleteSelected': '删除所选',
|
||||
'places.kmlKmzImported': '已从 KMZ/KML 导入 {count} 个地点',
|
||||
'places.urlResolved': '已从 URL 导入地点',
|
||||
'places.importList': '列表导入',
|
||||
@@ -946,6 +963,7 @@ const zh: Record<string, string> = {
|
||||
'places.assignToDay': '添加到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未规划',
|
||||
'places.filterTracks': '路线',
|
||||
'places.search': '搜索地点...',
|
||||
'places.allCategories': '所有分类',
|
||||
'places.categoriesSelected': '个分类',
|
||||
@@ -1117,6 +1135,7 @@ const zh: Record<string, string> = {
|
||||
'reservations.span.end': '结束',
|
||||
'reservations.span.ongoing': '进行中',
|
||||
'reservations.validation.endBeforeStart': '结束日期/时间必须晚于开始日期/时间',
|
||||
'reservations.addBooking': '添加预订',
|
||||
|
||||
// Budget
|
||||
'budget.title': '预算',
|
||||
@@ -1715,6 +1734,7 @@ const zh: Record<string, string> = {
|
||||
'undo.reorder': '地点已重新排序',
|
||||
'undo.optimize': '路线已优化',
|
||||
'undo.deletePlace': '地点已删除',
|
||||
'undo.deletePlaces': '地点已删除',
|
||||
'undo.moveDay': '地点已移至另一天',
|
||||
'undo.lock': '地点锁定已切换',
|
||||
'undo.importGpx': 'GPX 导入',
|
||||
@@ -2267,6 +2287,11 @@ const zh: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '来自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你继续之前——我想停下来说几句。\n\nTREK 最初只是我为自己的旅行而做的一个业余项目。我从未想过它会成长为 4,000 人信赖的冒险规划工具。每一颗星标、每一个 issue、每一个功能请求——我都会读,它们在全职工作和大学学业之间的深夜里支撑着我继续前行。\n\n我想让你们知道:TREK 将永远开源,永远可自托管,永远属于你们。没有追踪,没有订阅,没有任何附加条件。只是一个热爱旅行的人为同样热爱旅行的你们打造的工具。\n\n特别感谢 [jubnl](https://github.com/jubnl)——你已经成为一位不可思议的合作者。3.0 版本中许多精彩之处都留下了你的印记。感谢你在这个项目还很粗糙的时候就选择了相信它。\n\n也感谢你们每一位——报告了 bug、翻译了文本、向朋友分享了 TREK,或者只是用它规划了一次旅行——**谢谢你们**。你们是这一切存在的原因。\n\n愿我们一起踏上更多的冒险旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社区](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 让你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能让这盏灯一直亮着。',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手动添加交通',
|
||||
}
|
||||
|
||||
export default zh
|
||||
|
||||
@@ -10,6 +10,9 @@ const zhTw: Record<string, string> = {
|
||||
'common.add': '新增',
|
||||
'common.loading': '載入中...',
|
||||
'common.import': '匯入',
|
||||
'common.select': '選擇',
|
||||
'common.selectAll': '全選',
|
||||
'common.deselectAll': '取消全選',
|
||||
'common.error': '錯誤',
|
||||
'common.unknownError': '未知錯誤',
|
||||
'common.tooManyAttempts': '嘗試次數過多,請稍後再試。',
|
||||
@@ -924,6 +927,7 @@ const zhTw: Record<string, string> = {
|
||||
|
||||
// Trip Planner
|
||||
'trip.tabs.plan': '計劃',
|
||||
'trip.tabs.transports': '交通',
|
||||
'trip.tabs.reservations': '預訂',
|
||||
'trip.tabs.reservationsShort': '預訂',
|
||||
'trip.tabs.packing': '行李清單',
|
||||
@@ -946,6 +950,8 @@ const zhTw: Record<string, string> = {
|
||||
'trip.toast.reservationAdded': '預訂已新增',
|
||||
'trip.toast.deleted': '已刪除',
|
||||
'trip.confirm.deletePlace': '確定要刪除這個地點嗎?',
|
||||
'trip.confirm.deletePlaces': '刪除 {count} 個地點?',
|
||||
'trip.toast.placesDeleted': '已刪除 {count} 個地點',
|
||||
|
||||
// Day Plan Sidebar
|
||||
'dayplan.emptyDay': '當天暫無計劃',
|
||||
@@ -990,6 +996,17 @@ const zhTw: Record<string, string> = {
|
||||
'places.importFileError': '匯入失敗',
|
||||
'places.importAllSkipped': '所有地點已在行程中。',
|
||||
'places.gpxImported': '已從 GPX 匯入 {count} 個地點',
|
||||
'places.gpxImportTypes': '要匯入什麼?',
|
||||
'places.gpxImportWaypoints': '路點',
|
||||
'places.gpxImportRoutes': '路線',
|
||||
'places.gpxImportTracks': '軌跡(含路徑幾何)',
|
||||
'places.gpxImportNoneSelected': '請至少選擇一種匯入類型。',
|
||||
'places.kmlImportTypes': '要匯入什麼?',
|
||||
'places.kmlImportPoints': '點(Placemarks)',
|
||||
'places.kmlImportPaths': '路徑(LineStrings)',
|
||||
'places.kmlImportNoneSelected': '請至少選擇一種類型。',
|
||||
'places.selectionCount': '已選 {count} 項',
|
||||
'places.deleteSelected': '刪除所選',
|
||||
'places.kmlKmzImported': '已從 KMZ/KML 匯入 {count} 個地點',
|
||||
'places.urlResolved': '已從 URL 匯入地點',
|
||||
'places.importList': '列表匯入',
|
||||
@@ -1006,6 +1023,7 @@ const zhTw: Record<string, string> = {
|
||||
'places.assignToDay': '新增到哪一天?',
|
||||
'places.all': '全部',
|
||||
'places.unplanned': '未規劃',
|
||||
'places.filterTracks': '路線',
|
||||
'places.search': '搜尋地點...',
|
||||
'places.allCategories': '所有分類',
|
||||
'places.categoriesSelected': '個分類',
|
||||
@@ -1177,6 +1195,7 @@ const zhTw: Record<string, string> = {
|
||||
'reservations.span.end': '結束',
|
||||
'reservations.span.ongoing': '進行中',
|
||||
'reservations.validation.endBeforeStart': '結束日期/時間必須晚於開始日期/時間',
|
||||
'reservations.addBooking': '新增預訂',
|
||||
|
||||
// Budget
|
||||
'budget.title': '預算',
|
||||
@@ -1775,6 +1794,7 @@ const zhTw: Record<string, string> = {
|
||||
'undo.reorder': '地點已重新排序',
|
||||
'undo.optimize': '路線已最佳化',
|
||||
'undo.deletePlace': '地點已刪除',
|
||||
'undo.deletePlaces': '地點已刪除',
|
||||
'undo.moveDay': '地點已移至另一天',
|
||||
'undo.lock': '地點鎖定已切換',
|
||||
'undo.importGpx': 'GPX 匯入',
|
||||
@@ -2268,6 +2288,11 @@ const zhTw: Record<string, string> = {
|
||||
// System notices — personal thank you
|
||||
'system_notice.v3_thankyou.title': '來自我的一封私人信',
|
||||
'system_notice.v3_thankyou.body': '在你繼續之前——我想停下來說幾句。\n\nTREK 最初只是我為自己的旅行而做的一個業餘專案。我從未想過它會成長為 4,000 人信賴的冒險規劃工具。每一顆星標、每一個 issue、每一個功能請求——我都會讀,它們在全職工作和大學學業之間的深夜裡支撐著我繼續前行。\n\n我想讓你們知道:TREK 將永遠開源,永遠可自託管,永遠屬於你們。沒有追蹤,沒有訂閱,沒有任何附加條件。只是一個熱愛旅行的人為同樣熱愛旅行的你們打造的工具。\n\n特別感謝 [jubnl](https://github.com/jubnl)——你已經成為一位不可思議的合作者。3.0 版本中許多精彩之處都留下了你的印記。感謝你在這個專案還很粗糙的時候就選擇了相信它。\n\n也感謝你們每一位——回報了 bug、翻譯了文字、向朋友分享了 TREK,或者只是用它規劃了一次旅行——**謝謝你們**。你們是這一切存在的原因。\n\n願我們一起踏上更多的冒險旅程。\n\n— Maurice\n\n---\n\n[加入 Discord 社群](https://discord.gg/7Q6M6jDwzf)\n\n如果 TREK 讓你的旅行更美好,一杯[小小的咖啡](https://ko-fi.com/mauriceboe)能讓這盞燈一直亮著。',
|
||||
'transport.addTransport': 'Add transport',
|
||||
'transport.modalTitle.create': 'Add transport',
|
||||
'transport.modalTitle.edit': 'Edit transport',
|
||||
'transport.title': '交通',
|
||||
'transport.addManual': '手動新增交通',
|
||||
}
|
||||
|
||||
export default zhTw
|
||||
@@ -14,6 +14,7 @@ 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 { TransportModal } from '../components/Planner/TransportModal'
|
||||
// MemoriesPanel moved to Journey addon
|
||||
import ReservationsPanel from '../components/Planner/ReservationsPanel'
|
||||
import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
@@ -23,7 +24,7 @@ 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, Ticket, PackageCheck, Wallet, FolderOpen, Users } from 'lucide-react'
|
||||
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'
|
||||
@@ -192,8 +193,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
}).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const TRANSPORT_TYPES = new Set(['flight', 'train', 'car', 'cruise', 'bus'])
|
||||
|
||||
const TRIP_TABS = [
|
||||
{ id: 'plan', label: t('trip.tabs.plan'), icon: Map },
|
||||
{ id: 'transports', label: t('trip.tabs.transports'), icon: Train },
|
||||
{ id: 'buchungen', label: t('trip.tabs.reservations'), shortLabel: t('trip.tabs.reservationsShort'), icon: Ticket },
|
||||
...(enabledAddons.packing ? [{ id: 'listen', label: t('trip.tabs.lists'), shortLabel: t('trip.tabs.listsShort'), icon: PackageCheck }] : []),
|
||||
...(enabledAddons.budget ? [{ id: 'finanzplan', label: t('trip.tabs.budget'), icon: Wallet }] : []),
|
||||
@@ -232,10 +236,15 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [showMembersModal, setShowMembersModal] = useState<boolean>(false)
|
||||
const [showReservationModal, setShowReservationModal] = useState<boolean>(false)
|
||||
const [editingReservation, setEditingReservation] = useState<Reservation | null>(null)
|
||||
const [bookingForAssignmentId, setBookingForAssignmentId] = useState<number | null>(null)
|
||||
const [showTransportModal, setShowTransportModal] = useState<boolean>(false)
|
||||
const [editingTransport, setEditingTransport] = useState<Reservation | null>(null)
|
||||
const [transportModalDayId, setTransportModalDayId] = useState<number | null>(null)
|
||||
const [fitKey, setFitKey] = useState<number>(0)
|
||||
const initialFitTripId = useRef<number | null>(null)
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState<'left' | 'right' | null>(null)
|
||||
const [deletePlaceId, setDeletePlaceId] = useState<number | null>(null)
|
||||
const [deletePlaceIds, setDeletePlaceIds] = useState<number[] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!trip) return
|
||||
@@ -343,6 +352,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
|
||||
return places.filter(p => {
|
||||
if (!p.lat || !p.lng) return false
|
||||
if (mapPlacesFilter === 'tracks' && !p.route_geometry) return false
|
||||
if (mapCategoryFilter.size > 0) {
|
||||
if (p.category_id == null) {
|
||||
if (!mapCategoryFilter.has('uncategorized')) return false
|
||||
@@ -456,6 +466,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
try {
|
||||
await tripActions.deletePlace(tripId, deletePlaceId)
|
||||
if (selectedPlaceId === deletePlaceId) setSelectedPlaceId(null)
|
||||
updateRouteForDay(selectedDayId)
|
||||
toast.success(t('trip.toast.placeDeleted'))
|
||||
if (capturedPlace) {
|
||||
pushUndo(t('undo.deletePlace'), async () => {
|
||||
@@ -475,7 +486,38 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, pushUndo])
|
||||
}, [deletePlaceId, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
|
||||
const confirmDeletePlaces = useCallback(async (ids?: number[]) => {
|
||||
const targetIds = ids ?? deletePlaceIds
|
||||
if (!targetIds?.length) return
|
||||
const state = useTripStore.getState()
|
||||
const capturedPlaces = state.places.filter(p => targetIds.includes(p.id))
|
||||
const capturedAssignments = Object.entries(state.assignments).flatMap(([dayId, as]) =>
|
||||
as.filter(a => a.place?.id != null && targetIds.includes(a.place.id)).map(a => ({ dayId: Number(dayId), placeId: a.place!.id, orderIndex: a.order_index }))
|
||||
)
|
||||
try {
|
||||
await tripActions.deletePlacesMany(tripId, targetIds)
|
||||
if (selectedPlaceId != null && targetIds.includes(selectedPlaceId)) setSelectedPlaceId(null)
|
||||
if (!ids) setDeletePlaceIds(null)
|
||||
updateRouteForDay(selectedDayId)
|
||||
toast.success(t('trip.toast.placesDeleted', { count: capturedPlaces.length }))
|
||||
if (capturedPlaces.length > 0) {
|
||||
pushUndo(t('undo.deletePlaces'), async () => {
|
||||
for (const place of capturedPlaces) {
|
||||
const newPlace = await tripActions.addPlace(tripId, {
|
||||
name: place.name, description: place.description,
|
||||
lat: place.lat, lng: place.lng, address: place.address,
|
||||
category_id: place.category_id, icon: place.icon, price: place.price,
|
||||
})
|
||||
for (const a of capturedAssignments.filter(x => x.placeId === place.id)) {
|
||||
await tripActions.assignPlaceToDay(tripId, a.dayId, newPlace.id, a.orderIndex)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [deletePlaceIds, tripId, toast, selectedPlaceId, selectedDayId, updateRouteForDay, pushUndo])
|
||||
|
||||
const handleAssignToDay = useCallback(async (placeId, dayId, position) => {
|
||||
const target = dayId || selectedDayId
|
||||
@@ -501,6 +543,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const capturedOrderIndex = capturedAssignment?.order_index ?? 0
|
||||
try {
|
||||
await tripActions.removeAssignment(tripId, dayId, assignmentId)
|
||||
updateRouteForDay(dayId)
|
||||
if (capturedPlaceId != null) {
|
||||
const capturedDayId = dayId
|
||||
const capturedPos = capturedOrderIndex
|
||||
@@ -524,17 +567,11 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
await tripActions.reorderAssignments(tripId, capturedDayId, capturedPrevIds)
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
// Update route immediately from orderedIds
|
||||
const dayItems = useTripStore.getState().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(err => toast.error(err instanceof Error ? err.message : t('trip.toast.reorderError')))
|
||||
updateRouteForDay(dayId)
|
||||
}
|
||||
catch { toast.error(t('trip.toast.reorderError')) }
|
||||
}, [tripId, toast, pushUndo])
|
||||
}, [tripId, toast, pushUndo, updateRouteForDay])
|
||||
|
||||
const handleUpdateDayTitle = useCallback(async (dayId, title) => {
|
||||
try { await tripActions.updateDayTitle(tripId, dayId, title) }
|
||||
@@ -564,6 +601,21 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleSaveTransport = async (data) => {
|
||||
try {
|
||||
if (editingTransport) {
|
||||
await tripActions.updateReservation(tripId, editingTransport.id, data)
|
||||
toast.success(t('trip.toast.reservationUpdated'))
|
||||
} else {
|
||||
await tripActions.addReservation(tripId, data)
|
||||
toast.success(t('trip.toast.reservationAdded'))
|
||||
}
|
||||
setShowTransportModal(false)
|
||||
setEditingTransport(null)
|
||||
setTransportModalDayId(null)
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleDeleteReservation = async (id) => {
|
||||
try {
|
||||
await tripActions.deleteReservation(tripId, id)
|
||||
@@ -772,13 +824,16 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onReorder={handleReorder}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute(r.coordinates); setRouteInfo({ distance: r.distanceText, duration: r.durationText, walkingText: r.walkingText, drivingText: r.drivingText }) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
onRouteCalculated={(r) => { 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}
|
||||
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) }}
|
||||
@@ -790,6 +845,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
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 && (
|
||||
<div
|
||||
@@ -849,6 +906,7 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true) }}
|
||||
onDeletePlace={(placeId) => handleDeletePlace(placeId)}
|
||||
onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)}
|
||||
onCategoryFilterChange={setMapCategoryFilter}
|
||||
onPlacesFilterChange={setMapPlacesFilter}
|
||||
pushUndo={pushUndo}
|
||||
@@ -1006,8 +1064,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} 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} />
|
||||
: <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)} 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); setMobileSidebarOpen(null) }} 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); 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} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1017,11 +1075,29 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'transports' && (
|
||||
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations.filter(r => TRANSPORT_TYPES.has(r.type))}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
onAdd={() => { setEditingTransport(null); setShowTransportModal(true) }}
|
||||
onEdit={(r) => { setEditingTransport(r); setShowTransportModal(true) }}
|
||||
onDelete={handleDeleteReservation}
|
||||
onNavigateToFiles={() => handleTabChange('dateien')}
|
||||
titleKey="transport.title"
|
||||
addManualKey="transport.addManual"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'buchungen' && (
|
||||
<div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', overflowY: 'auto', overscrollBehavior: 'contain', paddingBottom: 'var(--bottom-nav-h)' }}>
|
||||
<ReservationsPanel
|
||||
tripId={tripId}
|
||||
reservations={reservations}
|
||||
reservations={reservations.filter(r => !TRANSPORT_TYPES.has(r.type))}
|
||||
days={days}
|
||||
assignments={assignments}
|
||||
files={files}
|
||||
@@ -1072,7 +1148,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null) }} 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} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} />}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceId}
|
||||
onClose={() => setDeletePlaceId(null)}
|
||||
@@ -1080,6 +1157,13 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlace')}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={!!deletePlaceIds?.length}
|
||||
onClose={() => setDeletePlaceIds(null)}
|
||||
onConfirm={confirmDeletePlaces}
|
||||
title={t('common.delete')}
|
||||
message={t('trip.confirm.deletePlaces', { count: deletePlaceIds?.length ?? 0 })}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -84,4 +84,26 @@ export const placeRepo = {
|
||||
offlineDb.places.delete(Number(id))
|
||||
return result
|
||||
},
|
||||
|
||||
async deleteMany(tripId: number | string, ids: number[]): Promise<unknown> {
|
||||
if (!navigator.onLine) {
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
for (const id of ids) {
|
||||
const mutId = generateUUID()
|
||||
await mutationQueue.enqueue({
|
||||
id: mutId,
|
||||
tripId: Number(tripId),
|
||||
method: 'DELETE',
|
||||
url: `/trips/${tripId}/places/${id}`,
|
||||
body: undefined,
|
||||
resource: 'places',
|
||||
entityId: id,
|
||||
})
|
||||
}
|
||||
return { deleted: ids, count: ids.length }
|
||||
}
|
||||
const result = await placesApi.bulkDelete(tripId, ids)
|
||||
await offlineDb.places.bulkDelete(ids)
|
||||
return result
|
||||
},
|
||||
}
|
||||
|
||||
@@ -12,6 +12,29 @@ const listeners = new Map<string, Set<(entry: PhotoEntry) => void>>()
|
||||
// Separate thumb listeners — called when thumbDataUrl becomes available after initial load
|
||||
const thumbListeners = new Map<string, Set<(thumb: string) => void>>()
|
||||
|
||||
// Concurrency limiter — at most N photo API requests in flight at once.
|
||||
// Prevents flooding the server (and external APIs it calls) when many places appear at once.
|
||||
const MAX_CONCURRENT = 5
|
||||
let activeRequests = 0
|
||||
const requestQueue: Array<() => void> = []
|
||||
|
||||
function acquireRequestSlot(): Promise<void> {
|
||||
if (activeRequests < MAX_CONCURRENT) {
|
||||
activeRequests++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise(resolve => requestQueue.push(resolve))
|
||||
}
|
||||
|
||||
function releaseRequestSlot(): void {
|
||||
const next = requestQueue.shift()
|
||||
if (next) {
|
||||
next()
|
||||
} else {
|
||||
activeRequests--
|
||||
}
|
||||
}
|
||||
|
||||
function notify(key: string, entry: PhotoEntry) {
|
||||
listeners.get(key)?.forEach(fn => fn(entry))
|
||||
listeners.delete(key)
|
||||
@@ -99,37 +122,39 @@ export function fetchPhoto(
|
||||
}
|
||||
|
||||
inFlight.add(cacheKey)
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
const photoUrl = data.photoUrl || null
|
||||
if (!photoUrl) {
|
||||
acquireRequestSlot().then(() =>
|
||||
mapsApi.placePhoto(photoId, lat, lng, name)
|
||||
.then(async (data: { photoUrl?: string }) => {
|
||||
const photoUrl = data.photoUrl || null
|
||||
if (!photoUrl) {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL first — sidebar can show immediately
|
||||
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
|
||||
// Generate base64 thumb in background
|
||||
const thumb = await urlToBase64(photoUrl)
|
||||
if (thumb) {
|
||||
entry.thumbDataUrl = thumb
|
||||
notifyThumb(cacheKey, thumb)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
return
|
||||
}
|
||||
|
||||
// Store URL first — sidebar can show immediately
|
||||
const entry: PhotoEntry = { photoUrl, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
|
||||
// Generate base64 thumb in background
|
||||
const thumb = await urlToBase64(photoUrl)
|
||||
if (thumb) {
|
||||
entry.thumbDataUrl = thumb
|
||||
notifyThumb(cacheKey, thumb)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
const entry: PhotoEntry = { photoUrl: null, thumbDataUrl: null }
|
||||
cache.set(cacheKey, entry)
|
||||
callback?.(entry)
|
||||
notify(cacheKey, entry)
|
||||
})
|
||||
.finally(() => { inFlight.delete(cacheKey) })
|
||||
})
|
||||
.finally(() => { inFlight.delete(cacheKey); releaseRequestSlot() })
|
||||
)
|
||||
}
|
||||
|
||||
export function getAllThumbs(): Record<string, string> {
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { weatherApi } from '../api/client'
|
||||
|
||||
const MAX_CONCURRENT = 3
|
||||
let active = 0
|
||||
const queue: Array<() => void> = []
|
||||
|
||||
function acquire(): Promise<void> {
|
||||
if (active < MAX_CONCURRENT) { active++; return Promise.resolve() }
|
||||
return new Promise(resolve => queue.push(resolve))
|
||||
}
|
||||
|
||||
function release(): void {
|
||||
const next = queue.shift()
|
||||
if (next) next()
|
||||
else active--
|
||||
}
|
||||
|
||||
export async function fetchWeather(lat: number, lng: number, date: string) {
|
||||
await acquire()
|
||||
try {
|
||||
return await weatherApi.get(lat, lng, date)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface PlacesSlice {
|
||||
addPlace: (tripId: number | string, placeData: Partial<Place>) => Promise<Place>
|
||||
updatePlace: (tripId: number | string, placeId: number, placeData: Partial<Place>) => Promise<Place>
|
||||
deletePlace: (tripId: number | string, placeId: number) => Promise<void>
|
||||
deletePlacesMany: (tripId: number | string, placeIds: number[]) => Promise<void>
|
||||
}
|
||||
|
||||
export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice => ({
|
||||
@@ -80,4 +81,28 @@ export const createPlacesSlice = (set: SetState, get: GetState): PlacesSlice =>
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting place'))
|
||||
}
|
||||
},
|
||||
|
||||
deletePlacesMany: async (tripId, placeIds) => {
|
||||
if (placeIds.length === 0) return
|
||||
try {
|
||||
await placeRepo.deleteMany(tripId, placeIds)
|
||||
const idSet = new Set(placeIds)
|
||||
set(state => {
|
||||
const updatedAssignments = { ...state.assignments }
|
||||
let changed = false
|
||||
for (const [dayId, items] of Object.entries(state.assignments)) {
|
||||
if (items.some((a: Assignment) => a.place?.id != null && idSet.has(a.place.id))) {
|
||||
updatedAssignments[dayId] = items.filter((a: Assignment) => !idSet.has(a.place?.id!))
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return {
|
||||
places: state.places.filter(p => !idSet.has(p.id)),
|
||||
...(changed ? { assignments: updatedAssignments } : {}),
|
||||
}
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
throw new Error(getApiErrorMessage(err, 'Error deleting places'))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -167,6 +167,7 @@ export interface Reservation {
|
||||
notes: string | null
|
||||
url: string | null
|
||||
day_id?: number | null
|
||||
end_day_id?: number | null
|
||||
place_id?: number | null
|
||||
assignment_id?: number | null
|
||||
accommodation_id?: number | null
|
||||
|
||||
@@ -2,6 +2,7 @@ import { renderHook, act } from '@testing-library/react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useRouteCalculation } from '../../../src/hooks/useRouteCalculation';
|
||||
import { useSettingsStore } from '../../../src/store/settingsStore';
|
||||
import { useTripStore } from '../../../src/store/tripStore';
|
||||
import { buildAssignment, buildPlace } from '../../helpers/factories';
|
||||
import type { TripStoreState } from '../../../src/store/tripStore';
|
||||
import type { RouteSegment } from '../../../src/types';
|
||||
@@ -17,6 +18,10 @@ vi.mock('../../../src/components/Map/RouteCalculator', () => ({
|
||||
const { calculateSegments } = await import('../../../src/components/Map/RouteCalculator');
|
||||
|
||||
function buildMockStore(assignments: Record<string, ReturnType<typeof buildAssignment>[]> = {}): Partial<TripStoreState> {
|
||||
// Also populate the real Zustand store so updateRouteForDay (which reads from
|
||||
// useTripStore.getState()) sees the same assignments as the hook's tripStore param.
|
||||
// Reset reservations and days to empty so transport-split logic doesn't interfere.
|
||||
useTripStore.setState({ assignments, reservations: [], days: [] } as any);
|
||||
return { assignments } as Partial<TripStoreState>;
|
||||
}
|
||||
|
||||
@@ -35,6 +40,8 @@ describe('useRouteCalculation', () => {
|
||||
vi.clearAllMocks();
|
||||
// Default: route_calculation disabled
|
||||
useSettingsStore.setState({ settings: { route_calculation: false } as any });
|
||||
// Reset trip store assignments so each test starts clean
|
||||
useTripStore.setState({ assignments: {} } as any);
|
||||
(calculateSegments as ReturnType<typeof vi.fn>).mockResolvedValue(MOCK_SEGMENTS);
|
||||
});
|
||||
|
||||
@@ -71,9 +78,9 @@ describe('useRouteCalculation', () => {
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
// route is an array of segments; no transport → single segment with all places
|
||||
expect(result.current.route).toEqual([
|
||||
[p1.lat, p1.lng],
|
||||
[p2.lat, p2.lng],
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -133,8 +140,7 @@ describe('useRouteCalculation', () => {
|
||||
|
||||
// After sort: a2 (order_index=0) first, then a1 (order_index=1)
|
||||
expect(result.current.route).toEqual([
|
||||
[p2.lat, p2.lng],
|
||||
[p1.lat, p1.lng],
|
||||
[[p2.lat, p2.lng], [p1.lat, p1.lng]],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -266,7 +272,7 @@ describe('useRouteCalculation', () => {
|
||||
expect(result.current.setRouteInfo).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('FE-HOOK-ROUTE-013: hook uses tripStoreRef — late store updates reflected correctly', async () => {
|
||||
it('FE-HOOK-ROUTE-013: route recalculates when assignments change via store update', async () => {
|
||||
useSettingsStore.setState({ settings: { route_calculation: true } as any });
|
||||
|
||||
const p1 = buildPlace({ lat: 10, lng: 10 });
|
||||
@@ -283,14 +289,13 @@ describe('useRouteCalculation', () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(result.current.route).toEqual([
|
||||
[p1.lat, p1.lng],
|
||||
[p2.lat, p2.lng],
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng]],
|
||||
]);
|
||||
|
||||
// Now add a third place
|
||||
// Now add a third place — update both the local store object and the Zustand store
|
||||
const p3 = buildPlace({ lat: 30, lng: 30 });
|
||||
const a3 = buildAssignment({ day_id: 5, order_index: 2, place: p3 });
|
||||
storeData = buildMockStore({ '5': [a1, a2, a3] });
|
||||
storeData = buildMockStore({ '5': [a1, a2, a3] }); // also calls useTripStore.setState
|
||||
|
||||
await act(async () => {
|
||||
rerender();
|
||||
@@ -299,9 +304,7 @@ describe('useRouteCalculation', () => {
|
||||
await act(async () => {});
|
||||
|
||||
expect(result.current.route).toEqual([
|
||||
[p1.lat, p1.lng],
|
||||
[p2.lat, p2.lng],
|
||||
[p3.lat, p3.lng],
|
||||
[[p1.lat, p1.lng], [p2.lat, p2.lng], [p3.lat, p3.lng]],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +134,8 @@ describe('fetchPhoto — in-flight deduplication', () => {
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb1);
|
||||
svc.fetchPhoto('k', 'pid', undefined, undefined, undefined, cb2);
|
||||
|
||||
// acquireRequestSlot() is async (Promise.resolve), so flush microtasks before asserting
|
||||
await flush();
|
||||
expect(mockPlacePhoto).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolve({ photoUrl: 'https://example.com/photo.jpg' });
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env node
|
||||
// Chrome Performance Trace Analyzer — outputs a compact summary
|
||||
// Usage: node analyze-trace.js <trace.json>
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const file = process.argv[2]
|
||||
if (!file) { console.error('Usage: node analyze-trace.js <trace.json>'); process.exit(1) }
|
||||
|
||||
console.log(`Reading ${path.basename(file)} (${(fs.statSync(file).size / 1e6).toFixed(1)} MB)...`)
|
||||
const raw = fs.readFileSync(file, 'utf8')
|
||||
console.log('Parsing...')
|
||||
const trace = JSON.parse(raw)
|
||||
const events = Array.isArray(trace) ? trace : (trace.traceEvents || [])
|
||||
console.log(`Total events: ${events.length.toLocaleString()}\n`)
|
||||
|
||||
// ── 1. Long Tasks (> 50ms on main thread) ────────────────────────────────────
|
||||
const LONG_TASK_MS = 50
|
||||
const tasks = events
|
||||
.filter(e => e.ph === 'X' && e.dur && e.dur > LONG_TASK_MS * 1000)
|
||||
.sort((a, b) => b.dur - a.dur)
|
||||
.slice(0, 30)
|
||||
|
||||
console.log(`═══ TOP LONG TASKS (>${LONG_TASK_MS}ms) ═══`)
|
||||
for (const t of tasks) {
|
||||
const ms = (t.dur / 1000).toFixed(1)
|
||||
const name = t.name || '(unknown)'
|
||||
const cat = t.cat || ''
|
||||
console.log(` ${ms.padStart(8)}ms ${name} [${cat}]`)
|
||||
}
|
||||
|
||||
// ── 2. Summarise all complete events by name ──────────────────────────────────
|
||||
const byName = new Map()
|
||||
for (const e of events) {
|
||||
if (e.ph !== 'X' || !e.dur) continue
|
||||
const key = e.name
|
||||
const existing = byName.get(key)
|
||||
if (existing) {
|
||||
existing.totalMs += e.dur / 1000
|
||||
existing.count++
|
||||
if (e.dur > existing.maxMs * 1000) existing.maxMs = e.dur / 1000
|
||||
} else {
|
||||
byName.set(key, { totalMs: e.dur / 1000, count: 1, maxMs: e.dur / 1000 })
|
||||
}
|
||||
}
|
||||
const topByTotal = [...byName.entries()]
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, 40)
|
||||
|
||||
console.log('\n═══ TOP EVENTS BY TOTAL TIME ═══')
|
||||
console.log(' Total(ms) Max(ms) Count Name')
|
||||
for (const [name, s] of topByTotal) {
|
||||
console.log(
|
||||
` ${s.totalMs.toFixed(1).padStart(9)} ${s.maxMs.toFixed(1).padStart(7)} ${String(s.count).padStart(5)} ${name}`
|
||||
)
|
||||
}
|
||||
|
||||
// ── 3. React-specific events ──────────────────────────────────────────────────
|
||||
const reactKeywords = ['react', 'React', 'setState', 'useState', 'useMemo', 'useEffect',
|
||||
'reconcil', 'Reconcil', 'render', 'Render', 'commit', 'Commit', 'fiber', 'Fiber',
|
||||
'Marker', 'MapView', 'photoUrl', 'createPlace', 'markers']
|
||||
const reactEvents = [...byName.entries()]
|
||||
.filter(([name]) => reactKeywords.some(k => name.includes(k)))
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, 30)
|
||||
|
||||
if (reactEvents.length > 0) {
|
||||
console.log('\n═══ REACT / MAP EVENTS ═══')
|
||||
for (const [name, s] of reactEvents) {
|
||||
console.log(` ${s.totalMs.toFixed(1).padStart(9)}ms total ${s.maxMs.toFixed(1).padStart(7)}ms max ${s.count}x ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. V8 / JS heavy hitters ─────────────────────────────────────────────────
|
||||
const jsEvents = [...byName.entries()]
|
||||
.filter(([, s]) => s.totalMs > 20)
|
||||
.filter(([name]) => {
|
||||
const cat = (events.find(e => e.name === name)?.cat || '')
|
||||
return cat.includes('v8') || cat.includes('devtools.timeline') || name.includes('JS') || name.includes('Compile') || name.includes('GC')
|
||||
})
|
||||
.sort((a, b) => b[1].totalMs - a[1].totalMs)
|
||||
.slice(0, 20)
|
||||
|
||||
if (jsEvents.length > 0) {
|
||||
console.log('\n═══ V8 / JS EVENTS (>20ms total) ═══')
|
||||
for (const [name, s] of jsEvents) {
|
||||
console.log(` ${s.totalMs.toFixed(1).padStart(9)}ms ${s.count}x ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. CPU profile — top self-time functions ─────────────────────────────────
|
||||
const profileChunks = events.filter(e => e.name === 'ProfileChunk')
|
||||
if (profileChunks.length > 0) {
|
||||
const selfTime = new Map()
|
||||
for (const chunk of profileChunks) {
|
||||
const nodes = chunk.args?.data?.cpuProfile?.nodes || []
|
||||
const samples = chunk.args?.data?.cpuProfile?.samples || []
|
||||
const timeDeltas = chunk.args?.data?.timeDeltas || []
|
||||
// Build node map
|
||||
const nodeMap = new Map(nodes.map(n => [n.id, n]))
|
||||
// Accumulate self time per node
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const nodeId = samples[i]
|
||||
const dt = (timeDeltas[i] || 0) / 1000 // µs → ms
|
||||
const node = nodeMap.get(nodeId)
|
||||
if (!node) continue
|
||||
const fn = node.callFrame?.functionName || '(anonymous)'
|
||||
const url = node.callFrame?.url || ''
|
||||
const line = node.callFrame?.lineNumber || 0
|
||||
const key = `${fn} @ ${url.split('/').slice(-2).join('/')}:${line}`
|
||||
selfTime.set(key, (selfTime.get(key) || 0) + dt)
|
||||
}
|
||||
}
|
||||
const topSelf = [...selfTime.entries()]
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 40)
|
||||
|
||||
console.log('\n═══ CPU PROFILE — TOP SELF-TIME FUNCTIONS ═══')
|
||||
for (const [name, ms] of topSelf) {
|
||||
console.log(` ${ms.toFixed(1).padStart(8)}ms ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Paint / Layout costs ───────────────────────────────────────────────────
|
||||
const renderCats = ['Layout', 'UpdateLayoutTree', 'Paint', 'CompositeLayers', 'RasterTask']
|
||||
console.log('\n═══ RENDERING COSTS ═══')
|
||||
for (const cat of renderCats) {
|
||||
const s = byName.get(cat)
|
||||
if (s) console.log(` ${s.totalMs.toFixed(1).padStart(9)}ms total ${s.maxMs.toFixed(1).padStart(7)}ms max ${s.count}x ${cat}`)
|
||||
}
|
||||
|
||||
console.log('\nDone.')
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/env node
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const file = process.argv[2]
|
||||
if (!file) { console.error('Usage: node analyze-react-profiler.cjs <profile.json>'); process.exit(1) }
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync(path.resolve(file), 'utf8'))
|
||||
const root = raw.dataForRoots[0]
|
||||
const commits = root.commitData
|
||||
|
||||
// snapshots: array of [fiberId, {displayName, ...}]
|
||||
const nameMap = new Map()
|
||||
for (const snap of root.snapshots) {
|
||||
const id = snap[0]
|
||||
const data = snap[1]
|
||||
if (data?.displayName) nameMap.set(id, data.displayName)
|
||||
}
|
||||
|
||||
console.log(`Commits: ${commits.length} Tracked components: ${nameMap.size}`)
|
||||
|
||||
// Probe the unit of fiberActualDurations against the known commit duration
|
||||
// fiberActualDurations contains durations for the subtree — the root fiber's
|
||||
// actual duration should be >= commit.duration. Find a plausible scale factor.
|
||||
const c0 = commits[0]
|
||||
const knownDur = c0.duration // already in ms per React DevTools spec
|
||||
const rootId = root.rootID ?? 1
|
||||
// Check a few values to pick scale
|
||||
const sampleDurs = c0.fiberActualDurations.slice(0, 10).map(e => e[1])
|
||||
console.log(`\nDebug — commit[0].duration=${knownDur}ms, first 5 raw fiberActualDurations values:`, sampleDurs.slice(0,5))
|
||||
// If max sample > 10*knownDur, values are in units of 1/100 ms; otherwise already ms
|
||||
const maxSample = Math.max(...c0.fiberActualDurations.map(e => e[1]))
|
||||
const scale = maxSample > knownDur * 10 ? 0.01 : 1
|
||||
|
||||
console.log(`Unit scale: ${scale === 0.01 ? '1/100 ms (dividing by 100)' : 'ms (no conversion)'}\n`)
|
||||
|
||||
// --- 1. Commit summary ---
|
||||
const fmt = (v) => v == null ? ' -' : (v * 1).toFixed(1).padStart(7)
|
||||
console.log('=== Commit summary ===')
|
||||
console.log(' # t(s) dur(ms) passive(ms) effects(ms) priority')
|
||||
const sorted = [...commits].map((c, i) => ({ i, ...c })).sort((a, b) => b.duration - a.duration)
|
||||
for (const c of sorted.slice(0, 15)) {
|
||||
const ts = (c.timestamp / 1000).toFixed(3)
|
||||
console.log(` ${String(c.i).padStart(2)} ${ts} ${fmt(c.duration)} ${fmt(c.passiveEffectDuration)} ${fmt(c.effectDuration)} ${c.priorityLevel ?? ''}`)
|
||||
}
|
||||
|
||||
// --- 2. Aggregate self + actual duration per component ---
|
||||
const selfTotals = new Map() // name → { total, count, max }
|
||||
const actualTotals = new Map()
|
||||
|
||||
for (const commit of commits) {
|
||||
for (const [id, raw] of commit.fiberActualDurations) {
|
||||
const dur = raw * scale
|
||||
const name = nameMap.get(id) ?? `(fiber#${id})`
|
||||
const e = actualTotals.get(name) ?? { total: 0, count: 0, max: 0 }
|
||||
e.total += dur; e.count += 1; e.max = Math.max(e.max, dur)
|
||||
actualTotals.set(name, e)
|
||||
}
|
||||
for (const [id, raw] of commit.fiberSelfDurations) {
|
||||
const dur = raw * scale
|
||||
const name = nameMap.get(id) ?? `(fiber#${id})`
|
||||
const e = selfTotals.get(name) ?? { total: 0, count: 0, max: 0 }
|
||||
e.total += dur; e.count += 1; e.max = Math.max(e.max, dur)
|
||||
selfTotals.set(name, e)
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = [...selfTotals.entries()]
|
||||
.sort((a, b) => b[1].total - a[1].total)
|
||||
.filter(([, s]) => s.total > 0.5)
|
||||
|
||||
console.log('\n=== Top 40 components by SELF render time (excludes children) ===')
|
||||
console.log(' Component Self total Renders Self max Actual total')
|
||||
for (const [name, s] of ranked.slice(0, 40)) {
|
||||
const actual = actualTotals.get(name) ?? { total: 0 }
|
||||
console.log(
|
||||
` ${name.padEnd(48)} ${s.total.toFixed(1).padStart(8)} ms` +
|
||||
` ${String(s.count).padStart(6)}x` +
|
||||
` ${s.max.toFixed(1).padStart(7)} ms` +
|
||||
` ${actual.total.toFixed(1).padStart(10)} ms`
|
||||
)
|
||||
}
|
||||
|
||||
console.log('\n=== Most frequently re-rendering components (top 20) ===')
|
||||
const byCount = [...selfTotals.entries()].sort((a, b) => b[1].count - a[1].count)
|
||||
console.log(' Component Renders Self total')
|
||||
for (const [name, s] of byCount.slice(0, 20)) {
|
||||
console.log(` ${name.padEnd(48)} ${String(s.count).padStart(6)}x ${s.total.toFixed(1).padStart(8)} ms`)
|
||||
}
|
||||
|
||||
const totalPassive = commits.reduce((a, c) => a + (c.passiveEffectDuration ?? 0), 0)
|
||||
const totalCommit = commits.reduce((a, c) => a + c.duration, 0)
|
||||
console.log(`\n=== Totals ===`)
|
||||
console.log(` Total commit render time: ${totalCommit.toFixed(1)} ms (${commits.length} commits)`)
|
||||
console.log(` Total passive effect time: ${totalPassive.toFixed(1)} ms (useEffect)`)
|
||||
console.log(` Avg commit duration: ${(totalCommit / commits.length).toFixed(1)} ms`)
|
||||
@@ -1703,6 +1703,41 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('CREATE INDEX IF NOT EXISTS idx_reservation_endpoints_reservation_id ON reservation_endpoints(reservation_id)');
|
||||
try { db.exec('ALTER TABLE reservations ADD COLUMN needs_review INTEGER NOT NULL DEFAULT 0'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
// Migration 110 — link transport reservations to days via day_id / end_day_id
|
||||
() => {
|
||||
try {
|
||||
db.exec('ALTER TABLE reservations ADD COLUMN end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL');
|
||||
} catch (err: any) {
|
||||
if (!err.message?.includes('duplicate column name')) throw err;
|
||||
}
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET day_id = (
|
||||
SELECT d.id FROM days d
|
||||
WHERE d.trip_id = reservations.trip_id
|
||||
AND d.date = substr(reservations.reservation_time, 1, 10)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE type IN ('flight','train','car','cruise','bus')
|
||||
AND reservation_time IS NOT NULL
|
||||
AND day_id IS NULL
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
UPDATE reservations
|
||||
SET end_day_id = (
|
||||
SELECT d.id FROM days d
|
||||
WHERE d.trip_id = reservations.trip_id
|
||||
AND d.date = substr(reservations.reservation_end_time, 1, 10)
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE type IN ('flight','train','car','cruise','bus')
|
||||
AND reservation_end_time IS NOT NULL
|
||||
AND end_day_id IS NULL
|
||||
AND substr(reservations.reservation_end_time, 1, 10) != substr(reservations.reservation_time, 1, 10)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -165,6 +165,7 @@ function createTables(db: Database.Database): void {
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
trip_id INTEGER NOT NULL REFERENCES trips(id) ON DELETE CASCADE,
|
||||
day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||
end_day_id INTEGER REFERENCES days(id) ON DELETE SET NULL,
|
||||
place_id INTEGER REFERENCES places(id) ON DELETE SET NULL,
|
||||
assignment_id INTEGER REFERENCES day_assignments(id) ON DELETE SET NULL,
|
||||
title TEXT NOT NULL,
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
getPlace,
|
||||
updatePlace,
|
||||
deletePlace,
|
||||
deletePlacesMany,
|
||||
importGpx,
|
||||
importMapFile,
|
||||
importGoogleList,
|
||||
importNaverList,
|
||||
searchPlaceImage,
|
||||
type KmlImportOptions,
|
||||
} from '../services/placeService';
|
||||
import { onPlaceCreated, onPlaceUpdated, onPlaceDeleted } from '../services/journeyService';
|
||||
|
||||
@@ -65,9 +67,18 @@ router.post('/import/gpx', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const result = importGpx(tripId, file.buffer);
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importWaypoints = parseBool(req.body.importWaypoints, true);
|
||||
const importRoutes = parseBool(req.body.importRoutes, true);
|
||||
const importTracks = parseBool(req.body.importTracks, true);
|
||||
|
||||
if (!importWaypoints && !importRoutes && !importTracks) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const result = importGpx(tripId, file.buffer, { importWaypoints, importRoutes, importTracks });
|
||||
if (!result) {
|
||||
return res.status(400).json({ error: 'No waypoints found in GPX file' });
|
||||
return res.status(400).json({ error: 'No matching places found in GPX file' });
|
||||
}
|
||||
|
||||
res.status(201).json({ places: result.places, count: result.count, skipped: result.skipped });
|
||||
@@ -86,8 +97,18 @@ router.post('/import/map', authenticate, requireTripAccess, uploadMulter.single(
|
||||
const file = req.file as Express.Multer.File | undefined;
|
||||
if (!file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
|
||||
const parseBool = (v: unknown, defaultVal: boolean) => v === undefined || v === null ? defaultVal : String(v) === 'true';
|
||||
const importPoints = parseBool(req.body.importPoints, true);
|
||||
const importPaths = parseBool(req.body.importPaths, true);
|
||||
|
||||
if (!importPoints && !importPaths) {
|
||||
return res.status(400).json({ error: 'No import types selected' });
|
||||
}
|
||||
|
||||
const kmlOpts: KmlImportOptions = { importPoints, importPaths };
|
||||
|
||||
try {
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname);
|
||||
const result = await importMapFile(tripId, file.buffer, file.originalname, kmlOpts);
|
||||
if (result.summary?.totalPlacemarks === 0) {
|
||||
return res.status(400).json({ error: 'No valid Placemarks found in map file', summary: result.summary });
|
||||
}
|
||||
@@ -201,6 +222,30 @@ router.put('/:id', authenticate, requireTripAccess, validateStringLengths({ name
|
||||
try { onPlaceUpdated(place.id); } catch {}
|
||||
});
|
||||
|
||||
// Bulk delete (must be before /:id)
|
||||
router.post('/bulk-delete', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { ids } = req.body as { ids?: unknown };
|
||||
if (!Array.isArray(ids) || ids.some(v => typeof v !== 'number'))
|
||||
return res.status(400).json({ error: 'ids must be an array of numbers' });
|
||||
|
||||
const idList = ids as number[];
|
||||
if (idList.length === 0) return res.json({ deleted: [], count: 0 });
|
||||
|
||||
for (const id of idList) { try { onPlaceDeleted(id); } catch {} }
|
||||
const deleted = deletePlacesMany(tripId, idList);
|
||||
|
||||
res.json({ deleted, count: deleted.length });
|
||||
const socketId = req.headers['x-socket-id'] as string;
|
||||
for (const id of deleted) {
|
||||
broadcast(tripId, 'place:deleted', { placeId: id }, socketId);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authenticate, requireTripAccess, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
|
||||
@@ -31,7 +31,7 @@ router.get('/', authenticate, (req: Request, res: Response) => {
|
||||
router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId } = req.params;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
@@ -43,7 +43,7 @@ router.post('/', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
const { reservation, accommodationCreated } = createReservation(tripId, {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
});
|
||||
@@ -102,7 +102,7 @@ router.put('/positions', authenticate, (req: Request, res: Response) => {
|
||||
router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
const authReq = req as AuthRequest;
|
||||
const { tripId, id } = req.params;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||
const { title, reservation_time, reservation_end_time, location, confirmation_number, notes, day_id, end_day_id, place_id, assignment_id, status, type, accommodation_id, metadata, create_accommodation, create_budget_entry, endpoints, needs_review } = req.body;
|
||||
|
||||
const trip = verifyTripAccess(tripId, authReq.user.id);
|
||||
if (!trip) return res.status(404).json({ error: 'Trip not found' });
|
||||
@@ -115,7 +115,7 @@ router.put('/:id', authenticate, (req: Request, res: Response) => {
|
||||
|
||||
const { reservation, accommodationChanged } = updateReservation(id, tripId, {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
}, current);
|
||||
|
||||
@@ -264,6 +264,54 @@ function getPlacesForTrips(tripIds: number[]): Place[] {
|
||||
return db.prepare(`SELECT * FROM places WHERE trip_id IN (${placeholders})`).all(...tripIds) as Place[];
|
||||
}
|
||||
|
||||
// ── Country resolution (batch DB cache + sync fallback + background geocoding) ──
|
||||
|
||||
function resolvePlaceCountries(places: Place[]): Map<number, string> {
|
||||
const out = new Map<number, string>();
|
||||
const geoPlaces = places.filter(p => p.lat && p.lng);
|
||||
const placeIds = geoPlaces.map(p => p.id);
|
||||
|
||||
const cached = placeIds.length > 0
|
||||
? (db.prepare(
|
||||
`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${placeIds.map(() => '?').join(',')})`
|
||||
).all(...placeIds) as { place_id: number; country_code: string }[])
|
||||
: [];
|
||||
const cachedMap = new Map(cached.map(r => [r.place_id, r.country_code]));
|
||||
|
||||
const uncachedForGeocode: Place[] = [];
|
||||
for (const p of places) {
|
||||
const fromDb = cachedMap.get(p.id);
|
||||
if (fromDb) { out.set(p.id, fromDb); continue; }
|
||||
const sync = resolveCountryCodeSync(p);
|
||||
if (sync) { out.set(p.id, sync); continue; }
|
||||
if (p.lat && p.lng && !geocodingInFlight.has(p.id)) {
|
||||
uncachedForGeocode.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (uncachedForGeocode.length > 0) {
|
||||
const insertStmt = db.prepare(
|
||||
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.add(p.id);
|
||||
void (async () => {
|
||||
try {
|
||||
for (const place of uncachedForGeocode) {
|
||||
try {
|
||||
const info = await reverseGeocodeRegion(place.lat!, place.lng!);
|
||||
if (info) insertStmt.run(place.id, info.country_code, info.region_code, info.region_name);
|
||||
} catch { /* continue */ }
|
||||
finally { geocodingInFlight.delete(place.id); }
|
||||
}
|
||||
} catch {
|
||||
for (const p of uncachedForGeocode) geocodingInFlight.delete(p.id);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── getStats ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getStats(userId: number) {
|
||||
@@ -279,9 +327,10 @@ export async function getStats(userId: number) {
|
||||
const places = getPlacesForTrips(tripIds);
|
||||
|
||||
interface CountryEntry { code: string; places: { id: number; name: string; lat: number | null; lng: number | null }[]; tripIds: Set<number> }
|
||||
const placeCountries = resolvePlaceCountries(places);
|
||||
const countrySet = new Map<string, CountryEntry>();
|
||||
for (const place of places) {
|
||||
const code = await resolveCountryCode(place);
|
||||
const code = placeCountries.get(place.id);
|
||||
if (code) {
|
||||
if (!countrySet.has(code)) {
|
||||
countrySet.set(code, { code, places: [], tripIds: new Set() });
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface ParsedKmlPlacemark {
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
folderName: string | null;
|
||||
routeGeometry: string | null;
|
||||
}
|
||||
|
||||
export interface KmlPlacemarkNode {
|
||||
@@ -97,6 +98,26 @@ export function sanitizeKmlDescription(value: unknown): string | null {
|
||||
return decoded || null;
|
||||
}
|
||||
|
||||
export function parseKmlLineStringCoordinates(value: unknown): Array<{ lat: number; lng: number; ele: number | null }> | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
|
||||
const points = coordinates
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(coord => {
|
||||
const parts = coord.split(',');
|
||||
const lng = Number.parseFloat(parts[0] ?? '');
|
||||
const lat = Number.parseFloat(parts[1] ?? '');
|
||||
const eleRaw = parts[2] != null ? Number.parseFloat(parts[2]) : NaN;
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return { lat, lng, ele: Number.isFinite(eleRaw) ? eleRaw : null };
|
||||
})
|
||||
.filter((p): p is { lat: number; lng: number; ele: number | null } => p !== null);
|
||||
|
||||
return points.length >= 2 ? points : null;
|
||||
}
|
||||
|
||||
export function parseKmlPointCoordinates(value: unknown): { lat: number; lng: number } | null {
|
||||
const coordinates = asTrimmedString(value);
|
||||
if (!coordinates) return null;
|
||||
@@ -167,13 +188,25 @@ export function extractKmlPlacemarkNodes(kmlRoot: any): KmlPlacemarkNode[] {
|
||||
}
|
||||
|
||||
export function parsePlacemarkNode(node: KmlPlacemarkNode): ParsedKmlPlacemark {
|
||||
const coordinates = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
|
||||
const pointCoords = parseKmlPointCoordinates(node.placemark?.Point?.coordinates);
|
||||
|
||||
let routeGeometry: string | null = null;
|
||||
let pathFirstPt: { lat: number; lng: number } | null = null;
|
||||
if (!pointCoords) {
|
||||
const linePts = parseKmlLineStringCoordinates(node.placemark?.LineString?.coordinates);
|
||||
if (linePts) {
|
||||
pathFirstPt = { lat: linePts[0].lat, lng: linePts[0].lng };
|
||||
const hasAllEle = linePts.every(p => p.ele !== null);
|
||||
routeGeometry = JSON.stringify(linePts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: asTrimmedString(node.placemark?.name),
|
||||
description: sanitizeKmlDescription(node.placemark?.description),
|
||||
lat: coordinates?.lat ?? null,
|
||||
lng: coordinates?.lng ?? null,
|
||||
lat: pointCoords?.lat ?? pathFirstPt?.lat ?? null,
|
||||
lng: pointCoords?.lng ?? pathFirstPt?.lng ?? null,
|
||||
folderName: node.folderName,
|
||||
routeGeometry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -71,6 +71,30 @@ const UA = 'TREK Travel Planner (https://github.com/mauriceboe/TREK)';
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import * as placePhotoCache from './placePhotoCache';
|
||||
|
||||
// ── Concurrency limiter for outbound photo fetches ───────────────────────────
|
||||
// Caps simultaneous Wikimedia/Google photo requests so a bulk import of hundreds
|
||||
// of places cannot monopolise the event loop or trigger external API rate limits.
|
||||
const MAX_CONCURRENT_PHOTO_FETCHES = 5;
|
||||
let photoFetchActive = 0;
|
||||
const photoFetchQueue: Array<() => void> = [];
|
||||
|
||||
function acquirePhotoFetchSlot(): Promise<void> {
|
||||
if (photoFetchActive < MAX_CONCURRENT_PHOTO_FETCHES) {
|
||||
photoFetchActive++;
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise(resolve => photoFetchQueue.push(resolve));
|
||||
}
|
||||
|
||||
function releasePhotoFetchSlot(): void {
|
||||
const next = photoFetchQueue.shift();
|
||||
if (next) {
|
||||
next();
|
||||
} else {
|
||||
photoFetchActive--;
|
||||
}
|
||||
}
|
||||
|
||||
// ── API key retrieval ────────────────────────────────────────────────────────
|
||||
|
||||
export function getMapsKey(userId: number): string | null {
|
||||
@@ -597,6 +621,8 @@ export async function getPlacePhoto(
|
||||
}
|
||||
|
||||
const fetchPromise = (async (): Promise<{ filePath: string; attribution: string | null } | null> => {
|
||||
await acquirePhotoFetchSlot();
|
||||
try {
|
||||
const apiKey = getMapsKey(userId);
|
||||
const isCoordLookup = placeId.startsWith('coords:');
|
||||
|
||||
@@ -676,6 +702,9 @@ export async function getPlacePhoto(
|
||||
}
|
||||
|
||||
return { filePath: cached.filePath, attribution };
|
||||
} finally {
|
||||
releasePhotoFetchSlot();
|
||||
}
|
||||
})();
|
||||
|
||||
placePhotoCache.setInFlight(placeId, fetchPromise);
|
||||
|
||||
@@ -10,11 +10,14 @@ const ERROR_TTL = 5 * 60 * 1000;
|
||||
// In-flight dedup — prevents stampedes when multiple requests hit the same uncached placeId simultaneously
|
||||
const inFlight = new Map<string, Promise<{ filePath: string; attribution: string | null } | null>>();
|
||||
|
||||
function ensureDir(): void {
|
||||
if (!fs.existsSync(GOOGLE_PHOTO_DIR)) {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
// In-memory set of placeIds whose file is confirmed on disk this session.
|
||||
// Avoids a synchronous fs.existsSync() call on every cache hit after the first verification.
|
||||
const knownOnDisk = new Set<string>();
|
||||
|
||||
// Ensure upload dir exists once at startup — avoids sync FS calls inside put() on every write.
|
||||
try {
|
||||
fs.mkdirSync(GOOGLE_PHOTO_DIR, { recursive: true });
|
||||
} catch { /* already exists */ }
|
||||
|
||||
function filePath(placeId: string): string {
|
||||
// Hash to avoid filename collisions — coords:lat:lng pseudo-IDs contain characters that
|
||||
@@ -41,10 +44,15 @@ export function get(placeId: string): CachedPhoto | null {
|
||||
if (!row) return null;
|
||||
|
||||
const fp = filePath(placeId);
|
||||
if (!fs.existsSync(fp)) {
|
||||
// File missing (e.g. volume wiped) — clear row so it refetches
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
|
||||
if (!knownOnDisk.has(placeId)) {
|
||||
// First time this placeId is checked this session — verify the file exists on disk.
|
||||
// (Guards against volume wipes or manual deletion between server restarts.)
|
||||
if (!fs.existsSync(fp)) {
|
||||
db.prepare('DELETE FROM google_place_photo_meta WHERE place_id = ?').run(placeId);
|
||||
return null;
|
||||
}
|
||||
knownOnDisk.add(placeId);
|
||||
}
|
||||
|
||||
return { photoUrl: proxyUrl(placeId), filePath: fp, attribution: row.attribution };
|
||||
@@ -60,19 +68,21 @@ export function getErrored(placeId: string): boolean {
|
||||
}
|
||||
|
||||
export function markError(placeId: string): void {
|
||||
knownOnDisk.delete(placeId);
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, NULL, ?, ?)'
|
||||
).run(placeId, Date.now(), Date.now());
|
||||
}
|
||||
|
||||
export async function put(placeId: string, bytes: Buffer, attribution: string | null): Promise<CachedPhoto> {
|
||||
ensureDir();
|
||||
const fp = filePath(placeId);
|
||||
const tmp = fp + '.tmp';
|
||||
|
||||
await fsPromises.writeFile(tmp, bytes);
|
||||
await fsPromises.rename(tmp, fp);
|
||||
|
||||
knownOnDisk.add(placeId);
|
||||
|
||||
db.prepare(
|
||||
'INSERT OR REPLACE INTO google_place_photo_meta (place_id, attribution, fetched_at, error_at) VALUES (?, ?, ?, NULL)'
|
||||
).run(placeId, attribution, Date.now());
|
||||
@@ -90,6 +100,9 @@ export function setInFlight(placeId: string, promise: Promise<{ filePath: string
|
||||
}
|
||||
|
||||
export function serveFilePath(placeId: string): string | null {
|
||||
if (knownOnDisk.has(placeId)) return filePath(placeId);
|
||||
const fp = filePath(placeId);
|
||||
return fs.existsSync(fp) ? fp : null;
|
||||
if (!fs.existsSync(fp)) return null;
|
||||
knownOnDisk.add(placeId);
|
||||
return fp;
|
||||
}
|
||||
|
||||
@@ -240,6 +240,22 @@ export function deletePlace(tripId: string, placeId: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export function deletePlacesMany(tripId: string, ids: number[]): number[] {
|
||||
if (ids.length === 0) return [];
|
||||
const selectStmt = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?');
|
||||
const deleteStmt = db.prepare('DELETE FROM places WHERE id = ?');
|
||||
const deleted: number[] = [];
|
||||
const run = db.transaction((list: number[]) => {
|
||||
for (const id of list) {
|
||||
if (!selectStmt.get(id, tripId)) continue;
|
||||
deleteStmt.run(id);
|
||||
deleted.push(id);
|
||||
}
|
||||
});
|
||||
run(ids);
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Import GPX
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -326,7 +342,20 @@ function trackInsertedInDedupSet(
|
||||
}
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
export interface GpxImportOptions {
|
||||
importWaypoints?: boolean;
|
||||
importRoutes?: boolean;
|
||||
importTracks?: boolean;
|
||||
}
|
||||
|
||||
export interface KmlImportOptions {
|
||||
importPoints?: boolean;
|
||||
importPaths?: boolean;
|
||||
}
|
||||
|
||||
export function importGpx(tripId: string, fileBuffer: Buffer, opts: GpxImportOptions = {}) {
|
||||
const { importWaypoints = true, importRoutes = true, importTracks = true } = opts;
|
||||
|
||||
const parsed = gpxParser.parse(fileBuffer.toString('utf-8'));
|
||||
const gpx = parsed?.gpx;
|
||||
if (!gpx) return null;
|
||||
@@ -338,41 +367,46 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
const waypoints: WaypointEntry[] = [];
|
||||
|
||||
// 1) Parse <wpt> elements (named waypoints / POIs)
|
||||
for (const wpt of gpx.wpt ?? []) {
|
||||
const lat = num(wpt['@_lat']);
|
||||
const lng = num(wpt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||
if (importWaypoints) {
|
||||
for (const wpt of gpx.wpt ?? []) {
|
||||
const lat = num(wpt['@_lat']);
|
||||
const lng = num(wpt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(wpt.name) || `Waypoint ${waypoints.length + 1}`, description: str(wpt.desc) });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) If no <wpt>, try <rte> route points as individual places
|
||||
if (waypoints.length === 0) {
|
||||
// 2) Parse <rte> routes as polyline-places (one place per route with route_geometry)
|
||||
if (importRoutes) {
|
||||
for (const rte of gpx.rte ?? []) {
|
||||
for (const rtept of rte.rtept ?? []) {
|
||||
const lat = num(rtept['@_lat']);
|
||||
const lng = num(rtept['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
waypoints.push({ lat, lng, name: str(rtept.name) || `Route Point ${waypoints.length + 1}`, description: str(rtept.desc) });
|
||||
}
|
||||
const pts = (rte.rtept ?? [])
|
||||
.map((pt: Record<string, unknown>) => ({ lat: num(pt['@_lat']), lng: num(pt['@_lon']), ele: num(pt['ele']) }))
|
||||
.filter((p: { lat: number | null; lng: number | null; ele: number | null }) => p.lat !== null && p.lng !== null) as Array<{ lat: number; lng: number; ele: number | null }>;
|
||||
if (pts.length === 0) continue;
|
||||
const hasAllEle = pts.every(p => p.ele !== null);
|
||||
const routeGeometry = pts.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: pts[0].lat, lng: pts[0].lng, name: str(rte.name) || 'GPX Route', description: str(rte.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Extract full track geometry from <trk> (always, even if <wpt> were found)
|
||||
for (const trk of gpx.trk ?? []) {
|
||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||
for (const seg of trk.trkseg ?? []) {
|
||||
for (const pt of seg.trkpt ?? []) {
|
||||
const lat = num(pt['@_lat']);
|
||||
const lng = num(pt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||
// 3) Extract full track geometry from <trk>
|
||||
if (importTracks) {
|
||||
for (const trk of gpx.trk ?? []) {
|
||||
const trackPoints: { lat: number; lng: number; ele: number | null }[] = [];
|
||||
for (const seg of trk.trkseg ?? []) {
|
||||
for (const pt of seg.trkpt ?? []) {
|
||||
const lat = num(pt['@_lat']);
|
||||
const lng = num(pt['@_lon']);
|
||||
if (lat === null || lng === null) continue;
|
||||
trackPoints.push({ lat, lng, ele: num(pt.ele) });
|
||||
}
|
||||
}
|
||||
if (trackPoints.length === 0) continue;
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
if (trackPoints.length === 0) continue;
|
||||
const start = trackPoints[0];
|
||||
const hasAllEle = trackPoints.every(p => p.ele !== null);
|
||||
const routeGeometry = trackPoints.map(p => hasAllEle ? [p.lat, p.lng, p.ele] : [p.lat, p.lng]);
|
||||
waypoints.push({ lat: start.lat, lng: start.lng, name: str(trk.name) || 'GPX Track', description: str(trk.desc), routeGeometry: JSON.stringify(routeGeometry) });
|
||||
}
|
||||
|
||||
if (waypoints.length === 0) return null;
|
||||
@@ -401,7 +435,8 @@ export function importGpx(tripId: string, fileBuffer: Buffer) {
|
||||
return { places: created, count: created.length, skipped };
|
||||
}
|
||||
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImportResult {
|
||||
export function importKmlPlaces(tripId: string, fileBuffer: Buffer, opts: KmlImportOptions = {}): PlaceImportResult {
|
||||
const { importPoints = true, importPaths = true } = opts;
|
||||
const decoded = decodeUtf8WithWarning(fileBuffer);
|
||||
|
||||
const validationResult = XMLValidator.validate(decoded.text);
|
||||
@@ -430,19 +465,32 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
let dupCount = 0;
|
||||
|
||||
const insertStmt = db.prepare(`
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking')
|
||||
INSERT INTO places (trip_id, name, description, lat, lng, category_id, transport_mode, route_geometry)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'walking', ?)
|
||||
`);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
let fallbackIndex = 1;
|
||||
for (const node of placemarkNodes) {
|
||||
const parsedPlacemark = parsePlacemarkNode(node);
|
||||
const isPath = parsedPlacemark.routeGeometry !== null;
|
||||
|
||||
// KML geometry support is intentionally limited to <Placemark><Point> coordinates.
|
||||
// Unsupported geometry type (polygon, multi-geometry, no geometry, etc.)
|
||||
if (parsedPlacemark.lat === null || parsedPlacemark.lng === null) {
|
||||
summary.skippedCount += 1;
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: missing Point coordinates.`);
|
||||
summary.errors.push(`Skipped Placemark ${fallbackIndex}: unsupported geometry type.`);
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Type filtering: respect importPoints / importPaths opts
|
||||
if (isPath && !importPaths) {
|
||||
summary.skippedCount += 1;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
if (!isPath && !importPoints) {
|
||||
summary.skippedCount += 1;
|
||||
fallbackIndex += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -466,6 +514,7 @@ export function importKmlPlaces(tripId: string, fileBuffer: Buffer): PlaceImport
|
||||
parsedPlacemark.lat,
|
||||
parsedPlacemark.lng,
|
||||
categoryId,
|
||||
parsedPlacemark.routeGeometry,
|
||||
);
|
||||
|
||||
const place = getPlaceWithTags(Number(result.lastInsertRowid));
|
||||
@@ -514,15 +563,15 @@ export async function unpackKmzToKml(
|
||||
return preferredEntry.buffer();
|
||||
}
|
||||
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer): Promise<PlaceImportResult> {
|
||||
export async function importKmzPlaces(tripId: string, kmzBuffer: Buffer, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
||||
const kmlBuffer = await unpackKmzToKml(kmzBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer);
|
||||
return importKmlPlaces(tripId, kmlBuffer, opts);
|
||||
}
|
||||
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string): Promise<PlaceImportResult> {
|
||||
export async function importMapFile(tripId: string, fileBuffer: Buffer, filename: string, opts: KmlImportOptions = {}): Promise<PlaceImportResult> {
|
||||
const ext = filename.toLowerCase().split('.').pop();
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer);
|
||||
if (ext === 'kmz') return importKmzPlaces(tripId, fileBuffer, opts);
|
||||
if (ext === 'kml') return importKmlPlaces(tripId, fileBuffer, opts);
|
||||
throw new Error(`Unsupported map file format: .${ext}. Please upload a .kml or .kmz file.`);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ interface CreateReservationData {
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
@@ -137,7 +138,7 @@ interface CreateReservationData {
|
||||
export function createReservation(tripId: string | number, data: CreateReservationData): { reservation: any; accommodationCreated: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
@@ -158,11 +159,12 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO reservations (trip_id, day_id, end_day_id, place_id, assignment_id, title, reservation_time, reservation_end_time, location, confirmation_number, notes, status, type, accommodation_id, metadata, needs_review)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
tripId,
|
||||
day_id || null,
|
||||
end_day_id ?? null,
|
||||
place_id || null,
|
||||
assignment_id || null,
|
||||
title,
|
||||
@@ -242,6 +244,7 @@ interface UpdateReservationData {
|
||||
confirmation_number?: string;
|
||||
notes?: string;
|
||||
day_id?: number;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number;
|
||||
assignment_id?: number;
|
||||
status?: string;
|
||||
@@ -256,7 +259,7 @@ interface UpdateReservationData {
|
||||
export function updateReservation(id: string | number, tripId: string | number, data: UpdateReservationData, current: Reservation): { reservation: any; accommodationChanged: boolean } {
|
||||
const {
|
||||
title, reservation_time, reservation_end_time, location,
|
||||
confirmation_number, notes, day_id, place_id, assignment_id,
|
||||
confirmation_number, notes, day_id, end_day_id, place_id, assignment_id,
|
||||
status, type, accommodation_id, metadata, create_accommodation,
|
||||
endpoints, needs_review
|
||||
} = data;
|
||||
@@ -294,6 +297,7 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
confirmation_number = ?,
|
||||
notes = ?,
|
||||
day_id = ?,
|
||||
end_day_id = ?,
|
||||
place_id = ?,
|
||||
assignment_id = ?,
|
||||
status = COALESCE(?, status),
|
||||
@@ -310,6 +314,7 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
confirmation_number !== undefined ? (confirmation_number || null) : current.confirmation_number,
|
||||
notes !== undefined ? (notes || null) : current.notes,
|
||||
day_id !== undefined ? (day_id || null) : current.day_id,
|
||||
end_day_id !== undefined ? (end_day_id ?? null) : (current as any).end_day_id ?? null,
|
||||
place_id !== undefined ? (place_id || null) : current.place_id,
|
||||
assignment_id !== undefined ? (assignment_id || null) : current.assignment_id,
|
||||
status || null,
|
||||
|
||||
@@ -95,6 +95,7 @@ const WMO_DESCRIPTION_EN: Record<number, string> = {
|
||||
// ── Cache management ────────────────────────────────────────────────────
|
||||
|
||||
const weatherCache = new Map<string, { data: WeatherResult; expiresAt: number }>();
|
||||
const inFlight = new Map<string, Promise<WeatherResult>>();
|
||||
const CACHE_MAX_ENTRIES = 1000;
|
||||
const CACHE_PRUNE_TARGET = 500;
|
||||
const CACHE_CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -146,7 +147,7 @@ export function estimateCondition(tempAvg: number, precipMm: number): string {
|
||||
|
||||
// ── getWeather ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function getWeather(
|
||||
async function _getWeatherImpl(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
@@ -281,9 +282,27 @@ export async function getWeather(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string | undefined,
|
||||
lang: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = cacheKey(lat, lng, date);
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
|
||||
// ── getDetailedWeather ──────────────────────────────────────────────────
|
||||
|
||||
export async function getDetailedWeather(
|
||||
async function _getDetailedWeatherImpl(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
@@ -434,6 +453,24 @@ export async function getDetailedWeather(
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getDetailedWeather(
|
||||
lat: string,
|
||||
lng: string,
|
||||
date: string,
|
||||
lang: string,
|
||||
): Promise<WeatherResult> {
|
||||
const ck = `detailed_${cacheKey(lat, lng, date)}`;
|
||||
const cached = getCached(ck);
|
||||
if (cached) return cached;
|
||||
|
||||
const inFlightKey = `${ck}:${lang}`;
|
||||
const existing = inFlight.get(inFlightKey);
|
||||
if (existing) return existing;
|
||||
const promise = _getDetailedWeatherImpl(lat, lng, date, lang);
|
||||
inFlight.set(inFlightKey, promise);
|
||||
try { return await promise; } finally { inFlight.delete(inFlightKey); }
|
||||
}
|
||||
|
||||
// ── ApiError ────────────────────────────────────────────────────────────
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
||||
@@ -157,6 +157,7 @@ export interface Reservation {
|
||||
id: number;
|
||||
trip_id: number;
|
||||
day_id?: number | null;
|
||||
end_day_id?: number | null;
|
||||
place_id?: number | null;
|
||||
assignment_id?: number | null;
|
||||
title: string;
|
||||
|
||||
Vendored
+17773
File diff suppressed because it is too large
Load Diff
Vendored
+3181
File diff suppressed because it is too large
Load Diff
Vendored
BIN
Binary file not shown.
@@ -771,7 +771,7 @@ describe('KML/KMZ Import', () => {
|
||||
expect(res.body.summary.totalPlacemarks).toBe(3);
|
||||
expect(res.body.summary.skippedCount).toBe(1);
|
||||
expect(Array.isArray(res.body.summary.errors)).toBe(true);
|
||||
expect(res.body.summary.errors.join(' ')).toContain('missing Point coordinates');
|
||||
expect(res.body.summary.errors.join(' ')).toContain('unsupported geometry type');
|
||||
|
||||
const nested = res.body.places.find((p: any) => p.name === 'Nested Place');
|
||||
expect(nested).toBeDefined();
|
||||
@@ -862,7 +862,7 @@ describe('GPX Import — edge cases', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.attach('file', emptyGpx, { filename: 'empty.gpx', contentType: 'application/gpx+xml' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/no waypoints/i);
|
||||
expect(res.body.error).toMatch(/no matching places/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -277,19 +277,24 @@ describe('importGpx', () => {
|
||||
expect(result.places[1].name).toBe('London');
|
||||
});
|
||||
|
||||
it('PLACE-SVC-022 — falls back to <rte> route points when no <wpt> elements exist', () => {
|
||||
it('PLACE-SVC-022 — imports <rte> as a single polyline-place with routeGeometry', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const gpx = Buffer.from(`<?xml version="1.0"?><gpx version="1.1">
|
||||
<rte>
|
||||
<name>My Route</name>
|
||||
<rtept lat="48.8566" lon="2.3522"><name>Start</name></rtept>
|
||||
<rtept lat="51.5074" lon="-0.1278"><name>End</name></rtept>
|
||||
</rte>
|
||||
</gpx>`);
|
||||
const result = importGpx(String(trip.id), gpx) as any;
|
||||
expect(result.places).toHaveLength(2);
|
||||
expect(result.places[0].name).toBe('Start');
|
||||
expect(result.places[1].name).toBe('End');
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].name).toBe('My Route');
|
||||
expect(result.places[0].lat).toBe(48.8566);
|
||||
expect(result.places[0].lng).toBe(2.3522);
|
||||
expect(result.places[0].route_geometry).toBeTruthy();
|
||||
const coords = JSON.parse(result.places[0].route_geometry);
|
||||
expect(coords).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('PLACE-SVC-023 — imports <trk> track as a single place with routeGeometry', () => {
|
||||
|
||||
Reference in New Issue
Block a user