mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f46cc8a98e | |||
| 1378c95078 | |||
| bb477645a3 |
@@ -18,7 +18,7 @@ import {
|
||||
type TripAddMemberRequest, type AssignmentReorderRequest,
|
||||
type PackingReorderRequest, type PackingCreateBagRequest, type TodoReorderRequest,
|
||||
type TripCreateRequest, type TripUpdateRequest, type TripCopyRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest,
|
||||
type DayCreateRequest, type DayUpdateRequest, type DayReorderRequest,
|
||||
type PlaceCreateRequest, type PlaceUpdateRequest,
|
||||
type ReservationCreateRequest, type ReservationUpdateRequest,
|
||||
type AccommodationCreateRequest, type AccommodationUpdateRequest,
|
||||
@@ -341,6 +341,7 @@ export const daysApi = {
|
||||
create: (tripId: number | string, data: DayCreateRequest) => apiClient.post(`/trips/${tripId}/days`, data).then(r => r.data),
|
||||
update: (tripId: number | string, dayId: number | string, data: DayUpdateRequest) => apiClient.put(`/trips/${tripId}/days/${dayId}`, data).then(r => r.data),
|
||||
delete: (tripId: number | string, dayId: number | string) => apiClient.delete(`/trips/${tripId}/days/${dayId}`).then(r => r.data),
|
||||
reorder: (tripId: number | string, orderedIds: number[]) => apiClient.put(`/trips/${tripId}/days/reorder`, { orderedIds } satisfies DayReorderRequest).then(r => r.data),
|
||||
}
|
||||
|
||||
export const placesApi = {
|
||||
@@ -557,6 +558,9 @@ export const mapsApi = {
|
||||
placePhoto: (placeId: string, lat?: number, lng?: number, name?: string) => apiClient.get(`/maps/place-photo/${encodeURIComponent(placeId)}`, { params: { lat, lng, name } }).then(r => checkInDev(mapsPlacePhotoResultSchema, r.data, 'maps.placePhoto')),
|
||||
reverse: (lat: number, lng: number, lang?: string) => apiClient.get('/maps/reverse', { params: { lat, lng, lang } }).then(r => checkInDev(mapsReverseResultSchema, r.data, 'maps.reverse')),
|
||||
resolveUrl: (url: string) => apiClient.post('/maps/resolve-url', { url }).then(r => checkInDev(mapsResolveUrlResultSchema, r.data, 'maps.resolveUrl')),
|
||||
// OSM-only POI explore: places of a category within the current map viewport bbox.
|
||||
pois: (category: string, bbox: { south: number; west: number; north: number; east: number }, signal?: AbortSignal) =>
|
||||
apiClient.get('/maps/pois', { params: { category, ...bbox }, signal }).then(r => r.data as { pois: import('../components/Map/poiCategories').Poi[]; source: string; truncated: boolean }),
|
||||
}
|
||||
|
||||
export const airportsApi = {
|
||||
|
||||
@@ -22,8 +22,22 @@ type Defaults = {
|
||||
time_format?: string
|
||||
blur_booking_codes?: boolean
|
||||
map_tile_url?: string
|
||||
map_provider?: string
|
||||
mapbox_access_token?: string
|
||||
mapbox_style?: string
|
||||
mapbox_3d_enabled?: boolean
|
||||
mapbox_quality_mode?: boolean
|
||||
}
|
||||
|
||||
const MAPBOX_STYLE_PRESETS = [
|
||||
{ name: 'Standard', url: 'mapbox://styles/mapbox/standard' },
|
||||
{ name: 'Streets', url: 'mapbox://styles/mapbox/streets-v12' },
|
||||
{ name: 'Outdoors', url: 'mapbox://styles/mapbox/outdoors-v12' },
|
||||
{ name: 'Light', url: 'mapbox://styles/mapbox/light-v11' },
|
||||
{ name: 'Dark', url: 'mapbox://styles/mapbox/dark-v11' },
|
||||
{ name: 'Satellite Streets', url: 'mapbox://styles/mapbox/satellite-streets-v12' },
|
||||
]
|
||||
|
||||
function OptionRow({
|
||||
label,
|
||||
hint,
|
||||
@@ -77,11 +91,15 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const [defaults, setDefaults] = useState<Defaults>({})
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const [mapTileUrl, setMapTileUrl] = useState('')
|
||||
const [mapboxToken, setMapboxToken] = useState('')
|
||||
const [mapboxStyle, setMapboxStyle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDefaultUserSettings().then((data: Defaults) => {
|
||||
setDefaults(data)
|
||||
setMapTileUrl(data.map_tile_url || '')
|
||||
setMapboxToken(data.mapbox_access_token || '')
|
||||
setMapboxStyle(data.mapbox_style || '')
|
||||
setLoaded(true)
|
||||
}).catch(() => setLoaded(true))
|
||||
}, [])
|
||||
@@ -101,6 +119,8 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
const updated = await adminApi.updateDefaultUserSettings({ [key]: null })
|
||||
setDefaults(updated)
|
||||
if (key === 'map_tile_url') setMapTileUrl('')
|
||||
if (key === 'mapbox_access_token') setMapboxToken('')
|
||||
if (key === 'mapbox_style') setMapboxStyle('')
|
||||
toast.success(t('admin.defaultSettings.reset'))
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.error'))
|
||||
@@ -267,6 +287,94 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Map provider / instance-wide Mapbox ───────────────────────── */}
|
||||
<div style={{ borderTop: '1px solid var(--border-primary)', paddingTop: 20, marginTop: 4 }}>
|
||||
<OptionRow
|
||||
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
|
||||
hint={t('admin.defaultSettings.mapProviderHint')}
|
||||
>
|
||||
{([
|
||||
{ value: 'leaflet', label: t('admin.defaultSettings.providerLeaflet') },
|
||||
{ value: 'mapbox-gl', label: t('admin.defaultSettings.providerMapbox') },
|
||||
] as const).map(opt => (
|
||||
<OptionButton
|
||||
key={opt.value}
|
||||
active={(defaults.map_provider || 'leaflet') === opt.value}
|
||||
onClick={() => save({ map_provider: opt.value })}
|
||||
>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
{defaults.map_provider === 'mapbox-gl' && (
|
||||
<div style={{ marginTop: 16, display: 'flex', flexDirection: 'column', gap: 18 }}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxToken')}
|
||||
<ResetButton field="mapbox_access_token" />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxToken}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxToken(e.target.value)}
|
||||
onBlur={() => save({ mapbox_access_token: mapboxToken })}
|
||||
placeholder="pk.eyJ…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('admin.defaultSettings.mapboxTokenHint')}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1.5 text-content-secondary">
|
||||
{t('admin.defaultSettings.mapboxStyle')}
|
||||
<ResetButton field="mapbox_style" />
|
||||
</label>
|
||||
<CustomSelect
|
||||
value={mapboxStyle}
|
||||
onChange={(value: string) => { if (value) { setMapboxStyle(value); save({ mapbox_style: value }) } }}
|
||||
placeholder={t('admin.defaultSettings.mapboxStylePlaceholder')}
|
||||
options={MAPBOX_STYLE_PRESETS.map(p => ({ value: p.url, label: p.name }))}
|
||||
size="sm"
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={mapboxStyle}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setMapboxStyle(e.target.value)}
|
||||
onBlur={() => save({ mapbox_style: mapboxStyle })}
|
||||
placeholder="mapbox://styles/mapbox/standard"
|
||||
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_3d_enabled ?? true) === opt.value} onClick={() => save({ mapbox_3d_enabled: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
|
||||
<OptionRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
|
||||
{([
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
] as const).map(opt => (
|
||||
<OptionButton key={String(opt.value)} active={(defaults.mapbox_quality_mode ?? false) === opt.value} onClick={() => save({ mapbox_quality_mode: opt.value })}>
|
||||
{opt.label}
|
||||
</OptionButton>
|
||||
))}
|
||||
</OptionRow>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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, Polyline, CircleMarker, Circle, useMap } from 'react-leaflet'
|
||||
import { MapContainer, TileLayer, Marker, Polyline, CircleMarker, Circle, useMap, Tooltip } from 'react-leaflet'
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet.markercluster/dist/MarkerCluster.css'
|
||||
@@ -10,6 +10,7 @@ import { mapsApi } from '../../api/client'
|
||||
import { getCategoryIcon, CATEGORY_ICON_MAP } from '../shared/categoryIcons'
|
||||
import ReservationOverlay from './ReservationOverlay'
|
||||
import type { Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -118,6 +119,44 @@ function createPlaceIcon(place, orderNumbers, isSelected) {
|
||||
return fallbackIcon
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI — distinct from the photo-circle
|
||||
// markers of planned places; the colour matches its pill category.
|
||||
const poiIconCache = new Map<string, L.DivIcon>()
|
||||
function createPoiIcon(category: string) {
|
||||
const cached = poiIconCache.get(category)
|
||||
if (cached) return cached
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const icon = L.divIcon({
|
||||
className: '',
|
||||
html: `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid white;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;cursor:pointer;">${svg}</div>`,
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13],
|
||||
tooltipAnchor: [0, -14],
|
||||
})
|
||||
poiIconCache.set(category, icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
// Emits the current viewport bbox on pan/zoom so the POI-explore pill can fetch
|
||||
// OSM places for the visible area.
|
||||
function ViewportController({ onViewportChange }: { onViewportChange?: (b: { south: number; west: number; north: number; east: number }) => void }) {
|
||||
const map = useMap()
|
||||
useEffect(() => {
|
||||
if (!onViewportChange) return
|
||||
const emit = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChange({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
}
|
||||
map.whenReady(emit) // ensure the first bbox is captured once the map is laid out
|
||||
map.on('moveend', emit)
|
||||
map.on('zoomend', emit)
|
||||
return () => { map.off('moveend', emit); map.off('zoomend', emit) }
|
||||
}, [map, onViewportChange])
|
||||
return null
|
||||
}
|
||||
|
||||
interface SelectionControllerProps {
|
||||
places: Place[]
|
||||
selectedPlaceId: number | null
|
||||
@@ -367,7 +406,21 @@ export const MapView = memo(function MapView({
|
||||
showReservationStats = false,
|
||||
visibleConnectionIds = [] as number[],
|
||||
onReservationClick,
|
||||
pois = [] as Poi[],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
}: any) {
|
||||
const poiMarkers = useMemo(() => (pois as Poi[]).map((poi: Poi) => (
|
||||
<Marker
|
||||
key={`poi-${poi.osm_id}`}
|
||||
position={[poi.lat, poi.lng]}
|
||||
icon={createPoiIcon(poi.category)}
|
||||
zIndexOffset={500}
|
||||
eventHandlers={{ click: () => onPoiClick?.(poi) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -10]} opacity={1} className="map-tooltip">{poi.name}</Tooltip>
|
||||
</Marker>
|
||||
)), [pois, onPoiClick])
|
||||
const visibleReservations = useMemo(() => {
|
||||
if (!visibleConnectionIds || visibleConnectionIds.length === 0) return []
|
||||
const set = new Set(visibleConnectionIds)
|
||||
@@ -543,6 +596,7 @@ export const MapView = memo(function MapView({
|
||||
<SelectionController places={places} selectedPlaceId={selectedPlaceId} dayPlaces={dayPlaces} paddingOpts={paddingOpts} />
|
||||
<MapClickHandler onClick={onMapClick} />
|
||||
<MapContextMenuHandler onContextMenu={onMapContextMenu} />
|
||||
<ViewportController onViewportChange={onViewportChange} />
|
||||
<LeafletLocationLayer position={userPosition} mode={trackingMode} />
|
||||
|
||||
<MarkerClusterGroup
|
||||
@@ -583,6 +637,8 @@ export const MapView = memo(function MapView({
|
||||
showStats={showReservationStats}
|
||||
onEndpointClick={onReservationClick}
|
||||
/>
|
||||
|
||||
{poiMarkers}
|
||||
</MapContainer>
|
||||
{isMobile && <LocationButton
|
||||
mode={trackingMode}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ReservationMapboxOverlay } from './reservationsMapbox'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
import type { Place, Reservation } from '../../types'
|
||||
import { POI_CATEGORY_BY_KEY, type Poi } from './poiCategories'
|
||||
|
||||
function categoryIconSvg(iconName: string | null | undefined, size: number): string {
|
||||
const IconComponent = (iconName && CATEGORY_ICON_MAP[iconName]) || CATEGORY_ICON_MAP['MapPin']
|
||||
@@ -49,6 +50,9 @@ interface Props {
|
||||
visibleConnectionIds?: number[]
|
||||
showReservationStats?: boolean
|
||||
onReservationClick?: (reservationId: number) => void
|
||||
pois?: Poi[]
|
||||
onPoiClick?: (poi: Poi) => void
|
||||
onViewportChange?: (bbox: { south: number; west: number; north: number; east: number }) => void
|
||||
}
|
||||
|
||||
function createMarkerElement(place: Place & { category_color?: string; category_icon?: string }, photoUrl: string | null, orderNumbers: number[] | null, selected: boolean): HTMLDivElement {
|
||||
@@ -128,6 +132,17 @@ function createMarkerElement(place: Place & { category_color?: string; category_
|
||||
return wrap
|
||||
}
|
||||
|
||||
// Small coloured pin for an OSM "explore" POI (matches the pill category colour).
|
||||
function createPoiMarkerElement(category: string): HTMLDivElement {
|
||||
const cat = POI_CATEGORY_BY_KEY[category]
|
||||
const color = cat?.color || '#6b7280'
|
||||
const svg = cat ? renderToStaticMarkup(createElement(cat.Icon, { size: 13, color: 'white', strokeWidth: 2.5 })) : ''
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = 'width:26px;height:26px;cursor:pointer;'
|
||||
el.innerHTML = `<div style="width:26px;height:26px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 5px rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;box-sizing:border-box;">${svg}</div>`
|
||||
return el
|
||||
}
|
||||
|
||||
export function MapViewGL({
|
||||
places = [],
|
||||
dayPlaces = [],
|
||||
@@ -149,6 +164,9 @@ export function MapViewGL({
|
||||
visibleConnectionIds = [],
|
||||
showReservationStats = false,
|
||||
onReservationClick,
|
||||
pois = [],
|
||||
onPoiClick,
|
||||
onViewportChange,
|
||||
}: Props) {
|
||||
const mapboxStyle = useSettingsStore(s => s.settings.mapbox_style || 'mapbox://styles/mapbox/standard')
|
||||
const mapboxToken = useSettingsStore(s => s.settings.mapbox_access_token || '')
|
||||
@@ -167,6 +185,11 @@ export function MapViewGL({
|
||||
// options without forcing a full overlay rebuild on every prop change.
|
||||
const onReservationClickRef = useRef(onReservationClick)
|
||||
onReservationClickRef.current = onReservationClick
|
||||
const poiMarkersRef = useRef<mapboxgl.Marker[]>([])
|
||||
const onPoiClickRef = useRef(onPoiClick)
|
||||
onPoiClickRef.current = onPoiClick
|
||||
const onViewportChangeRef = useRef(onViewportChange)
|
||||
onViewportChangeRef.current = onViewportChange
|
||||
const { position: userPosition, mode: trackingMode, error: trackingError, cycleMode: cycleTrackingMode, setMode: setTrackingMode } = useGeolocation()
|
||||
const onClickRefs = useRef({ marker: onMarkerClick, map: onMapClick, context: onMapContextMenu })
|
||||
onClickRefs.current.marker = onMarkerClick
|
||||
@@ -260,6 +283,14 @@ export function MapViewGL({
|
||||
if (t.closest('.mapboxgl-marker')) return // markers handle their own click
|
||||
onClickRefs.current.map?.({ latlng: { lat: e.lngLat.lat, lng: e.lngLat.lng } })
|
||||
})
|
||||
// Emit the viewport bbox (pan/zoom + once on first idle) so the POI-explore
|
||||
// pill can fetch OSM places for the visible area.
|
||||
const emitViewport = () => {
|
||||
const b = map.getBounds()
|
||||
onViewportChangeRef.current?.({ south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() })
|
||||
}
|
||||
map.on('moveend', emitViewport)
|
||||
map.once('idle', emitViewport)
|
||||
// In the mapbox-gl map the right mouse button is reserved for the
|
||||
// built-in rotate/pitch gesture, so we bind the "add place" action
|
||||
// to the middle mouse button (button === 1) instead.
|
||||
@@ -435,6 +466,22 @@ export function MapViewGL({
|
||||
})
|
||||
}, [places, selectedPlaceId, dayOrderMap, photoUrls])
|
||||
|
||||
// Reconcile OSM "explore" POI markers (imperative, kept separate from the
|
||||
// planned-place markers so they don't cluster or get confused with them).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
if (!map || !mapReady) return
|
||||
poiMarkersRef.current.forEach(m => m.remove())
|
||||
poiMarkersRef.current = []
|
||||
for (const poi of (pois as Poi[])) {
|
||||
const el = createPoiMarkerElement(poi.category)
|
||||
el.title = poi.name
|
||||
el.addEventListener('click', (ev) => { ev.stopPropagation(); onPoiClickRef.current?.(poi) })
|
||||
const m = new mapboxgl.Marker({ element: el, anchor: 'center' }).setLngLat([poi.lng, poi.lat]).addTo(map)
|
||||
poiMarkersRef.current.push(m)
|
||||
}
|
||||
}, [pois, mapReady])
|
||||
|
||||
// Update route geojson
|
||||
useEffect(() => {
|
||||
const map = mapRef.current
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { RotateCw } from 'lucide-react'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { Tooltip } from '../shared/Tooltip'
|
||||
import { POI_CATEGORIES } from './poiCategories'
|
||||
|
||||
interface Props {
|
||||
active: Set<string>
|
||||
onToggle: (key: string) => void
|
||||
loadingKeys?: Set<string>
|
||||
/** true when the map moved since the last search → offer "search this area" */
|
||||
moved?: boolean
|
||||
onSearchArea?: () => void
|
||||
}
|
||||
|
||||
// Frosted, icon-only segmented control that floats over the map. Active segments
|
||||
// fill with the category colour (matching their markers); the label shows in a
|
||||
// custom tooltip on hover so the pill stays compact and never needs to scroll.
|
||||
export default function PoiCategoryPill({ active, onToggle, loadingKeys, moved, onSearchArea }: Props) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const frosted: React.CSSProperties = {
|
||||
background: 'var(--sidebar-bg)',
|
||||
backdropFilter: 'blur(20px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(20px) saturate(180%)',
|
||||
boxShadow: 'var(--sidebar-shadow, 0 4px 16px rgba(0,0,0,0.14))',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 2, padding: 4, borderRadius: 999, pointerEvents: 'auto', ...frosted }}>
|
||||
{POI_CATEGORIES.map(cat => {
|
||||
const on = active.has(cat.key)
|
||||
const loading = loadingKeys?.has(cat.key)
|
||||
return (
|
||||
<Tooltip key={cat.key} label={t(cat.labelKey)} placement="bottom">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(cat.key)}
|
||||
aria-pressed={on}
|
||||
aria-label={t(cat.labelKey)}
|
||||
className={on ? '' : 'text-content-muted'}
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 34, height: 34, borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
background: on ? cat.color : 'transparent',
|
||||
color: on ? '#fff' : undefined,
|
||||
transition: 'background 0.14s, color 0.14s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!on) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!on) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
{loading ? (
|
||||
<span
|
||||
className="animate-spin"
|
||||
style={{
|
||||
width: 14, height: 14, borderRadius: 999, display: 'inline-block',
|
||||
border: '2px solid', borderColor: on ? 'rgba(255,255,255,0.45)' : 'var(--border-primary)',
|
||||
borderTopColor: on ? '#fff' : 'var(--text-muted)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<cat.Icon size={16} strokeWidth={2} />
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{moved && active.size > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSearchArea}
|
||||
className="text-content"
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||||
padding: '6px 13px', borderRadius: 999, border: 'none', cursor: 'pointer',
|
||||
fontSize: 12, fontWeight: 600, fontFamily: 'inherit', pointerEvents: 'auto',
|
||||
...frosted,
|
||||
}}
|
||||
>
|
||||
<RotateCw size={13} strokeWidth={2.4} /> {t('poi.searchThisArea')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -158,6 +158,7 @@ interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
to: ReservationEndpoint
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
primaryArc: [number, number][]
|
||||
@@ -353,15 +354,29 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
const out: TransportItem[] = []
|
||||
for (const r of reservations) {
|
||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) continue
|
||||
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
|
||||
// so the arc + markers below are byte-identical to before for it.
|
||||
const waypoints = (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
if (waypoints.length < 2) continue
|
||||
const from = waypoints[0]
|
||||
const to = waypoints[waypoints.length - 1]
|
||||
const type = r.type as TransportType
|
||||
const isGeo = TYPE_META[type].geodesic
|
||||
const arcs = isGeo
|
||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||
// One arc per leg (between consecutive waypoints), concatenated.
|
||||
const arcs: [number, number][][] = []
|
||||
let distanceKm = 0
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
}
|
||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||
const primaryArc = arcs[primaryIdx] ?? []
|
||||
const fallback: [number, number] = primaryArc.length > 0
|
||||
@@ -369,12 +384,15 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
: [(from.lat + to.lat) / 2, (from.lng + to.lng) / 2]
|
||||
|
||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||
const distance = `${Math.round(distanceKm)} km`
|
||||
// Show the full route (FRA → BER → HND) when every waypoint has a code.
|
||||
const mainLabel = waypoints.every(w => w.code)
|
||||
? waypoints.map(w => w.code).join(' → ')
|
||||
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||
|
||||
out.push({ res: r, from, to, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, fallback, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}, [reservations])
|
||||
@@ -416,38 +434,21 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
||||
/>
|
||||
)))}
|
||||
|
||||
{visibleItems.flatMap(item => [
|
||||
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||
<Marker
|
||||
key={`from-${item.res.id}`}
|
||||
position={[item.from.lat, item.from.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
||||
key={`wp-${item.res.id}-${wi}`}
|
||||
position={[wp.lat, wp.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
|
||||
pane={ENDPOINT_PANE}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.from.name}</div>
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{wp.name}</div>
|
||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
<Marker
|
||||
key={`to-${item.res.id}`}
|
||||
position={[item.to.lat, item.to.lng]}
|
||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.to.code || cleanName(item.to.name)) : null)}
|
||||
pane={ENDPOINT_PANE}
|
||||
zIndexOffset={1000}
|
||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
||||
<div style={{ fontWeight: 600, fontSize: 12 }}>{item.to.name}</div>
|
||||
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||
</Tooltip>
|
||||
</Marker>,
|
||||
])}
|
||||
|
||||
{showStats && visibleItems.map(item => item.type === 'flight' && (item.mainLabel || item.subLabel) && labelVisibleIds.has(item.res.id) && (
|
||||
<StatsLabel key={`stats-${item.res.id}`} item={item} />
|
||||
))}
|
||||
</Marker>
|
||||
)))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Utensils, Coffee, Wine, BedDouble, Camera, Landmark, Trees, Ticket, type LucideIcon } from 'lucide-react'
|
||||
|
||||
// The POI categories shown in the map "explore" pill. The `key` is the contract
|
||||
// with the server (CATEGORY_OSM_FILTERS in mapsService.ts) — the OSM tag mapping
|
||||
// lives there; label/icon/colour live here. `color` doubles as the active-pill
|
||||
// fill AND the marker colour, so the pill and the map agree visually.
|
||||
export interface PoiCategory {
|
||||
key: string
|
||||
labelKey: string
|
||||
Icon: LucideIcon
|
||||
color: string
|
||||
}
|
||||
|
||||
export const POI_CATEGORIES: PoiCategory[] = [
|
||||
{ key: 'restaurant', labelKey: 'poi.cat.restaurants', Icon: Utensils, color: '#EF4444' },
|
||||
{ key: 'cafe', labelKey: 'poi.cat.cafes', Icon: Coffee, color: '#B45309' },
|
||||
{ key: 'bar', labelKey: 'poi.cat.bars', Icon: Wine, color: '#A855F7' },
|
||||
{ key: 'hotel', labelKey: 'poi.cat.hotels', Icon: BedDouble, color: '#2563EB' },
|
||||
{ key: 'sights', labelKey: 'poi.cat.sights', Icon: Camera, color: '#EC4899' },
|
||||
{ key: 'museum', labelKey: 'poi.cat.museums', Icon: Landmark, color: '#6366F1' },
|
||||
{ key: 'nature', labelKey: 'poi.cat.nature', Icon: Trees, color: '#16A34A' },
|
||||
{ key: 'activity', labelKey: 'poi.cat.activities', Icon: Ticket, color: '#F59E0B' },
|
||||
]
|
||||
|
||||
export const POI_CATEGORY_BY_KEY: Record<string, PoiCategory> = Object.fromEntries(
|
||||
POI_CATEGORIES.map(c => [c.key, c]),
|
||||
)
|
||||
|
||||
// One POI result from /api/maps/pois (mirror of the server's OverpassPoi).
|
||||
export interface Poi {
|
||||
osm_id: string
|
||||
name: string
|
||||
lat: number
|
||||
lng: number
|
||||
category: string
|
||||
poi_type: string
|
||||
address: string | null
|
||||
website: string | null
|
||||
phone: string | null
|
||||
opening_hours: string | null
|
||||
cuisine: string | null
|
||||
source: 'openstreetmap'
|
||||
}
|
||||
@@ -126,6 +126,7 @@ interface TransportItem {
|
||||
res: Reservation
|
||||
from: ReservationEndpoint
|
||||
to: ReservationEndpoint
|
||||
waypoints: ReservationEndpoint[]
|
||||
type: TransportType
|
||||
arcs: [number, number][][]
|
||||
primaryArc: [number, number][]
|
||||
@@ -137,23 +138,38 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
||||
const out: TransportItem[] = []
|
||||
for (const r of reservations) {
|
||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) continue
|
||||
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
|
||||
const waypoints = (r.endpoints || [])
|
||||
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||
.slice()
|
||||
.sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
if (waypoints.length < 2) continue
|
||||
const from = waypoints[0]
|
||||
const to = waypoints[waypoints.length - 1]
|
||||
const type = r.type as TransportType
|
||||
const isGeo = TYPE_META[type].geodesic
|
||||
const arcs = isGeo
|
||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [number, number][]]
|
||||
// One arc per leg (between consecutive waypoints), concatenated.
|
||||
const arcs: [number, number][][] = []
|
||||
let distanceKm = 0
|
||||
for (let i = 0; i < waypoints.length - 1; i++) {
|
||||
const a = waypoints[i]
|
||||
const b = waypoints[i + 1]
|
||||
const segArcs = isGeo
|
||||
? splitAntimeridian(greatCircle([a.lat, a.lng], [b.lat, b.lng]))
|
||||
: [[[a.lat, a.lng], [b.lat, b.lng]] as [number, number][]]
|
||||
arcs.push(...segArcs)
|
||||
distanceKm += haversineKm([a.lat, a.lng], [b.lat, b.lng])
|
||||
}
|
||||
const primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||
const primaryArc = arcs[primaryIdx] ?? []
|
||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
||||
const distance = `${Math.round(haversineKm([from.lat, from.lng], [to.lat, to.lng]))} km`
|
||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
||||
const distance = `${Math.round(distanceKm)} km`
|
||||
const mainLabel = waypoints.every(w => w.code)
|
||||
? waypoints.map(w => w.code).join(' → ')
|
||||
: (from.code && to.code ? `${from.code} → ${to.code}` : null)
|
||||
const subParts = [duration, distance].filter(Boolean) as string[]
|
||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
||||
out.push({ res: r, from, to, type, arcs, primaryArc, mainLabel, subLabel })
|
||||
out.push({ res: r, from, to, waypoints, type, arcs, primaryArc, mainLabel, subLabel })
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -321,7 +337,7 @@ export class ReservationMapboxOverlay {
|
||||
if (show) {
|
||||
for (const item of visibleItems) {
|
||||
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
||||
for (const ep of [item.from, item.to]) {
|
||||
for (const ep of item.waypoints) {
|
||||
const label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
||||
const el = document.createElement('div')
|
||||
el.innerHTML = endpointMarkerHtml(item.type, label)
|
||||
@@ -342,29 +358,10 @@ export class ReservationMapboxOverlay {
|
||||
}
|
||||
}
|
||||
|
||||
// ── stats label (flights only) ──────────────────────────────────
|
||||
// Stats badge removed — the floating route/duration label on the arc is no
|
||||
// longer drawn; only the connection line and the airport markers remain.
|
||||
this.statsMarkers.forEach(s => s.marker.remove())
|
||||
this.statsMarkers = []
|
||||
if (show && this.opts.showStats) {
|
||||
for (const item of visibleItems) {
|
||||
if (item.type !== 'flight') continue
|
||||
if (!labelVisibleIds.has(item.res.id)) continue
|
||||
if (!item.mainLabel && !item.subLabel) continue
|
||||
const arc = item.primaryArc
|
||||
if (arc.length < 2) continue
|
||||
const mid = arc[Math.floor(arc.length / 2)]!
|
||||
const { html, width, height } = buildStatsHtml(item.mainLabel, item.subLabel)
|
||||
const el = document.createElement('div')
|
||||
el.style.cssText = `width:${width}px;height:${height}px;pointer-events:none;`
|
||||
el.innerHTML = html
|
||||
const marker = new mapboxgl.Marker({ element: el, anchor: 'center' })
|
||||
.setLngLat([mid[1], mid[0]])
|
||||
.addTo(map)
|
||||
this.statsMarkers.push({ marker, arc })
|
||||
}
|
||||
}
|
||||
// Prime rotation once so labels don't flash horizontal on first paint.
|
||||
this.updateStatsRotation()
|
||||
}
|
||||
|
||||
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { mapsApi } from '../../api/client'
|
||||
import type { Poi } from './poiCategories'
|
||||
|
||||
export interface Bbox { south: number; west: number; north: number; east: number }
|
||||
|
||||
/**
|
||||
* State for the map POI "explore" pill. Toggling a category fetches its OSM POIs
|
||||
* for the current viewport; panning/zooming does NOT auto-refetch — it just marks
|
||||
* the results stale (`moved`) so the pill can offer "search this area". This keeps
|
||||
* Overpass load (and visual churn) down.
|
||||
*/
|
||||
export function usePoiExplore() {
|
||||
const [active, setActive] = useState<Set<string>>(() => new Set())
|
||||
const [byCat, setByCat] = useState<Record<string, Poi[]>>({})
|
||||
const [loadingKeys, setLoadingKeys] = useState<Set<string>>(() => new Set())
|
||||
const [moved, setMoved] = useState(false)
|
||||
|
||||
const bboxRef = useRef<Bbox | null>(null)
|
||||
// activeRef always mirrors the latest active set so async callbacks (fetch
|
||||
// completions) can check whether a category is still wanted.
|
||||
const activeRef = useRef(active)
|
||||
activeRef.current = active
|
||||
|
||||
const setLoading = useCallback((key: string, on: boolean) => setLoadingKeys(prev => {
|
||||
const next = new Set(prev)
|
||||
if (on) next.add(key); else next.delete(key)
|
||||
return next
|
||||
}), [])
|
||||
|
||||
const fetchCat = useCallback(async (key: string, bbox: Bbox) => {
|
||||
setLoading(key, true)
|
||||
try {
|
||||
const res = await mapsApi.pois(key, bbox)
|
||||
// Drop the result if the user toggled this category off while the (slow)
|
||||
// Overpass request was in flight — otherwise stale results re-appear.
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: res.pois } : prev))
|
||||
} catch {
|
||||
setByCat(prev => (activeRef.current.has(key) ? { ...prev, [key]: [] } : prev))
|
||||
} finally {
|
||||
setLoading(key, false)
|
||||
}
|
||||
}, [setLoading])
|
||||
|
||||
const onViewportChange = useCallback((bbox: Bbox) => {
|
||||
bboxRef.current = bbox
|
||||
if (activeRef.current.size > 0) setMoved(true)
|
||||
}, [])
|
||||
|
||||
// Single-select: clicking a category switches to it (dropping the previous one
|
||||
// and its markers immediately) and fetches it for the current viewport; clicking
|
||||
// the already-active category turns it off.
|
||||
const toggle = useCallback((key: string) => {
|
||||
const isOnlyActive = activeRef.current.has(key) && activeRef.current.size === 1
|
||||
setMoved(false)
|
||||
if (isOnlyActive) {
|
||||
setActive(new Set())
|
||||
setByCat({})
|
||||
return
|
||||
}
|
||||
setActive(new Set([key]))
|
||||
setByCat({})
|
||||
if (bboxRef.current) fetchCat(key, bboxRef.current)
|
||||
}, [fetchCat])
|
||||
|
||||
const searchArea = useCallback(() => {
|
||||
const bbox = bboxRef.current
|
||||
if (!bbox) return
|
||||
setMoved(false)
|
||||
activeRef.current.forEach(key => fetchCat(key, bbox))
|
||||
}, [fetchCat])
|
||||
|
||||
const pois = useMemo(() => Object.values(byCat).flat(), [byCat])
|
||||
|
||||
return { active, pois, loadingKeys, moved, toggle, searchArea, onViewportChange }
|
||||
}
|
||||
@@ -215,7 +215,13 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
||||
const icon = reservationIconSvg(r.type)
|
||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||
let subtitle = ''
|
||||
if (r.type === 'flight') subtitle = [meta.airline, meta.flight_number, meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : ''].filter(Boolean).join(' · ')
|
||||
if (r.type === 'flight') {
|
||||
// Full route over all waypoints (FRA → BER → HND), falling back to the
|
||||
// flat metadata pair for legacy single-leg flights without endpoints.
|
||||
const stops = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0)).map(e => e.code || e.name)
|
||||
const route = stops.length >= 2 ? stops.join(' → ') : (meta.departure_airport && meta.arrival_airport ? `${meta.departure_airport} → ${meta.arrival_airport}` : '')
|
||||
subtitle = [meta.airline, meta.flight_number, route].filter(Boolean).join(' · ')
|
||||
}
|
||||
else if (r.type === 'train') subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Seat ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||
|
||||
@@ -51,6 +51,8 @@ interface DayPlanSidebarProps {
|
||||
onDayDetail: (day: Day) => void
|
||||
accommodations?: Accommodation[]
|
||||
onReorder: (dayId: number, orderedIds: number[]) => void
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
onUpdateDayTitle: (dayId: number, title: string) => void
|
||||
onRouteCalculated: (route: RouteResult | null) => void
|
||||
onAssignToDay: (placeId: number, dayId: number, position?: number) => void
|
||||
@@ -96,7 +98,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
trip, days, places, categories, assignments,
|
||||
selectedDayId, selectedPlaceId, selectedAssignmentId,
|
||||
onSelectDay, onPlaceClick, onDayDetail, accommodations = [],
|
||||
onReorder, onUpdateDayTitle, onRouteCalculated,
|
||||
onReorder, onReorderDays, onAddDay, onUpdateDayTitle, onRouteCalculated,
|
||||
onAssignToDay, onRemoveAssignment, onEditPlace, onDeletePlace,
|
||||
reservations = [],
|
||||
visibleConnectionIds = [],
|
||||
@@ -171,7 +173,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const [timeConfirm, setTimeConfirm] = useState<{
|
||||
dayId: number; fromId: number; time: string;
|
||||
// For drag & drop reorder
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean;
|
||||
fromType?: string; toType?: string; toId?: number; insertAfter?: boolean; toLegIndex?: number | null;
|
||||
// For arrow reorder
|
||||
reorderIds?: number[];
|
||||
} | null>(null)
|
||||
@@ -376,14 +378,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (legsAbortRef.current) legsAbortRef.current.abort()
|
||||
if (!selectedDayId || !routeShown) { setRouteLegs({}); return }
|
||||
const merged = mergedItemsMap[selectedDayId] || []
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
const runs: { id: number; lat: number; lng: number }[][] = []
|
||||
let cur: { id: number; lat: number; lng: number }[] = []
|
||||
for (const it of merged) {
|
||||
if (it.type === 'place' && it.data.place?.lat && it.data.place?.lng) {
|
||||
cur.push({ id: it.data.id, lat: it.data.place.lat, lng: it.data.place.lng })
|
||||
} else if (it.type === 'transport') {
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
cur = []
|
||||
const r = it.data
|
||||
const from = epLoc(r, 'from'), to = epLoc(r, 'to')
|
||||
if (from || to) {
|
||||
// Located transport: route to its departure point, break the run (the
|
||||
// flight/train itself isn't driven), and let its arrival start the next.
|
||||
if (from) cur.push({ id: r.id, lat: from.lat, lng: from.lng })
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
cur = []
|
||||
if (to) cur.push({ id: r.id, lat: to.lat, lng: to.lng })
|
||||
} else if (cur.length > 0) {
|
||||
// No location: ignore for routing, but attribute the through-leg to the
|
||||
// booking so its distance/duration shows under it (purely cosmetic).
|
||||
cur[cur.length - 1] = { ...cur[cur.length - 1], id: r.id }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cur.length >= 2) runs.push(cur)
|
||||
@@ -471,6 +489,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const assignmentIds: number[] = []
|
||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||
const transportUpdates: { id: number; day_plan_position: number }[] = []
|
||||
// Multi-leg flight legs share a reservation id, so their positions can't live in
|
||||
// the single per-booking slot — collect them per leg, keyed reservationId → legIndex → pos.
|
||||
const legPosUpdates: Record<number, Record<number, number>> = {}
|
||||
|
||||
let placeCount = 0
|
||||
let i = 0
|
||||
@@ -491,7 +512,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
group.forEach((g, idx) => {
|
||||
const pos = base + (idx + 1) / (group.length + 1)
|
||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
||||
else if (g.type === 'transport') transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||
else if (g.type === 'transport') {
|
||||
if (g.data.__leg) ((legPosUpdates[g.data.id] ??= {})[g.data.__leg.index] = pos)
|
||||
else transportUpdates.push({ id: g.data.id, day_plan_position: pos })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -510,6 +534,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
}))
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
// Per-leg positions of multi-leg flights live in metadata.legs[i].day_positions
|
||||
// (the single per-booking slot can't hold one position per leg).
|
||||
const legResIds = Object.keys(legPosUpdates)
|
||||
if (legResIds.length) {
|
||||
for (const ridStr of legResIds) {
|
||||
const rid = Number(ridStr)
|
||||
const r = useTripStore.getState().reservations.find(x => x.id === rid)
|
||||
if (!r) continue
|
||||
let parsed: any = {}
|
||||
try { parsed = typeof r.metadata === 'string' ? JSON.parse(r.metadata || '{}') : (r.metadata || {}) } catch { parsed = {} }
|
||||
if (!Array.isArray(parsed.legs)) continue
|
||||
const legs = parsed.legs.map((leg: any, i: number) => {
|
||||
const pos = legPosUpdates[rid][i]
|
||||
return pos == null ? leg : { ...leg, day_positions: { ...(leg.day_positions || {}), [dayId]: pos } }
|
||||
})
|
||||
// Send metadata as an OBJECT (like the form does) — passing a JSON string
|
||||
// here double-encodes it on the server, which wipes metadata.legs on read
|
||||
// and collapses the flight back to a single span.
|
||||
const newMeta = { ...parsed, legs }
|
||||
useTripStore.setState(state => ({ reservations: state.reservations.map(x => (x.id === rid ? { ...x, metadata: newMeta } : x)) }))
|
||||
await tripActions.updateReservation(tripId, rid, { metadata: newMeta })
|
||||
}
|
||||
setTransportPosVersion(v => v + 1)
|
||||
}
|
||||
if (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||
if (transportUpdates.length) {
|
||||
onRouteRefresh?.()
|
||||
@@ -528,8 +576,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false) => {
|
||||
const handleMergedDrop = async (dayId, fromType, fromId, toType, toId, insertAfter = false, toLegIndex = null) => {
|
||||
const m = getMergedItems(dayId)
|
||||
// Multi-leg flights expose one item per leg sharing the same reservation id;
|
||||
// disambiguate the drop target by leg index so you can drop BETWEEN legs.
|
||||
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||
|
||||
// Check if a timed place is being moved → would it break chronological order?
|
||||
if (fromType === 'place') {
|
||||
@@ -537,11 +588,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||
if (fromItem && fromMinutes !== null) {
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
if (fromIdx !== -1 && toIdx !== -1) {
|
||||
const simulated = [...m]
|
||||
const [moved] = simulated.splice(fromIdx, 1)
|
||||
let insertIdx = simulated.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
let insertIdx = simulated.findIndex(matchTo)
|
||||
if (insertIdx === -1) insertIdx = simulated.length
|
||||
if (insertAfter) insertIdx += 1
|
||||
simulated.splice(insertIdx, 0, moved)
|
||||
@@ -558,7 +609,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
if (!isChronological) {
|
||||
const placeTime = fromItem.data.place.place_time
|
||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, time: timeStr })
|
||||
setTimeConfirm({ dayId, fromType, fromId, toType, toId, insertAfter, toLegIndex, time: timeStr })
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
}
|
||||
@@ -568,7 +619,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
// Build new order: remove the dragged item, insert at target position
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||
return
|
||||
@@ -576,7 +627,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
let adjustedTo = newOrder.findIndex(matchTo)
|
||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
@@ -590,7 +641,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
const confirmTimeRemoval = async () => {
|
||||
if (!timeConfirm) return
|
||||
const saved = { ...timeConfirm }
|
||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||
setTimeConfirm(null)
|
||||
|
||||
// Remove time from assignment
|
||||
@@ -633,13 +684,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
// Drag & drop reorder
|
||||
if (fromType && toType) {
|
||||
const matchTo = (i: any) => i.type === toType && i.data.id === toId && (toLegIndex == null || i.data?.__leg?.index === toLegIndex)
|
||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
||||
const toIdx = m.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
const toIdx = m.findIndex(matchTo)
|
||||
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||
|
||||
const newOrder = [...m]
|
||||
const [moved] = newOrder.splice(fromIdx, 1)
|
||||
let adjustedTo = newOrder.findIndex(i => i.type === toType && i.data.id === toId)
|
||||
let adjustedTo = newOrder.findIndex(matchTo)
|
||||
if (adjustedTo === -1) adjustedTo = newOrder.length
|
||||
if (insertAfter) adjustedTo += 1
|
||||
newOrder.splice(adjustedTo, 0, moved)
|
||||
@@ -697,11 +749,13 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
|
||||
const prevIds = da.map(a => a.id)
|
||||
|
||||
// Separate locked (stay at their index) and unlocked assignments
|
||||
// Separate fixed (stay at their index) and movable assignments. A place is
|
||||
// fixed if it's locked OR has a set time — timed places are anchored by their
|
||||
// time, so the optimizer must not reshuffle them.
|
||||
const locked = new Map() // index -> assignment
|
||||
const unlocked = []
|
||||
da.forEach((a, i) => {
|
||||
if (lockedIds.has(a.id)) locked.set(i, a)
|
||||
if (lockedIds.has(a.id) || a.place?.place_time) locked.set(i, a)
|
||||
else unlocked.push(a)
|
||||
})
|
||||
|
||||
@@ -814,6 +868,8 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
||||
onDayDetail,
|
||||
accommodations,
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onUpdateDayTitle,
|
||||
onRouteCalculated,
|
||||
onAssignToDay,
|
||||
@@ -958,6 +1014,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
onDayDetail,
|
||||
accommodations,
|
||||
onReorder,
|
||||
onReorderDays,
|
||||
onAddDay,
|
||||
onUpdateDayTitle,
|
||||
onRouteCalculated,
|
||||
onAssignToDay,
|
||||
@@ -1109,6 +1167,9 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
undoHover={undoHover}
|
||||
setUndoHover={setUndoHover}
|
||||
lastActionLabel={lastActionLabel}
|
||||
canEditDays={canEditDays}
|
||||
onReorderDays={onReorderDays}
|
||||
onAddDay={onAddDay}
|
||||
/>
|
||||
|
||||
{/* Tagesliste */}
|
||||
@@ -1311,6 +1372,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||
const transportId = Number(parts[0])
|
||||
const legPart = parts.find(p => /^leg\d+$/.test(p))
|
||||
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
|
||||
|
||||
if (placeId) {
|
||||
onAssignToDay?.(parseInt(placeId), day.id)
|
||||
@@ -1318,15 +1381,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
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)
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', transportId, isAfter, toLegIndex)
|
||||
} 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) {
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter)
|
||||
handleMergedDrop(day.id, 'place', Number(assignmentId), 'transport', transportId, isAfter, toLegIndex)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', transportId, isAfter, toLegIndex)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
return
|
||||
@@ -1372,9 +1435,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
) : (
|
||||
merged.map((item, idx) => {
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const legSuffix = item.data?.__leg ? `-leg${item.data.__leg.index}` : ''
|
||||
const itemKey = item.type === 'transport' ? `transport-${item.data.id}${legSuffix}-${day.id}` : (item.type === 'place' ? `place-${item.data.id}` : `note-${item.data.id}`)
|
||||
const showDropLine = (!!draggingId || !!dropTargetKey) && dropTargetKey === itemKey
|
||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}-${day.id}`
|
||||
const showDropLineAfter = item.type === 'transport' && (!!draggingId || !!dropTargetKey) && dropTargetKey === `transport-after-${item.data.id}${legSuffix}-${day.id}`
|
||||
|
||||
if (item.type === 'place') {
|
||||
const assignment = item.data
|
||||
@@ -1722,7 +1786,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
|
||||
// Subtitle aus Metadaten zusammensetzen
|
||||
let subtitle = ''
|
||||
if (res.type === 'flight') {
|
||||
if (res.__leg) {
|
||||
// One leg of a multi-leg flight — show this segment's own route.
|
||||
const parts = [res.__leg.airline, res.__leg.flight_number].filter(Boolean)
|
||||
if (res.__leg.from || res.__leg.to)
|
||||
parts.push([res.__leg.from, res.__leg.to].filter(Boolean).join(' → '))
|
||||
subtitle = parts.join(' · ')
|
||||
} else if (res.type === 'flight') {
|
||||
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||
if (meta.departure_airport || meta.arrival_airport)
|
||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||
@@ -1731,28 +1801,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
// Multi-day span phase
|
||||
const spanLabel = getSpanLabel(res, spanPhase)
|
||||
// Multi-day span phase (single-leg / non-flight only — a
|
||||
// multi-leg flight is shown as one row per leg, see below).
|
||||
const spanLabel = res.__leg ? null : getSpanLabel(res, spanPhase)
|
||||
const displayTime = getDisplayTimeForDay(res, day.id)
|
||||
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
||||
|
||||
return (
|
||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
||||
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
if (!canEditDays) return
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
||||
else onEditReservation?.(res)
|
||||
const target = reservations.find(x => x.id === res.id) ?? res
|
||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||
else onEditReservation?.(target)
|
||||
}}
|
||||
onDragOver={e => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
||||
const key = inBottom ? `transport-after-${res.id}-${day.id}` : `transport-${res.id}-${day.id}`
|
||||
const ls = res.__leg ? `-leg${res.__leg.index}` : ''
|
||||
const key = inBottom ? `transport-after-${res.id}${ls}-${day.id}` : `transport-${res.id}${ls}-${day.id}`
|
||||
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||
}}
|
||||
draggable={canEditDays && spanPhase !== 'middle'}
|
||||
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||
onDragStart={e => {
|
||||
if (!canEditDays || spanPhase === 'middle') { e.preventDefault(); return }
|
||||
if (!canEditDays || spanPhase === 'middle' || res.__leg) { e.preventDefault(); return }
|
||||
// setData is required for the drag to start reliably (Firefox) and
|
||||
// matches how place/note items initiate their drag.
|
||||
e.dataTransfer.setData('reservationId', String(res.id))
|
||||
@@ -1773,15 +1847,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
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)
|
||||
handleMergedDrop(day.id, 'transport', Number(fromReservationId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||
} 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) {
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter)
|
||||
handleMergedDrop(day.id, 'place', Number(fromAssignmentId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||
} else if (noteId && fromDayId !== day.id) {
|
||||
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||
} else if (noteId) {
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter)
|
||||
handleMergedDrop(day.id, 'note', Number(noteId), 'transport', res.id, insertAfter, res.__leg?.index ?? null)
|
||||
}
|
||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||
}}
|
||||
@@ -1801,7 +1875,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
||||
}}
|
||||
>
|
||||
{canEditDays && spanPhase !== 'middle' && (
|
||||
{canEditDays && spanPhase !== 'middle' && !res.__leg && (
|
||||
<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>
|
||||
@@ -1846,7 +1920,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
||||
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||
const active = visibleConnectionIds.includes(res.id)
|
||||
return (
|
||||
<button
|
||||
@@ -1870,6 +1944,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
{routeLegs[res.id] && <RouteConnector seg={routeLegs[res.id]} profile={routeProfile} />}
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2 } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { ChevronsDownUp, ChevronsUpDown, FileDown, Undo2, ArrowUpDown } from 'lucide-react'
|
||||
import { downloadTripPDF } from '../PDF/TripPDF'
|
||||
import { DayReorderPopup } from './DayReorderPopup'
|
||||
import Tooltip from '../shared/Tooltip'
|
||||
import { useToast } from '../shared/Toast'
|
||||
import type { Trip, Day, Place, Category, AssignmentsMap, Reservation, DayNote } from '../../types'
|
||||
@@ -27,13 +29,18 @@ interface DayPlanSidebarToolbarProps {
|
||||
undoHover: boolean
|
||||
setUndoHover: (v: boolean) => void
|
||||
lastActionLabel: string | null
|
||||
canEditDays?: boolean
|
||||
onReorderDays?: (orderedIds: number[]) => void
|
||||
onAddDay?: (position?: number) => void
|
||||
}
|
||||
|
||||
export function DayPlanSidebarToolbar({
|
||||
tripId, trip, days, places, categories, assignments, reservations, dayNotes,
|
||||
t, locale, toast, pdfHover, setPdfHover, icsHover, setIcsHover,
|
||||
expandedDays, setExpandedDays, onUndo, canUndo, undoHover, setUndoHover, lastActionLabel,
|
||||
canEditDays, onReorderDays, onAddDay,
|
||||
}: DayPlanSidebarToolbarProps) {
|
||||
const [reorderOpen, setReorderOpen] = useState(false)
|
||||
return (
|
||||
<div className="border-b border-edge-faint" style={{ padding: '12px 16px', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8 }}>
|
||||
@@ -197,6 +204,39 @@ export function DayPlanSidebarToolbar({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{canEditDays && onReorderDays && onAddDay && days.length > 0 && (
|
||||
<div style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<Tooltip label={t('dayplan.reorderDays')} placement="bottom">
|
||||
<button
|
||||
onClick={() => setReorderOpen(v => !v)}
|
||||
aria-label={t('dayplan.reorderDays')}
|
||||
aria-pressed={reorderOpen}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 30, height: 30, borderRadius: 8,
|
||||
border: '1px solid var(--border-primary)',
|
||||
background: reorderOpen ? 'var(--bg-hover)' : 'none',
|
||||
color: 'var(--text-primary)', cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||
transition: 'color 0.15s, border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!reorderOpen) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!reorderOpen) e.currentTarget.style.background = 'transparent' }}
|
||||
>
|
||||
<ArrowUpDown size={14} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{reorderOpen && (
|
||||
<DayReorderPopup
|
||||
days={days}
|
||||
t={t}
|
||||
locale={locale}
|
||||
onReorder={onReorderDays}
|
||||
onAddDay={() => onAddDay()}
|
||||
onClose={() => setReorderOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, ArrowUp, ArrowDown, Plus } from 'lucide-react'
|
||||
import type { Day } from '../../types'
|
||||
|
||||
interface DayReorderPopupProps {
|
||||
days: Day[]
|
||||
t: (key: string, params?: Record<string, any>) => string
|
||||
locale: string
|
||||
onReorder: (orderedIds: number[]) => void
|
||||
onAddDay: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact panel for moving whole days around: drag a row by its grip or use the
|
||||
* up/down arrows, and add a day at the end. Day headers stay untouched — this is
|
||||
* the single surface for ordering. Reorders are applied optimistically by the
|
||||
* store, so the list reflects each move immediately.
|
||||
*/
|
||||
export function DayReorderPopup({ days, t, locale, onReorder, onAddDay, onClose }: DayReorderPopupProps) {
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null)
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null)
|
||||
|
||||
const ordered = [...days].sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0))
|
||||
|
||||
const label = (day: Day, index: number) => {
|
||||
if (day.title) return day.title
|
||||
if (day.date) {
|
||||
const d = new Date(day.date + 'T00:00:00')
|
||||
return d.toLocaleDateString(locale, { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
return t('dayplan.dayN', { n: index + 1 })
|
||||
}
|
||||
|
||||
const move = (from: number, to: number) => {
|
||||
if (to < 0 || to >= ordered.length || from === to) return
|
||||
const ids = ordered.map(d => d.id)
|
||||
const [moved] = ids.splice(from, 1)
|
||||
ids.splice(to, 0, moved)
|
||||
onReorder(ids)
|
||||
}
|
||||
|
||||
const cellBtn = {
|
||||
display: 'grid', placeItems: 'center', width: 26, height: 26,
|
||||
border: '1px solid var(--border-faint)', borderRadius: 7,
|
||||
background: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0,
|
||||
} as const
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* outside-click catcher */}
|
||||
<div onClick={onClose} style={{ position: 'fixed', inset: 0, zIndex: 250 }} />
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute', top: 'calc(100% + 6px)', right: 0, zIndex: 251,
|
||||
width: 290, maxHeight: 360, display: 'flex', flexDirection: 'column',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary)',
|
||||
border: '1px solid var(--border-faint)', borderRadius: 12,
|
||||
boxShadow: '0 12px 32px rgba(0,0,0,0.18)', overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, padding: '11px 12px 8px' }}>
|
||||
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{t('dayplan.reorderTitle')}</span>
|
||||
<button
|
||||
onClick={onAddDay}
|
||||
className="bg-accent text-accent-text"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 9px',
|
||||
borderRadius: 7, border: 'none', fontSize: 11, fontWeight: 500,
|
||||
cursor: 'pointer', fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Plus size={13} strokeWidth={2} />
|
||||
{t('dayplan.addDay')}
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ padding: '0 12px 8px', fontSize: 10.5, color: 'var(--text-faint)', lineHeight: 1.35 }}>
|
||||
{t('dayplan.reorderHint')}
|
||||
</div>
|
||||
|
||||
<div className="scroll-container" style={{ overflowY: 'auto', padding: '0 8px 8px', minHeight: 0 }}>
|
||||
{ordered.map((day, index) => (
|
||||
<div
|
||||
key={day.id}
|
||||
draggable
|
||||
onDragStart={() => setDragIndex(index)}
|
||||
onDragEnd={() => { setDragIndex(null); setOverIndex(null) }}
|
||||
onDragOver={e => { e.preventDefault(); if (overIndex !== index) setOverIndex(index) }}
|
||||
onDrop={e => {
|
||||
e.preventDefault()
|
||||
if (dragIndex !== null && dragIndex !== index) move(dragIndex, index)
|
||||
setDragIndex(null); setOverIndex(null)
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '6px 8px',
|
||||
borderRadius: 8, marginTop: 2,
|
||||
background: overIndex === index && dragIndex !== null && dragIndex !== index ? 'var(--bg-hover)' : 'transparent',
|
||||
opacity: dragIndex === index ? 0.5 : 1,
|
||||
outline: overIndex === index && dragIndex !== null && dragIndex !== index ? '2px dashed var(--border-primary)' : 'none',
|
||||
outlineOffset: -2,
|
||||
}}
|
||||
>
|
||||
<GripVertical size={14} strokeWidth={1.8} style={{ cursor: 'grab', color: 'var(--text-faint)', flexShrink: 0 }} />
|
||||
<span style={{
|
||||
flexShrink: 0, width: 22, height: 22, borderRadius: '50%',
|
||||
background: 'var(--bg-hover)', color: 'var(--text-muted)',
|
||||
display: 'grid', placeItems: 'center', fontSize: 10.5, fontWeight: 700,
|
||||
}}>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span style={{ flex: 1, minWidth: 0, fontSize: 12.5, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{label(day, index)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => move(index, index - 1)}
|
||||
disabled={index === 0}
|
||||
aria-label={t('dayplan.moveUp')}
|
||||
style={{ ...cellBtn, opacity: index === 0 ? 0.35 : 1, cursor: index === 0 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowUp size={13} strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => move(index, index + 1)}
|
||||
disabled={index === ordered.length - 1}
|
||||
aria-label={t('dayplan.moveDown')}
|
||||
style={{ ...cellBtn, opacity: index === ordered.length - 1 ? 0.35 : 1, cursor: index === ordered.length - 1 ? 'default' : 'pointer' }}
|
||||
>
|
||||
<ArrowDown size={13} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ interface PlaceFormModalProps {
|
||||
onClose: () => void
|
||||
onSave: (data: PlaceSubmitData, files?: File[]) => Promise<void> | void
|
||||
place: Place | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string } | null
|
||||
prefillCoords?: { lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null
|
||||
tripId: number
|
||||
categories: Category[]
|
||||
onCategoryCreated: (category: { name: string; color?: string; icon?: string }) => Promise<Category> | undefined
|
||||
@@ -86,6 +86,9 @@ function usePlaceFormModal(props: PlaceFormModalProps) {
|
||||
lng: String(prefillCoords.lng),
|
||||
name: prefillCoords.name || '',
|
||||
address: prefillCoords.address || '',
|
||||
website: prefillCoords.website || '',
|
||||
phone: prefillCoords.phone || '',
|
||||
osm_id: prefillCoords.osm_id,
|
||||
})
|
||||
} else {
|
||||
setForm(DEFAULT_FORM)
|
||||
|
||||
@@ -271,19 +271,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
||||
)}
|
||||
|
||||
{(() => {
|
||||
const eps = r.endpoints || []
|
||||
const from = eps.find(e => e.role === 'from')
|
||||
const to = eps.find(e => e.role === 'to')
|
||||
if (!from || !to) return null
|
||||
// Full route over all waypoints (from · stops · to), ordered by sequence.
|
||||
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
if (eps.length < 2) return null
|
||||
return (
|
||||
<div className="bg-surface-tertiary text-content" style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||
padding: '8px 12px', borderRadius: 10,
|
||||
fontSize: 12.5,
|
||||
fontSize: 12.5, flexWrap: 'wrap',
|
||||
}}>
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{from.name}</span>
|
||||
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
||||
{eps.map((ep, i) => (
|
||||
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{i > 0 && <TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />}
|
||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ep.name}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2 } from 'lucide-react'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
@@ -14,6 +14,7 @@ import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
@@ -23,7 +24,7 @@ interface EndpointPick {
|
||||
location?: LocationPoint
|
||||
}
|
||||
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to', sequence: number, date: string | null, time: string | null): Omit<ReservationEndpoint, 'id' | 'reservation_id'> {
|
||||
function endpointFromAirport(a: Airport, role: 'from' | 'to' | 'stop', 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,
|
||||
@@ -63,6 +64,24 @@ function locationFromEndpoint(e: ReservationEndpoint | undefined): LocationPoint
|
||||
return { name: e.name, lat: e.lat, lng: e.lng, address: null }
|
||||
}
|
||||
|
||||
// ── Multi-leg flight waypoints ─────────────────────────────────────────────
|
||||
// A flight is an ordered list of airports. The origin has only a departure, the
|
||||
// destination only an arrival, and each intermediate stop has both — plus the
|
||||
// airline/flight number of the flight LEAVING it. N waypoints = N-1 legs. A
|
||||
// single-leg flight is just two waypoints, so it persists exactly as before.
|
||||
interface WaypointForm {
|
||||
airport: Airport | null
|
||||
arrDayId: string | number
|
||||
arrTime: string
|
||||
depDayId: string | number
|
||||
depTime: string
|
||||
airline: string
|
||||
flight_number: string
|
||||
}
|
||||
function emptyWaypoint(dayId: string | number = ''): WaypointForm {
|
||||
return { airport: null, arrDayId: dayId, arrTime: '', depDayId: dayId, depTime: '', airline: '', flight_number: '' }
|
||||
}
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
||||
@@ -122,6 +141,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
const [toPick, setToPick] = useState<EndpointPick>({})
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const [waypoints, setWaypoints] = useState<WaypointForm[]>([emptyWaypoint(), emptyWaypoint()])
|
||||
const [uploadingFile, setUploadingFile] = useState(false)
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
||||
@@ -159,8 +180,38 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
||||
const orderedEps = orderedEndpoints(reservation)
|
||||
const metaLegs: any[] = Array.isArray(meta.legs) ? meta.legs : []
|
||||
let wps: WaypointForm[]
|
||||
if (orderedEps.length >= 2) {
|
||||
wps = orderedEps.map((ep, i) => {
|
||||
const legInto = metaLegs[i - 1] // leg arriving INTO waypoint i
|
||||
const legOut = metaLegs[i] // leg departing FROM waypoint i
|
||||
const isFirst = i === 0
|
||||
const isLast = i === orderedEps.length - 1
|
||||
return {
|
||||
airport: airportFromEndpoint(ep),
|
||||
arrDayId: legInto?.arr_day_id ?? (isLast ? (reservation.end_day_id ?? '') : ''),
|
||||
arrTime: legInto?.arr_time ?? (!isFirst ? (ep.local_time ?? '') : ''),
|
||||
depDayId: legOut?.dep_day_id ?? (isFirst ? (reservation.day_id ?? '') : ''),
|
||||
depTime: legOut?.dep_time ?? (!isLast ? (ep.local_time ?? '') : ''),
|
||||
airline: legOut?.airline ?? (isFirst ? (meta.airline ?? '') : ''),
|
||||
flight_number: legOut?.flight_number ?? (isFirst ? (meta.flight_number ?? '') : ''),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
// Legacy flight with no (or partial) endpoints — seed two waypoints.
|
||||
const dep = emptyWaypoint(reservation.day_id ?? '')
|
||||
dep.airport = airportFromEndpoint(from)
|
||||
dep.depTime = splitReservationDateTime(reservation.reservation_time).time ?? ''
|
||||
dep.airline = meta.airline ?? ''
|
||||
dep.flight_number = meta.flight_number ?? ''
|
||||
const arr = emptyWaypoint(reservation.end_day_id ?? reservation.day_id ?? '')
|
||||
arr.airport = airportFromEndpoint(to)
|
||||
arr.arrTime = splitReservationDateTime(reservation.reservation_end_time).time ?? ''
|
||||
wps = [dep, arr]
|
||||
}
|
||||
setWaypoints(wps)
|
||||
} else {
|
||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||
setToPick({ location: locationFromEndpoint(to) || undefined })
|
||||
@@ -169,6 +220,7 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||
setFromPick({})
|
||||
setToPick({})
|
||||
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||
}
|
||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
||||
|
||||
@@ -187,17 +239,45 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
return day?.date ? `${day.date}T${time}` : time
|
||||
}
|
||||
|
||||
const metadata: Record<string, string> = {}
|
||||
const dayDate = (id: string | number): string | null => days.find(d => d.id === Number(id))?.date ?? null
|
||||
// Flight route as an ordered list of airports (origin .. stops .. destination).
|
||||
const flightWps = form.type === 'flight' ? waypoints.filter(w => w.airport) : []
|
||||
const firstWp = flightWps[0]
|
||||
const lastWp = flightWps[flightWps.length - 1]
|
||||
// Per-leg day-plan positions are owned by the day planner, not this form — keep
|
||||
// them when re-saving so editing a flight doesn't reset where its legs sit.
|
||||
const origLegs: any[] = reservation ? (parseReservationMetadata(reservation).legs || []) : []
|
||||
|
||||
const metadata: Record<string, any> = {}
|
||||
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
|
||||
// Top-level keys mirror the first/last leg so legacy readers keep working.
|
||||
if (firstWp?.airline) metadata.airline = firstWp.airline
|
||||
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
|
||||
if (firstWp?.airport) {
|
||||
metadata.departure_airport = firstWp.airport.iata
|
||||
metadata.departure_timezone = firstWp.airport.tz
|
||||
}
|
||||
if (toPick.airport) {
|
||||
metadata.arrival_airport = toPick.airport.iata
|
||||
metadata.arrival_timezone = toPick.airport.tz
|
||||
if (lastWp?.airport) {
|
||||
metadata.arrival_airport = lastWp.airport.iata
|
||||
metadata.arrival_timezone = lastWp.airport.tz
|
||||
}
|
||||
// Per-leg detail only for true multi-leg flights — a single-leg flight
|
||||
// keeps the exact same (flat) metadata it had before this feature.
|
||||
if (flightWps.length > 2) {
|
||||
metadata.legs = flightWps.slice(0, -1).map((w, i) => {
|
||||
const next = flightWps[i + 1]
|
||||
return {
|
||||
from: w.airport!.iata,
|
||||
to: next.airport!.iata,
|
||||
...(w.airline ? { airline: w.airline } : {}),
|
||||
...(w.flight_number ? { flight_number: w.flight_number } : {}),
|
||||
dep_day_id: w.depDayId ? Number(w.depDayId) : null,
|
||||
dep_time: w.depTime || null,
|
||||
arr_day_id: next.arrDayId ? Number(next.arrDayId) : null,
|
||||
arr_time: next.arrTime || null,
|
||||
...(origLegs[i]?.day_positions ? { day_positions: origLegs[i].day_positions } : {}),
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (form.type === 'train') {
|
||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
||||
@@ -213,21 +293,35 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
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))
|
||||
flightWps.forEach((w, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === flightWps.length - 1
|
||||
const role: 'from' | 'to' | 'stop' = isFirst ? 'from' : isLast ? 'to' : 'stop'
|
||||
const dId = isLast ? w.arrDayId : w.depDayId
|
||||
const time = isLast ? w.arrTime : w.depTime
|
||||
endpoints.push(endpointFromAirport(w.airport!, role, i, dayDate(dId), 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))
|
||||
}
|
||||
|
||||
// Flights derive their span from the first/last waypoint; other transports
|
||||
// keep using the single departure/arrival form fields unchanged.
|
||||
const flightDepDay = firstWp && firstWp.depDayId ? Number(firstWp.depDayId) : null
|
||||
const flightArrDay = lastWp && lastWp.arrDayId ? Number(lastWp.arrDayId) : 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),
|
||||
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||
reservation_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightDepDay), firstWp?.depTime || '')
|
||||
: buildTime(startDay, form.departure_time),
|
||||
reservation_end_time: form.type === 'flight'
|
||||
? buildTime(days.find(d => d.id === flightArrDay), lastWp?.arrTime || '')
|
||||
: buildTime(endDay ?? startDay, form.arrival_time),
|
||||
location: null,
|
||||
confirmation_number: form.confirmation_number || null,
|
||||
notes: form.notes || null,
|
||||
@@ -348,100 +442,126 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
||||
</div>
|
||||
|
||||
{/* From / To endpoints */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{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 })} />
|
||||
)}
|
||||
{form.type === 'flight' ? (
|
||||
/* ── Flight route: ordered airports (origin · stops · destination) ── */
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||
{waypoints.map((wp, i) => {
|
||||
const isFirst = i === 0
|
||||
const isLast = i === waypoints.length - 1
|
||||
const updateWp = (patch: Partial<WaypointForm>) => setWaypoints(prev => prev.map((w, j) => (j === i ? { ...w, ...patch } : w)))
|
||||
const roleLabel = isFirst ? t('reservations.meta.from') : isLast ? t('reservations.meta.to') : t('reservations.layover.stop')
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<div className="bg-surface-card" style={{ border: '1px solid var(--border-primary)', borderRadius: 10, padding: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 10, fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em', flexShrink: 0 }}>{roleLabel}</span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<AirportSelect value={wp.airport} onChange={a => updateWp({ airport: a || null })} />
|
||||
</div>
|
||||
{!isFirst && !isLast && (
|
||||
<button type="button" onClick={() => setWaypoints(prev => prev.filter((_, j) => j !== i))} aria-label={t('common.delete')} className="text-content-faint" style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 4, flexShrink: 0 }}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isFirst && (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalDate')}</label>
|
||||
<CustomSelect value={wp.arrDayId} onChange={v => updateWp({ arrDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.arrivalTime')}</label>
|
||||
<CustomTimePicker value={wp.arrTime} onChange={v => updateWp({ arrTime: v })} />
|
||||
</div>
|
||||
{wp.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isLast && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureDate')}</label>
|
||||
<CustomSelect value={wp.depDayId} onChange={v => updateWp({ depDayId: v })} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.departureTime')}</label>
|
||||
<CustomTimePicker value={wp.depTime} onChange={v => updateWp({ depTime: v })} />
|
||||
</div>
|
||||
{wp.airport && (
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>{wp.airport.tz}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={wp.airline} onChange={e => updateWp({ airline: e.target.value })} placeholder="Lufthansa" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.flightNumber')}</label>
|
||||
<input type="text" value={wp.flight_number} onChange={e => updateWp({ flight_number: e.target.value })} placeholder="LH 123" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isLast && (
|
||||
<button type="button" onClick={() => setWaypoints(prev => [...prev.slice(0, i + 1), emptyWaypoint(prev[i]?.depDayId || ''), ...prev.slice(i + 1)])}
|
||||
className="text-content-faint hover:text-content-secondary" style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5, padding: '6px 10px', border: '1px dashed var(--border-primary)', borderRadius: 8, background: 'none', fontSize: 11, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={12} /> {t('reservations.layover.addStop')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{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 className={labelClass}>
|
||||
{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 className={labelClass}>
|
||||
{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 className={labelClass}>{t('reservations.meta.departureTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{fromPick.airport.tz}
|
||||
) : (
|
||||
<>
|
||||
{/* From / To endpoints (non-flight) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>
|
||||
{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 className={labelClass}>
|
||||
{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 className={labelClass}>{t('reservations.meta.arrivalTimezone')}</label>
|
||||
<div className={inputClass} style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 12, background: 'var(--bg-tertiary)' }}>
|
||||
{toPick.airport.tz}
|
||||
{/* Departure row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{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 className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flight-specific fields */}
|
||||
{form.type === 'flight' && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
||||
placeholder="Lufthansa" className={inputClass} />
|
||||
{/* Arrival row */}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{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 className={labelClass}>{form.type === 'car' ? t('reservations.returnTime') : t('reservations.endTime')}</label>
|
||||
<CustomTimePicker value={form.arrival_time} onChange={v => set('arrival_time', v)} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className={labelClass}>{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" className={inputClass} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Train-specific fields */}
|
||||
|
||||
@@ -262,6 +262,37 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.bookingLabelsHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Explore places on the map (POI category pill) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.mapPoiPill')}</label>
|
||||
<div className="flex gap-3">
|
||||
{[
|
||||
{ value: true, label: t('settings.on') || 'On' },
|
||||
{ value: false, label: t('settings.off') || 'Off' },
|
||||
].map(opt => (
|
||||
<button
|
||||
key={String(opt.value)}
|
||||
onClick={async () => {
|
||||
try { await updateSetting('map_poi_pill_enabled', opt.value) }
|
||||
catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 14, fontWeight: 500,
|
||||
border: (settings.map_poi_pill_enabled !== false) === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
|
||||
background: (settings.map_poi_pill_enabled !== false) === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
|
||||
color: 'var(--text-primary)',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs mt-1 text-content-faint">{t('settings.mapPoiPillHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Blur Booking Codes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.blurBookingCodes')}</label>
|
||||
|
||||
@@ -53,29 +53,44 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
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 })[] = [
|
||||
// The departure/arrival coordinate of a transport, if its endpoints carry one.
|
||||
const epLoc = (r: any, role: 'from' | 'to'): { lat: number; lng: number } | null => {
|
||||
const e = (r.endpoints || []).find((x: any) => x.role === role)
|
||||
return e && e.lat != null && e.lng != null ? { lat: e.lat, lng: e.lng } : null
|
||||
}
|
||||
|
||||
// Build a unified list of places + transports sorted by effective position.
|
||||
type Entry =
|
||||
| { kind: 'place'; lat: number; lng: number; pos: number }
|
||||
| { kind: 'transport'; from: { lat: number; lng: number } | null; to: { lat: number; lng: number } | null; pos: number }
|
||||
const entries: Entry[] = [
|
||||
...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,
|
||||
from: epLoc(r, 'from'),
|
||||
to: epLoc(r, 'to'),
|
||||
pos: (r.day_positions?.[dayId] ?? r.day_positions?.[String(dayId)] ?? r.day_plan_position) as number,
|
||||
})),
|
||||
].sort((a, b) => a.pos - b.pos)
|
||||
|
||||
// Group consecutive located places into runs, resetting whenever a transport
|
||||
// appears (you don't drive between a flight's endpoints) — mirrors getMergedItems order.
|
||||
// Group located places into driving runs.
|
||||
// - A transport WITH a location anchors the route to its departure point (you
|
||||
// travel there), then breaks the run (you don't drive the flight/train); its
|
||||
// arrival point starts the next run.
|
||||
// - A transport WITHOUT a location is ignored entirely — the places around it
|
||||
// connect directly, as if the booking weren't there.
|
||||
const runs: { lat: number; lng: number }[][] = []
|
||||
let currentRun: { lat: number; lng: number }[] = []
|
||||
for (const entry of entries) {
|
||||
if (entry.kind === 'place') {
|
||||
currentRun.push({ lat: entry.lat, lng: entry.lng })
|
||||
} else {
|
||||
} else if (entry.from || entry.to) {
|
||||
if (entry.from) currentRun.push(entry.from)
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
currentRun = []
|
||||
if (entry.to) currentRun.push(entry.to)
|
||||
}
|
||||
}
|
||||
if (currentRun.length >= 2) runs.push(currentRun)
|
||||
@@ -120,7 +135,9 @@ export function useRouteCalculation(tripStore: TripStoreState, selectedDayId: nu
|
||||
.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 ?? ''}`
|
||||
// Include endpoints so adding/moving a departure/arrival location re-routes.
|
||||
const eps = (r.endpoints || []).map(e => `${e.role}@${e.lat ?? ''},${e.lng ?? ''}`).join(';')
|
||||
return `${r.id}:${r.day_id ?? ''}:${r.end_day_id ?? ''}:${r.reservation_time ?? ''}:${pos ?? ''}:${eps}`
|
||||
})
|
||||
.sort()
|
||||
.join('|')
|
||||
|
||||
@@ -42,6 +42,8 @@ import { usePlannerHistory } from '../hooks/usePlannerHistory'
|
||||
import type { Accommodation, TripMember, Day, Place, Reservation, PackingItem, TodoItem } from '../types'
|
||||
import { ListTodo, Upload, Plus, Trash2, FolderPlus } from 'lucide-react'
|
||||
import { useTripPlanner } from './tripPlanner/useTripPlanner'
|
||||
import { usePoiExplore } from '../components/Map/usePoiExplore'
|
||||
import PoiCategoryPill from '../components/Map/PoiCategoryPill'
|
||||
|
||||
function ListsContainer({ tripId, packingItems, todoItems }: { tripId: number; packingItems: PackingItem[]; todoItems: TodoItem[] }) {
|
||||
const [subTab, setSubTab] = useState<'packing' | 'todo'>(() => {
|
||||
@@ -195,14 +197,17 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||
} = useTripPlanner()
|
||||
|
||||
const poi = usePoiExplore()
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div className="bg-surface" style={{
|
||||
@@ -300,8 +305,16 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const r = reservations.find(x => x.id === rid)
|
||||
if (r) setMapTransportDetail(r)
|
||||
}}
|
||||
pois={poi.pois}
|
||||
onPoiClick={openAddPlaceFromPoi}
|
||||
onViewportChange={poi.onViewportChange}
|
||||
/>
|
||||
|
||||
{poiPillEnabled && (
|
||||
<div className="hidden md:flex" style={{ position: 'absolute', top: 14, left: '50%', transform: 'translateX(-50%)', zIndex: 25, pointerEvents: 'none' }}>
|
||||
<PoiCategoryPill active={poi.active} onToggle={poi.toggle} loadingKeys={poi.loadingKeys} moved={poi.moved} onSearchArea={poi.searchArea} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hidden md:block" style={{ position: 'absolute', left: 10, top: 10, bottom: 10, zIndex: 20 }}>
|
||||
<button onClick={() => setLeftCollapsed(c => !c)}
|
||||
@@ -342,6 +355,8 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
onSelectDay={handleSelectDay}
|
||||
onPlaceClick={handlePlaceClick}
|
||||
onReorder={handleReorder}
|
||||
onReorderDays={handleReorderDays}
|
||||
onAddDay={handleAddDay}
|
||||
onUpdateDayTitle={handleUpdateDayTitle}
|
||||
onAssignToDay={handleAssignToDay}
|
||||
onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } else { setRoute(null); setRouteInfo(null) } }}
|
||||
@@ -593,7 +608,7 @@ 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) }} onReorder={handleReorder} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
? <DayPlanSidebar tripId={tripId} trip={trip} days={days} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} selectedAssignmentId={selectedAssignmentId} onSelectDay={(id) => { handleSelectDay(id); setMobileSidebarOpen(null) }} onPlaceClick={(placeId, assignmentId) => { handlePlaceClick(placeId, assignmentId) }} onReorder={handleReorder} onReorderDays={handleReorderDays} onAddDay={handleAddDay} onUpdateDayTitle={handleUpdateDayTitle} onAssignToDay={handleAssignToDay} onRouteCalculated={(r) => { if (r) { setRoute([r.coordinates]); setRouteInfo(r) } }} reservations={reservations} visibleConnectionIds={visibleConnections} onToggleConnection={toggleConnection} onAddReservation={(dayId) => { setEditingReservation(null); tripActions.setSelectedDay(dayId); setShowReservationModal(true); setMobileSidebarOpen(null) }} onAddTransport={can('day_edit', trip) ? (dayId) => { setTransportModalDayId(dayId); setEditingTransport(null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDayDetail={(day) => { setShowDayDetail(day); setSelectedPlaceId(null); selectAssignment(null) }} onRemoveAssignment={handleRemoveAssignment} onEditPlace={(place, assignmentId) => { setEditingPlace(place); setEditingAssignmentId(assignmentId || null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} accommodations={tripAccommodations} routeShown={routeShown} routeProfile={routeProfile} onToggleRoute={() => setRouteShown(v => !v)} onSetRouteProfile={setRouteProfile} onNavigateToFiles={() => { setMobileSidebarOpen(null); handleTabChange('dateien') }} onExpandedDaysChange={setExpandedDayIds} pushUndo={pushUndo} canUndo={canUndo} lastActionLabel={lastActionLabel} onUndo={handleUndo} onEditTransport={can('day_edit', trip) ? (reservation) => { setEditingTransport(reservation); setTransportModalDayId(reservation.day_id ?? null); setShowTransportModal(true); setMobileSidebarOpen(null) } : undefined} onEditReservation={can('reservation_edit', trip) ? (r) => { setEditingReservation(r); setShowReservationModal(true); setMobileSidebarOpen(null) } : undefined} initialScrollTop={mobilePlanScrollTopRef.current} onScrollTopChange={(top) => { mobilePlanScrollTopRef.current = top }} />
|
||||
: <PlacesSidebar tripId={tripId} places={places} categories={categories} assignments={assignments} selectedDayId={selectedDayId} selectedPlaceId={selectedPlaceId} onPlaceClick={(placeId) => { handlePlaceClick(placeId); setMobileSidebarOpen(null) }} onAddPlace={() => { setEditingPlace(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onAssignToDay={handleAssignToDay} onEditPlace={(place) => { setEditingPlace(place); setEditingAssignmentId(null); setShowPlaceForm(true); setMobileSidebarOpen(null) }} onDeletePlace={(placeId) => handleDeletePlace(placeId)} onBulkDeletePlaces={(ids) => setDeletePlaceIds(ids)} onBulkDeleteConfirm={(ids) => confirmDeletePlaces(ids)} days={days} isMobile onCategoryFilterChange={setMapCategoryFilter} onPlacesFilterChange={setMapPlacesFilter} pushUndo={pushUndo} initialScrollTop={mobilePlacesScrollTopRef.current} onScrollTopChange={(top) => { mobilePlacesScrollTopRef.current = top }} />
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -123,7 +123,7 @@ export function useTripPlanner() {
|
||||
const [dayDetailCollapsed, setDayDetailCollapsed] = useState(false)
|
||||
const [showPlaceForm, setShowPlaceForm] = useState<boolean>(false)
|
||||
const [editingPlace, setEditingPlace] = useState<Place | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string } | null>(null)
|
||||
const [prefillCoords, setPrefillCoords] = useState<{ lat: number; lng: number; name?: string; address?: string; website?: string; phone?: string; osm_id?: string } | null>(null)
|
||||
const [editingAssignmentId, setEditingAssignmentId] = useState<number | null>(null)
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
@@ -356,6 +356,24 @@ export function useTripPlanner() {
|
||||
} catch { /* best effort */ }
|
||||
}, [language])
|
||||
|
||||
// Open the Add-Place form pre-filled from an OSM "explore" POI marker — all the
|
||||
// data already comes from the POI, so no reverse-geocode is needed.
|
||||
const openAddPlaceFromPoi = useCallback((poi: { lat: number; lng: number; name: string; address: string | null; website: string | null; phone: string | null; osm_id: string }) => {
|
||||
if (!can('place_edit', trip)) return
|
||||
setPrefillCoords({
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
name: poi.name,
|
||||
address: poi.address || '',
|
||||
website: poi.website || undefined,
|
||||
phone: poi.phone || undefined,
|
||||
osm_id: poi.osm_id,
|
||||
})
|
||||
setEditingPlace(null)
|
||||
setEditingAssignmentId(null)
|
||||
setShowPlaceForm(true)
|
||||
}, [trip])
|
||||
|
||||
const handleSavePlace = useCallback(async (data) => {
|
||||
const pendingFiles = data._pendingFiles
|
||||
delete data._pendingFiles
|
||||
@@ -523,6 +541,23 @@ export function useTripPlanner() {
|
||||
catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
||||
}, [tripId, toast])
|
||||
|
||||
const handleReorderDays = useCallback((orderedIds: number[]) => {
|
||||
const prevIds = (useTripStore.getState().days || [])
|
||||
.slice().sort((a, b) => (a.day_number ?? 0) - (b.day_number ?? 0)).map(d => d.id)
|
||||
tripActions.reorderDays(tripId, orderedIds)
|
||||
.then(() => {
|
||||
pushUndo(t('dayplan.reorderUndo'), async () => {
|
||||
await tripActions.reorderDays(tripId, prevIds)
|
||||
})
|
||||
})
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.reorderError')))
|
||||
}, [tripId, toast, pushUndo])
|
||||
|
||||
const handleAddDay = useCallback((position?: number) => {
|
||||
tripActions.insertDay(tripId, position)
|
||||
.catch(err => toast.error(err instanceof Error ? err.message : t('dayplan.addDayError')))
|
||||
}, [tripId, toast])
|
||||
|
||||
const handleSaveReservation = async (data: Record<string, string | number | null> & { title: string }) => {
|
||||
try {
|
||||
if (editingReservation) {
|
||||
@@ -641,9 +676,9 @@ export function useTripPlanner() {
|
||||
isMobile, mapCategoryFilter, setMapCategoryFilter, mapPlacesFilter, setMapPlacesFilter,
|
||||
expandedDayIds, setExpandedDayIds, mapPlaces,
|
||||
route, routeSegments, routeInfo, setRoute, setRouteInfo, updateRouteForDay,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu,
|
||||
handleSelectDay, handlePlaceClick, handleMarkerClick, handleMapClick, handleMapContextMenu, openAddPlaceFromPoi,
|
||||
handleSavePlace, handleDeletePlace, confirmDeletePlace, confirmDeletePlaces,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleUpdateDayTitle,
|
||||
handleAssignToDay, handleRemoveAssignment, handleReorder, handleReorderDays, handleAddDay, handleUpdateDayTitle,
|
||||
handleSaveReservation, handleSaveTransport, handleDeleteReservation,
|
||||
selectedPlace, dayOrderMap, dayPlaces,
|
||||
mapTileUrl, defaultCenter, defaultZoom, fontStyle, splashDone,
|
||||
|
||||
@@ -34,6 +34,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
show_place_description: false,
|
||||
optimize_from_accommodation: true,
|
||||
map_provider: 'leaflet',
|
||||
map_poi_pill_enabled: true,
|
||||
mapbox_access_token: '',
|
||||
mapbox_style: 'mapbox://styles/mapbox/standard',
|
||||
mapbox_3d_enabled: true,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { daysApi } from '../../api/client'
|
||||
import type { StoreApi } from 'zustand'
|
||||
import type { TripStoreState } from '../tripStore'
|
||||
import type { Day } from '../../types'
|
||||
import { getApiErrorMessage } from '../../types'
|
||||
|
||||
type SetState = StoreApi<TripStoreState>['setState']
|
||||
type GetState = StoreApi<TripStoreState>['getState']
|
||||
|
||||
export interface DaysSlice {
|
||||
reorderDays: (tripId: number | string, orderedIds: number[]) => Promise<void>
|
||||
insertDay: (tripId: number | string, position?: number) => Promise<Day | undefined>
|
||||
}
|
||||
|
||||
export const createDaysSlice = (set: SetState, get: GetState): DaysSlice => ({
|
||||
// Move whole days. Day rows stay stable (assignments/notes/bookings ride along
|
||||
// by id); only positions change and, on a dated trip, dates stay pinned to
|
||||
// their slots while the content moves across them. Optimistically reorder the
|
||||
// list, then refresh to pull the server-side re-stamped dates + booking times.
|
||||
reorderDays: async (tripId, orderedIds) => {
|
||||
const prevDays = get().days
|
||||
const byId = new Map(prevDays.map(d => [d.id, d]))
|
||||
const sortedDates = prevDays.map(d => d.date).filter((d): d is string => !!d).sort()
|
||||
const optimistic = orderedIds
|
||||
.map((id, i) => {
|
||||
const d = byId.get(id)
|
||||
if (!d) return null
|
||||
return { ...d, day_number: i + 1, date: sortedDates.length ? (sortedDates[i] ?? null) : d.date }
|
||||
})
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null)
|
||||
|
||||
set({ days: optimistic })
|
||||
|
||||
try {
|
||||
await daysApi.reorder(tripId, orderedIds)
|
||||
await get().refreshDays(tripId)
|
||||
await get().loadReservations(tripId)
|
||||
} catch (err: unknown) {
|
||||
set({ days: prevDays })
|
||||
throw new Error(getApiErrorMessage(err, 'Error reordering days'))
|
||||
}
|
||||
},
|
||||
|
||||
// Insert a new empty day at a 1-based position (omit to append). On a dated
|
||||
// trip this extends the trip by one day and re-pins dates server-side.
|
||||
insertDay: async (tripId, position) => {
|
||||
const prevDays = get().days
|
||||
try {
|
||||
const result = await daysApi.create(tripId, { position })
|
||||
await get().refreshDays(tripId)
|
||||
await get().loadReservations(tripId)
|
||||
return result.day
|
||||
} catch (err: unknown) {
|
||||
set({ days: prevDays })
|
||||
throw new Error(getApiErrorMessage(err, 'Error adding day'))
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -283,6 +283,15 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
dayNotes: newDayNotes,
|
||||
}
|
||||
}
|
||||
case 'day:reordered': {
|
||||
// Apply the new order instantly when we know all ids; the authoritative
|
||||
// dates + re-stamped booking times are pulled by the refresh below.
|
||||
const orderedIds = payload.orderedIds as number[] | undefined
|
||||
if (!orderedIds || orderedIds.length !== state.days.length) return {}
|
||||
const byId = new Map(state.days.map(d => [d.id, d]))
|
||||
if (!orderedIds.every(id => byId.has(id))) return {}
|
||||
return { days: orderedIds.map((id, i) => ({ ...byId.get(id)!, day_number: i + 1 })) }
|
||||
}
|
||||
|
||||
// Day Notes
|
||||
case 'dayNote:created': {
|
||||
@@ -442,6 +451,16 @@ export function handleRemoteEvent(set: SetState, get: GetState, event: WebSocket
|
||||
}
|
||||
})
|
||||
|
||||
// A reorder/insert re-pins dates and re-stamps booking times server-side, so
|
||||
// pull the authoritative days + reservations for collaborators.
|
||||
if (type === 'day:reordered') {
|
||||
const tripId = get().trip?.id
|
||||
if (tripId) {
|
||||
get().refreshDays(tripId)
|
||||
get().loadReservations(tripId)
|
||||
}
|
||||
}
|
||||
|
||||
// Write the change through to IndexedDB using the post-update state
|
||||
writeToDexie(type, payload as Record<string, unknown>, get())
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { packingRepo } from '../repo/packingRepo'
|
||||
import { todoRepo } from '../repo/todoRepo'
|
||||
import { createPlacesSlice } from './slices/placesSlice'
|
||||
import { createAssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import { createDaysSlice } from './slices/daysSlice'
|
||||
import { createDayNotesSlice } from './slices/dayNotesSlice'
|
||||
import { createPackingSlice } from './slices/packingSlice'
|
||||
import { createTodoSlice } from './slices/todoSlice'
|
||||
@@ -24,6 +25,7 @@ import type {
|
||||
import { getApiErrorMessage } from '../types'
|
||||
import type { PlacesSlice } from './slices/placesSlice'
|
||||
import type { AssignmentsSlice } from './slices/assignmentsSlice'
|
||||
import type { DaysSlice } from './slices/daysSlice'
|
||||
import type { DayNotesSlice } from './slices/dayNotesSlice'
|
||||
import type { PackingSlice } from './slices/packingSlice'
|
||||
import type { TodoSlice } from './slices/todoSlice'
|
||||
@@ -34,6 +36,7 @@ import type { FilesSlice } from './slices/filesSlice'
|
||||
export interface TripStoreState
|
||||
extends PlacesSlice,
|
||||
AssignmentsSlice,
|
||||
DaysSlice,
|
||||
DayNotesSlice,
|
||||
PackingSlice,
|
||||
TodoSlice,
|
||||
@@ -184,6 +187,7 @@ export const useTripStore = create<TripStoreState>((set, get) => ({
|
||||
|
||||
...createPlacesSlice(set, get),
|
||||
...createAssignmentsSlice(set, get),
|
||||
...createDaysSlice(set, get),
|
||||
...createDayNotesSlice(set, get),
|
||||
...createPackingSlice(set, get),
|
||||
...createTodoSlice(set, get),
|
||||
|
||||
@@ -113,6 +113,7 @@ export interface Settings {
|
||||
show_place_description: boolean
|
||||
blur_booking_codes?: boolean
|
||||
map_booking_labels?: boolean
|
||||
map_poi_pill_enabled?: boolean
|
||||
optimize_from_accommodation?: boolean
|
||||
map_provider?: 'leaflet' | 'mapbox-gl'
|
||||
mapbox_access_token?: string
|
||||
|
||||
@@ -126,18 +126,18 @@ describe('getMergedItems', () => {
|
||||
expect(types).toEqual(['place', 'transport', 'place'])
|
||||
})
|
||||
|
||||
it('per-day position overrides time-based insertion', () => {
|
||||
it('orders a timed transport chronologically regardless of a stale per-day position', () => {
|
||||
const dayAssignments = [
|
||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||
]
|
||||
// Transport at 10:30 would normally go between the two places
|
||||
// but per-day position 1.5 puts it after the second place
|
||||
// The train is at 10:30, so it sorts between the 08:00 and 13:00 places by time —
|
||||
// timed items are arranged chronologically even if an old manual position exists.
|
||||
const dayTransports = [
|
||||
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
||||
]
|
||||
const result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||
const types = result.map(i => i.type)
|
||||
expect(types).toEqual(['place', 'place', 'transport'])
|
||||
expect(types).toEqual(['place', 'transport', 'place'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,12 +39,66 @@ export function getDisplayTimeForDay(
|
||||
return r.reservation_time || null
|
||||
}
|
||||
|
||||
/** Per-leg detail of a multi-leg flight, or null for single-leg / non-flight. */
|
||||
function parseFlightLegs(r: any): any[] | null {
|
||||
if (r?.type !== 'flight') return null
|
||||
let meta = r.metadata
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
// Defensive: recover metadata that was accidentally double-encoded by an earlier
|
||||
// bug (a JSON string of a JSON string) so already-saved flights heal on read.
|
||||
if (typeof meta === 'string') { try { meta = JSON.parse(meta || '{}') } catch { meta = {} } }
|
||||
if (meta && Array.isArray(meta.legs) && meta.legs.length > 1) return meta.legs
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a multi-leg flight into one synthetic reservation per leg that touches
|
||||
* `dayId`, each with its own day span + departure/arrival time so it slots into
|
||||
* the timeline independently. A single-leg flight (or any other reservation) is
|
||||
* returned untouched, so existing behaviour is unchanged.
|
||||
*/
|
||||
export function expandFlightLegsForDay(
|
||||
r: any,
|
||||
dayId: number,
|
||||
getDayOrder: (id: number) => number,
|
||||
days: Array<{ id: number; date?: string | null }>
|
||||
): any[] {
|
||||
const legs = parseFlightLegs(r)
|
||||
if (!legs) return [r]
|
||||
const dateOf = (id: number | null): string | null => (id == null ? null : (days.find(d => d.id === id)?.date ?? null))
|
||||
const thisOrder = getDayOrder(dayId)
|
||||
const out: any[] = []
|
||||
legs.forEach((leg, i) => {
|
||||
const dep = leg.dep_day_id ?? r.day_id ?? null
|
||||
const arr = leg.arr_day_id ?? dep
|
||||
if (dep == null) return
|
||||
const depOrder = getDayOrder(dep)
|
||||
const arrOrder = getDayOrder(arr ?? dep)
|
||||
if (!(thisOrder >= depOrder && thisOrder <= arrOrder)) return
|
||||
const depDate = dateOf(dep)
|
||||
const arrDate = dateOf(arr ?? dep)
|
||||
out.push({
|
||||
...r,
|
||||
day_id: dep,
|
||||
end_day_id: arr ?? dep,
|
||||
reservation_time: leg.dep_time ? (depDate ? `${depDate}T${leg.dep_time}` : leg.dep_time) : null,
|
||||
reservation_end_time: leg.arr_time ? (arrDate ? `${arrDate}T${leg.arr_time}` : leg.arr_time) : null,
|
||||
// Each leg carries its OWN saved position (not the booking's) so items can be
|
||||
// dropped between legs and persist; absent → falls back to time ordering.
|
||||
day_positions: leg.day_positions || undefined,
|
||||
day_plan_position: undefined,
|
||||
__leg: { index: i, total: legs.length, from: leg.from ?? null, to: leg.to ?? null, airline: leg.airline ?? null, flight_number: leg.flight_number ?? null },
|
||||
})
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||
export function getTransportForDay(opts: {
|
||||
reservations: any[]
|
||||
dayId: number
|
||||
dayAssignmentIds: number[]
|
||||
days: Array<{ id: number; day_number?: number }>
|
||||
days: Array<{ id: number; day_number?: number; date?: string | null }>
|
||||
}): any[] {
|
||||
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||
|
||||
@@ -69,7 +123,34 @@ export function getTransportForDay(opts: {
|
||||
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||
}
|
||||
return startDayId === dayId
|
||||
})
|
||||
}).flatMap(r => expandFlightLegsForDay(r, dayId, getDayOrder, days))
|
||||
}
|
||||
|
||||
/**
|
||||
* Order items chronologically: anything with a time (a place's place_time, a
|
||||
* transport/leg display time, a timed note) sorts by that time. An item WITHOUT a
|
||||
* time inherits the time of the timed item before it, so untimed items stay where
|
||||
* they were manually placed. Stable on the incoming order for ties.
|
||||
*/
|
||||
function applyChronoOrder(
|
||||
items: MergedItem[],
|
||||
dayId: number,
|
||||
getDisplayTime: (r: any, dayId: number) => string | null
|
||||
): MergedItem[] {
|
||||
const timeOf = (it: MergedItem): number | null => {
|
||||
if (it.type === 'place') return parseTimeToMinutes(it.data?.place?.place_time)
|
||||
if (it.type === 'note') return parseTimeToMinutes(it.data?.time)
|
||||
return parseTimeToMinutes(getDisplayTime(it.data, dayId))
|
||||
}
|
||||
let last = -Infinity
|
||||
return items
|
||||
.map((it, i) => {
|
||||
const t = timeOf(it)
|
||||
if (t != null) last = t
|
||||
return { it, i, eff: t != null ? t : last }
|
||||
})
|
||||
.sort((a, b) => a.eff - b.eff || a.i - b.i)
|
||||
.map(k => k.it)
|
||||
}
|
||||
|
||||
/** Merge places, notes, and transports into a single ordered day timeline. */
|
||||
@@ -94,9 +175,9 @@ export function getMergedItems(opts: {
|
||||
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||
})).sort((a, b) => a.minutes - b.minutes)
|
||||
|
||||
if (timedTransports.length === 0) return baseItems
|
||||
if (timedTransports.length === 0) return applyChronoOrder(baseItems, dayId, getDisplayTime)
|
||||
if (baseItems.length === 0) {
|
||||
return timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data }))
|
||||
return applyChronoOrder(timedTransports.map((item, i) => ({ type: item.type, sortKey: i, data: item.data })), dayId, getDisplayTime)
|
||||
}
|
||||
|
||||
// Insert transports among base items based on per-day position or time
|
||||
@@ -132,5 +213,5 @@ export function getMergedItems(opts: {
|
||||
result.push({ type: timed.type, sortKey, data: timed.data })
|
||||
}
|
||||
|
||||
return result.sort((a, b) => a.sortKey - b.sortKey)
|
||||
return applyChronoOrder(result.sort((a, b) => a.sortKey - b.sortKey), dayId, getDisplayTime)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Multi-leg (layover) flight support.
|
||||
//
|
||||
// A flight booking is ONE reservation whose route is an ordered chain of airports
|
||||
// (e.g. FRA -> BER -> HND). The geometry + order are the source of truth in
|
||||
// `reservation.endpoints` (role 'from' for the first airport, 'stop' for each
|
||||
// intermediate one, 'to' for the last, ordered by `sequence`). The per-leg detail
|
||||
// — airline, flight number, and each segment's own day/time — lives in
|
||||
// `metadata.legs`. The top-level metadata (`departure_airport`/`arrival_airport`/
|
||||
// `airline`/`flight_number`) and `day_id`/`end_day_id` mirror the FIRST and LAST
|
||||
// leg so legacy readers keep working.
|
||||
//
|
||||
// A legacy single-leg flight (two endpoints, flat metadata, no `metadata.legs`)
|
||||
// is normalised here into a one-leg chain, so every renderer can use one path.
|
||||
|
||||
import type { Reservation, ReservationEndpoint } from '../types'
|
||||
|
||||
export interface FlightLeg {
|
||||
from: string | null // IATA code (or null)
|
||||
to: string | null
|
||||
airline?: string
|
||||
flight_number?: string
|
||||
dep_day_id?: number | null
|
||||
dep_time?: string | null // 'HH:mm'
|
||||
arr_day_id?: number | null
|
||||
arr_time?: string | null
|
||||
}
|
||||
|
||||
/** reservation.metadata may be a JSON string or an already-parsed object. */
|
||||
export function parseReservationMetadata(r: Pick<Reservation, 'metadata'>): Record<string, any> {
|
||||
const m = r.metadata
|
||||
if (!m) return {}
|
||||
if (typeof m === 'string') {
|
||||
try {
|
||||
let parsed = JSON.parse(m || '{}')
|
||||
// Defensive: an earlier bug could double-encode metadata (a JSON string of a
|
||||
// JSON string) — unwrap it once more so saved flights heal on read.
|
||||
if (typeof parsed === 'string') { try { parsed = JSON.parse(parsed) } catch { /* keep */ } }
|
||||
return (parsed && typeof parsed === 'object') ? parsed : {}
|
||||
} catch { return {} }
|
||||
}
|
||||
return m as Record<string, any>
|
||||
}
|
||||
|
||||
/** Endpoints ordered by `sequence` (geometry + order source of truth). */
|
||||
export function orderedEndpoints(r: Pick<Reservation, 'endpoints'>): ReservationEndpoint[] {
|
||||
return (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered legs of a flight. `metadata.legs` is preferred; otherwise a single leg
|
||||
* is derived from the endpoints (and finally the flat metadata) so that legacy
|
||||
* single-leg flights — and flights created before this feature — still work.
|
||||
*/
|
||||
export function getFlightLegs(r: Reservation): FlightLeg[] {
|
||||
const meta = parseReservationMetadata(r)
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 0) {
|
||||
return meta.legs.map((l: any): FlightLeg => ({
|
||||
from: l.from ?? null,
|
||||
to: l.to ?? null,
|
||||
airline: l.airline || undefined,
|
||||
flight_number: l.flight_number || undefined,
|
||||
dep_day_id: l.dep_day_id ?? null,
|
||||
dep_time: l.dep_time ?? null,
|
||||
arr_day_id: l.arr_day_id ?? null,
|
||||
arr_time: l.arr_time ?? null,
|
||||
}))
|
||||
}
|
||||
// Legacy fallback: one leg from the endpoints / flat metadata.
|
||||
const eps = orderedEndpoints(r)
|
||||
const first = eps[0]
|
||||
const last = eps[eps.length - 1]
|
||||
const fromCode = first?.code ?? meta.departure_airport ?? null
|
||||
const toCode = last?.code ?? meta.arrival_airport ?? null
|
||||
if (!fromCode && !toCode) return []
|
||||
return [{
|
||||
from: fromCode,
|
||||
to: toCode,
|
||||
airline: meta.airline || undefined,
|
||||
flight_number: meta.flight_number || undefined,
|
||||
dep_day_id: r.day_id ?? null,
|
||||
dep_time: first?.local_time ?? null,
|
||||
arr_day_id: r.end_day_id ?? r.day_id ?? null,
|
||||
arr_time: last?.local_time ?? null,
|
||||
}]
|
||||
}
|
||||
|
||||
/** Number of flight segments. 1 for a simple from -> to booking. */
|
||||
export function legCount(r: Reservation): number {
|
||||
return getFlightLegs(r).length
|
||||
}
|
||||
|
||||
export function isMultiLegFlight(r: Reservation): boolean {
|
||||
return r.type === 'flight' && legCount(r) > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered route labels (IATA codes, or names when no code) for display, e.g.
|
||||
* ['FRA','BER','HND']. Uses endpoints; falls back to the flat metadata pair.
|
||||
*/
|
||||
export function routeStops(r: Reservation): string[] {
|
||||
const eps = orderedEndpoints(r)
|
||||
if (eps.length >= 2) return eps.map(e => e.code || e.name)
|
||||
const meta = parseReservationMetadata(r)
|
||||
return [meta.departure_airport, meta.arrival_airport].filter(Boolean) as string[]
|
||||
}
|
||||
@@ -90,6 +90,90 @@ function mapFlight(r: KiReservation, source: ParsedBookingItem['source']): Parse
|
||||
};
|
||||
}
|
||||
|
||||
/** True when flight `b` is a short layover connection that continues flight `a`. */
|
||||
function sameConnection(a: KiReservation, b: KiReservation): boolean {
|
||||
const fa = a.reservationFor as KiFlight | undefined;
|
||||
const fb = b.reservationFor as KiFlight | undefined;
|
||||
if (!fa || !fb) return false;
|
||||
const arrIata = fa.arrivalAirport?.iataCode?.toUpperCase();
|
||||
const depIata = fb.departureAirport?.iataCode?.toUpperCase();
|
||||
if (!arrIata || !depIata || arrIata !== depIata) return false; // must connect at the same airport
|
||||
const arrIso = toIsoString(fa.arrivalTime);
|
||||
const depIso = toIsoString(fb.departureTime);
|
||||
if (arrIso && depIso) {
|
||||
const gapMs = new Date(depIso).getTime() - new Date(arrIso).getTime();
|
||||
// A real layover is forward in time and short — anything longer (e.g. a
|
||||
// round-trip return days later) stays a separate booking.
|
||||
if (gapMs < 0 || gapMs > 24 * 3600 * 1000) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Collapse several connecting flight legs (same PNR) into one multi-leg booking. */
|
||||
function mapFlightGroup(legs: KiReservation[], source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
const flights = legs.map(l => l.reservationFor as KiFlight | undefined);
|
||||
if (flights.some(f => !f)) return mapFlight(legs[0], source); // malformed → fall back to single
|
||||
const fs = flights as KiFlight[];
|
||||
|
||||
const iataOf = (ap: KiFlight['departureAirport']) => ap?.iataCode?.toUpperCase() ?? null;
|
||||
const makeEndpoint = (
|
||||
ap: KiFlight['departureAirport'], role: 'from' | 'stop' | 'to', time: string | null, date: string | null,
|
||||
): ParsedEndpoint | null => {
|
||||
const iata = iataOf(ap);
|
||||
const found = iata ? findByIata(iata) : null;
|
||||
const label = found ? (found.city ? `${found.city} (${found.iata})` : found.name) : (ap?.name ?? iata ?? 'Unknown');
|
||||
if (found) return { role, sequence: 0, name: label, code: found.iata, lat: found.lat, lng: found.lng, timezone: found.tz, local_time: time, local_date: date };
|
||||
const c = coords(ap?.geo);
|
||||
if (c) return { role, sequence: 0, name: label, code: iata, lat: c.lat, lng: c.lng, timezone: null, local_time: time, local_date: date };
|
||||
return null;
|
||||
};
|
||||
|
||||
const endpoints: ParsedEndpoint[] = [];
|
||||
const metaLegs: Record<string, unknown>[] = [];
|
||||
const first = fs[0];
|
||||
const firstDep = splitIso(first.departureTime);
|
||||
const originEp = makeEndpoint(first.departureAirport, 'from', firstDep.time, firstDep.date);
|
||||
if (originEp) endpoints.push(originEp);
|
||||
|
||||
fs.forEach((f, i) => {
|
||||
const isLast = i === fs.length - 1;
|
||||
const arr = splitIso(f.arrivalTime);
|
||||
const arrEp = makeEndpoint(f.arrivalAirport, isLast ? 'to' : 'stop', arr.time, arr.date);
|
||||
if (arrEp) endpoints.push(arrEp);
|
||||
const airline = f.airline?.name ?? f.airline?.iataCode ?? '';
|
||||
metaLegs.push({
|
||||
from: iataOf(f.departureAirport),
|
||||
to: iataOf(f.arrivalAirport),
|
||||
...(airline ? { airline } : {}),
|
||||
...(f.flightNumber ? { flight_number: f.flightNumber } : {}),
|
||||
dep_time: splitIso(f.departureTime).time,
|
||||
arr_time: arr.time,
|
||||
});
|
||||
});
|
||||
endpoints.forEach((e, i) => { e.sequence = i; });
|
||||
|
||||
const last = fs[fs.length - 1];
|
||||
const airline = first.airline?.name ?? first.airline?.iataCode ?? '';
|
||||
const route = [iataOf(first.departureAirport), ...fs.map(f => iataOf(f.arrivalAirport))].filter(Boolean).join(' → ');
|
||||
return {
|
||||
type: 'flight',
|
||||
title: airline ? `${airline} ${route}` : `Flight ${route}`,
|
||||
reservation_time: toIsoString(first.departureTime),
|
||||
reservation_end_time: toIsoString(last.arrivalTime),
|
||||
confirmation_number: legs[0].reservationNumber ?? null,
|
||||
metadata: {
|
||||
...(airline ? { airline } : {}),
|
||||
...(first.flightNumber ? { flight_number: first.flightNumber } : {}),
|
||||
...(iataOf(first.departureAirport) ? { departure_airport: iataOf(first.departureAirport) } : {}),
|
||||
...(iataOf(last.arrivalAirport) ? { arrival_airport: iataOf(last.arrivalAirport) } : {}),
|
||||
legs: metaLegs,
|
||||
},
|
||||
endpoints,
|
||||
needs_review: endpoints.length < fs.length + 1,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||
const t = r.reservationFor as KiTrainTrip | undefined;
|
||||
if (!t) return null;
|
||||
@@ -233,8 +317,25 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
||||
const source = { fileName, index: i };
|
||||
let item: ParsedBookingItem | null = null;
|
||||
|
||||
// Group consecutive connecting flight legs that share a PNR into one booking.
|
||||
if (r['@type'] === 'FlightReservation') {
|
||||
const pnr = r.reservationNumber ?? null;
|
||||
const group = [r];
|
||||
while (
|
||||
i + 1 < kiItems.length &&
|
||||
kiItems[i + 1]['@type'] === 'FlightReservation' &&
|
||||
pnr != null &&
|
||||
(kiItems[i + 1].reservationNumber ?? null) === pnr &&
|
||||
sameConnection(group[group.length - 1], kiItems[i + 1])
|
||||
) {
|
||||
group.push(kiItems[++i]);
|
||||
}
|
||||
item = group.length > 1 ? mapFlightGroup(group, source) : mapFlight(r, source);
|
||||
if (item) items.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (r['@type']) {
|
||||
case 'FlightReservation': item = mapFlight(r, source); break;
|
||||
case 'TrainReservation': item = mapTrain(r, source); break;
|
||||
case 'BusReservation': item = mapBus(r, source); break;
|
||||
case 'BoatReservation': item = mapBoat(r, source); break;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import type { User } from '../../types';
|
||||
import { DaysService } from './days.service';
|
||||
import { DayReorderError } from '../../services/dayService';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
|
||||
@@ -52,16 +53,47 @@ export class DaysController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { date?: string; notes?: string },
|
||||
@Body() body: { date?: string; notes?: string; position?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
const day = this.days.create(tripId, body.date, body.notes);
|
||||
this.days.broadcast(tripId, 'day:created', { day }, socketId);
|
||||
// A `position` means "insert a new empty day here" (which on a dated trip
|
||||
// extends the trip and re-pins dates); without it, the legacy append.
|
||||
const day = body.position !== undefined
|
||||
? this.days.insert(tripId, body.position)
|
||||
: this.days.create(tripId, body.date, body.notes);
|
||||
// An insert can shuffle dates/positions of other days, so collaborators
|
||||
// refetch the whole list; a plain append only needs the new day.
|
||||
const event = body.position !== undefined ? 'day:reordered' : 'day:created';
|
||||
this.days.broadcast(tripId, event, { day }, socketId);
|
||||
return { day };
|
||||
}
|
||||
|
||||
@Put('reorder')
|
||||
reorder(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { orderedIds?: number[] },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
this.requireEdit(trip, user);
|
||||
if (!Array.isArray(body.orderedIds)) {
|
||||
throw new HttpException({ error: 'orderedIds must be an array' }, 400);
|
||||
}
|
||||
try {
|
||||
this.days.reorder(tripId, body.orderedIds);
|
||||
} catch (err) {
|
||||
if (err instanceof DayReorderError) {
|
||||
throw new HttpException({ error: err.message }, 400);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
this.days.broadcast(tripId, 'day:reordered', { orderedIds: body.orderedIds }, socketId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(
|
||||
@CurrentUser() user: User,
|
||||
|
||||
@@ -39,6 +39,14 @@ export class DaysService {
|
||||
return dayService.createDay(tripId, date, notes);
|
||||
}
|
||||
|
||||
insert(tripId: string, position?: number) {
|
||||
return dayService.insertDay(tripId, position);
|
||||
}
|
||||
|
||||
reorder(tripId: string, orderedIds: number[]) {
|
||||
return dayService.reorderDays(tripId, orderedIds);
|
||||
}
|
||||
|
||||
update(id: string, current: Parameters<typeof dayService.updateDay>[1], fields: { notes?: string; title?: string | null }) {
|
||||
return dayService.updateDay(id, current, fields);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,27 @@ export class MapsController {
|
||||
}
|
||||
}
|
||||
|
||||
// OSM-only POI explore: places of a category within the current map viewport.
|
||||
@Get('pois')
|
||||
async pois(
|
||||
@Query('category') category?: string,
|
||||
@Query('south') south?: string,
|
||||
@Query('west') west?: string,
|
||||
@Query('north') north?: string,
|
||||
@Query('east') east?: string,
|
||||
) {
|
||||
if (!category) throw new HttpException({ error: 'A category is required' }, 400);
|
||||
const bbox = { south: Number(south), west: Number(west), north: Number(north), east: Number(east) };
|
||||
if (Object.values(bbox).some(v => !Number.isFinite(v))) {
|
||||
throw new HttpException({ error: 'A valid bbox (south, west, north, east) is required' }, 400);
|
||||
}
|
||||
try {
|
||||
return await this.maps.pois(category, bbox);
|
||||
} catch (err: unknown) {
|
||||
throw toHttpException(err, 'POI search error', 500);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('autocomplete')
|
||||
@HttpCode(200)
|
||||
async autocomplete(
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
searchOverpassPois,
|
||||
} from '../../services/mapsService';
|
||||
import { serveFilePath } from '../../services/placePhotoCache';
|
||||
|
||||
@@ -86,4 +87,9 @@ export class MapsService {
|
||||
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
|
||||
return resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
|
||||
}
|
||||
|
||||
// OSM-only POI search by category within a viewport bbox (never calls Google).
|
||||
pois(category: string, bbox: { south: number; west: number; north: number; east: number }) {
|
||||
return searchOverpassPois(category, bbox);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +157,220 @@ export function deleteDay(id: string | number) {
|
||||
db.prepare('DELETE FROM days WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Day reorder / insert (#589)
|
||||
//
|
||||
// Reordering keeps every day ROW stable (so assignments, notes, accommodations,
|
||||
// photos and multi-day reservation positions ride along by id) and only changes
|
||||
// each row's day_number — its position. On a dated trip the calendar dates stay
|
||||
// pinned to their slots (position i keeps the i-th date) and the day's content
|
||||
// moves across them. Because a booking's day is derived from the date part of
|
||||
// reservation_time, every booking on a day whose date changed gets that date
|
||||
// re-stamped onto the day's new date (time-of-day preserved), so day_id stays
|
||||
// consistent and the booking moves with its day.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function addDays(date: string, n: number): string {
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
const t = Date.UTC(y, m - 1, d) + n * MS_PER_DAY;
|
||||
const dt = new Date(t);
|
||||
const yyyy = dt.getUTCFullYear();
|
||||
const mm = String(dt.getUTCMonth() + 1).padStart(2, '0');
|
||||
const dd = String(dt.getUTCDate()).padStart(2, '0');
|
||||
return `${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
function dayDelta(from: string, to: string): number {
|
||||
const [fy, fm, fd] = from.split('-').map(Number);
|
||||
const [ty, tm, td] = to.split('-').map(Number);
|
||||
return Math.round((Date.UTC(ty, tm - 1, td) - Date.UTC(fy, fm - 1, fd)) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
/** Replace the date part of an ISO-ish timestamp, keeping any time suffix. */
|
||||
function withDatePart(timestamp: string, date: string): string {
|
||||
return date + (timestamp.length > 10 ? timestamp.slice(10) : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* After day dates have been re-pinned, re-stamp the date of every booking on a
|
||||
* moved day so reservation_time/reservation_end_time follow their day's new
|
||||
* date (time-of-day preserved). Transport endpoints (flight legs) shift by the
|
||||
* same per-booking day delta so multi-leg timing stays internally consistent.
|
||||
*/
|
||||
function restampReservationDates(
|
||||
tripId: string | number,
|
||||
oldDateById: Map<number, string | null>,
|
||||
newDateById: Map<number, string | null>,
|
||||
): void {
|
||||
const reservations = db.prepare(
|
||||
'SELECT id, day_id, end_day_id, reservation_time, reservation_end_time FROM reservations WHERE trip_id = ?'
|
||||
).all(tripId) as {
|
||||
id: number; day_id: number | null; end_day_id: number | null;
|
||||
reservation_time: string | null; reservation_end_time: string | null;
|
||||
}[];
|
||||
|
||||
const setTime = db.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?');
|
||||
const setEndTime = db.prepare('UPDATE reservations SET reservation_end_time = ? WHERE id = ?');
|
||||
const endpoints = db.prepare('SELECT id, local_date FROM reservation_endpoints WHERE reservation_id = ?');
|
||||
const setEndpointDate = db.prepare('UPDATE reservation_endpoints SET local_date = ? WHERE id = ?');
|
||||
|
||||
for (const r of reservations) {
|
||||
if (r.day_id != null && r.reservation_time) {
|
||||
const oldDate = oldDateById.get(r.day_id);
|
||||
const newDate = newDateById.get(r.day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setTime.run(withDatePart(r.reservation_time, newDate), r.id);
|
||||
// Shift each transport leg's local_date by the same number of days.
|
||||
const delta = dayDelta(oldDate, newDate);
|
||||
if (delta !== 0) {
|
||||
for (const ep of endpoints.all(r.id) as { id: number; local_date: string | null }[]) {
|
||||
if (ep.local_date) setEndpointDate.run(addDays(ep.local_date, delta), ep.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.end_day_id != null && r.reservation_end_time) {
|
||||
const oldDate = oldDateById.get(r.end_day_id);
|
||||
const newDate = newDateById.get(r.end_day_id);
|
||||
if (oldDate && newDate && oldDate !== newDate) {
|
||||
setEndTime.run(withDatePart(r.reservation_end_time, newDate), r.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A stay must not end before it begins after a reorder/insert. */
|
||||
function assertNoInvertedAccommodation(tripId: string | number): void {
|
||||
const spans = db.prepare(`
|
||||
SELECT a.id, s.day_number AS start_no, e.day_number AS end_no
|
||||
FROM day_accommodations a
|
||||
JOIN days s ON a.start_day_id = s.id
|
||||
JOIN days e ON a.end_day_id = e.id
|
||||
WHERE a.trip_id = ?
|
||||
`).all(tripId) as { id: number; start_no: number; end_no: number }[];
|
||||
for (const span of spans) {
|
||||
if (span.start_no > span.end_no) {
|
||||
throw new DayReorderError('This move would make an accommodation end before it starts.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown for invalid reorder/insert requests; mapped to HTTP 400 by the controller. */
|
||||
export class DayReorderError extends Error {}
|
||||
|
||||
/**
|
||||
* Reorder whole days. `orderedIds` is the desired full sequence of this trip's
|
||||
* day ids (a permutation of the current ids).
|
||||
*/
|
||||
export function reorderDays(tripId: string | number, orderedIds: number[]) {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
|
||||
).all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
|
||||
const existingIds = new Set(rows.map(r => r.id));
|
||||
if (orderedIds.length !== rows.length || !orderedIds.every(id => existingIds.has(id))) {
|
||||
throw new DayReorderError('orderedIds must be a permutation of the trip day ids.');
|
||||
}
|
||||
|
||||
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
|
||||
// Dates stay pinned to slots: position i keeps the i-th date (ascending).
|
||||
const sortedDates = rows.map(r => r.date).filter((d): d is string => !!d).sort();
|
||||
const isDated = sortedDates.length > 0;
|
||||
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
// Two-phase renumber to dodge UNIQUE(trip_id, day_number) collisions.
|
||||
orderedIds.forEach((id, i) => setDayNumber.run(-(i + 1), id));
|
||||
const newDateById = new Map<number, string | null>();
|
||||
orderedIds.forEach((id, i) => {
|
||||
const date = isDated ? (sortedDates[i] ?? null) : null;
|
||||
setDayNumberAndDate.run(i + 1, date, id);
|
||||
newDateById.set(id, date);
|
||||
});
|
||||
|
||||
if (isDated) restampReservationDates(tripId, oldDateById, newDateById);
|
||||
assertNoInvertedAccommodation(tripId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
|
||||
return listDays(tripId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a new empty day at a 1-based position (default: append at the end).
|
||||
* On a dated trip the trip gains one calendar day: dates re-pin so the slots
|
||||
* stay contiguous, the trip's end_date extends by one day, and bookings on
|
||||
* shifted days have their dates re-stamped (same rules as reorderDays).
|
||||
*/
|
||||
export function insertDay(tripId: string | number, position?: number) {
|
||||
const rows = db.prepare(
|
||||
'SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number'
|
||||
).all(tripId) as { id: number; day_number: number; date: string | null }[];
|
||||
const n = rows.length;
|
||||
const pos = Math.min(Math.max(position ?? n + 1, 1), n + 1);
|
||||
const datedRows = rows.filter(r => r.date) as { id: number; day_number: number; date: string }[];
|
||||
const isDated = datedRows.length > 0;
|
||||
|
||||
const setDayNumber = db.prepare('UPDATE days SET day_number = ? WHERE id = ?');
|
||||
|
||||
if (!isDated) {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
const toShift = rows.filter(r => r.day_number >= pos);
|
||||
toShift.forEach(r => setDayNumber.run(-r.day_number, r.id));
|
||||
const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, NULL)').run(tripId, pos);
|
||||
toShift.forEach(r => setDayNumber.run(r.day_number + 1, r.id));
|
||||
db.exec('COMMIT');
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(result.lastInsertRowid) as Day;
|
||||
return { ...day, assignments: [], notes_items: [] };
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Dated trip: rebuild N+1 contiguous dates from the earliest date.
|
||||
const start = datedRows.map(r => r.date).sort()[0];
|
||||
const dates = Array.from({ length: n + 1 }, (_, i) => addDays(start, i));
|
||||
const oldDateById = new Map(rows.map(r => [r.id, r.date]));
|
||||
const setDayNumberAndDate = db.prepare('UPDATE days SET day_number = ?, date = ? WHERE id = ?');
|
||||
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
rows.forEach((r, i) => setDayNumber.run(-(i + 1), r.id));
|
||||
const result = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)').run(tripId, pos, dates[pos - 1]);
|
||||
const newId = Number(result.lastInsertRowid);
|
||||
|
||||
const orderedIds = rows.map(r => r.id);
|
||||
orderedIds.splice(pos - 1, 0, newId);
|
||||
const newDateById = new Map<number, string | null>();
|
||||
orderedIds.forEach((id, i) => {
|
||||
setDayNumberAndDate.run(i + 1, dates[i], id);
|
||||
newDateById.set(id, dates[i]);
|
||||
});
|
||||
|
||||
restampReservationDates(tripId, oldDateById, newDateById);
|
||||
assertNoInvertedAccommodation(tripId);
|
||||
db.prepare('UPDATE trips SET end_date = ? WHERE id = ?').run(dates[dates.length - 1], tripId);
|
||||
|
||||
db.exec('COMMIT');
|
||||
const day = db.prepare('SELECT * FROM days WHERE id = ?').get(newId) as Day;
|
||||
return { ...day, assignments: [], notes_items: [] };
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accommodation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -204,6 +204,114 @@ export async function fetchOverpassDetails(osmType: string, osmId: string): Prom
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ── Overpass POI search (by category within a viewport bbox) ─────────────────
|
||||
// Powers the "explore places on the map" pill. OSM-ONLY by design — this never
|
||||
// calls Google, even when a Google key is configured.
|
||||
|
||||
export interface OverpassPoi {
|
||||
osm_id: string; // 'node:123' | 'way:123' | 'relation:123' (matches the placeId format elsewhere)
|
||||
name: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
category: string; // the requested pill category key, e.g. 'restaurant'
|
||||
poi_type: string; // the raw OSM tag that matched, e.g. 'amenity=restaurant'
|
||||
address: string | null;
|
||||
website: string | null;
|
||||
phone: string | null;
|
||||
opening_hours: string | null;
|
||||
cuisine: string | null;
|
||||
source: 'openstreetmap';
|
||||
}
|
||||
|
||||
// Each pill category → the OSM tag selectors it searches. Keys here are the
|
||||
// contract with the client's POI_CATEGORIES (same keys, label/icon/colour live
|
||||
// client-side).
|
||||
const CATEGORY_OSM_FILTERS: Record<string, string[]> = {
|
||||
restaurant: ['amenity=restaurant', 'amenity=fast_food'],
|
||||
cafe: ['amenity=cafe'],
|
||||
bar: ['amenity=bar', 'amenity=pub', 'amenity=nightclub'],
|
||||
hotel: ['tourism=hotel', 'tourism=hostel', 'tourism=guest_house', 'tourism=apartment', 'tourism=motel'],
|
||||
sights: ['tourism=attraction', 'tourism=viewpoint', 'historic=monument', 'historic=castle', 'historic=memorial', 'historic=ruins'],
|
||||
museum: ['tourism=museum', 'tourism=gallery', 'tourism=artwork', 'amenity=theatre'],
|
||||
nature: ['leisure=park', 'leisure=garden', 'natural=beach', 'natural=peak'],
|
||||
activity: ['tourism=theme_park', 'tourism=zoo', 'tourism=aquarium', 'leisure=water_park'],
|
||||
shopping: ['shop=mall', 'shop=department_store', 'amenity=marketplace'],
|
||||
supermarket: ['shop=supermarket', 'shop=convenience'],
|
||||
};
|
||||
|
||||
export const POI_CATEGORY_KEYS = Object.keys(CATEGORY_OSM_FILTERS);
|
||||
|
||||
interface OverpassPoiElement {
|
||||
type: string;
|
||||
id: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
center?: { lat: number; lon: number };
|
||||
tags?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function searchOverpassPois(
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
limit = 60,
|
||||
): Promise<{ pois: OverpassPoi[]; source: 'openstreetmap'; truncated: boolean }> {
|
||||
const filters = CATEGORY_OSM_FILTERS[category];
|
||||
if (!filters) throw Object.assign(new Error('Unknown POI category'), { status: 400 });
|
||||
|
||||
// Overpass wants the box as (south,west,north,east) = (minLat,minLng,maxLat,maxLng).
|
||||
const box = `(${bbox.south},${bbox.west},${bbox.north},${bbox.east})`;
|
||||
const selectors = filters.map(f => {
|
||||
const [k, v] = f.split('=');
|
||||
return ` nwr["${k}"="${v}"]${box};`;
|
||||
}).join('\n');
|
||||
// `out center tags <n>` returns ways/relations with a computed center and caps
|
||||
// the result count in one round-trip.
|
||||
const query = `[out:json][timeout:25];\n(\n${selectors}\n);\nout center tags ${limit + 25};`;
|
||||
|
||||
let elements: OverpassPoiElement[] = [];
|
||||
try {
|
||||
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
||||
method: 'POST',
|
||||
headers: { 'User-Agent': UA, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `data=${encodeURIComponent(query)}`,
|
||||
});
|
||||
if (!res.ok) throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
const data = await res.json() as { elements?: OverpassPoiElement[] };
|
||||
elements = data.elements || [];
|
||||
} catch (err: any) {
|
||||
if (err?.status) throw err;
|
||||
throw Object.assign(new Error('Overpass request failed'), { status: 502 });
|
||||
}
|
||||
|
||||
const pois: OverpassPoi[] = [];
|
||||
for (const el of elements) {
|
||||
const tags = el.tags || {};
|
||||
const name = tags.name || tags['name:en'] || tags.brand || null;
|
||||
if (!name) continue; // unnamed POIs aren't useful to add to a plan
|
||||
const lat = el.lat ?? el.center?.lat;
|
||||
const lng = el.lon ?? el.center?.lon;
|
||||
if (lat == null || lng == null) continue;
|
||||
const matched = filters.find(f => { const [k, v] = f.split('='); return tags[k] === v; }) || filters[0];
|
||||
const addr = [tags['addr:street'], tags['addr:housenumber'], tags['addr:postcode'], tags['addr:city']].filter(Boolean).join(' ') || null;
|
||||
pois.push({
|
||||
osm_id: `${el.type}:${el.id}`,
|
||||
name,
|
||||
lat,
|
||||
lng,
|
||||
category,
|
||||
poi_type: matched,
|
||||
address: addr,
|
||||
website: tags.website || tags['contact:website'] || null,
|
||||
phone: tags.phone || tags['contact:phone'] || null,
|
||||
opening_hours: tags.opening_hours || null,
|
||||
cuisine: tags.cuisine || null,
|
||||
source: 'openstreetmap',
|
||||
});
|
||||
}
|
||||
const truncated = pois.length > limit;
|
||||
return { pois: pois.slice(0, limit), source: 'openstreetmap', truncated };
|
||||
}
|
||||
|
||||
// ── Opening hours parsing ────────────────────────────────────────────────────
|
||||
|
||||
export function parseOpeningHours(ohString: string): { weekdayDescriptions: string[]; openNow: boolean | null } {
|
||||
|
||||
@@ -12,6 +12,13 @@ export const DEFAULTABLE_USER_SETTING_KEYS = [
|
||||
'time_format',
|
||||
'blur_booking_codes',
|
||||
'map_tile_url',
|
||||
// Instance-wide Mapbox defaults: an admin can set a shared token + style so the
|
||||
// whole instance uses Mapbox without each user pasting their own key (#920).
|
||||
'map_provider',
|
||||
'mapbox_access_token',
|
||||
'mapbox_style',
|
||||
'mapbox_3d_enabled',
|
||||
'mapbox_quality_mode',
|
||||
] as const;
|
||||
|
||||
type DefaultableKey = typeof DEFAULTABLE_USER_SETTING_KEYS[number];
|
||||
@@ -20,9 +27,10 @@ const VALID_VALUES: Partial<Record<DefaultableKey, unknown[]>> = {
|
||||
temperature_unit: ['fahrenheit', 'celsius'],
|
||||
time_format: ['12h', '24h'],
|
||||
dark_mode: [true, false, 'light', 'dark', 'auto'],
|
||||
map_provider: ['leaflet', 'mapbox-gl'],
|
||||
};
|
||||
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes']);
|
||||
const BOOLEAN_KEYS = new Set<DefaultableKey>(['blur_booking_codes', 'mapbox_3d_enabled', 'mapbox_quality_mode']);
|
||||
|
||||
function parseValue(raw: string): unknown {
|
||||
try { return JSON.parse(raw); } catch { return raw; }
|
||||
@@ -35,7 +43,11 @@ export function getAdminUserDefaults(): Record<string, unknown> {
|
||||
const defaults: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
const settingKey = row.key.slice('default_user_setting_'.length);
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
if (ENCRYPTED_SETTING_KEYS.has(settingKey)) {
|
||||
defaults[settingKey] = row.value ? (decrypt_api_key(row.value) ?? '') : '';
|
||||
} else {
|
||||
defaults[settingKey] = parseValue(row.value);
|
||||
}
|
||||
}
|
||||
return defaults;
|
||||
}
|
||||
@@ -70,7 +82,12 @@ export function setAdminUserDefaults(partial: Record<string, unknown>): void {
|
||||
throw new Error(`Invalid value for ${key}: ${value}`);
|
||||
}
|
||||
|
||||
upsert.run(appKey, JSON.stringify(value));
|
||||
// Encrypt sensitive defaults (the shared Mapbox token) at rest, like the
|
||||
// per-user equivalents; everything else is stored as plain JSON.
|
||||
const stored = ENCRYPTED_SETTING_KEYS.has(key)
|
||||
? (maybe_encrypt_api_key(String(value)) ?? String(value))
|
||||
: JSON.stringify(value);
|
||||
upsert.run(appKey, stored);
|
||||
}
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
|
||||
@@ -543,8 +543,14 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
||||
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
|
||||
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
|
||||
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
|
||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||||
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
|
||||
// Multi-leg flight: show the whole route (FRA → BER → HND) on one event.
|
||||
const stops = [meta.legs[0]?.from, ...meta.legs.map((l: { to?: string }) => l.to)].filter(Boolean);
|
||||
if (stops.length) desc += `\nRoute: ${stops.join(' → ')}`;
|
||||
} else {
|
||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
||||
}
|
||||
if (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
|
||||
if (r.notes) desc += `\n${r.notes}`;
|
||||
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Day reorder + insert integration tests (#589) — exercises the real
|
||||
* dayService against the real schema. Covers: position renumber, dates pinned
|
||||
* to slots while content rides along by id, booking-date re-stamp, permutation
|
||||
* validation, the accommodation-inversion guard, and insert (dated + dateless).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database(':memory:');
|
||||
db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec('PRAGMA busy_timeout = 5000');
|
||||
return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: vi.fn() } };
|
||||
});
|
||||
|
||||
vi.mock('../../src/db/database', () => dbMock);
|
||||
vi.mock('../../src/config', () => ({
|
||||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
DEFAULT_LANGUAGE: 'en',
|
||||
}));
|
||||
|
||||
import { createTables } from '../../src/db/schema';
|
||||
import { runMigrations } from '../../src/db/migrations';
|
||||
import { resetTestDb } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createDay, createDayAssignment, createReservation, createDayAccommodation } from '../helpers/factories';
|
||||
import { reorderDays, insertDay, DayReorderError } from '../../src/services/dayService';
|
||||
|
||||
let userId: number;
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
runMigrations(testDb);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetTestDb(testDb);
|
||||
userId = createUser(testDb).user.id;
|
||||
});
|
||||
|
||||
afterAll(() => testDb.close());
|
||||
|
||||
const orderedDays = (tripId: number) =>
|
||||
testDb.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as
|
||||
{ id: number; day_number: number; date: string | null }[];
|
||||
|
||||
describe('reorderDays', () => {
|
||||
it('permutes positions, pins dates to slots, and content rides along by id', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAssignment(testDb, d2.id, place.id); // place sits on day 2
|
||||
|
||||
// Move day 2 to the front: [d2, d1, d3]
|
||||
reorderDays(trip.id, [d2.id, d1.id, d3.id]);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
expect(after.map(d => d.id)).toEqual([d2.id, d1.id, d3.id]);
|
||||
// Dates stay pinned to their calendar slots
|
||||
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03']);
|
||||
// The place rides along with its day row (still attached to d2.id, now at slot 1)
|
||||
const onD2 = testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(d2.id);
|
||||
expect(onD2).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('re-stamps a booking\'s date onto its day\'s new date, keeping the time', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const res = createReservation(testDb, trip.id, { day_id: d2.id, type: 'restaurant' });
|
||||
testDb.prepare('UPDATE reservations SET reservation_time = ? WHERE id = ?').run('2026-03-02T19:00', res.id);
|
||||
|
||||
reorderDays(trip.id, [d2.id, d1.id, d3.id]); // d2 moves to the 2026-03-01 slot
|
||||
|
||||
const r = testDb.prepare('SELECT reservation_time FROM reservations WHERE id = ?').get(res.id) as { reservation_time: string };
|
||||
expect(r.reservation_time).toBe('2026-03-01T19:00');
|
||||
});
|
||||
|
||||
it('rejects an orderedIds list that is not a permutation of the trip days', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2] = orderedDays(trip.id);
|
||||
expect(() => reorderDays(trip.id, [d1.id, d2.id])).toThrow(DayReorderError);
|
||||
});
|
||||
|
||||
it('blocks a move that would make an accommodation end before it starts, and rolls back', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
const place = createPlace(testDb, trip.id);
|
||||
createDayAccommodation(testDb, trip.id, place.id, d1.id, d2.id); // stay spans day 1 -> day 2
|
||||
|
||||
// Put the start day (d1) after the end day (d2): [d2, d3, d1]
|
||||
expect(() => reorderDays(trip.id, [d2.id, d3.id, d1.id])).toThrow(DayReorderError);
|
||||
|
||||
// Transaction rolled back: original order intact
|
||||
expect(orderedDays(trip.id).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertDay', () => {
|
||||
it('inserts an empty day at a position on a dateless trip and shifts the rest', () => {
|
||||
const trip = createTrip(testDb, userId);
|
||||
const d1 = createDay(testDb, trip.id);
|
||||
const d2 = createDay(testDb, trip.id);
|
||||
const d3 = createDay(testDb, trip.id);
|
||||
|
||||
const created = insertDay(trip.id, 1);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
expect(after).toHaveLength(4);
|
||||
expect(after[0].id).toBe(created.id);
|
||||
expect(after[0].date).toBeNull();
|
||||
expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
});
|
||||
|
||||
it('inserts at the front of a dated trip: dates stay contiguous and the trip extends', () => {
|
||||
const trip = createTrip(testDb, userId, { start_date: '2026-03-01', end_date: '2026-03-03' });
|
||||
const [d1, d2, d3] = orderedDays(trip.id);
|
||||
|
||||
const created = insertDay(trip.id, 1);
|
||||
|
||||
const after = orderedDays(trip.id);
|
||||
expect(after).toHaveLength(4);
|
||||
expect(after[0].id).toBe(created.id);
|
||||
expect(after.map(d => d.date)).toEqual(['2026-03-01', '2026-03-02', '2026-03-03', '2026-03-04']);
|
||||
// Old content shifted down a slot
|
||||
expect(after.slice(1).map(d => d.id)).toEqual([d1.id, d2.id, d3.id]);
|
||||
// Trip range extended by one day
|
||||
const t = testDb.prepare('SELECT end_date FROM trips WHERE id = ?').get(trip.id) as { end_date: string };
|
||||
expect(t.end_date).toBe('2026-03-04');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mapReservations } from '../../../src/nest/booking-import/kitinerary-mapper';
|
||||
|
||||
const airport = (iata: string, lat: number, lng: number) => ({
|
||||
iataCode: iata,
|
||||
name: iata,
|
||||
geo: { latitude: lat, longitude: lng },
|
||||
});
|
||||
|
||||
const flight = (pnr: string, dep: any, arr: any, depTime: string, arrTime: string, flightNumber: string) => ({
|
||||
'@type': 'FlightReservation',
|
||||
reservationNumber: pnr,
|
||||
reservationFor: {
|
||||
departureAirport: dep,
|
||||
arrivalAirport: arr,
|
||||
departureTime: depTime,
|
||||
arrivalTime: arrTime,
|
||||
airline: { name: 'Lufthansa', iataCode: 'LH' },
|
||||
flightNumber,
|
||||
},
|
||||
});
|
||||
|
||||
const FRA = airport('FRA', 50.04, 8.57);
|
||||
const BER = airport('BER', 52.36, 13.50);
|
||||
const HND = airport('HND', 35.55, 139.78);
|
||||
|
||||
describe('kitinerary mapper — multi-leg flight grouping', () => {
|
||||
it('groups two connecting same-PNR legs into one multi-leg booking', () => {
|
||||
const { items } = mapReservations([
|
||||
flight('ABC123', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 100'),
|
||||
flight('ABC123', BER, HND, '2026-06-11T14:30:00', '2026-06-11T23:30:00', 'LH 200'),
|
||||
] as any, 'test.json');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
const booking = items[0];
|
||||
expect(booking.type).toBe('flight');
|
||||
expect(booking.endpoints).toHaveLength(3);
|
||||
expect(booking.endpoints!.map(e => e.role)).toEqual(['from', 'stop', 'to']);
|
||||
expect(booking.endpoints!.map(e => e.sequence)).toEqual([0, 1, 2]);
|
||||
const meta = booking.metadata as any;
|
||||
expect(meta.legs).toHaveLength(2);
|
||||
expect(meta.legs[0]).toMatchObject({ from: 'FRA', to: 'BER', flight_number: 'LH 100' });
|
||||
expect(meta.legs[1]).toMatchObject({ from: 'BER', to: 'HND', flight_number: 'LH 200' });
|
||||
expect(meta.departure_airport).toBe('FRA');
|
||||
expect(meta.arrival_airport).toBe('HND');
|
||||
expect(booking.reservation_time).toContain('10:00');
|
||||
expect(booking.reservation_end_time).toContain('23:30');
|
||||
});
|
||||
|
||||
it('keeps a round trip (same PNR, multi-day gap) as two separate bookings', () => {
|
||||
const { items } = mapReservations([
|
||||
flight('RT999', FRA, HND, '2026-06-11T10:00:00', '2026-06-11T20:00:00', 'LH 700'),
|
||||
flight('RT999', HND, FRA, '2026-06-20T10:00:00', '2026-06-20T18:00:00', 'LH 701'),
|
||||
] as any, 'test.json');
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
expect((items[0].metadata as any).legs).toBeUndefined();
|
||||
expect((items[1].metadata as any).legs).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves a single flight unchanged (two endpoints, no legs array)', () => {
|
||||
const { items } = mapReservations([
|
||||
flight('S1', FRA, BER, '2026-06-11T10:00:00', '2026-06-11T12:00:00', 'LH 1'),
|
||||
] as any, 'test.json');
|
||||
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].endpoints).toHaveLength(2);
|
||||
expect((items[0].metadata as any).legs).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -49,9 +49,17 @@ export type Day = z.infer<typeof daySchema>;
|
||||
export const dayCreateRequestSchema = z.object({
|
||||
date: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
// 1-based slot to insert a new empty day at (omit to append at the end).
|
||||
position: z.number().int().positive().optional(),
|
||||
});
|
||||
export type DayCreateRequest = z.infer<typeof dayCreateRequestSchema>;
|
||||
|
||||
/** Reorder whole days: the desired full sequence of this trip's day ids. */
|
||||
export const dayReorderRequestSchema = z.object({
|
||||
orderedIds: z.array(z.number()),
|
||||
});
|
||||
export type DayReorderRequest = z.infer<typeof dayReorderRequestSchema>;
|
||||
|
||||
export const dayUpdateRequestSchema = z.object({
|
||||
notes: z.string().optional(),
|
||||
title: z.string().nullable().optional(),
|
||||
|
||||
@@ -360,5 +360,15 @@ const admin: TranslationStrings = {
|
||||
'إزالة جميع مفاتيح المرور لهذا المستخدم (مثلًا عند فقدان جهاز). سيظل بإمكانه تسجيل الدخول بكلمة المرور.',
|
||||
'admin.passkey.resetConfirm': 'إزالة جميع مفاتيح المرور لـ {name}؟',
|
||||
'admin.passkey.resetDone': 'تمت إزالة {count} من مفاتيح المرور',
|
||||
'admin.defaultSettings.mapProvider': 'محرك الخرائط',
|
||||
'admin.defaultSettings.mapProviderHint': 'الخريطة الافتراضية لجميع المستخدمين على هذا الخادم. لا يزال بإمكان كل مستخدم تجاوزها في إعداداته الخاصة.',
|
||||
'admin.defaultSettings.providerLeaflet': 'قياسي (مجاني)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (ثلاثي الأبعاد)',
|
||||
'admin.defaultSettings.mapboxToken': 'رمز Mapbox المشترك',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'يُستخدم لكل مستخدم لم يُدخل رمزه الخاص — حتى يحصل الخادم بأكمله على Mapbox دون مشاركة المفتاح بشكل فردي. يُخزَّن مشفّرًا.',
|
||||
'admin.defaultSettings.mapboxStyle': 'نمط الخريطة',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'اختر نمطًا…',
|
||||
'admin.defaultSettings.mapbox3d': 'المباني والتضاريس ثلاثية الأبعاد',
|
||||
'admin.defaultSettings.mapboxQuality': 'وضع الجودة العالية',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
||||
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
||||
'budget.netBalances': 'الأرصدة الصافية',
|
||||
'budget.categoriesLabel': 'فئات',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "أنت",
|
||||
"costs.youShort": "أنت",
|
||||
"costs.youLower": "أنت",
|
||||
"costs.youOwe": "عليك",
|
||||
"costs.youOweSub": "عليك أن تدفع للآخرين",
|
||||
"costs.youreOwed": "لك",
|
||||
"costs.youreOwedSub": "على الآخرين أن يدفعوا لك",
|
||||
"costs.totalSpend": "إجمالي إنفاق الرحلة",
|
||||
"costs.totalSpendSub": "عبر جميع المسافرين",
|
||||
"costs.to": "إلى",
|
||||
"costs.from": "من",
|
||||
"costs.allSettled": "لقد سوّيت كل حساباتك",
|
||||
"costs.nothingOwed": "لا شيء مستحق لك",
|
||||
"costs.yourShare": "حصتك",
|
||||
"costs.youPaid": "أنت دفعت",
|
||||
"costs.expenses": "المصروفات",
|
||||
"costs.entries": "{count} إدخالات",
|
||||
"costs.searchPlaceholder": "ابحث في المصروفات…",
|
||||
"costs.filter.all": "الكل",
|
||||
"costs.filter.mine": "دفعتها أنا",
|
||||
"costs.filter.owed": "مستحق لي",
|
||||
"costs.addExpense": "إضافة مصروف",
|
||||
"costs.editExpense": "تعديل المصروف",
|
||||
"costs.noMatch": "لا توجد مصروفات تطابق بحثك.",
|
||||
"costs.emptyText": "لا توجد مصروفات بعد. أضف أول مصروف لك.",
|
||||
"costs.spent": "تم إنفاق {amount}",
|
||||
"costs.noDate": "بدون تاريخ",
|
||||
"costs.noOnePaid": "لم يدفع أحد بعد",
|
||||
"costs.youLent": "أقرضت {amount}",
|
||||
"costs.youBorrowed": "اقترضت {amount}",
|
||||
"costs.settleUp": "تسوية الحساب",
|
||||
"costs.history": "السجل",
|
||||
"costs.everyoneSquare": "الجميع متعادلون",
|
||||
"costs.nothingOutstanding": "لا توجد مدفوعات معلّقة الآن.",
|
||||
"costs.pay": "ادفع",
|
||||
"costs.pays": "يدفع",
|
||||
"costs.settle": "تسوية",
|
||||
"costs.balances": "الأرصدة",
|
||||
"costs.byCategory": "حسب الفئة",
|
||||
"costs.noCategories": "لا توجد مصروفات بعد.",
|
||||
"costs.settleHistory": "سجل التسويات",
|
||||
"costs.noSettlements": "لا توجد مدفوعات مسوّاة بعد.",
|
||||
"costs.paymentsSettled": "تمت تسوية {count} مدفوعات",
|
||||
"costs.paid": "مدفوع",
|
||||
"costs.undo": "تراجع",
|
||||
"costs.whatFor": "لأجل ماذا كان؟",
|
||||
"costs.namePlaceholder": "مثل: عشاء، هدايا تذكارية، وقود…",
|
||||
"costs.totalAmount": "المبلغ الإجمالي",
|
||||
"costs.currency": "العملة",
|
||||
"costs.day": "اليوم",
|
||||
"costs.rateLabel": "1 {from} بـ {to}",
|
||||
"costs.category": "الفئة",
|
||||
"costs.whoPaid": "من دفع؟",
|
||||
"costs.splitBetween": "تقسيم بالتساوي بين",
|
||||
"costs.pickSomeone": "اختر شخصًا واحدًا على الأقل للتقسيم معه.",
|
||||
"costs.splitSummary": "تقسيم على {count} · {amount} لكل واحد",
|
||||
"costs.cat.accommodation": "الإقامة",
|
||||
"costs.cat.food": "الطعام والشراب",
|
||||
"costs.cat.groceries": "البقالة",
|
||||
"costs.cat.transport": "النقل",
|
||||
"costs.cat.flights": "الرحلات الجوية",
|
||||
"costs.cat.activities": "الأنشطة",
|
||||
"costs.cat.sightseeing": "معالم سياحية",
|
||||
"costs.cat.shopping": "التسوق",
|
||||
"costs.cat.fees": "الرسوم والتذاكر",
|
||||
"costs.cat.health": "الصحة",
|
||||
"costs.cat.tips": "البقشيش",
|
||||
"costs.cat.other": "أخرى",
|
||||
"costs.daysCount": "{count} أيام",
|
||||
"costs.travelers": "{count} مسافرين",
|
||||
"costs.liveRate": "سعر مباشر",
|
||||
"costs.settleAll": "تسوية الكل",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -45,5 +45,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.allAssigned': 'All places assigned', // en-fallback
|
||||
'dayplan.mobile.noMatch': 'No match', // en-fallback
|
||||
'dayplan.mobile.createNew': 'Create new place', // en-fallback
|
||||
'dayplan.reorderDays': 'إعادة ترتيب الأيام',
|
||||
'dayplan.reorderTitle': 'إعادة ترتيب الأيام',
|
||||
'dayplan.reorderHint': 'تنتقل أماكن اليوم وملاحظاته وحجوزاته معه.',
|
||||
'dayplan.addDay': 'إضافة يوم',
|
||||
'dayplan.moveUp': 'تحريك لأعلى',
|
||||
'dayplan.moveDown': 'تحريك لأسفل',
|
||||
'dayplan.reorderUndo': 'إعادة ترتيب الأيام',
|
||||
'dayplan.reorderError': 'تعذّر إعادة ترتيب الأيام',
|
||||
'dayplan.addDayError': 'تعذّر إضافة يوم',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'الاتصالات',
|
||||
'map.showConnections': 'عرض مسارات الحجوزات',
|
||||
'map.hideConnections': 'إخفاء مسارات الحجوزات',
|
||||
'poi.searchThisArea': 'البحث في هذه المنطقة',
|
||||
'poi.cat.restaurants': 'مطاعم',
|
||||
'poi.cat.cafes': 'مقاهٍ',
|
||||
'poi.cat.bars': 'حانات وحياة ليلية',
|
||||
'poi.cat.hotels': 'أماكن إقامة',
|
||||
'poi.cat.sights': 'معالم',
|
||||
'poi.cat.museums': 'متاحف وثقافة',
|
||||
'poi.cat.nature': 'طبيعة وحدائق',
|
||||
'poi.cat.activities': 'أنشطة',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'رقم الرحلة',
|
||||
'reservations.meta.from': 'من',
|
||||
'reservations.meta.to': 'إلى',
|
||||
'reservations.layover.route': 'المسار',
|
||||
'reservations.layover.stop': 'محطة توقف',
|
||||
'reservations.layover.addStop': 'إضافة محطة توقف',
|
||||
'reservations.layover.connection': 'رحلة متّصلة',
|
||||
'reservations.layover.layover': 'توقف بيني',
|
||||
'reservations.needsReview': 'مراجعة',
|
||||
'reservations.needsReviewHint':
|
||||
'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
|
||||
|
||||
@@ -317,6 +317,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'هذا الجهاز',
|
||||
'settings.passkey.lastUsed': 'آخر استخدام',
|
||||
'settings.passkey.neverUsed': 'لم يُستخدم قط',
|
||||
'settings.mapPoiPill': 'استكشاف الأماكن على الخريطة',
|
||||
'settings.mapPoiPillHint': 'أظهر شريط فئات على خريطة الرحلة للعثور على المطاعم والفنادق والمزيد القريبة من OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -382,5 +382,15 @@ const admin: TranslationStrings = {
|
||||
'Remove todas as passkeys deste usuário (ex.: em caso de perda do dispositivo). Ele ainda poderá entrar com a senha.',
|
||||
'admin.passkey.resetConfirm': 'Remover todas as passkeys de {name}?',
|
||||
'admin.passkey.resetDone': 'Removida(s) {count} passkey(s)',
|
||||
'admin.defaultSettings.mapProvider': 'Motor de mapas',
|
||||
'admin.defaultSettings.mapProviderHint': 'O mapa padrão para todos nesta instância. Cada usuário ainda pode substituí-lo nas próprias configurações.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Padrão (gratuito)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Token compartilhado do Mapbox',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Usado para todos os usuários que não inseriram o próprio token — assim toda a instância usa o Mapbox sem compartilhar a chave individualmente. Armazenado de forma criptografada.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Estilo do mapa',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Escolha um estilo…',
|
||||
'admin.defaultSettings.mapbox3d': 'Edifícios & relevo em 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Modo de alta qualidade',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
|
||||
'Clique no avatar de um membro em um item do orçamento para marcá-lo em verde — significa que ele pagou. O acerto mostra quem deve quanto a quem.',
|
||||
'budget.netBalances': 'Saldos líquidos',
|
||||
'budget.categoriesLabel': 'categorias',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "Você",
|
||||
"costs.youShort": "V",
|
||||
"costs.youLower": "você",
|
||||
"costs.youOwe": "Você deve",
|
||||
"costs.youOweSub": "Você deve pagar os outros",
|
||||
"costs.youreOwed": "Devem a você",
|
||||
"costs.youreOwedSub": "Os outros devem pagar você",
|
||||
"costs.totalSpend": "Gasto total da viagem",
|
||||
"costs.totalSpendSub": "Entre todos os viajantes",
|
||||
"costs.to": "Para",
|
||||
"costs.from": "De",
|
||||
"costs.allSettled": "Suas contas estão acertadas",
|
||||
"costs.nothingOwed": "Ninguém deve nada a você",
|
||||
"costs.yourShare": "Sua parte",
|
||||
"costs.youPaid": "Você pagou",
|
||||
"costs.expenses": "Despesas",
|
||||
"costs.entries": "{count} lançamentos",
|
||||
"costs.searchPlaceholder": "Buscar despesas…",
|
||||
"costs.filter.all": "Todas",
|
||||
"costs.filter.mine": "Pagas por mim",
|
||||
"costs.filter.owed": "Devem a mim",
|
||||
"costs.addExpense": "Adicionar despesa",
|
||||
"costs.editExpense": "Editar despesa",
|
||||
"costs.noMatch": "Nenhuma despesa corresponde à busca.",
|
||||
"costs.emptyText": "Nenhuma despesa ainda. Adicione a primeira.",
|
||||
"costs.spent": "{amount} gastos",
|
||||
"costs.noDate": "Sem data",
|
||||
"costs.noOnePaid": "Ninguém pagou ainda",
|
||||
"costs.youLent": "você emprestou {amount}",
|
||||
"costs.youBorrowed": "você pegou emprestado {amount}",
|
||||
"costs.settleUp": "Acertar contas",
|
||||
"costs.history": "Histórico",
|
||||
"costs.everyoneSquare": "Todos quitados",
|
||||
"costs.nothingOutstanding": "Nenhum pagamento pendente no momento.",
|
||||
"costs.pay": "paga",
|
||||
"costs.pays": "paga",
|
||||
"costs.settle": "Acertar",
|
||||
"costs.balances": "Saldos",
|
||||
"costs.byCategory": "Por categoria",
|
||||
"costs.noCategories": "Nenhuma despesa ainda.",
|
||||
"costs.settleHistory": "Histórico de acertos",
|
||||
"costs.noSettlements": "Nenhum pagamento acertado ainda.",
|
||||
"costs.paymentsSettled": "{count} pagamentos acertados",
|
||||
"costs.paid": "pago",
|
||||
"costs.undo": "Desfazer",
|
||||
"costs.whatFor": "Para que foi?",
|
||||
"costs.namePlaceholder": "ex.: jantar, lembranças, gasolina…",
|
||||
"costs.totalAmount": "Valor total",
|
||||
"costs.currency": "Moeda",
|
||||
"costs.day": "Dia",
|
||||
"costs.rateLabel": "1 {from} em {to}",
|
||||
"costs.category": "Categoria",
|
||||
"costs.whoPaid": "Quem pagou?",
|
||||
"costs.splitBetween": "Dividir igualmente entre",
|
||||
"costs.pickSomeone": "Escolha pelo menos uma pessoa para dividir.",
|
||||
"costs.splitSummary": "Dividido entre {count} · {amount} cada",
|
||||
"costs.cat.accommodation": "Hospedagem",
|
||||
"costs.cat.food": "Comida e bebida",
|
||||
"costs.cat.groceries": "Mercado",
|
||||
"costs.cat.transport": "Transporte",
|
||||
"costs.cat.flights": "Voos",
|
||||
"costs.cat.activities": "Atividades",
|
||||
"costs.cat.sightseeing": "Passeios turísticos",
|
||||
"costs.cat.shopping": "Compras",
|
||||
"costs.cat.fees": "Taxas e ingressos",
|
||||
"costs.cat.health": "Saúde",
|
||||
"costs.cat.tips": "Gorjetas",
|
||||
"costs.cat.other": "Outros",
|
||||
"costs.daysCount": "{count} dias",
|
||||
"costs.travelers": "{count} viajantes",
|
||||
"costs.liveRate": "taxa ao vivo",
|
||||
"costs.settleAll": "Acertar tudo",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.createNew': 'Criar novo lugar',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Reordenar dias',
|
||||
'dayplan.reorderTitle': 'Reordenar dias',
|
||||
'dayplan.reorderHint': 'Os lugares, notas e reservas de um dia se movem junto com ele.',
|
||||
'dayplan.addDay': 'Adicionar dia',
|
||||
'dayplan.moveUp': 'Mover para cima',
|
||||
'dayplan.moveDown': 'Mover para baixo',
|
||||
'dayplan.reorderUndo': 'Reordenar dias',
|
||||
'dayplan.reorderError': 'Falha ao reordenar os dias',
|
||||
'dayplan.addDayError': 'Falha ao adicionar o dia',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Conexões',
|
||||
'map.showConnections': 'Mostrar rotas de reservas',
|
||||
'map.hideConnections': 'Ocultar rotas de reservas',
|
||||
'poi.searchThisArea': 'Pesquisar nesta área',
|
||||
'poi.cat.restaurants': 'Restaurantes',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.bars': 'Bares e vida noturna',
|
||||
'poi.cat.hotels': 'Hospedagem',
|
||||
'poi.cat.sights': 'Pontos turísticos',
|
||||
'poi.cat.museums': 'Museus e cultura',
|
||||
'poi.cat.nature': 'Natureza e parques',
|
||||
'poi.cat.activities': 'Atividades',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'Nº do voo',
|
||||
'reservations.meta.from': 'De',
|
||||
'reservations.meta.to': 'Para',
|
||||
'reservations.layover.route': 'Rota',
|
||||
'reservations.layover.stop': 'Parada',
|
||||
'reservations.layover.addStop': 'Adicionar parada',
|
||||
'reservations.layover.connection': 'Conexão',
|
||||
'reservations.layover.layover': 'Escala',
|
||||
'reservations.needsReview': 'Verificar',
|
||||
'reservations.needsReviewHint':
|
||||
'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
|
||||
|
||||
@@ -323,6 +323,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Este dispositivo',
|
||||
'settings.passkey.lastUsed': 'Último uso',
|
||||
'settings.passkey.neverUsed': 'Nunca usada',
|
||||
'settings.mapPoiPill': 'Explorar lugares no mapa',
|
||||
'settings.mapPoiPillHint': 'Mostrar uma etiqueta de categoria no mapa da viagem para encontrar restaurantes, hotéis e mais por perto a partir do OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -377,5 +377,15 @@ const admin: TranslationStrings = {
|
||||
'Odebere všechny přístupové klíče tohoto uživatele (např. při ztrátě zařízení). Stále se může přihlásit svým heslem.',
|
||||
'admin.passkey.resetConfirm': 'Odebrat všechny přístupové klíče uživatele {name}?',
|
||||
'admin.passkey.resetDone': 'Odebráno {count} přístupových klíčů',
|
||||
'admin.defaultSettings.mapProvider': 'Mapový engine',
|
||||
'admin.defaultSettings.mapProviderHint': 'Výchozí mapa pro všechny uživatele na této instanci. Každý uživatel ji může i nadále změnit ve svém vlastním nastavení.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Standardní (zdarma)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Sdílený token Mapbox',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Použije se pro každého uživatele, který nezadal vlastní token — takže celá instance získá Mapbox, aniž byste klíč sdíleli s každým zvlášť. Ukládá se šifrovaně.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Styl mapy',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Vyberte styl…',
|
||||
'admin.defaultSettings.mapbox3d': '3D budovy & terén',
|
||||
'admin.defaultSettings.mapboxQuality': 'Režim vysoké kvality',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
|
||||
'Klikněte na avatar člena u rozpočtové položky pro zelené označení – to znamená, že zaplatil. Vyúčtování pak ukazuje, kdo komu a kolik dluží.',
|
||||
'budget.netBalances': 'Čisté zůstatky',
|
||||
'budget.categoriesLabel': 'kategorie',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "Vy",
|
||||
"costs.youShort": "Vy",
|
||||
"costs.youLower": "vy",
|
||||
"costs.youOwe": "Dlužíte",
|
||||
"costs.youOweSub": "Měli byste zaplatit ostatním",
|
||||
"costs.youreOwed": "Dluží vám",
|
||||
"costs.youreOwedSub": "Ostatní by měli zaplatit vám",
|
||||
"costs.totalSpend": "Celkové výdaje na cestu",
|
||||
"costs.totalSpendSub": "Za všechny cestovatele",
|
||||
"costs.to": "Komu",
|
||||
"costs.from": "Od",
|
||||
"costs.allSettled": "Máte vše vyrovnáno",
|
||||
"costs.nothingOwed": "Nikdo vám nic nedluží",
|
||||
"costs.yourShare": "Váš podíl",
|
||||
"costs.youPaid": "Zaplatili jste",
|
||||
"costs.expenses": "Výdaje",
|
||||
"costs.entries": "{count} položek",
|
||||
"costs.searchPlaceholder": "Hledat výdaje…",
|
||||
"costs.filter.all": "Vše",
|
||||
"costs.filter.mine": "Zaplaceno mnou",
|
||||
"costs.filter.owed": "Dluží mi",
|
||||
"costs.addExpense": "Přidat výdaj",
|
||||
"costs.editExpense": "Upravit výdaj",
|
||||
"costs.noMatch": "Žádné výdaje neodpovídají vašemu hledání.",
|
||||
"costs.emptyText": "Zatím žádné výdaje. Přidejte první.",
|
||||
"costs.spent": "Utraceno {amount}",
|
||||
"costs.noDate": "Bez data",
|
||||
"costs.noOnePaid": "Zatím nikdo nezaplatil",
|
||||
"costs.youLent": "půjčili jste {amount}",
|
||||
"costs.youBorrowed": "vypůjčili jste si {amount}",
|
||||
"costs.settleUp": "Vyrovnat",
|
||||
"costs.history": "Historie",
|
||||
"costs.everyoneSquare": "Všichni jsou vyrovnáni",
|
||||
"costs.nothingOutstanding": "Momentálně žádné nevyrovnané platby.",
|
||||
"costs.pay": "zaplatí",
|
||||
"costs.pays": "zaplatí",
|
||||
"costs.settle": "Vyrovnat",
|
||||
"costs.balances": "Zůstatky",
|
||||
"costs.byCategory": "Podle kategorie",
|
||||
"costs.noCategories": "Zatím žádné výdaje.",
|
||||
"costs.settleHistory": "Historie vyrovnání",
|
||||
"costs.noSettlements": "Zatím žádné vyrovnané platby.",
|
||||
"costs.paymentsSettled": "{count} plateb vyrovnáno",
|
||||
"costs.paid": "zaplaceno",
|
||||
"costs.undo": "Vrátit zpět",
|
||||
"costs.whatFor": "Za co to bylo?",
|
||||
"costs.namePlaceholder": "např. večeře, suvenýry, benzín…",
|
||||
"costs.totalAmount": "Celková částka",
|
||||
"costs.currency": "Měna",
|
||||
"costs.day": "Den",
|
||||
"costs.rateLabel": "1 {from} v {to}",
|
||||
"costs.category": "Kategorie",
|
||||
"costs.whoPaid": "Kdo zaplatil?",
|
||||
"costs.splitBetween": "Rozdělit rovným dílem mezi",
|
||||
"costs.pickSomeone": "Vyberte alespoň jednu osobu pro rozdělení.",
|
||||
"costs.splitSummary": "Rozděleno na {count} dílů · {amount} každý",
|
||||
"costs.cat.accommodation": "Ubytování",
|
||||
"costs.cat.food": "Jídlo a pití",
|
||||
"costs.cat.groceries": "Potraviny",
|
||||
"costs.cat.transport": "Doprava",
|
||||
"costs.cat.flights": "Lety",
|
||||
"costs.cat.activities": "Aktivity",
|
||||
"costs.cat.sightseeing": "Prohlídka památek",
|
||||
"costs.cat.shopping": "Nákupy",
|
||||
"costs.cat.fees": "Poplatky a vstupenky",
|
||||
"costs.cat.health": "Zdraví",
|
||||
"costs.cat.tips": "Spropitné",
|
||||
"costs.cat.other": "Ostatní",
|
||||
"costs.daysCount": "{count} dní",
|
||||
"costs.travelers": "{count} cestovatelů",
|
||||
"costs.liveRate": "aktuální kurz",
|
||||
"costs.settleAll": "Vyrovnat vše",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.createNew': 'Vytvořit nové místo',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Změnit pořadí dnů',
|
||||
'dayplan.reorderTitle': 'Změnit pořadí dnů',
|
||||
'dayplan.reorderHint': 'Místa, poznámky a rezervace daného dne se přesunou spolu s ním.',
|
||||
'dayplan.addDay': 'Přidat den',
|
||||
'dayplan.moveUp': 'Posunout nahoru',
|
||||
'dayplan.moveDown': 'Posunout dolů',
|
||||
'dayplan.reorderUndo': 'Změnit pořadí dnů',
|
||||
'dayplan.reorderError': 'Nepodařilo se změnit pořadí dnů',
|
||||
'dayplan.addDayError': 'Nepodařilo se přidat den',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Spojení',
|
||||
'map.showConnections': 'Zobrazit trasy rezervací',
|
||||
'map.hideConnections': 'Skrýt trasy rezervací',
|
||||
'poi.searchThisArea': 'Hledat v této oblasti',
|
||||
'poi.cat.restaurants': 'Restaurace',
|
||||
'poi.cat.cafes': 'Kavárny',
|
||||
'poi.cat.bars': 'Bary a noční život',
|
||||
'poi.cat.hotels': 'Ubytování',
|
||||
'poi.cat.sights': 'Památky',
|
||||
'poi.cat.museums': 'Muzea a kultura',
|
||||
'poi.cat.nature': 'Příroda a parky',
|
||||
'poi.cat.activities': 'Aktivity',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'Číslo letu',
|
||||
'reservations.meta.from': 'Z',
|
||||
'reservations.meta.to': 'Do',
|
||||
'reservations.layover.route': 'Trasa',
|
||||
'reservations.layover.stop': 'Zastávka',
|
||||
'reservations.layover.addStop': 'Přidat zastávku',
|
||||
'reservations.layover.connection': 'Přípoj',
|
||||
'reservations.layover.layover': 'Mezipřistání',
|
||||
'reservations.needsReview': 'Zkontrolovat',
|
||||
'reservations.needsReviewHint':
|
||||
'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
|
||||
|
||||
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Toto zařízení',
|
||||
'settings.passkey.lastUsed': 'Naposledy použito',
|
||||
'settings.passkey.neverUsed': 'Nikdy nepoužito',
|
||||
'settings.mapPoiPill': 'Objevovat místa na mapě',
|
||||
'settings.mapPoiPillHint': 'Zobrazit na mapě výletu kategorie pro hledání restaurací, hotelů a dalšího v okolí z OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -380,5 +380,15 @@ const admin: TranslationStrings = {
|
||||
'Entfernt alle Passkeys dieses Benutzers (z. B. bei einem verlorenen Gerät). Die Anmeldung mit Passwort bleibt weiterhin möglich.',
|
||||
'admin.passkey.resetConfirm': 'Alle Passkeys von {name} entfernen?',
|
||||
'admin.passkey.resetDone': '{count} Passkey(s) entfernt',
|
||||
'admin.defaultSettings.mapProvider': 'Kartendienst',
|
||||
'admin.defaultSettings.mapProviderHint': 'Die Standardkarte für alle auf dieser Instanz. Jeder Nutzer kann sie weiterhin in den eigenen Einstellungen überschreiben.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Standard (kostenlos)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Gemeinsames Mapbox-Token',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Wird für jeden Nutzer verwendet, der kein eigenes Token eingegeben hat — so erhält die gesamte Instanz Mapbox, ohne den Schlüssel einzeln teilen zu müssen. Verschlüsselt gespeichert.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Kartenstil',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Stil auswählen…',
|
||||
'admin.defaultSettings.mapbox3d': '3D-Gebäude & Gelände',
|
||||
'admin.defaultSettings.mapboxQuality': 'Hochqualitätsmodus',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.allAssigned': 'Alle Orte zugeordnet',
|
||||
'dayplan.mobile.noMatch': 'Kein Treffer',
|
||||
'dayplan.mobile.createNew': 'Neuen Ort erstellen',
|
||||
'dayplan.reorderDays': 'Tage neu anordnen',
|
||||
'dayplan.reorderTitle': 'Tage neu anordnen',
|
||||
'dayplan.reorderHint': 'Orte, Notizen und Buchungen eines Tages werden mitverschoben.',
|
||||
'dayplan.addDay': 'Tag hinzufügen',
|
||||
'dayplan.moveUp': 'Nach oben',
|
||||
'dayplan.moveDown': 'Nach unten',
|
||||
'dayplan.reorderUndo': 'Tage neu anordnen',
|
||||
'dayplan.reorderError': 'Tage konnten nicht neu angeordnet werden',
|
||||
'dayplan.addDayError': 'Tag konnte nicht hinzugefügt werden',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Verbindungen',
|
||||
'map.showConnections': 'Buchungsrouten anzeigen',
|
||||
'map.hideConnections': 'Buchungsrouten ausblenden',
|
||||
'poi.searchThisArea': 'Diesen Bereich durchsuchen',
|
||||
'poi.cat.restaurants': 'Restaurants',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.bars': 'Bars & Nachtleben',
|
||||
'poi.cat.hotels': 'Unterkünfte',
|
||||
'poi.cat.sights': 'Sehenswürdigkeiten',
|
||||
'poi.cat.museums': 'Museen & Kultur',
|
||||
'poi.cat.nature': 'Natur & Parks',
|
||||
'poi.cat.activities': 'Aktivitäten',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'Flugnr.',
|
||||
'reservations.meta.from': 'Von',
|
||||
'reservations.meta.to': 'Nach',
|
||||
'reservations.layover.route': 'Route',
|
||||
'reservations.layover.stop': 'Zwischenstopp',
|
||||
'reservations.layover.addStop': 'Zwischenstopp hinzufügen',
|
||||
'reservations.layover.connection': 'Anschlussflug',
|
||||
'reservations.layover.layover': 'Zwischenstopp',
|
||||
'reservations.needsReview': 'Prüfen',
|
||||
'reservations.needsReviewHint':
|
||||
'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
|
||||
|
||||
@@ -327,6 +327,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Dieses Gerät',
|
||||
'settings.passkey.lastUsed': 'Zuletzt verwendet',
|
||||
'settings.passkey.neverUsed': 'Noch nie verwendet',
|
||||
'settings.mapPoiPill': 'Orte auf der Karte entdecken',
|
||||
'settings.mapPoiPillHint': 'Zeigt auf der Reisekarte eine Kategorie-Pille an, um Restaurants, Hotels und mehr aus OpenStreetMap in der Nähe zu finden.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -198,6 +198,18 @@ const admin: TranslationStrings = {
|
||||
'admin.defaultSettings.saved': 'Default saved',
|
||||
'admin.defaultSettings.reset': 'Reset to built-in default',
|
||||
'admin.defaultSettings.resetToBuiltIn': 'reset',
|
||||
'admin.defaultSettings.mapProvider': 'Map engine',
|
||||
'admin.defaultSettings.mapProviderHint':
|
||||
'The default map for everyone on this instance. Each user can still override it in their own settings.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Standard (free)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Shared Mapbox token',
|
||||
'admin.defaultSettings.mapboxTokenHint':
|
||||
'Used for every user who has not entered their own token — so the whole instance gets Mapbox without sharing the key individually. Stored encrypted.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Map style',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Choose a style…',
|
||||
'admin.defaultSettings.mapbox3d': '3D buildings & terrain',
|
||||
'admin.defaultSettings.mapboxQuality': 'High-quality mode',
|
||||
'admin.tabs.templates': 'Packing Templates',
|
||||
'admin.packingTemplates.title': 'Packing Templates',
|
||||
'admin.packingTemplates.subtitle':
|
||||
|
||||
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.allAssigned': 'All places assigned',
|
||||
'dayplan.mobile.noMatch': 'No match',
|
||||
'dayplan.mobile.createNew': 'Create new place',
|
||||
'dayplan.reorderDays': 'Reorder days',
|
||||
'dayplan.reorderTitle': 'Reorder days',
|
||||
'dayplan.reorderHint': "A day's places, notes and bookings move with it.",
|
||||
'dayplan.addDay': 'Add day',
|
||||
'dayplan.moveUp': 'Move up',
|
||||
'dayplan.moveDown': 'Move down',
|
||||
'dayplan.reorderUndo': 'Reorder days',
|
||||
'dayplan.reorderError': 'Failed to reorder days',
|
||||
'dayplan.addDayError': 'Failed to add day',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Connections',
|
||||
'map.showConnections': 'Show booking routes',
|
||||
'map.hideConnections': 'Hide booking routes',
|
||||
'poi.searchThisArea': 'Search this area',
|
||||
'poi.cat.restaurants': 'Restaurants',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.bars': 'Bars & nightlife',
|
||||
'poi.cat.hotels': 'Accommodation',
|
||||
'poi.cat.sights': 'Sights',
|
||||
'poi.cat.museums': 'Museums & culture',
|
||||
'poi.cat.nature': 'Nature & parks',
|
||||
'poi.cat.activities': 'Activities',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'Flight No.',
|
||||
'reservations.meta.from': 'From',
|
||||
'reservations.meta.to': 'To',
|
||||
'reservations.layover.route': 'Route',
|
||||
'reservations.layover.stop': 'Stop',
|
||||
'reservations.layover.addStop': 'Add stop',
|
||||
'reservations.layover.connection': 'Connection',
|
||||
'reservations.layover.layover': 'Layover',
|
||||
'reservations.needsReview': 'Review',
|
||||
'reservations.needsReviewHint':
|
||||
'Airport could not be matched automatically — please confirm the location.',
|
||||
|
||||
@@ -63,6 +63,9 @@ const settings: TranslationStrings = {
|
||||
'settings.bookingLabels': 'Booking route labels',
|
||||
'settings.bookingLabelsHint':
|
||||
'Show station / airport names on the map. When off, only the icon is shown.',
|
||||
'settings.mapPoiPill': 'Explore places on the map',
|
||||
'settings.mapPoiPillHint':
|
||||
'Show a category pill on the trip map to find nearby restaurants, hotels and more from OpenStreetMap.',
|
||||
'settings.blurBookingCodes': 'Blur Booking Codes',
|
||||
'settings.optimizeFromAccommodation': 'Optimize route from accommodation',
|
||||
'settings.optimizeFromAccommodationHint':
|
||||
|
||||
@@ -388,5 +388,15 @@ const admin: TranslationStrings = {
|
||||
'Elimina todas las passkeys de este usuario (p. ej. tras perder un dispositivo). Aún podrá iniciar sesión con su contraseña.',
|
||||
'admin.passkey.resetConfirm': '¿Eliminar todas las passkeys de {name}?',
|
||||
'admin.passkey.resetDone': 'Se eliminaron {count} passkey(s)',
|
||||
'admin.defaultSettings.mapProvider': 'Motor de mapas',
|
||||
'admin.defaultSettings.mapProviderHint': 'El mapa predeterminado para todos en esta instancia. Cada usuario puede cambiarlo en sus propios ajustes.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Estándar (gratis)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Token de Mapbox compartido',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Se usa para cada usuario que no haya introducido su propio token, de modo que toda la instancia obtenga Mapbox sin compartir la clave individualmente. Se almacena cifrado.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Estilo de mapa',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Elige un estilo…',
|
||||
'admin.defaultSettings.mapbox3d': 'Edificios y terreno en 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Modo de alta calidad',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
||||
'Haz clic en el avatar de un miembro en una partida del presupuesto para marcarlo en verde — esto significa que ha pagado. La liquidación muestra quién debe cuánto a quién.',
|
||||
'budget.netBalances': 'Saldos netos',
|
||||
'budget.categoriesLabel': 'categorías',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "Tú",
|
||||
"costs.youShort": "Tú",
|
||||
"costs.youLower": "tú",
|
||||
"costs.youOwe": "Debes",
|
||||
"costs.youOweSub": "Deberías pagar a otros",
|
||||
"costs.youreOwed": "Te deben",
|
||||
"costs.youreOwedSub": "Otros deberían pagarte",
|
||||
"costs.totalSpend": "Gasto total del viaje",
|
||||
"costs.totalSpendSub": "Entre todos los viajeros",
|
||||
"costs.to": "Para",
|
||||
"costs.from": "De",
|
||||
"costs.allSettled": "Estás al día con todo",
|
||||
"costs.nothingOwed": "Nadie te debe nada",
|
||||
"costs.yourShare": "Tu parte",
|
||||
"costs.youPaid": "Pagaste",
|
||||
"costs.expenses": "Gastos",
|
||||
"costs.entries": "{count} entradas",
|
||||
"costs.searchPlaceholder": "Buscar gastos…",
|
||||
"costs.filter.all": "Todos",
|
||||
"costs.filter.mine": "Pagados por mí",
|
||||
"costs.filter.owed": "Me deben",
|
||||
"costs.addExpense": "Añadir gasto",
|
||||
"costs.editExpense": "Editar gasto",
|
||||
"costs.noMatch": "Ningún gasto coincide con tu búsqueda.",
|
||||
"costs.emptyText": "Aún no hay gastos. Añade el primero.",
|
||||
"costs.spent": "{amount} gastados",
|
||||
"costs.noDate": "Sin fecha",
|
||||
"costs.noOnePaid": "Nadie ha pagado aún",
|
||||
"costs.youLent": "prestaste {amount}",
|
||||
"costs.youBorrowed": "tomaste prestado {amount}",
|
||||
"costs.settleUp": "Saldar cuentas",
|
||||
"costs.history": "Historial",
|
||||
"costs.everyoneSquare": "Todos están en paz",
|
||||
"costs.nothingOutstanding": "No hay pagos pendientes ahora mismo.",
|
||||
"costs.pay": "paga",
|
||||
"costs.pays": "paga",
|
||||
"costs.settle": "Saldar",
|
||||
"costs.balances": "Saldos",
|
||||
"costs.byCategory": "Por categoría",
|
||||
"costs.noCategories": "Aún no hay gastos.",
|
||||
"costs.settleHistory": "Historial de pagos",
|
||||
"costs.noSettlements": "Aún no hay pagos saldados.",
|
||||
"costs.paymentsSettled": "{count} pagos saldados",
|
||||
"costs.paid": "pagado",
|
||||
"costs.undo": "Deshacer",
|
||||
"costs.whatFor": "¿Para qué fue?",
|
||||
"costs.namePlaceholder": "p. ej. Cena, souvenirs, gasolina…",
|
||||
"costs.totalAmount": "Importe total",
|
||||
"costs.currency": "Moneda",
|
||||
"costs.day": "Día",
|
||||
"costs.rateLabel": "1 {from} en {to}",
|
||||
"costs.category": "Categoría",
|
||||
"costs.whoPaid": "¿Quién pagó?",
|
||||
"costs.splitBetween": "Dividir a partes iguales entre",
|
||||
"costs.pickSomeone": "Elige al menos una persona con quien dividir.",
|
||||
"costs.splitSummary": "Dividido entre {count} · {amount} cada uno",
|
||||
"costs.cat.accommodation": "Alojamiento",
|
||||
"costs.cat.food": "Comida y bebida",
|
||||
"costs.cat.groceries": "Compras de comida",
|
||||
"costs.cat.transport": "Transporte",
|
||||
"costs.cat.flights": "Vuelos",
|
||||
"costs.cat.activities": "Actividades",
|
||||
"costs.cat.sightseeing": "Turismo",
|
||||
"costs.cat.shopping": "Compras",
|
||||
"costs.cat.fees": "Tasas y entradas",
|
||||
"costs.cat.health": "Salud",
|
||||
"costs.cat.tips": "Propinas",
|
||||
"costs.cat.other": "Otros",
|
||||
"costs.daysCount": "{count} días",
|
||||
"costs.travelers": "{count} viajeros",
|
||||
"costs.liveRate": "tasa en vivo",
|
||||
"costs.settleAll": "Saldar todo",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -47,5 +47,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.createNew': 'Crear nuevo lugar',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Reordenar días',
|
||||
'dayplan.reorderTitle': 'Reordenar días',
|
||||
'dayplan.reorderHint': 'Los lugares, las notas y las reservas de un día se mueven con él.',
|
||||
'dayplan.addDay': 'Añadir día',
|
||||
'dayplan.moveUp': 'Subir',
|
||||
'dayplan.moveDown': 'Bajar',
|
||||
'dayplan.reorderUndo': 'Reordenar días',
|
||||
'dayplan.reorderError': 'No se pudieron reordenar los días',
|
||||
'dayplan.addDayError': 'No se pudo añadir el día',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Conexiones',
|
||||
'map.showConnections': 'Mostrar rutas de reservas',
|
||||
'map.hideConnections': 'Ocultar rutas de reservas',
|
||||
'poi.searchThisArea': 'Buscar en esta zona',
|
||||
'poi.cat.restaurants': 'Restaurantes',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.bars': 'Bares y ocio nocturno',
|
||||
'poi.cat.hotels': 'Alojamiento',
|
||||
'poi.cat.sights': 'Lugares de interés',
|
||||
'poi.cat.museums': 'Museos y cultura',
|
||||
'poi.cat.nature': 'Naturaleza y parques',
|
||||
'poi.cat.activities': 'Actividades',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -101,6 +101,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'N° de vuelo',
|
||||
'reservations.meta.from': 'Desde',
|
||||
'reservations.meta.to': 'Hasta',
|
||||
'reservations.layover.route': 'Ruta',
|
||||
'reservations.layover.stop': 'Escala',
|
||||
'reservations.layover.addStop': 'Añadir escala',
|
||||
'reservations.layover.connection': 'Conexión',
|
||||
'reservations.layover.layover': 'Escala',
|
||||
'reservations.needsReview': 'Revisar',
|
||||
'reservations.needsReviewHint':
|
||||
'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
|
||||
|
||||
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Este dispositivo',
|
||||
'settings.passkey.lastUsed': 'Último uso',
|
||||
'settings.passkey.neverUsed': 'Nunca usada',
|
||||
'settings.mapPoiPill': 'Explorar lugares en el mapa',
|
||||
'settings.mapPoiPillHint': 'Muestra una píldora de categorías en el mapa del viaje para encontrar restaurantes, alojamientos y más cerca, desde OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -388,5 +388,15 @@ const admin: TranslationStrings = {
|
||||
"Supprime toutes les passkeys de cet utilisateur (ex. en cas d'appareil perdu). Il pourra toujours se connecter avec son mot de passe.",
|
||||
'admin.passkey.resetConfirm': 'Supprimer toutes les passkeys de {name} ?',
|
||||
'admin.passkey.resetDone': '{count} passkey(s) supprimée(s)',
|
||||
'admin.defaultSettings.mapProvider': 'Moteur cartographique',
|
||||
'admin.defaultSettings.mapProviderHint': 'La carte par défaut pour tous les utilisateurs de cette instance. Chaque utilisateur peut toujours la remplacer dans ses propres paramètres.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Standard (gratuit)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Jeton Mapbox partagé',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Utilisé pour chaque utilisateur n\'ayant pas saisi son propre jeton — ainsi toute l\'instance bénéficie de Mapbox sans partager la clé individuellement. Stocké de façon chiffrée.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Style de carte',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Choisissez un style…',
|
||||
'admin.defaultSettings.mapbox3d': 'Bâtiments & terrain en 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Mode haute qualité',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
||||
"Cliquez sur l'avatar d'un membre sur un poste budgétaire pour le marquer en vert — cela signifie qu'il a payé. Le règlement indique ensuite qui doit combien à qui.",
|
||||
'budget.netBalances': 'Soldes nets',
|
||||
'budget.categoriesLabel': 'catégories',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.you": "Vous",
|
||||
"costs.youShort": "V",
|
||||
"costs.youLower": "vous",
|
||||
"costs.youOwe": "Vous devez",
|
||||
"costs.youOweSub": "Vous devez payer les autres",
|
||||
"costs.youreOwed": "On vous doit",
|
||||
"costs.youreOwedSub": "Les autres doivent vous payer",
|
||||
"costs.totalSpend": "Dépenses totales du voyage",
|
||||
"costs.totalSpendSub": "Tous voyageurs confondus",
|
||||
"costs.to": "À",
|
||||
"costs.from": "De",
|
||||
"costs.allSettled": "Tout est réglé pour vous",
|
||||
"costs.nothingOwed": "On ne vous doit rien",
|
||||
"costs.yourShare": "Votre part",
|
||||
"costs.youPaid": "Vous avez payé",
|
||||
"costs.expenses": "Dépenses",
|
||||
"costs.entries": "{count} entrées",
|
||||
"costs.searchPlaceholder": "Rechercher des dépenses…",
|
||||
"costs.filter.all": "Toutes",
|
||||
"costs.filter.mine": "Payées par moi",
|
||||
"costs.filter.owed": "On me doit",
|
||||
"costs.addExpense": "Ajouter une dépense",
|
||||
"costs.editExpense": "Modifier la dépense",
|
||||
"costs.noMatch": "Aucune dépense ne correspond à votre recherche.",
|
||||
"costs.emptyText": "Aucune dépense pour le moment. Ajoutez la première.",
|
||||
"costs.spent": "{amount} dépensés",
|
||||
"costs.noDate": "Aucune date",
|
||||
"costs.noOnePaid": "Personne n'a encore payé",
|
||||
"costs.youLent": "vous avez prêté {amount}",
|
||||
"costs.youBorrowed": "vous avez emprunté {amount}",
|
||||
"costs.settleUp": "Régler",
|
||||
"costs.history": "Historique",
|
||||
"costs.everyoneSquare": "Tout le monde est quitte",
|
||||
"costs.nothingOutstanding": "Aucun paiement en attente pour le moment.",
|
||||
"costs.pay": "payer",
|
||||
"costs.pays": "paie",
|
||||
"costs.settle": "Régler",
|
||||
"costs.balances": "Soldes",
|
||||
"costs.byCategory": "Par catégorie",
|
||||
"costs.noCategories": "Aucune dépense pour le moment.",
|
||||
"costs.settleHistory": "Historique des règlements",
|
||||
"costs.noSettlements": "Aucun paiement réglé pour le moment.",
|
||||
"costs.paymentsSettled": "{count} paiements réglés",
|
||||
"costs.paid": "payé",
|
||||
"costs.undo": "Annuler",
|
||||
"costs.whatFor": "C'était pour quoi ?",
|
||||
"costs.namePlaceholder": "ex. dîner, souvenirs, essence…",
|
||||
"costs.totalAmount": "Montant total",
|
||||
"costs.currency": "Devise",
|
||||
"costs.day": "Jour",
|
||||
"costs.rateLabel": "1 {from} en {to}",
|
||||
"costs.category": "Catégorie",
|
||||
"costs.whoPaid": "Qui a payé ?",
|
||||
"costs.splitBetween": "Partager équitablement entre",
|
||||
"costs.pickSomeone": "Choisissez au moins une personne avec qui partager.",
|
||||
"costs.splitSummary": "Partagé en {count} · {amount} chacun",
|
||||
"costs.cat.accommodation": "Hébergement",
|
||||
"costs.cat.food": "Nourriture et boissons",
|
||||
"costs.cat.groceries": "Courses",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.flights": "Vols",
|
||||
"costs.cat.activities": "Activités",
|
||||
"costs.cat.sightseeing": "Visites",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.cat.fees": "Frais et billets",
|
||||
"costs.cat.health": "Santé",
|
||||
"costs.cat.tips": "Pourboires",
|
||||
"costs.cat.other": "Autre",
|
||||
"costs.daysCount": "{count} jours",
|
||||
"costs.travelers": "{count} voyageurs",
|
||||
"costs.liveRate": "taux en direct",
|
||||
"costs.settleAll": "Tout régler",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -49,5 +49,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.createNew': 'Créer un nouveau lieu',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Réorganiser les jours',
|
||||
'dayplan.reorderTitle': 'Réorganiser les jours',
|
||||
'dayplan.reorderHint': 'Les lieux, notes et réservations d\'un jour le suivent.',
|
||||
'dayplan.addDay': 'Ajouter un jour',
|
||||
'dayplan.moveUp': 'Monter',
|
||||
'dayplan.moveDown': 'Descendre',
|
||||
'dayplan.reorderUndo': 'Réorganiser les jours',
|
||||
'dayplan.reorderError': 'Échec de la réorganisation des jours',
|
||||
'dayplan.addDayError': 'Échec de l\'ajout du jour',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Connexions',
|
||||
'map.showConnections': 'Afficher les itinéraires',
|
||||
'map.hideConnections': 'Masquer les itinéraires',
|
||||
'poi.searchThisArea': 'Rechercher dans cette zone',
|
||||
'poi.cat.restaurants': 'Restaurants',
|
||||
'poi.cat.cafes': 'Cafés',
|
||||
'poi.cat.bars': 'Bars & vie nocturne',
|
||||
'poi.cat.hotels': 'Hébergement',
|
||||
'poi.cat.sights': 'Sites touristiques',
|
||||
'poi.cat.museums': 'Musées & culture',
|
||||
'poi.cat.nature': 'Nature & parcs',
|
||||
'poi.cat.activities': 'Activités',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'N° de vol',
|
||||
'reservations.meta.from': 'De',
|
||||
'reservations.meta.to': 'À',
|
||||
'reservations.layover.route': 'Itinéraire',
|
||||
'reservations.layover.stop': 'Escale',
|
||||
'reservations.layover.addStop': 'Ajouter une escale',
|
||||
'reservations.layover.connection': 'Correspondance',
|
||||
'reservations.layover.layover': 'Escale',
|
||||
'reservations.needsReview': 'Vérifier',
|
||||
'reservations.needsReviewHint':
|
||||
"L'aéroport n'a pas pu être identifié automatiquement — veuillez confirmer l'emplacement.",
|
||||
|
||||
@@ -329,6 +329,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Cet appareil',
|
||||
'settings.passkey.lastUsed': 'Dernière utilisation',
|
||||
'settings.passkey.neverUsed': 'Jamais utilisée',
|
||||
'settings.mapPoiPill': 'Explorer les lieux sur la carte',
|
||||
'settings.mapPoiPillHint': 'Afficher une pastille de catégorie sur la carte du voyage pour trouver à proximité des restaurants, hébergements et plus encore depuis OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -389,5 +389,15 @@ const admin: TranslationStrings = {
|
||||
'Αφαιρέστε όλα τα passkeys αυτού του χρήστη (π.χ. σε περίπτωση χαμένης συσκευής). Μπορούν ακόμη να συνδεθούν με τον κωδικό τους.',
|
||||
'admin.passkey.resetConfirm': 'Αφαίρεση όλων των passkeys για τον/την {name};',
|
||||
'admin.passkey.resetDone': 'Αφαιρέθηκαν {count} passkey(s)',
|
||||
'admin.defaultSettings.mapProvider': 'Μηχανή χάρτη',
|
||||
'admin.defaultSettings.mapProviderHint': 'Ο προεπιλεγμένος χάρτης για όλους σε αυτή την εγκατάσταση. Κάθε χρήστης μπορεί να τον αλλάξει στις δικές του ρυθμίσεις.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Τυπικός (δωρεάν)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Κοινόχρηστο διακριτικό Mapbox',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Χρησιμοποιείται για κάθε χρήστη που δεν έχει εισαγάγει το δικό του διακριτικό — έτσι ολόκληρη η εγκατάσταση αποκτά Mapbox χωρίς να μοιράζεται το κλειδί ξεχωριστά. Αποθηκεύεται κρυπτογραφημένο.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Στυλ χάρτη',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Επιλέξτε ένα στυλ…',
|
||||
'admin.defaultSettings.mapbox3d': 'Κτίρια & ανάγλυφο 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Λειτουργία υψηλής ποιότητας',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
||||
'Κάντε κλικ στο avatar ενός μέλους σε μια εγγραφή προϋπολογισμού για να το επισημάνετε πράσινο — αυτό σημαίνει ότι πλήρωσε. Η εκκαθάριση δείχνει στη συνέχεια ποιος χρωστάει σε ποιον και πόσα.',
|
||||
'budget.netBalances': 'Καθαρά Υπόλοιπα',
|
||||
'budget.categoriesLabel': 'κατηγορίες',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "Εσείς",
|
||||
"costs.youShort": "Ε",
|
||||
"costs.youLower": "εσείς",
|
||||
"costs.youOwe": "Χρωστάτε",
|
||||
"costs.youOweSub": "Πρέπει να πληρώσετε άλλους",
|
||||
"costs.youreOwed": "Σας χρωστούν",
|
||||
"costs.youreOwedSub": "Άλλοι πρέπει να σας πληρώσουν",
|
||||
"costs.totalSpend": "Συνολικές δαπάνες ταξιδιού",
|
||||
"costs.totalSpendSub": "Όλων των ταξιδιωτών",
|
||||
"costs.to": "Προς",
|
||||
"costs.from": "Από",
|
||||
"costs.allSettled": "Έχετε εξοφλήσει τα πάντα",
|
||||
"costs.nothingOwed": "Δεν σας χρωστάει κανείς",
|
||||
"costs.yourShare": "Το μερίδιό σας",
|
||||
"costs.youPaid": "Πληρώσατε",
|
||||
"costs.expenses": "Έξοδα",
|
||||
"costs.entries": "{count} εγγραφές",
|
||||
"costs.searchPlaceholder": "Αναζήτηση εξόδων…",
|
||||
"costs.filter.all": "Όλα",
|
||||
"costs.filter.mine": "Πληρωμένα από εμένα",
|
||||
"costs.filter.owed": "Μου χρωστούν",
|
||||
"costs.addExpense": "Προσθήκη εξόδου",
|
||||
"costs.editExpense": "Επεξεργασία εξόδου",
|
||||
"costs.noMatch": "Κανένα έξοδο δεν ταιριάζει με την αναζήτησή σας.",
|
||||
"costs.emptyText": "Δεν υπάρχουν έξοδα ακόμη. Προσθέστε το πρώτο σας.",
|
||||
"costs.spent": "{amount} δαπάνη",
|
||||
"costs.noDate": "Χωρίς ημερομηνία",
|
||||
"costs.noOnePaid": "Δεν πλήρωσε κανείς ακόμη",
|
||||
"costs.youLent": "δανείσατε {amount}",
|
||||
"costs.youBorrowed": "δανειστήκατε {amount}",
|
||||
"costs.settleUp": "Εξόφληση",
|
||||
"costs.history": "Ιστορικό",
|
||||
"costs.everyoneSquare": "Όλοι είναι ξεκάθαροι",
|
||||
"costs.nothingOutstanding": "Δεν υπάρχουν εκκρεμείς πληρωμές αυτή τη στιγμή.",
|
||||
"costs.pay": "πληρώνει",
|
||||
"costs.pays": "πληρώνει",
|
||||
"costs.settle": "Εξόφληση",
|
||||
"costs.balances": "Υπόλοιπα",
|
||||
"costs.byCategory": "Ανά κατηγορία",
|
||||
"costs.noCategories": "Δεν υπάρχουν έξοδα ακόμη.",
|
||||
"costs.settleHistory": "Ιστορικό εξοφλήσεων",
|
||||
"costs.noSettlements": "Δεν υπάρχουν εξοφλημένες πληρωμές ακόμη.",
|
||||
"costs.paymentsSettled": "{count} πληρωμές εξοφλήθηκαν",
|
||||
"costs.paid": "πλήρωσε",
|
||||
"costs.undo": "Αναίρεση",
|
||||
"costs.whatFor": "Για τι ήταν;",
|
||||
"costs.namePlaceholder": "π.χ. Δείπνο, σουβενίρ, βενζίνη…",
|
||||
"costs.totalAmount": "Συνολικό ποσό",
|
||||
"costs.currency": "Νόμισμα",
|
||||
"costs.day": "Ημέρα",
|
||||
"costs.rateLabel": "1 {from} σε {to}",
|
||||
"costs.category": "Κατηγορία",
|
||||
"costs.whoPaid": "Ποιος πλήρωσε;",
|
||||
"costs.splitBetween": "Ισόποση κατανομή μεταξύ",
|
||||
"costs.pickSomeone": "Επιλέξτε τουλάχιστον ένα άτομο για τον διαμοιρασμό.",
|
||||
"costs.splitSummary": "Κατανομή σε {count} μέρη · {amount} το καθένα",
|
||||
"costs.cat.accommodation": "Διαμονή",
|
||||
"costs.cat.food": "Φαγητό & ποτό",
|
||||
"costs.cat.groceries": "Ψώνια σούπερ μάρκετ",
|
||||
"costs.cat.transport": "Μεταφορά",
|
||||
"costs.cat.flights": "Πτήσεις",
|
||||
"costs.cat.activities": "Δραστηριότητες",
|
||||
"costs.cat.sightseeing": "Αξιοθέατα",
|
||||
"costs.cat.shopping": "Ψώνια",
|
||||
"costs.cat.fees": "Τέλη & εισιτήρια",
|
||||
"costs.cat.health": "Υγεία",
|
||||
"costs.cat.tips": "Φιλοδωρήματα",
|
||||
"costs.cat.other": "Άλλα",
|
||||
"costs.daysCount": "{count} ημέρες",
|
||||
"costs.travelers": "{count} ταξιδιώτες",
|
||||
"costs.liveRate": "ζωντανή ισοτιμία",
|
||||
"costs.settleAll": "Εξόφληση όλων",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -49,5 +49,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.allAssigned': 'Όλες οι τοποθεσίες έχουν ανατεθεί',
|
||||
'dayplan.mobile.noMatch': 'Καμία αντιστοιχία',
|
||||
'dayplan.mobile.createNew': 'Δημιουργία νέας τοποθεσίας',
|
||||
'dayplan.reorderDays': 'Αναδιάταξη ημερών',
|
||||
'dayplan.reorderTitle': 'Αναδιάταξη ημερών',
|
||||
'dayplan.reorderHint': 'Τα μέρη, οι σημειώσεις και οι κρατήσεις μιας ημέρας μετακινούνται μαζί της.',
|
||||
'dayplan.addDay': 'Προσθήκη ημέρας',
|
||||
'dayplan.moveUp': 'Μετακίνηση πάνω',
|
||||
'dayplan.moveDown': 'Μετακίνηση κάτω',
|
||||
'dayplan.reorderUndo': 'Αναδιάταξη ημερών',
|
||||
'dayplan.reorderError': 'Η αναδιάταξη των ημερών απέτυχε',
|
||||
'dayplan.addDayError': 'Η προσθήκη ημέρας απέτυχε',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Συνδέσεις',
|
||||
'map.showConnections': 'Εμφάνιση διαδρομών κρατήσεων',
|
||||
'map.hideConnections': 'Απόκρυψη διαδρομών κρατήσεων',
|
||||
'poi.searchThisArea': 'Αναζήτηση σε αυτήν την περιοχή',
|
||||
'poi.cat.restaurants': 'Εστιατόρια',
|
||||
'poi.cat.cafes': 'Καφέ',
|
||||
'poi.cat.bars': 'Μπαρ & νυχτερινή ζωή',
|
||||
'poi.cat.hotels': 'Διαμονή',
|
||||
'poi.cat.sights': 'Αξιοθέατα',
|
||||
'poi.cat.museums': 'Μουσεία & πολιτισμός',
|
||||
'poi.cat.nature': 'Φύση & πάρκα',
|
||||
'poi.cat.activities': 'Δραστηριότητες',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'Αρ. Πτήσης',
|
||||
'reservations.meta.from': 'Από',
|
||||
'reservations.meta.to': 'Προς',
|
||||
'reservations.layover.route': 'Διαδρομή',
|
||||
'reservations.layover.stop': 'Στάση',
|
||||
'reservations.layover.addStop': 'Προσθήκη στάσης',
|
||||
'reservations.layover.connection': 'Ανταπόκριση',
|
||||
'reservations.layover.layover': 'Ενδιάμεση στάση',
|
||||
'reservations.needsReview': 'Έλεγχος',
|
||||
'reservations.needsReviewHint':
|
||||
'Δεν ήταν δυνατή η αυτόματη αντιστοίχιση του αεροδρομίου — παρακαλώ επιβεβαιώστε την τοποθεσία.',
|
||||
|
||||
@@ -330,6 +330,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Αυτή η συσκευή',
|
||||
'settings.passkey.lastUsed': 'Τελευταία χρήση',
|
||||
'settings.passkey.neverUsed': 'Δεν χρησιμοποιήθηκε ποτέ',
|
||||
'settings.mapPoiPill': 'Εξερεύνηση μερών στον χάρτη',
|
||||
'settings.mapPoiPillHint': 'Εμφάνιση ετικέτας κατηγορίας στον χάρτη του ταξιδιού για εύρεση κοντινών εστιατορίων, ξενοδοχείων και άλλων από το OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -381,5 +381,15 @@ const admin: TranslationStrings = {
|
||||
'Eltávolítja a felhasználó összes passkey-jét (pl. elveszett eszköz esetén). A jelszavukkal továbbra is be tudnak jelentkezni.',
|
||||
'admin.passkey.resetConfirm': 'Eltávolítod {name} összes passkey-jét?',
|
||||
'admin.passkey.resetDone': '{count} passkey eltávolítva',
|
||||
'admin.defaultSettings.mapProvider': 'Térképmotor',
|
||||
'admin.defaultSettings.mapProviderHint': 'Az alapértelmezett térkép mindenkinek ezen a példányon. Minden felhasználó felülírhatja a saját beállításaiban.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Alapértelmezett (ingyenes)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Megosztott Mapbox-token',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Minden olyan felhasználóhoz használatos, aki nem adta meg a saját tokenjét — így az egész példány eléri a Mapboxot anélkül, hogy egyenként kellene megosztani a kulcsot. Titkosítva tárolódik.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Térképstílus',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Válassz stílust…',
|
||||
'admin.defaultSettings.mapbox3d': '3D épületek & domborzat',
|
||||
'admin.defaultSettings.mapboxQuality': 'Kiváló minőségű mód',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
||||
'Kattints egy tag avatárjára egy költségvetési tételen a zöld jelöléshez — ez azt jelenti, hogy fizetett. Az elszámolás ezután mutatja, ki kinek mennyivel tartozik.',
|
||||
'budget.netBalances': 'Nettó egyenlegek',
|
||||
'budget.categoriesLabel': 'kategóriák',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "Te",
|
||||
"costs.youShort": "T",
|
||||
"costs.youLower": "te",
|
||||
"costs.youOwe": "Tartozol",
|
||||
"costs.youOweSub": "Fizetned kell másoknak",
|
||||
"costs.youreOwed": "Neked tartoznak",
|
||||
"costs.youreOwedSub": "Mások fizetnek neked",
|
||||
"costs.totalSpend": "Teljes utazási költség",
|
||||
"costs.totalSpendSub": "Az összes utazóra vetítve",
|
||||
"costs.to": "Kinek",
|
||||
"costs.from": "Kitől",
|
||||
"costs.allSettled": "Minden el van számolva",
|
||||
"costs.nothingOwed": "Senki sem tartozik neked",
|
||||
"costs.yourShare": "A te részed",
|
||||
"costs.youPaid": "Te fizettél",
|
||||
"costs.expenses": "Költségek",
|
||||
"costs.entries": "{count} bejegyzés",
|
||||
"costs.searchPlaceholder": "Költségek keresése…",
|
||||
"costs.filter.all": "Mind",
|
||||
"costs.filter.mine": "Én fizettem",
|
||||
"costs.filter.owed": "Nekem tartoznak",
|
||||
"costs.addExpense": "Költség hozzáadása",
|
||||
"costs.editExpense": "Költség szerkesztése",
|
||||
"costs.noMatch": "Nincs a keresésnek megfelelő költség.",
|
||||
"costs.emptyText": "Még nincs költség. Add hozzá az elsőt.",
|
||||
"costs.spent": "{amount} elköltve",
|
||||
"costs.noDate": "Nincs dátum",
|
||||
"costs.noOnePaid": "Még senki sem fizetett",
|
||||
"costs.youLent": "{amount} kölcsönadtál",
|
||||
"costs.youBorrowed": "{amount} kölcsönkértél",
|
||||
"costs.settleUp": "Elszámolás",
|
||||
"costs.history": "Előzmények",
|
||||
"costs.everyoneSquare": "Mindenki kvittben van",
|
||||
"costs.nothingOutstanding": "Jelenleg nincs kifizetendő összeg.",
|
||||
"costs.pay": "fizet",
|
||||
"costs.pays": "fizet",
|
||||
"costs.settle": "Elszámol",
|
||||
"costs.balances": "Egyenlegek",
|
||||
"costs.byCategory": "Kategóriánként",
|
||||
"costs.noCategories": "Még nincs költség.",
|
||||
"costs.settleHistory": "Elszámolási előzmények",
|
||||
"costs.noSettlements": "Még nincs elszámolt fizetés.",
|
||||
"costs.paymentsSettled": "{count} fizetés elszámolva",
|
||||
"costs.paid": "fizetve",
|
||||
"costs.undo": "Visszavonás",
|
||||
"costs.whatFor": "Mire volt?",
|
||||
"costs.namePlaceholder": "pl. vacsora, ajándékok, benzin…",
|
||||
"costs.totalAmount": "Teljes összeg",
|
||||
"costs.currency": "Pénznem",
|
||||
"costs.day": "Nap",
|
||||
"costs.rateLabel": "1 {from} ennyi: {to}",
|
||||
"costs.category": "Kategória",
|
||||
"costs.whoPaid": "Ki fizetett?",
|
||||
"costs.splitBetween": "Egyenlően elosztva köztük",
|
||||
"costs.pickSomeone": "Válassz legalább egy személyt a megosztáshoz.",
|
||||
"costs.splitSummary": "{count} fő közt megosztva · egyenként {amount}",
|
||||
"costs.cat.accommodation": "Szállás",
|
||||
"costs.cat.food": "Étel és ital",
|
||||
"costs.cat.groceries": "Élelmiszer",
|
||||
"costs.cat.transport": "Közlekedés",
|
||||
"costs.cat.flights": "Repülőjáratok",
|
||||
"costs.cat.activities": "Programok",
|
||||
"costs.cat.sightseeing": "Városnézés",
|
||||
"costs.cat.shopping": "Vásárlás",
|
||||
"costs.cat.fees": "Díjak és jegyek",
|
||||
"costs.cat.health": "Egészség",
|
||||
"costs.cat.tips": "Borravaló",
|
||||
"costs.cat.other": "Egyéb",
|
||||
"costs.daysCount": "{count} nap",
|
||||
"costs.travelers": "{count} utazó",
|
||||
"costs.liveRate": "élő árfolyam",
|
||||
"costs.settleAll": "Összes elszámolása",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.createNew': 'Új helyszín létrehozása',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Napok átrendezése',
|
||||
'dayplan.reorderTitle': 'Napok átrendezése',
|
||||
'dayplan.reorderHint': 'A nap helyei, jegyzetei és foglalásai együtt mozognak vele.',
|
||||
'dayplan.addDay': 'Nap hozzáadása',
|
||||
'dayplan.moveUp': 'Mozgatás felfelé',
|
||||
'dayplan.moveDown': 'Mozgatás lefelé',
|
||||
'dayplan.reorderUndo': 'Napok átrendezése',
|
||||
'dayplan.reorderError': 'Nem sikerült átrendezni a napokat',
|
||||
'dayplan.addDayError': 'Nem sikerült napot hozzáadni',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Kapcsolatok',
|
||||
'map.showConnections': 'Foglalási útvonalak megjelenítése',
|
||||
'map.hideConnections': 'Foglalási útvonalak elrejtése',
|
||||
'poi.searchThisArea': 'Keresés ezen a területen',
|
||||
'poi.cat.restaurants': 'Éttermek',
|
||||
'poi.cat.cafes': 'Kávézók',
|
||||
'poi.cat.bars': 'Bárok és éjszakai élet',
|
||||
'poi.cat.hotels': 'Szállás',
|
||||
'poi.cat.sights': 'Látnivalók',
|
||||
'poi.cat.museums': 'Múzeumok és kultúra',
|
||||
'poi.cat.nature': 'Természet és parkok',
|
||||
'poi.cat.activities': 'Programok',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -29,6 +29,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'Járatszám',
|
||||
'reservations.meta.from': 'Honnan',
|
||||
'reservations.meta.to': 'Hová',
|
||||
'reservations.layover.route': 'Útvonal',
|
||||
'reservations.layover.stop': 'Megálló',
|
||||
'reservations.layover.addStop': 'Megálló hozzáadása',
|
||||
'reservations.layover.connection': 'Csatlakozás',
|
||||
'reservations.layover.layover': 'Átszállás',
|
||||
'reservations.needsReview': 'Ellenőrzés',
|
||||
'reservations.needsReviewHint':
|
||||
'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
|
||||
|
||||
@@ -326,6 +326,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Ez az eszköz',
|
||||
'settings.passkey.lastUsed': 'Utoljára használva',
|
||||
'settings.passkey.neverUsed': 'Még nem használt',
|
||||
'settings.mapPoiPill': 'Helyek felfedezése a térképen',
|
||||
'settings.mapPoiPillHint': 'Megjelenít egy kategóriasávot az utazási térképen, hogy az OpenStreetMap segítségével közeli éttermeket, szállásokat és továbbiakat találj.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -376,5 +376,15 @@ const admin: TranslationStrings = {
|
||||
'Hapus semua passkey pengguna ini (mis. saat perangkat hilang). Mereka tetap bisa masuk dengan kata sandi mereka.',
|
||||
'admin.passkey.resetConfirm': 'Hapus semua passkey untuk {name}?',
|
||||
'admin.passkey.resetDone': 'Menghapus {count} passkey',
|
||||
'admin.defaultSettings.mapProvider': 'Mesin peta',
|
||||
'admin.defaultSettings.mapProviderHint': 'Peta default untuk semua orang di instance ini. Setiap pengguna tetap dapat menggantinya di pengaturan masing-masing.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Standar (gratis)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Token Mapbox bersama',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Digunakan untuk setiap pengguna yang belum memasukkan token mereka sendiri — sehingga seluruh instance mendapatkan Mapbox tanpa perlu membagikan kunci satu per satu. Disimpan dalam bentuk terenkripsi.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Gaya peta',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Pilih gaya…',
|
||||
'admin.defaultSettings.mapbox3d': 'Bangunan & medan 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Mode kualitas tinggi',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
|
||||
'Klik foto anggota di item anggaran untuk menandainya hijau — artinya mereka sudah bayar. Penyelesaian lalu menunjukkan siapa berhutang ke siapa dan berapa.',
|
||||
'budget.netBalances': 'Saldo Bersih',
|
||||
'budget.categoriesLabel': 'kategori',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.you": "Kamu",
|
||||
"costs.youShort": "K",
|
||||
"costs.youLower": "kamu",
|
||||
"costs.youOwe": "Kamu berhutang",
|
||||
"costs.youOweSub": "Kamu harus membayar yang lain",
|
||||
"costs.youreOwed": "Kamu dipinjami",
|
||||
"costs.youreOwedSub": "Yang lain harus membayarmu",
|
||||
"costs.totalSpend": "Total pengeluaran perjalanan",
|
||||
"costs.totalSpendSub": "Untuk semua pelancong",
|
||||
"costs.to": "Ke",
|
||||
"costs.from": "Dari",
|
||||
"costs.allSettled": "Semua sudah lunas",
|
||||
"costs.nothingOwed": "Tidak ada yang berhutang padamu",
|
||||
"costs.yourShare": "Bagianmu",
|
||||
"costs.youPaid": "Kamu membayar",
|
||||
"costs.expenses": "Pengeluaran",
|
||||
"costs.entries": "{count} entri",
|
||||
"costs.searchPlaceholder": "Cari pengeluaran…",
|
||||
"costs.filter.all": "Semua",
|
||||
"costs.filter.mine": "Dibayar olehku",
|
||||
"costs.filter.owed": "Dipinjami padaku",
|
||||
"costs.addExpense": "Tambah pengeluaran",
|
||||
"costs.editExpense": "Edit pengeluaran",
|
||||
"costs.noMatch": "Tidak ada pengeluaran yang cocok dengan pencarianmu.",
|
||||
"costs.emptyText": "Belum ada pengeluaran. Tambahkan yang pertama.",
|
||||
"costs.spent": "{amount} dibelanjakan",
|
||||
"costs.noDate": "Tanpa tanggal",
|
||||
"costs.noOnePaid": "Belum ada yang membayar",
|
||||
"costs.youLent": "kamu meminjamkan {amount}",
|
||||
"costs.youBorrowed": "kamu meminjam {amount}",
|
||||
"costs.settleUp": "Lunasi",
|
||||
"costs.history": "Riwayat",
|
||||
"costs.everyoneSquare": "Semua sudah impas",
|
||||
"costs.nothingOutstanding": "Tidak ada pembayaran tertunggak saat ini.",
|
||||
"costs.pay": "bayar",
|
||||
"costs.pays": "membayar",
|
||||
"costs.settle": "Lunasi",
|
||||
"costs.balances": "Saldo",
|
||||
"costs.byCategory": "Per kategori",
|
||||
"costs.noCategories": "Belum ada pengeluaran.",
|
||||
"costs.settleHistory": "Riwayat pelunasan",
|
||||
"costs.noSettlements": "Belum ada pembayaran yang dilunasi.",
|
||||
"costs.paymentsSettled": "{count} pembayaran dilunasi",
|
||||
"costs.paid": "dibayar",
|
||||
"costs.undo": "Urungkan",
|
||||
"costs.whatFor": "Untuk apa?",
|
||||
"costs.namePlaceholder": "mis. Makan malam, oleh-oleh, bensin…",
|
||||
"costs.totalAmount": "Jumlah total",
|
||||
"costs.currency": "Mata uang",
|
||||
"costs.day": "Hari",
|
||||
"costs.rateLabel": "1 {from} dalam {to}",
|
||||
"costs.category": "Kategori",
|
||||
"costs.whoPaid": "Siapa yang membayar?",
|
||||
"costs.splitBetween": "Bagi rata antara",
|
||||
"costs.pickSomeone": "Pilih setidaknya satu orang untuk berbagi.",
|
||||
"costs.splitSummary": "Dibagi {count} cara · {amount} masing-masing",
|
||||
"costs.cat.accommodation": "Akomodasi",
|
||||
"costs.cat.food": "Makanan & minuman",
|
||||
"costs.cat.groceries": "Belanja kebutuhan",
|
||||
"costs.cat.transport": "Transportasi",
|
||||
"costs.cat.flights": "Penerbangan",
|
||||
"costs.cat.activities": "Aktivitas",
|
||||
"costs.cat.sightseeing": "Wisata",
|
||||
"costs.cat.shopping": "Belanja",
|
||||
"costs.cat.fees": "Biaya & tiket",
|
||||
"costs.cat.health": "Kesehatan",
|
||||
"costs.cat.tips": "Tip",
|
||||
"costs.cat.other": "Lainnya",
|
||||
"costs.daysCount": "{count} hari",
|
||||
"costs.travelers": "{count} pelancong",
|
||||
"costs.liveRate": "kurs langsung",
|
||||
"costs.settleAll": "Lunasi semua",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -48,5 +48,14 @@ const dayplan: TranslationStrings = {
|
||||
'dayplan.mobile.createNew': 'Buat tempat baru',
|
||||
'dayplan.expandAll': 'Expand all days', // en-fallback
|
||||
'dayplan.collapseAll': 'Collapse all days', // en-fallback
|
||||
'dayplan.reorderDays': 'Atur ulang hari',
|
||||
'dayplan.reorderTitle': 'Atur ulang hari',
|
||||
'dayplan.reorderHint': 'Tempat, catatan, dan pesanan pada suatu hari ikut berpindah.',
|
||||
'dayplan.addDay': 'Tambah hari',
|
||||
'dayplan.moveUp': 'Pindah ke atas',
|
||||
'dayplan.moveDown': 'Pindah ke bawah',
|
||||
'dayplan.reorderUndo': 'Atur ulang hari',
|
||||
'dayplan.reorderError': 'Gagal mengatur ulang hari',
|
||||
'dayplan.addDayError': 'Gagal menambah hari',
|
||||
};
|
||||
export default dayplan;
|
||||
|
||||
@@ -4,5 +4,14 @@ const map: TranslationStrings = {
|
||||
'map.connections': 'Koneksi',
|
||||
'map.showConnections': 'Tampilkan rute pemesanan',
|
||||
'map.hideConnections': 'Sembunyikan rute pemesanan',
|
||||
'poi.searchThisArea': 'Cari di area ini',
|
||||
'poi.cat.restaurants': 'Restoran',
|
||||
'poi.cat.cafes': 'Kafe',
|
||||
'poi.cat.bars': 'Bar & hiburan malam',
|
||||
'poi.cat.hotels': 'Penginapan',
|
||||
'poi.cat.sights': 'Tempat wisata',
|
||||
'poi.cat.museums': 'Museum & budaya',
|
||||
'poi.cat.nature': 'Alam & taman',
|
||||
'poi.cat.activities': 'Aktivitas',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
||||
'reservations.meta.flightNumber': 'No. Penerbangan',
|
||||
'reservations.meta.from': 'Dari',
|
||||
'reservations.meta.to': 'Ke',
|
||||
'reservations.layover.route': 'Rute',
|
||||
'reservations.layover.stop': 'Persinggahan',
|
||||
'reservations.layover.addStop': 'Tambah persinggahan',
|
||||
'reservations.layover.connection': 'Sambungan',
|
||||
'reservations.layover.layover': 'Transit',
|
||||
'reservations.needsReview': 'Tinjau',
|
||||
'reservations.needsReviewHint':
|
||||
'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
|
||||
|
||||
@@ -324,6 +324,8 @@ const settings: TranslationStrings = {
|
||||
'settings.passkey.deviceBound': 'Perangkat ini',
|
||||
'settings.passkey.lastUsed': 'Terakhir digunakan',
|
||||
'settings.passkey.neverUsed': 'Belum pernah digunakan',
|
||||
'settings.mapPoiPill': 'Jelajahi tempat di peta',
|
||||
'settings.mapPoiPillHint': 'Tampilkan pil kategori di peta perjalanan untuk menemukan restoran, hotel, dan lainnya di sekitar dari OpenStreetMap.',
|
||||
};
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -384,5 +384,15 @@ const admin: TranslationStrings = {
|
||||
"Rimuovi tutte le passkey di questo utente (es. in caso di dispositivo smarrito). Potrà comunque accedere con la sua password.",
|
||||
'admin.passkey.resetConfirm': 'Rimuovere tutte le passkey di {name}?',
|
||||
'admin.passkey.resetDone': 'Rimosse {count} passkey',
|
||||
'admin.defaultSettings.mapProvider': 'Motore mappe',
|
||||
'admin.defaultSettings.mapProviderHint': 'La mappa predefinita per tutti gli utenti di questa istanza. Ogni utente può comunque sostituirla nelle proprie impostazioni.',
|
||||
'admin.defaultSettings.providerLeaflet': 'Standard (gratuito)',
|
||||
'admin.defaultSettings.providerMapbox': 'Mapbox (3D)',
|
||||
'admin.defaultSettings.mapboxToken': 'Token Mapbox condiviso',
|
||||
'admin.defaultSettings.mapboxTokenHint': 'Usato per ogni utente che non ha inserito un proprio token — così tutta l\'istanza ottiene Mapbox senza dover condividere la chiave individualmente. Archiviato in forma crittografata.',
|
||||
'admin.defaultSettings.mapboxStyle': 'Stile mappa',
|
||||
'admin.defaultSettings.mapboxStylePlaceholder': 'Scegli uno stile…',
|
||||
'admin.defaultSettings.mapbox3d': 'Edifici & terreno in 3D',
|
||||
'admin.defaultSettings.mapboxQuality': 'Modalità alta qualità',
|
||||
};
|
||||
export default admin;
|
||||
|
||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
||||
"Clicca sull'avatar di un membro su una voce di budget per contrassegnarlo in verde — significa che ha pagato. Il regolamento mostra poi chi deve quanto a chi.",
|
||||
'budget.netBalances': 'Saldi netti',
|
||||
'budget.categoriesLabel': 'categorie',
|
||||
"costs.you": "You",
|
||||
"costs.youShort": "Y",
|
||||
"costs.youLower": "you",
|
||||
"costs.youOwe": "You owe",
|
||||
"costs.youOweSub": "You should pay others",
|
||||
"costs.youreOwed": "You're owed",
|
||||
"costs.youreOwedSub": "Others should pay you",
|
||||
"costs.totalSpend": "Total trip spend",
|
||||
"costs.totalSpendSub": "Across all travelers",
|
||||
"costs.to": "To",
|
||||
"costs.from": "From",
|
||||
"costs.allSettled": "You're all settled up",
|
||||
"costs.nothingOwed": "Nothing owed to you",
|
||||
"costs.yourShare": "Your share",
|
||||
"costs.youPaid": "You paid",
|
||||
"costs.expenses": "Expenses",
|
||||
"costs.entries": "{count} entries",
|
||||
"costs.searchPlaceholder": "Search expenses…",
|
||||
"costs.filter.all": "All",
|
||||
"costs.filter.mine": "Paid by me",
|
||||
"costs.filter.owed": "I'm owed",
|
||||
"costs.addExpense": "Add expense",
|
||||
"costs.editExpense": "Edit expense",
|
||||
"costs.noMatch": "No expenses match your search.",
|
||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
||||
"costs.spent": "{amount} spent",
|
||||
"costs.noDate": "No date",
|
||||
"costs.noOnePaid": "No one paid yet",
|
||||
"costs.youLent": "you lent {amount}",
|
||||
"costs.youBorrowed": "you borrowed {amount}",
|
||||
"costs.settleUp": "Settle up",
|
||||
"costs.history": "History",
|
||||
"costs.everyoneSquare": "Everyone's square",
|
||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
||||
"costs.pay": "pay",
|
||||
"costs.pays": "pays",
|
||||
"costs.settle": "Settle",
|
||||
"costs.balances": "Balances",
|
||||
"costs.byCategory": "By category",
|
||||
"costs.noCategories": "No expenses yet.",
|
||||
"costs.settleHistory": "Settle history",
|
||||
"costs.noSettlements": "No settled payments yet.",
|
||||
"costs.paymentsSettled": "{count} payments settled",
|
||||
"costs.paid": "paid",
|
||||
"costs.undo": "Undo",
|
||||
"costs.whatFor": "What was it for?",
|
||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
||||
"costs.totalAmount": "Total amount",
|
||||
"costs.currency": "Currency",
|
||||
"costs.day": "Day",
|
||||
"costs.you": "Tu",
|
||||
"costs.youShort": "T",
|
||||
"costs.youLower": "tu",
|
||||
"costs.youOwe": "Devi",
|
||||
"costs.youOweSub": "Dovresti pagare gli altri",
|
||||
"costs.youreOwed": "Ti devono",
|
||||
"costs.youreOwedSub": "Gli altri dovrebbero pagarti",
|
||||
"costs.totalSpend": "Spesa totale del viaggio",
|
||||
"costs.totalSpendSub": "Tra tutti i viaggiatori",
|
||||
"costs.to": "A",
|
||||
"costs.from": "Da",
|
||||
"costs.allSettled": "Hai saldato tutto",
|
||||
"costs.nothingOwed": "Nessuno ti deve nulla",
|
||||
"costs.yourShare": "La tua quota",
|
||||
"costs.youPaid": "Hai pagato",
|
||||
"costs.expenses": "Spese",
|
||||
"costs.entries": "{count} voci",
|
||||
"costs.searchPlaceholder": "Cerca spese…",
|
||||
"costs.filter.all": "Tutte",
|
||||
"costs.filter.mine": "Pagate da me",
|
||||
"costs.filter.owed": "Mi devono",
|
||||
"costs.addExpense": "Aggiungi spesa",
|
||||
"costs.editExpense": "Modifica spesa",
|
||||
"costs.noMatch": "Nessuna spesa corrisponde alla ricerca.",
|
||||
"costs.emptyText": "Ancora nessuna spesa. Aggiungi la prima.",
|
||||
"costs.spent": "{amount} spesi",
|
||||
"costs.noDate": "Nessuna data",
|
||||
"costs.noOnePaid": "Nessuno ha ancora pagato",
|
||||
"costs.youLent": "hai prestato {amount}",
|
||||
"costs.youBorrowed": "hai preso in prestito {amount}",
|
||||
"costs.settleUp": "Salda",
|
||||
"costs.history": "Cronologia",
|
||||
"costs.everyoneSquare": "Sono tutti in pari",
|
||||
"costs.nothingOutstanding": "Nessun pagamento in sospeso al momento.",
|
||||
"costs.pay": "paga",
|
||||
"costs.pays": "paga",
|
||||
"costs.settle": "Salda",
|
||||
"costs.balances": "Saldi",
|
||||
"costs.byCategory": "Per categoria",
|
||||
"costs.noCategories": "Ancora nessuna spesa.",
|
||||
"costs.settleHistory": "Cronologia saldi",
|
||||
"costs.noSettlements": "Ancora nessun pagamento saldato.",
|
||||
"costs.paymentsSettled": "{count} pagamenti saldati",
|
||||
"costs.paid": "pagato",
|
||||
"costs.undo": "Annulla",
|
||||
"costs.whatFor": "Per cosa era?",
|
||||
"costs.namePlaceholder": "es. Cena, souvenir, benzina…",
|
||||
"costs.totalAmount": "Importo totale",
|
||||
"costs.currency": "Valuta",
|
||||
"costs.day": "Giorno",
|
||||
"costs.rateLabel": "1 {from} in {to}",
|
||||
"costs.category": "Category",
|
||||
"costs.whoPaid": "Who paid?",
|
||||
"costs.splitBetween": "Split equally between",
|
||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
||||
"costs.cat.accommodation": "Accommodation",
|
||||
"costs.cat.food": "Food & drink",
|
||||
"costs.cat.groceries": "Groceries",
|
||||
"costs.cat.transport": "Transport",
|
||||
"costs.cat.flights": "Flights",
|
||||
"costs.cat.activities": "Activities",
|
||||
"costs.cat.sightseeing": "Sightseeing",
|
||||
"costs.category": "Categoria",
|
||||
"costs.whoPaid": "Chi ha pagato?",
|
||||
"costs.splitBetween": "Dividi equamente tra",
|
||||
"costs.pickSomeone": "Scegli almeno una persona con cui dividere.",
|
||||
"costs.splitSummary": "Diviso in {count} · {amount} ciascuno",
|
||||
"costs.cat.accommodation": "Alloggio",
|
||||
"costs.cat.food": "Cibo e bevande",
|
||||
"costs.cat.groceries": "Spesa alimentare",
|
||||
"costs.cat.transport": "Trasporti",
|
||||
"costs.cat.flights": "Voli",
|
||||
"costs.cat.activities": "Attività",
|
||||
"costs.cat.sightseeing": "Visite turistiche",
|
||||
"costs.cat.shopping": "Shopping",
|
||||
"costs.cat.fees": "Fees & tickets",
|
||||
"costs.cat.health": "Health",
|
||||
"costs.cat.tips": "Tips",
|
||||
"costs.cat.other": "Other",
|
||||
"costs.daysCount": "{count} days",
|
||||
"costs.travelers": "{count} travelers",
|
||||
"costs.liveRate": "live rate",
|
||||
"costs.settleAll": "Settle all",
|
||||
"costs.cat.fees": "Tariffe e biglietti",
|
||||
"costs.cat.health": "Salute",
|
||||
"costs.cat.tips": "Mance",
|
||||
"costs.cat.other": "Altro",
|
||||
"costs.daysCount": "{count} giorni",
|
||||
"costs.travelers": "{count} viaggiatori",
|
||||
"costs.liveRate": "tasso in tempo reale",
|
||||
"costs.settleAll": "Salda tutto",
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user