mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Support multi-leg (layover) flights (#1146)
* feat(transport): support multi-leg (layover) flights in the booking form
A flight booking can now hold an ordered chain of airports (e.g. FRA -> BER ->
HND) instead of a single departure/arrival pair. The route is entered as a list
of waypoints with a '+ add stop' button; each stop carries its own arrival and
departure time plus the airline/flight number of the segment leaving it, while
the whole booking keeps one price.
Stored without a schema change: the existing reservation_endpoints rows carry the
ordered waypoints (from/stop/to by sequence) and a metadata.legs array holds the
per-leg detail. Top-level metadata (departure_airport/arrival_airport/airline/
flight_number) mirrors the first and last leg, so a single-leg flight persists
exactly as before and legacy readers keep working.
* feat(planner): show each flight leg as its own day-plan entry, ordered by time
A multi-leg flight now expands into one entry per leg (BER -> FRA, then FRA ->
HND), each on its own day with its own times, instead of a single span. Each leg
is an addressable slot (reservation id + leg index) so places and notes can be
dropped into the layover gap between legs; the per-leg position is persisted in
metadata.legs[i].day_positions and survives a reload.
Day-plan items are now ordered chronologically: anything with a time (a place's
time, a flight leg, a timed note) sorts by that time, and untimed items inherit
the time of the item before them so they stay where they were placed.
* feat(planner): show the full multi-stop route in the bookings panel
The route row now lists every waypoint (FRA -> BER -> HND) by sequence instead of
just the first and last airport.
* feat(map): draw multi-leg flights as connected legs with a marker per airport
Both the Leaflet and Mapbox overlays now render a flight over all its waypoints:
one great-circle arc per leg and a marker at every airport, with the label
showing the full route and the summed distance. A single-leg flight is unchanged.
Also drops the floating stats badge that was drawn on transport arcs.
* fix(map): centre a clicked place above the bottom inspector panel
Selecting a place panned/flew it to the dead centre of the screen, where it sat
behind the detail card. Both overlays now bias the target into the visible area
above the bottom panel (Leaflet offsets the pan by the inspector inset; Mapbox
passes the padding to flyTo).
* feat: show the full multi-stop flight route in PDF and calendar export
The PDF day list and the ICS export now render the whole route (FRA → BER → HND)
for a multi-leg flight instead of just the first and last airport, falling back to
the flat metadata for single-leg flights. The ICS keeps a single event per booking.
* feat(import): group connecting flight legs into one multi-leg booking
When a booking confirmation contains several flight legs sharing a PNR that
connect at the same airport with a short layover (under 24h), they are now
imported as a single multi-leg booking (from/stop/to endpoints + metadata.legs)
instead of one booking per leg. A round trip (same PNR, multi-day gap) stays two
separate bookings, and a single flight is unchanged.
* i18n: translate the new flight-route strings into all languages
* i18n: translate the Costs page into every language
The Budget → Costs rework left the new costs.* strings untranslated in every
non-English locale (they fell back to English). Translate them across all
supported languages.
* Revert "fix(map): centre a clicked place above the bottom inspector panel"
This reverts commit 0936103f04.
This commit is contained in:
@@ -158,6 +158,7 @@ interface TransportItem {
|
|||||||
res: Reservation
|
res: Reservation
|
||||||
from: ReservationEndpoint
|
from: ReservationEndpoint
|
||||||
to: ReservationEndpoint
|
to: ReservationEndpoint
|
||||||
|
waypoints: ReservationEndpoint[]
|
||||||
type: TransportType
|
type: TransportType
|
||||||
arcs: [number, number][][]
|
arcs: [number, number][][]
|
||||||
primaryArc: [number, number][]
|
primaryArc: [number, number][]
|
||||||
@@ -353,15 +354,29 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
const out: TransportItem[] = []
|
const out: TransportItem[] = []
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
const eps = r.endpoints || []
|
// Ordered waypoints (from · stops · to). A single-leg booking has exactly two,
|
||||||
const from = eps.find(e => e.role === 'from')
|
// so the arc + markers below are byte-identical to before for it.
|
||||||
const to = eps.find(e => e.role === 'to')
|
const waypoints = (r.endpoints || [])
|
||||||
if (!from || !to) continue
|
.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 type = r.type as TransportType
|
||||||
const isGeo = TYPE_META[type].geodesic
|
const isGeo = TYPE_META[type].geodesic
|
||||||
const arcs = isGeo
|
// One arc per leg (between consecutive waypoints), concatenated.
|
||||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
const arcs: [number, number][][] = []
|
||||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [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 primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
const primaryArc = arcs[primaryIdx] ?? []
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
const fallback: [number, number] = primaryArc.length > 0
|
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]
|
: [(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 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 distance = `${Math.round(distanceKm)} km`
|
||||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
// 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 subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
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
|
return out
|
||||||
}, [reservations])
|
}, [reservations])
|
||||||
@@ -416,38 +434,21 @@ export default function ReservationOverlay({ reservations, showConnections, show
|
|||||||
/>
|
/>
|
||||||
)))}
|
)))}
|
||||||
|
|
||||||
{visibleItems.flatMap(item => [
|
{visibleItems.flatMap(item => item.waypoints.map((wp, wi) => (
|
||||||
<Marker
|
<Marker
|
||||||
key={`from-${item.res.id}`}
|
key={`wp-${item.res.id}-${wi}`}
|
||||||
position={[item.from.lat, item.from.lng]}
|
position={[wp.lat, wp.lng]}
|
||||||
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (item.from.code || cleanName(item.from.name)) : null)}
|
icon={endpointIcon(item.type, showEndpointLabels && labelVisibleIds.has(item.res.id) ? (wp.code || cleanName(wp.name)) : null)}
|
||||||
pane={ENDPOINT_PANE}
|
pane={ENDPOINT_PANE}
|
||||||
zIndexOffset={1000}
|
zIndexOffset={1000}
|
||||||
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
eventHandlers={{ click: () => onEndpointClick?.(item.res.id) }}
|
||||||
>
|
>
|
||||||
<Tooltip direction="top" offset={[0, -8]} opacity={1} className="map-tooltip">
|
<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>}
|
{item.res.title && <div className="text-content-muted" style={{ fontSize: 11 }}>{item.res.title}</div>}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Marker>,
|
</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} />
|
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ interface TransportItem {
|
|||||||
res: Reservation
|
res: Reservation
|
||||||
from: ReservationEndpoint
|
from: ReservationEndpoint
|
||||||
to: ReservationEndpoint
|
to: ReservationEndpoint
|
||||||
|
waypoints: ReservationEndpoint[]
|
||||||
type: TransportType
|
type: TransportType
|
||||||
arcs: [number, number][][]
|
arcs: [number, number][][]
|
||||||
primaryArc: [number, number][]
|
primaryArc: [number, number][]
|
||||||
@@ -137,23 +138,38 @@ function buildItems(reservations: Reservation[]): TransportItem[] {
|
|||||||
const out: TransportItem[] = []
|
const out: TransportItem[] = []
|
||||||
for (const r of reservations) {
|
for (const r of reservations) {
|
||||||
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
if (!TRANSPORT_TYPES.includes(r.type as TransportType)) continue
|
||||||
const eps = r.endpoints || []
|
// Ordered waypoints (from · stops · to); a single-leg booking has exactly two.
|
||||||
const from = eps.find(e => e.role === 'from')
|
const waypoints = (r.endpoints || [])
|
||||||
const to = eps.find(e => e.role === 'to')
|
.filter(e => e.role === 'from' || e.role === 'to' || e.role === 'stop')
|
||||||
if (!from || !to) continue
|
.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 type = r.type as TransportType
|
||||||
const isGeo = TYPE_META[type].geodesic
|
const isGeo = TYPE_META[type].geodesic
|
||||||
const arcs = isGeo
|
// One arc per leg (between consecutive waypoints), concatenated.
|
||||||
? splitAntimeridian(greatCircle([from.lat, from.lng], [to.lat, to.lng]))
|
const arcs: [number, number][][] = []
|
||||||
: [[[from.lat, from.lng], [to.lat, to.lng]] as [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 primaryIdx = arcs.reduce((best, seg, idx, all) => seg.length > all[best].length ? idx : best, 0)
|
||||||
const primaryArc = arcs[primaryIdx] ?? []
|
const primaryArc = arcs[primaryIdx] ?? []
|
||||||
const duration = computeDuration(from, to, r.reservation_time || null, r.reservation_end_time || null)
|
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 distance = `${Math.round(distanceKm)} km`
|
||||||
const mainLabel = from.code && to.code ? `${from.code} → ${to.code}` : null
|
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 subParts = [duration, distance].filter(Boolean) as string[]
|
||||||
const subLabel = subParts.length > 0 ? subParts.join(' · ') : null
|
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
|
return out
|
||||||
}
|
}
|
||||||
@@ -321,7 +337,7 @@ export class ReservationMapboxOverlay {
|
|||||||
if (show) {
|
if (show) {
|
||||||
for (const item of visibleItems) {
|
for (const item of visibleItems) {
|
||||||
const showLabel = this.opts.showEndpointLabels && labelVisibleIds.has(item.res.id)
|
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 label = showLabel ? (ep.code || cleanName(ep.name)) : null
|
||||||
const el = document.createElement('div')
|
const el = document.createElement('div')
|
||||||
el.innerHTML = endpointMarkerHtml(item.type, label)
|
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.forEach(s => s.marker.remove())
|
||||||
this.statsMarkers = []
|
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.
|
// Match the Leaflet overlay's "rotate the label along the arc" look.
|
||||||
|
|||||||
@@ -215,7 +215,13 @@ export async function downloadTripPDF({ trip, days, places, assignments, categor
|
|||||||
const icon = reservationIconSvg(r.type)
|
const icon = reservationIconSvg(r.type)
|
||||||
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
const color = RESERVATION_COLOR_MAP[r.type] || '#3b82f6'
|
||||||
let subtitle = ''
|
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 === '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 === 'restaurant') subtitle = [meta.party_size ? `${meta.party_size} guests` : ''].filter(Boolean).join(' · ')
|
||||||
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
else if (r.type === 'event') subtitle = [meta.venue].filter(Boolean).join(' · ')
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const [timeConfirm, setTimeConfirm] = useState<{
|
const [timeConfirm, setTimeConfirm] = useState<{
|
||||||
dayId: number; fromId: number; time: string;
|
dayId: number; fromId: number; time: string;
|
||||||
// For drag & drop reorder
|
// 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
|
// For arrow reorder
|
||||||
reorderIds?: number[];
|
reorderIds?: number[];
|
||||||
} | null>(null)
|
} | null>(null)
|
||||||
@@ -471,6 +471,9 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const assignmentIds: number[] = []
|
const assignmentIds: number[] = []
|
||||||
const noteUpdates: { id: number; sort_order: number }[] = []
|
const noteUpdates: { id: number; sort_order: number }[] = []
|
||||||
const transportUpdates: { id: number; day_plan_position: 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 placeCount = 0
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -491,7 +494,10 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
group.forEach((g, idx) => {
|
group.forEach((g, idx) => {
|
||||||
const pos = base + (idx + 1) / (group.length + 1)
|
const pos = base + (idx + 1) / (group.length + 1)
|
||||||
if (g.type === 'note') noteUpdates.push({ id: g.data.id, sort_order: pos })
|
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 +516,30 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
}))
|
}))
|
||||||
setTransportPosVersion(v => v + 1)
|
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 (assignmentIds.length) await onReorder(dayId, assignmentIds)
|
||||||
if (transportUpdates.length) {
|
if (transportUpdates.length) {
|
||||||
onRouteRefresh?.()
|
onRouteRefresh?.()
|
||||||
@@ -528,8 +558,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
} catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.unknownError')) }
|
} 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)
|
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?
|
// Check if a timed place is being moved → would it break chronological order?
|
||||||
if (fromType === 'place') {
|
if (fromType === 'place') {
|
||||||
@@ -537,11 +570,11 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
const fromMinutes = parseTimeToMinutes(fromItem?.data?.place?.place_time)
|
||||||
if (fromItem && fromMinutes !== null) {
|
if (fromItem && fromMinutes !== null) {
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
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) {
|
if (fromIdx !== -1 && toIdx !== -1) {
|
||||||
const simulated = [...m]
|
const simulated = [...m]
|
||||||
const [moved] = simulated.splice(fromIdx, 1)
|
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 (insertIdx === -1) insertIdx = simulated.length
|
||||||
if (insertAfter) insertIdx += 1
|
if (insertAfter) insertIdx += 1
|
||||||
simulated.splice(insertIdx, 0, moved)
|
simulated.splice(insertIdx, 0, moved)
|
||||||
@@ -558,7 +591,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
if (!isChronological) {
|
if (!isChronological) {
|
||||||
const placeTime = fromItem.data.place.place_time
|
const placeTime = fromItem.data.place.place_time
|
||||||
const timeStr = placeTime.includes(':') ? placeTime.substring(0, 5) : placeTime
|
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
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -568,7 +601,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Build new order: remove the dragged item, insert at target position
|
// Build new order: remove the dragged item, insert at target position
|
||||||
const fromIdx = m.findIndex(i => i.type === fromType && i.data.id === fromId)
|
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) {
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) {
|
||||||
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null
|
||||||
return
|
return
|
||||||
@@ -576,7 +609,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
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 (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -590,7 +623,7 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
const confirmTimeRemoval = async () => {
|
const confirmTimeRemoval = async () => {
|
||||||
if (!timeConfirm) return
|
if (!timeConfirm) return
|
||||||
const saved = { ...timeConfirm }
|
const saved = { ...timeConfirm }
|
||||||
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter } = saved
|
const { dayId, fromId, reorderIds, fromType, toType, toId, insertAfter, toLegIndex } = saved
|
||||||
setTimeConfirm(null)
|
setTimeConfirm(null)
|
||||||
|
|
||||||
// Remove time from assignment
|
// Remove time from assignment
|
||||||
@@ -633,13 +666,14 @@ function useDayPlanSidebar(props: DayPlanSidebarProps) {
|
|||||||
|
|
||||||
// Drag & drop reorder
|
// Drag & drop reorder
|
||||||
if (fromType && toType) {
|
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 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
|
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return
|
||||||
|
|
||||||
const newOrder = [...m]
|
const newOrder = [...m]
|
||||||
const [moved] = newOrder.splice(fromIdx, 1)
|
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 (adjustedTo === -1) adjustedTo = newOrder.length
|
||||||
if (insertAfter) adjustedTo += 1
|
if (insertAfter) adjustedTo += 1
|
||||||
newOrder.splice(adjustedTo, 0, moved)
|
newOrder.splice(adjustedTo, 0, moved)
|
||||||
@@ -1311,6 +1345,8 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
const isAfter = dropTargetRef.current.startsWith('transport-after-')
|
||||||
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
const parts = dropTargetRef.current.replace('transport-after-', '').replace('transport-', '').split('-')
|
||||||
const transportId = Number(parts[0])
|
const transportId = Number(parts[0])
|
||||||
|
const legPart = parts.find(p => /^leg\d+$/.test(p))
|
||||||
|
const toLegIndex = legPart ? Number(legPart.slice(3)) : null
|
||||||
|
|
||||||
if (placeId) {
|
if (placeId) {
|
||||||
onAssignToDay?.(parseInt(placeId), day.id)
|
onAssignToDay?.(parseInt(placeId), day.id)
|
||||||
@@ -1318,15 +1354,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r = reservations.find(x => x.id === Number(fromReservationId))
|
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'))) }
|
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) {
|
} 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) {
|
} 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')))
|
tripActions.moveAssignment(tripId, Number(assignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (assignmentId) {
|
} 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) {
|
} 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')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} 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
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
return
|
return
|
||||||
@@ -1372,9 +1408,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
merged.map((item, idx) => {
|
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 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') {
|
if (item.type === 'place') {
|
||||||
const assignment = item.data
|
const assignment = item.data
|
||||||
@@ -1722,7 +1759,13 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
|
|
||||||
// Subtitle aus Metadaten zusammensetzen
|
// Subtitle aus Metadaten zusammensetzen
|
||||||
let subtitle = ''
|
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)
|
const parts = [meta.airline, meta.flight_number].filter(Boolean)
|
||||||
if (meta.departure_airport || meta.arrival_airport)
|
if (meta.departure_airport || meta.arrival_airport)
|
||||||
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
parts.push([meta.departure_airport, meta.arrival_airport].filter(Boolean).join(' → '))
|
||||||
@@ -1731,28 +1774,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(' · ')
|
subtitle = [meta.train_number, meta.platform ? `Gl. ${meta.platform}` : '', meta.seat ? `Sitz ${meta.seat}` : ''].filter(Boolean).join(' · ')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Multi-day span phase
|
// Multi-day span phase (single-leg / non-flight only — a
|
||||||
const spanLabel = getSpanLabel(res, spanPhase)
|
// 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 displayTime = getDisplayTimeForDay(res, day.id)
|
||||||
|
const legKey = res.__leg ? `leg${res.__leg.index}` : 'x'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`transport-${res.id}-${day.id}`}>
|
<React.Fragment key={`transport-${res.id}-${legKey}-${day.id}`}>
|
||||||
<div
|
<div
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canEditDays) return
|
if (!canEditDays) return
|
||||||
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(res)
|
const target = reservations.find(x => x.id === res.id) ?? res
|
||||||
else onEditReservation?.(res)
|
if (TRANSPORT_TYPES.has(res.type)) onEditTransport?.(target)
|
||||||
|
else onEditReservation?.(target)
|
||||||
}}
|
}}
|
||||||
onDragOver={e => {
|
onDragOver={e => {
|
||||||
e.preventDefault(); e.stopPropagation()
|
e.preventDefault(); e.stopPropagation()
|
||||||
const rect = e.currentTarget.getBoundingClientRect()
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
const inBottom = e.clientY > rect.top + rect.height / 2
|
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)
|
if (dropTargetRef.current !== key) setDropTargetKey(key)
|
||||||
}}
|
}}
|
||||||
draggable={canEditDays && spanPhase !== 'middle'}
|
draggable={canEditDays && spanPhase !== 'middle' && !res.__leg}
|
||||||
onDragStart={e => {
|
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
|
// setData is required for the drag to start reliably (Firefox) and
|
||||||
// matches how place/note items initiate their drag.
|
// matches how place/note items initiate their drag.
|
||||||
e.dataTransfer.setData('reservationId', String(res.id))
|
e.dataTransfer.setData('reservationId', String(res.id))
|
||||||
@@ -1773,15 +1820,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
const r2 = reservations.find(x => x.id === Number(fromReservationId))
|
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'))) }
|
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) {
|
} 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) {
|
} 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')))
|
tripActions.moveAssignment(tripId, Number(fromAssignmentId), fromDayId, day.id).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (fromAssignmentId) {
|
} 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) {
|
} 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')))
|
tripActions.moveDayNote(tripId, fromDayId, day.id, Number(noteId)).catch((err: unknown) => toast.error(err instanceof Error ? err.message : t('common.unknownError')))
|
||||||
} else if (noteId) {
|
} 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
|
setDraggingId(null); setDropTargetKey(null); dragDataRef.current = null; window.__dragData = null
|
||||||
}}
|
}}
|
||||||
@@ -1801,7 +1848,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
opacity: draggingId === res.id ? 0.4 : spanPhase === 'middle' ? 0.65 : 1,
|
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' }}>
|
<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} />
|
<GripVertical size={13} strokeWidth={1.8} />
|
||||||
</div>
|
</div>
|
||||||
@@ -1846,7 +1893,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{onToggleConnection && (res.endpoints || []).length >= 2 && (() => {
|
{onToggleConnection && (!res.__leg || res.__leg.index === 0) && (res.endpoints || []).length >= 2 && (() => {
|
||||||
const active = visibleConnectionIds.includes(res.id)
|
const active = visibleConnectionIds.includes(res.id)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -271,19 +271,21 @@ function ReservationCard({ r, tripId, onEdit, onDelete, files = [], onNavigateTo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const eps = r.endpoints || []
|
// Full route over all waypoints (from · stops · to), ordered by sequence.
|
||||||
const from = eps.find(e => e.role === 'from')
|
const eps = (r.endpoints || []).slice().sort((a, b) => (a.sequence ?? 0) - (b.sequence ?? 0))
|
||||||
const to = eps.find(e => e.role === 'to')
|
if (eps.length < 2) return null
|
||||||
if (!from || !to) return null
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-tertiary text-content" style={{
|
<div className="bg-surface-tertiary text-content" style={{
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8,
|
||||||
padding: '8px 12px', borderRadius: 10,
|
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>
|
{eps.map((ep, i) => (
|
||||||
<TypeIcon size={14} style={{ color: typeInfo.color, flexShrink: 0 }} />
|
<span key={i} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||||
<span style={{ fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>{to.name}</span>
|
{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>
|
</div>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
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 Modal from '../shared/Modal'
|
||||||
import CustomSelect from '../shared/CustomSelect'
|
import CustomSelect from '../shared/CustomSelect'
|
||||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||||
@@ -14,6 +14,7 @@ import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
|||||||
import { openFile } from '../../utils/fileDownload'
|
import { openFile } from '../../utils/fileDownload'
|
||||||
import apiClient from '../../api/client'
|
import apiClient from '../../api/client'
|
||||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
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
|
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||||
@@ -23,7 +24,7 @@ interface EndpointPick {
|
|||||||
location?: LocationPoint
|
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 {
|
return {
|
||||||
role, sequence,
|
role, sequence,
|
||||||
name: a.city ? `${a.city} (${a.iata})` : a.name,
|
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 }
|
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 = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
{ value: 'flight', labelKey: 'reservations.type.flight', Icon: Plane },
|
||||||
{ value: 'train', labelKey: 'reservations.type.train', Icon: Train },
|
{ 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 [isSaving, setIsSaving] = useState(false)
|
||||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||||
const [toPick, setToPick] = 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 [uploadingFile, setUploadingFile] = useState(false)
|
||||||
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
||||||
const [showFilePicker, setShowFilePicker] = useState(false)
|
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 : '',
|
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||||
})
|
})
|
||||||
if (type === 'flight') {
|
if (type === 'flight') {
|
||||||
setFromPick({ airport: airportFromEndpoint(from) || undefined })
|
const orderedEps = orderedEndpoints(reservation)
|
||||||
setToPick({ airport: airportFromEndpoint(to) || undefined })
|
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 {
|
} else {
|
||||||
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
setFromPick({ location: locationFromEndpoint(from) || undefined })
|
||||||
setToPick({ location: locationFromEndpoint(to) || 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 ?? '' })
|
setForm({ ...defaultForm, start_day_id: selectedDayId ?? '', end_day_id: selectedDayId ?? '' })
|
||||||
setFromPick({})
|
setFromPick({})
|
||||||
setToPick({})
|
setToPick({})
|
||||||
|
setWaypoints([emptyWaypoint(selectedDayId ?? ''), emptyWaypoint(selectedDayId ?? '')])
|
||||||
}
|
}
|
||||||
}, [isOpen, reservation, selectedDayId, budgetItems])
|
}, [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
|
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.type === 'flight') {
|
||||||
if (form.meta_airline) metadata.airline = form.meta_airline
|
// Top-level keys mirror the first/last leg so legacy readers keep working.
|
||||||
if (form.meta_flight_number) metadata.flight_number = form.meta_flight_number
|
if (firstWp?.airline) metadata.airline = firstWp.airline
|
||||||
if (fromPick.airport) {
|
if (firstWp?.flight_number) metadata.flight_number = firstWp.flight_number
|
||||||
metadata.departure_airport = fromPick.airport.iata
|
if (firstWp?.airport) {
|
||||||
metadata.departure_timezone = fromPick.airport.tz
|
metadata.departure_airport = firstWp.airport.iata
|
||||||
|
metadata.departure_timezone = firstWp.airport.tz
|
||||||
}
|
}
|
||||||
if (toPick.airport) {
|
if (lastWp?.airport) {
|
||||||
metadata.arrival_airport = toPick.airport.iata
|
metadata.arrival_airport = lastWp.airport.iata
|
||||||
metadata.arrival_timezone = toPick.airport.tz
|
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') {
|
} else if (form.type === 'train') {
|
||||||
if (form.meta_train_number) metadata.train_number = form.meta_train_number
|
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 endDate = (endDay ?? startDay)?.date ?? null
|
||||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||||
if (form.type === 'flight') {
|
if (form.type === 'flight') {
|
||||||
if (fromPick.airport) endpoints.push(endpointFromAirport(fromPick.airport, 'from', 0, startDate, form.departure_time || null))
|
flightWps.forEach((w, i) => {
|
||||||
if (toPick.airport) endpoints.push(endpointFromAirport(toPick.airport, 'to', 1, endDate, form.arrival_time || null))
|
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 {
|
} else {
|
||||||
if (fromPick.location) endpoints.push(endpointFromLocation(fromPick.location, 'from', 0, startDate, form.departure_time || null))
|
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))
|
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 = {
|
const payload = {
|
||||||
title: form.title,
|
title: form.title,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
status: form.status,
|
status: form.status,
|
||||||
day_id: form.start_day_id ? Number(form.start_day_id) : null,
|
day_id: form.type === 'flight' ? flightDepDay : (form.start_day_id ? Number(form.start_day_id) : null),
|
||||||
end_day_id: form.end_day_id ? Number(form.end_day_id) : null,
|
end_day_id: form.type === 'flight' ? flightArrDay : (form.end_day_id ? Number(form.end_day_id) : null),
|
||||||
reservation_time: buildTime(startDay, form.departure_time),
|
reservation_time: form.type === 'flight'
|
||||||
reservation_end_time: buildTime(endDay ?? startDay, form.arrival_time),
|
? 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,
|
location: null,
|
||||||
confirmation_number: form.confirmation_number || null,
|
confirmation_number: form.confirmation_number || null,
|
||||||
notes: form.notes || null,
|
notes: form.notes || null,
|
||||||
@@ -348,100 +442,126 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
|||||||
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
placeholder={t('reservations.titlePlaceholder')} className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* From / To endpoints */}
|
{form.type === 'flight' ? (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
/* ── Flight route: ordered airports (origin · stops · destination) ── */
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
<label className={labelClass}>{t('reservations.layover.route')}</label>
|
||||||
{form.type === 'flight' ? (
|
{waypoints.map((wp, i) => {
|
||||||
<AirportSelect value={fromPick.airport || null} onChange={a => setFromPick({ airport: a || undefined })} />
|
const isFirst = i === 0
|
||||||
) : (
|
const isLast = i === waypoints.length - 1
|
||||||
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
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>
|
||||||
<div>
|
) : (
|
||||||
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
<>
|
||||||
{form.type === 'flight' ? (
|
{/* From / To endpoints (non-flight) */}
|
||||||
<AirportSelect value={toPick.airport || null} onChange={a => setToPick({ airport: a || undefined })} />
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
) : (
|
<div>
|
||||||
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
<label className={labelClass}>{t('reservations.meta.from')}</label>
|
||||||
)}
|
<LocationSelect value={fromPick.location || null} onChange={l => setFromPick({ location: l || undefined })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<label className={labelClass}>{t('reservations.meta.to')}</label>
|
||||||
{/* Departure row */}
|
<LocationSelect value={toPick.location || null} onChange={l => setToPick({ location: l || undefined })} />
|
||||||
<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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Arrival row */}
|
{/* Departure row */}
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<label className={labelClass}>
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupDate') : t('reservations.date')}</label>
|
||||||
{form.type === 'flight' ? t('reservations.arrivalDate') : form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}
|
<CustomSelect value={form.start_day_id} onChange={value => set('start_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||||
</label>
|
</div>
|
||||||
<CustomSelect
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
value={form.end_day_id}
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.pickupTime') : t('reservations.startTime')}</label>
|
||||||
onChange={value => set('end_day_id', value)}
|
<CustomTimePicker value={form.departure_time} onChange={v => set('departure_time', v)} />
|
||||||
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}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Flight-specific fields */}
|
{/* Arrival row */}
|
||||||
{form.type === 'flight' && (
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div>
|
<label className={labelClass}>{form.type === 'car' ? t('reservations.returnDate') : t('reservations.endDate')}</label>
|
||||||
<label className={labelClass}>{t('reservations.meta.airline')}</label>
|
<CustomSelect value={form.end_day_id} onChange={value => set('end_day_id', value)} placeholder={t('dayplan.dayN', { n: '?' })} options={dayOptions} size="sm" />
|
||||||
<input type="text" value={form.meta_airline} onChange={e => set('meta_airline', e.target.value)}
|
</div>
|
||||||
placeholder="Lufthansa" className={inputClass} />
|
<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>
|
||||||
<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 */}
|
{/* Train-specific fields */}
|
||||||
|
|||||||
@@ -126,18 +126,18 @@ describe('getMergedItems', () => {
|
|||||||
expect(types).toEqual(['place', 'transport', 'place'])
|
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 = [
|
const dayAssignments = [
|
||||||
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
{ id: 1, order_index: 0, place: { place_time: '08:00' } },
|
||||||
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
{ id: 2, order_index: 1, place: { place_time: '13:00' } },
|
||||||
]
|
]
|
||||||
// Transport at 10:30 would normally go between the two places
|
// The train is at 10:30, so it sorts between the 08:00 and 13:00 places by time —
|
||||||
// but per-day position 1.5 puts it after the second place
|
// timed items are arranged chronologically even if an old manual position exists.
|
||||||
const dayTransports = [
|
const dayTransports = [
|
||||||
{ id: 20, type: 'train', day_id: 5, end_day_id: 5, reservation_time: '10:30', day_positions: { 5: 1.5 } },
|
{ 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 result = getMergedItems({ dayAssignments, dayNotes: [], dayTransports, dayId: 5 })
|
||||||
const types = result.map(i => i.type)
|
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
|
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. */
|
/** Filter reservations that are active transports for the given day, excluding assignment-linked ones. */
|
||||||
export function getTransportForDay(opts: {
|
export function getTransportForDay(opts: {
|
||||||
reservations: any[]
|
reservations: any[]
|
||||||
dayId: number
|
dayId: number
|
||||||
dayAssignmentIds: number[]
|
dayAssignmentIds: number[]
|
||||||
days: Array<{ id: number; day_number?: number }>
|
days: Array<{ id: number; day_number?: number; date?: string | null }>
|
||||||
}): any[] {
|
}): any[] {
|
||||||
const { reservations, dayId, dayAssignmentIds, days } = opts
|
const { reservations, dayId, dayAssignmentIds, days } = opts
|
||||||
|
|
||||||
@@ -69,7 +123,34 @@ export function getTransportForDay(opts: {
|
|||||||
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
return thisDayOrder >= startOrder && thisDayOrder <= endOrder
|
||||||
}
|
}
|
||||||
return startDayId === dayId
|
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. */
|
/** 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,
|
minutes: parseTimeToMinutes(getDisplayTime(r, dayId)) ?? 0,
|
||||||
})).sort((a, b) => a.minutes - b.minutes)
|
})).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) {
|
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
|
// 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 })
|
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 {
|
function mapTrain(r: KiReservation, source: ParsedBookingItem['source']): ParsedBookingItem | null {
|
||||||
const t = r.reservationFor as KiTrainTrip | undefined;
|
const t = r.reservationFor as KiTrainTrip | undefined;
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
@@ -233,8 +317,25 @@ export function mapReservations(kiItems: KiReservation[], fileName: string): { i
|
|||||||
const source = { fileName, index: i };
|
const source = { fileName, index: i };
|
||||||
let item: ParsedBookingItem | null = null;
|
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']) {
|
switch (r['@type']) {
|
||||||
case 'FlightReservation': item = mapFlight(r, source); break;
|
|
||||||
case 'TrainReservation': item = mapTrain(r, source); break;
|
case 'TrainReservation': item = mapTrain(r, source); break;
|
||||||
case 'BusReservation': item = mapBus(r, source); break;
|
case 'BusReservation': item = mapBus(r, source); break;
|
||||||
case 'BoatReservation': item = mapBoat(r, source); break;
|
case 'BoatReservation': item = mapBoat(r, source); break;
|
||||||
|
|||||||
@@ -543,8 +543,14 @@ export function exportICS(tripId: string | number): { ics: string; filename: str
|
|||||||
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
|
if (r.confirmation_number) desc += `\nConfirmation: ${r.confirmation_number}`;
|
||||||
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
|
if (meta.airline) desc += `\nAirline: ${meta.airline}`;
|
||||||
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
|
if (meta.flight_number) desc += `\nFlight: ${meta.flight_number}`;
|
||||||
if (meta.departure_airport) desc += `\nFrom: ${meta.departure_airport}`;
|
if (Array.isArray(meta.legs) && meta.legs.length > 1) {
|
||||||
if (meta.arrival_airport) desc += `\nTo: ${meta.arrival_airport}`;
|
// 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 (meta.train_number) desc += `\nTrain: ${meta.train_number}`;
|
||||||
if (r.notes) desc += `\n${r.notes}`;
|
if (r.notes) desc += `\n${r.notes}`;
|
||||||
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
|
if (desc) ics += `DESCRIPTION:${esc(desc)}\r\n`;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
'انقر على صورة العضو في بند الميزانية لتحديده باللون الأخضر — وهذا يعني أنه دفع. ثم تُظهر التسوية من يدين لمن وبكم.',
|
||||||
'budget.netBalances': 'الأرصدة الصافية',
|
'budget.netBalances': 'الأرصدة الصافية',
|
||||||
'budget.categoriesLabel': 'فئات',
|
'budget.categoriesLabel': 'فئات',
|
||||||
"costs.you": "You",
|
"costs.you": "أنت",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "أنت",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "أنت",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "عليك",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "عليك أن تدفع للآخرين",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "لك",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "على الآخرين أن يدفعوا لك",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "إجمالي إنفاق الرحلة",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "عبر جميع المسافرين",
|
||||||
"costs.to": "To",
|
"costs.to": "إلى",
|
||||||
"costs.from": "From",
|
"costs.from": "من",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "لقد سوّيت كل حساباتك",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "لا شيء مستحق لك",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "حصتك",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "أنت دفعت",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "المصروفات",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} إدخالات",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "ابحث في المصروفات…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "الكل",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "دفعتها أنا",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "مستحق لي",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "إضافة مصروف",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "تعديل المصروف",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "لا توجد مصروفات تطابق بحثك.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "لا توجد مصروفات بعد. أضف أول مصروف لك.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "تم إنفاق {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "بدون تاريخ",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "لم يدفع أحد بعد",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "أقرضت {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "اقترضت {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "تسوية الحساب",
|
||||||
"costs.history": "History",
|
"costs.history": "السجل",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "الجميع متعادلون",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "لا توجد مدفوعات معلّقة الآن.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "ادفع",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "يدفع",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "تسوية",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "الأرصدة",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "حسب الفئة",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "لا توجد مصروفات بعد.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "سجل التسويات",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "لا توجد مدفوعات مسوّاة بعد.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "تمت تسوية {count} مدفوعات",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "مدفوع",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "تراجع",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "لأجل ماذا كان؟",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "مثل: عشاء، هدايا تذكارية، وقود…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "المبلغ الإجمالي",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "العملة",
|
||||||
"costs.day": "Day",
|
"costs.day": "اليوم",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} بـ {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "الفئة",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "من دفع؟",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "تقسيم بالتساوي بين",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "اختر شخصًا واحدًا على الأقل للتقسيم معه.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "تقسيم على {count} · {amount} لكل واحد",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "الإقامة",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "الطعام والشراب",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "البقالة",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "النقل",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "الرحلات الجوية",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "الأنشطة",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "معالم سياحية",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "التسوق",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "الرسوم والتذاكر",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "الصحة",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "البقشيش",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "أخرى",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} أيام",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} مسافرين",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "سعر مباشر",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "تسوية الكل",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'رقم الرحلة',
|
'reservations.meta.flightNumber': 'رقم الرحلة',
|
||||||
'reservations.meta.from': 'من',
|
'reservations.meta.from': 'من',
|
||||||
'reservations.meta.to': 'إلى',
|
'reservations.meta.to': 'إلى',
|
||||||
|
'reservations.layover.route': 'المسار',
|
||||||
|
'reservations.layover.stop': 'محطة توقف',
|
||||||
|
'reservations.layover.addStop': 'إضافة محطة توقف',
|
||||||
|
'reservations.layover.connection': 'رحلة متّصلة',
|
||||||
|
'reservations.layover.layover': 'توقف بيني',
|
||||||
'reservations.needsReview': 'مراجعة',
|
'reservations.needsReview': 'مراجعة',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
|
'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
|
||||||
|
|||||||
@@ -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.',
|
'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.netBalances': 'Saldos líquidos',
|
||||||
'budget.categoriesLabel': 'categorias',
|
'budget.categoriesLabel': 'categorias',
|
||||||
"costs.you": "You",
|
"costs.you": "Você",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "V",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "você",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Você deve",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Você deve pagar os outros",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Devem a você",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Os outros devem pagar você",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Gasto total da viagem",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Entre todos os viajantes",
|
||||||
"costs.to": "To",
|
"costs.to": "Para",
|
||||||
"costs.from": "From",
|
"costs.from": "De",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Suas contas estão acertadas",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Ninguém deve nada a você",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Sua parte",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Você pagou",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Despesas",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} lançamentos",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Buscar despesas…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Todas",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Pagas por mim",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Devem a mim",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Adicionar despesa",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Editar despesa",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Nenhuma despesa corresponde à busca.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Nenhuma despesa ainda. Adicione a primeira.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} gastos",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Sem data",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Ninguém pagou ainda",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "você emprestou {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "você pegou emprestado {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Acertar contas",
|
||||||
"costs.history": "History",
|
"costs.history": "Histórico",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Todos quitados",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Nenhum pagamento pendente no momento.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "paga",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "paga",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Acertar",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Saldos",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Por categoria",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Nenhuma despesa ainda.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Histórico de acertos",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Nenhum pagamento acertado ainda.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} pagamentos acertados",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "pago",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Desfazer",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Para que foi?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "ex.: jantar, lembranças, gasolina…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Valor total",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Moeda",
|
||||||
"costs.day": "Day",
|
"costs.day": "Dia",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} em {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Categoria",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Quem pagou?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Dividir igualmente entre",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Escolha pelo menos uma pessoa para dividir.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Dividido entre {count} · {amount} cada",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Hospedagem",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Comida e bebida",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Mercado",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Transporte",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Voos",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Atividades",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Passeios turísticos",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Compras",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Taxas e ingressos",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Saúde",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Gorjetas",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Outros",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} dias",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} viajantes",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "taxa ao vivo",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Acertar tudo",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Nº do voo',
|
'reservations.meta.flightNumber': 'Nº do voo',
|
||||||
'reservations.meta.from': 'De',
|
'reservations.meta.from': 'De',
|
||||||
'reservations.meta.to': 'Para',
|
'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.needsReview': 'Verificar',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
|
'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
|
||||||
|
|||||||
@@ -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ží.',
|
'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.netBalances': 'Čisté zůstatky',
|
||||||
'budget.categoriesLabel': 'kategorie',
|
'budget.categoriesLabel': 'kategorie',
|
||||||
"costs.you": "You",
|
"costs.you": "Vy",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Vy",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "vy",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Dlužíte",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Měli byste zaplatit ostatním",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Dluží vám",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Ostatní by měli zaplatit vám",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Celkové výdaje na cestu",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Za všechny cestovatele",
|
||||||
"costs.to": "To",
|
"costs.to": "Komu",
|
||||||
"costs.from": "From",
|
"costs.from": "Od",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Máte vše vyrovnáno",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Nikdo vám nic nedluží",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Váš podíl",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Zaplatili jste",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Výdaje",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} položek",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Hledat výdaje…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Vše",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Zaplaceno mnou",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Dluží mi",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Přidat výdaj",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Upravit výdaj",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Žádné výdaje neodpovídají vašemu hledání.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Zatím žádné výdaje. Přidejte první.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "Utraceno {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Bez data",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Zatím nikdo nezaplatil",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "půjčili jste {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "vypůjčili jste si {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Vyrovnat",
|
||||||
"costs.history": "History",
|
"costs.history": "Historie",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Všichni jsou vyrovnáni",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Momentálně žádné nevyrovnané platby.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "zaplatí",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "zaplatí",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Vyrovnat",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Zůstatky",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Podle kategorie",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Zatím žádné výdaje.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Historie vyrovnání",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Zatím žádné vyrovnané platby.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} plateb vyrovnáno",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "zaplaceno",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Vrátit zpět",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Za co to bylo?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "např. večeře, suvenýry, benzín…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Celková částka",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Měna",
|
||||||
"costs.day": "Day",
|
"costs.day": "Den",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} v {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Kategorie",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Kdo zaplatil?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Rozdělit rovným dílem mezi",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Vyberte alespoň jednu osobu pro rozdělení.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Rozděleno na {count} dílů · {amount} každý",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Ubytování",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Jídlo a pití",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Potraviny",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Doprava",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Lety",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Aktivity",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Prohlídka památek",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Nákupy",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Poplatky a vstupenky",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Zdraví",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Spropitné",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Ostatní",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} dní",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} cestovatelů",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "aktuální kurz",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Vyrovnat vše",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Číslo letu',
|
'reservations.meta.flightNumber': 'Číslo letu',
|
||||||
'reservations.meta.from': 'Z',
|
'reservations.meta.from': 'Z',
|
||||||
'reservations.meta.to': 'Do',
|
'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.needsReview': 'Zkontrolovat',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
|
'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Flugnr.',
|
'reservations.meta.flightNumber': 'Flugnr.',
|
||||||
'reservations.meta.from': 'Von',
|
'reservations.meta.from': 'Von',
|
||||||
'reservations.meta.to': 'Nach',
|
'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.needsReview': 'Prüfen',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
|
'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Flight No.',
|
'reservations.meta.flightNumber': 'Flight No.',
|
||||||
'reservations.meta.from': 'From',
|
'reservations.meta.from': 'From',
|
||||||
'reservations.meta.to': 'To',
|
'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.needsReview': 'Review',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Airport could not be matched automatically — please confirm the location.',
|
'Airport could not be matched automatically — please confirm the location.',
|
||||||
|
|||||||
@@ -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.',
|
'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.netBalances': 'Saldos netos',
|
||||||
'budget.categoriesLabel': 'categorías',
|
'budget.categoriesLabel': 'categorías',
|
||||||
"costs.you": "You",
|
"costs.you": "Tú",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Tú",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "tú",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Debes",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Deberías pagar a otros",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Te deben",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Otros deberían pagarte",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Gasto total del viaje",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Entre todos los viajeros",
|
||||||
"costs.to": "To",
|
"costs.to": "Para",
|
||||||
"costs.from": "From",
|
"costs.from": "De",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Estás al día con todo",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Nadie te debe nada",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Tu parte",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Pagaste",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Gastos",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} entradas",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Buscar gastos…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Todos",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Pagados por mí",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Me deben",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Añadir gasto",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Editar gasto",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Ningún gasto coincide con tu búsqueda.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Aún no hay gastos. Añade el primero.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} gastados",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Sin fecha",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Nadie ha pagado aún",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "prestaste {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "tomaste prestado {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Saldar cuentas",
|
||||||
"costs.history": "History",
|
"costs.history": "Historial",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Todos están en paz",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "No hay pagos pendientes ahora mismo.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "paga",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "paga",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Saldar",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Saldos",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Por categoría",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Aún no hay gastos.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Historial de pagos",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Aún no hay pagos saldados.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} pagos saldados",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "pagado",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Deshacer",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "¿Para qué fue?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "p. ej. Cena, souvenirs, gasolina…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Importe total",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Moneda",
|
||||||
"costs.day": "Day",
|
"costs.day": "Día",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} en {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Categoría",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "¿Quién pagó?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Dividir a partes iguales entre",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Elige al menos una persona con quien dividir.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Dividido entre {count} · {amount} cada uno",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Alojamiento",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Comida y bebida",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Compras de comida",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Transporte",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Vuelos",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Actividades",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Turismo",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Compras",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Tasas y entradas",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Salud",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Propinas",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Otros",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} días",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} viajeros",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "tasa en vivo",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Saldar todo",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'N° de vuelo',
|
'reservations.meta.flightNumber': 'N° de vuelo',
|
||||||
'reservations.meta.from': 'Desde',
|
'reservations.meta.from': 'Desde',
|
||||||
'reservations.meta.to': 'Hasta',
|
'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.needsReview': 'Revisar',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
|
'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
|
||||||
|
|||||||
@@ -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.",
|
"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.netBalances': 'Soldes nets',
|
||||||
'budget.categoriesLabel': 'catégories',
|
'budget.categoriesLabel': 'catégories',
|
||||||
"costs.you": "You",
|
"costs.you": "Vous",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "V",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "vous",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Vous devez",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Vous devez payer les autres",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "On vous doit",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Les autres doivent vous payer",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Dépenses totales du voyage",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Tous voyageurs confondus",
|
||||||
"costs.to": "To",
|
"costs.to": "À",
|
||||||
"costs.from": "From",
|
"costs.from": "De",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Tout est réglé pour vous",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "On ne vous doit rien",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Votre part",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Vous avez payé",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Dépenses",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} entrées",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Rechercher des dépenses…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Toutes",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Payées par moi",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "On me doit",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Ajouter une dépense",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Modifier la dépense",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Aucune dépense ne correspond à votre recherche.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Aucune dépense pour le moment. Ajoutez la première.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} dépensés",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Aucune date",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Personne n'a encore payé",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "vous avez prêté {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "vous avez emprunté {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Régler",
|
||||||
"costs.history": "History",
|
"costs.history": "Historique",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Tout le monde est quitte",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Aucun paiement en attente pour le moment.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "payer",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "paie",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Régler",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Soldes",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Par catégorie",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Aucune dépense pour le moment.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Historique des règlements",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Aucun paiement réglé pour le moment.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} paiements réglés",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "payé",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Annuler",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "C'était pour quoi ?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "ex. dîner, souvenirs, essence…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Montant total",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Devise",
|
||||||
"costs.day": "Day",
|
"costs.day": "Jour",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} en {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Catégorie",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Qui a payé ?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Partager équitablement entre",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Choisissez au moins une personne avec qui partager.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Partagé en {count} · {amount} chacun",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Hébergement",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Nourriture et boissons",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Courses",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Transport",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Vols",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Activités",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Visites",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Shopping",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Frais et billets",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Santé",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Pourboires",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Autre",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} jours",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} voyageurs",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "taux en direct",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Tout régler",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'N° de vol',
|
'reservations.meta.flightNumber': 'N° de vol',
|
||||||
'reservations.meta.from': 'De',
|
'reservations.meta.from': 'De',
|
||||||
'reservations.meta.to': 'À',
|
'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.needsReview': 'Vérifier',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
"L'aéroport n'a pas pu être identifié automatiquement — veuillez confirmer l'emplacement.",
|
"L'aéroport n'a pas pu être identifié automatiquement — veuillez confirmer l'emplacement.",
|
||||||
|
|||||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
|||||||
'Κάντε κλικ στο avatar ενός μέλους σε μια εγγραφή προϋπολογισμού για να το επισημάνετε πράσινο — αυτό σημαίνει ότι πλήρωσε. Η εκκαθάριση δείχνει στη συνέχεια ποιος χρωστάει σε ποιον και πόσα.',
|
'Κάντε κλικ στο avatar ενός μέλους σε μια εγγραφή προϋπολογισμού για να το επισημάνετε πράσινο — αυτό σημαίνει ότι πλήρωσε. Η εκκαθάριση δείχνει στη συνέχεια ποιος χρωστάει σε ποιον και πόσα.',
|
||||||
'budget.netBalances': 'Καθαρά Υπόλοιπα',
|
'budget.netBalances': 'Καθαρά Υπόλοιπα',
|
||||||
'budget.categoriesLabel': 'κατηγορίες',
|
'budget.categoriesLabel': 'κατηγορίες',
|
||||||
"costs.you": "You",
|
"costs.you": "Εσείς",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Ε",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "εσείς",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Χρωστάτε",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Πρέπει να πληρώσετε άλλους",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Σας χρωστούν",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Άλλοι πρέπει να σας πληρώσουν",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Συνολικές δαπάνες ταξιδιού",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Όλων των ταξιδιωτών",
|
||||||
"costs.to": "To",
|
"costs.to": "Προς",
|
||||||
"costs.from": "From",
|
"costs.from": "Από",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Έχετε εξοφλήσει τα πάντα",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Δεν σας χρωστάει κανείς",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Το μερίδιό σας",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Πληρώσατε",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Έξοδα",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} εγγραφές",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Αναζήτηση εξόδων…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Όλα",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Πληρωμένα από εμένα",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Μου χρωστούν",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Προσθήκη εξόδου",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Επεξεργασία εξόδου",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Κανένα έξοδο δεν ταιριάζει με την αναζήτησή σας.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Δεν υπάρχουν έξοδα ακόμη. Προσθέστε το πρώτο σας.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} δαπάνη",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Χωρίς ημερομηνία",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Δεν πλήρωσε κανείς ακόμη",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "δανείσατε {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "δανειστήκατε {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Εξόφληση",
|
||||||
"costs.history": "History",
|
"costs.history": "Ιστορικό",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Όλοι είναι ξεκάθαροι",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Δεν υπάρχουν εκκρεμείς πληρωμές αυτή τη στιγμή.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "πληρώνει",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "πληρώνει",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Εξόφληση",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Υπόλοιπα",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Ανά κατηγορία",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Δεν υπάρχουν έξοδα ακόμη.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Ιστορικό εξοφλήσεων",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Δεν υπάρχουν εξοφλημένες πληρωμές ακόμη.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} πληρωμές εξοφλήθηκαν",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "πλήρωσε",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Αναίρεση",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Για τι ήταν;",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "π.χ. Δείπνο, σουβενίρ, βενζίνη…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Συνολικό ποσό",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Νόμισμα",
|
||||||
"costs.day": "Day",
|
"costs.day": "Ημέρα",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} σε {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Κατηγορία",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Ποιος πλήρωσε;",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Ισόποση κατανομή μεταξύ",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Επιλέξτε τουλάχιστον ένα άτομο για τον διαμοιρασμό.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Κατανομή σε {count} μέρη · {amount} το καθένα",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Διαμονή",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Φαγητό & ποτό",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Ψώνια σούπερ μάρκετ",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Μεταφορά",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Πτήσεις",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Δραστηριότητες",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Αξιοθέατα",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Ψώνια",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Τέλη & εισιτήρια",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Υγεία",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Φιλοδωρήματα",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Άλλα",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} ημέρες",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} ταξιδιώτες",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "ζωντανή ισοτιμία",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Εξόφληση όλων",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Αρ. Πτήσης',
|
'reservations.meta.flightNumber': 'Αρ. Πτήσης',
|
||||||
'reservations.meta.from': 'Από',
|
'reservations.meta.from': 'Από',
|
||||||
'reservations.meta.to': 'Προς',
|
'reservations.meta.to': 'Προς',
|
||||||
|
'reservations.layover.route': 'Διαδρομή',
|
||||||
|
'reservations.layover.stop': 'Στάση',
|
||||||
|
'reservations.layover.addStop': 'Προσθήκη στάσης',
|
||||||
|
'reservations.layover.connection': 'Ανταπόκριση',
|
||||||
|
'reservations.layover.layover': 'Ενδιάμεση στάση',
|
||||||
'reservations.needsReview': 'Έλεγχος',
|
'reservations.needsReview': 'Έλεγχος',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Δεν ήταν δυνατή η αυτόματη αντιστοίχιση του αεροδρομίου — παρακαλώ επιβεβαιώστε την τοποθεσία.',
|
'Δεν ήταν δυνατή η αυτόματη αντιστοίχιση του αεροδρομίου — παρακαλώ επιβεβαιώστε την τοποθεσία.',
|
||||||
|
|||||||
@@ -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.',
|
'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.netBalances': 'Nettó egyenlegek',
|
||||||
'budget.categoriesLabel': 'kategóriák',
|
'budget.categoriesLabel': 'kategóriák',
|
||||||
"costs.you": "You",
|
"costs.you": "Te",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "T",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "te",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Tartozol",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Fizetned kell másoknak",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Neked tartoznak",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Mások fizetnek neked",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Teljes utazási költség",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Az összes utazóra vetítve",
|
||||||
"costs.to": "To",
|
"costs.to": "Kinek",
|
||||||
"costs.from": "From",
|
"costs.from": "Kitől",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Minden el van számolva",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Senki sem tartozik neked",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "A te részed",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Te fizettél",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Költségek",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} bejegyzés",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Költségek keresése…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Mind",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Én fizettem",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Nekem tartoznak",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Költség hozzáadása",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Költség szerkesztése",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Nincs a keresésnek megfelelő költség.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Még nincs költség. Add hozzá az elsőt.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} elköltve",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Nincs dátum",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Még senki sem fizetett",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "{amount} kölcsönadtál",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "{amount} kölcsönkértél",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Elszámolás",
|
||||||
"costs.history": "History",
|
"costs.history": "Előzmények",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Mindenki kvittben van",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Jelenleg nincs kifizetendő összeg.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "fizet",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "fizet",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Elszámol",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Egyenlegek",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Kategóriánként",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Még nincs költség.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Elszámolási előzmények",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Még nincs elszámolt fizetés.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} fizetés elszámolva",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "fizetve",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Visszavonás",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Mire volt?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "pl. vacsora, ajándékok, benzin…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Teljes összeg",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Pénznem",
|
||||||
"costs.day": "Day",
|
"costs.day": "Nap",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} ennyi: {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Kategória",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Ki fizetett?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Egyenlően elosztva köztük",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Válassz legalább egy személyt a megosztáshoz.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "{count} fő közt megosztva · egyenként {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Szállás",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Étel és ital",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Élelmiszer",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Közlekedés",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Repülőjáratok",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Programok",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Városnézés",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Vásárlás",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Díjak és jegyek",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Egészség",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Borravaló",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Egyéb",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} nap",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} utazó",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "élő árfolyam",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Összes elszámolása",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Járatszám',
|
'reservations.meta.flightNumber': 'Járatszám',
|
||||||
'reservations.meta.from': 'Honnan',
|
'reservations.meta.from': 'Honnan',
|
||||||
'reservations.meta.to': 'Hová',
|
'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.needsReview': 'Ellenőrzés',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
|
'A repülőteret nem sikerült automatikusan azonosítani — erősítsd meg a helyet.',
|
||||||
|
|||||||
@@ -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.',
|
'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.netBalances': 'Saldo Bersih',
|
||||||
'budget.categoriesLabel': 'kategori',
|
'budget.categoriesLabel': 'kategori',
|
||||||
"costs.you": "You",
|
"costs.you": "Kamu",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "K",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "kamu",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Kamu berhutang",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Kamu harus membayar yang lain",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Kamu dipinjami",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Yang lain harus membayarmu",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Total pengeluaran perjalanan",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Untuk semua pelancong",
|
||||||
"costs.to": "To",
|
"costs.to": "Ke",
|
||||||
"costs.from": "From",
|
"costs.from": "Dari",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Semua sudah lunas",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Tidak ada yang berhutang padamu",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Bagianmu",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Kamu membayar",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Pengeluaran",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} entri",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Cari pengeluaran…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Semua",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Dibayar olehku",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Dipinjami padaku",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Tambah pengeluaran",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Edit pengeluaran",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Tidak ada pengeluaran yang cocok dengan pencarianmu.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Belum ada pengeluaran. Tambahkan yang pertama.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} dibelanjakan",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Tanpa tanggal",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Belum ada yang membayar",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "kamu meminjamkan {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "kamu meminjam {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Lunasi",
|
||||||
"costs.history": "History",
|
"costs.history": "Riwayat",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Semua sudah impas",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Tidak ada pembayaran tertunggak saat ini.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "bayar",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "membayar",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Lunasi",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Saldo",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Per kategori",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Belum ada pengeluaran.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Riwayat pelunasan",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Belum ada pembayaran yang dilunasi.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} pembayaran dilunasi",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "dibayar",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Urungkan",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Untuk apa?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "mis. Makan malam, oleh-oleh, bensin…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Jumlah total",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Mata uang",
|
||||||
"costs.day": "Day",
|
"costs.day": "Hari",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} dalam {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Kategori",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Siapa yang membayar?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Bagi rata antara",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Pilih setidaknya satu orang untuk berbagi.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Dibagi {count} cara · {amount} masing-masing",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Akomodasi",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Makanan & minuman",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Belanja kebutuhan",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Transportasi",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Penerbangan",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Aktivitas",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Wisata",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Belanja",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Biaya & tiket",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Kesehatan",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Tip",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Lainnya",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} hari",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} pelancong",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "kurs langsung",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Lunasi semua",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'No. Penerbangan',
|
'reservations.meta.flightNumber': 'No. Penerbangan',
|
||||||
'reservations.meta.from': 'Dari',
|
'reservations.meta.from': 'Dari',
|
||||||
'reservations.meta.to': 'Ke',
|
'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.needsReview': 'Tinjau',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
|
'Bandara tidak dapat dicocokkan otomatis — konfirmasi lokasi.',
|
||||||
|
|||||||
@@ -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.",
|
"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.netBalances': 'Saldi netti',
|
||||||
'budget.categoriesLabel': 'categorie',
|
'budget.categoriesLabel': 'categorie',
|
||||||
"costs.you": "You",
|
"costs.you": "Tu",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "T",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "tu",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Devi",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Dovresti pagare gli altri",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Ti devono",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Gli altri dovrebbero pagarti",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Spesa totale del viaggio",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Tra tutti i viaggiatori",
|
||||||
"costs.to": "To",
|
"costs.to": "A",
|
||||||
"costs.from": "From",
|
"costs.from": "Da",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Hai saldato tutto",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Nessuno ti deve nulla",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "La tua quota",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Hai pagato",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Spese",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} voci",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Cerca spese…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Tutte",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Pagate da me",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Mi devono",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Aggiungi spesa",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Modifica spesa",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Nessuna spesa corrisponde alla ricerca.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Ancora nessuna spesa. Aggiungi la prima.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} spesi",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Nessuna data",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Nessuno ha ancora pagato",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "hai prestato {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "hai preso in prestito {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Salda",
|
||||||
"costs.history": "History",
|
"costs.history": "Cronologia",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Sono tutti in pari",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Nessun pagamento in sospeso al momento.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "paga",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "paga",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Salda",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Saldi",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Per categoria",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Ancora nessuna spesa.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Cronologia saldi",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Ancora nessun pagamento saldato.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} pagamenti saldati",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "pagato",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Annulla",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Per cosa era?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "es. Cena, souvenir, benzina…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Importo totale",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Valuta",
|
||||||
"costs.day": "Day",
|
"costs.day": "Giorno",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} in {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Categoria",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Chi ha pagato?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Dividi equamente tra",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Scegli almeno una persona con cui dividere.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Diviso in {count} · {amount} ciascuno",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Alloggio",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Cibo e bevande",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Spesa alimentare",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Trasporti",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Voli",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Attività",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Visite turistiche",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Shopping",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Tariffe e biglietti",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Salute",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Mance",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Altro",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} giorni",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} viaggiatori",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "tasso in tempo reale",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Salda tutto",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'N. volo',
|
'reservations.meta.flightNumber': 'N. volo',
|
||||||
'reservations.meta.from': 'Da',
|
'reservations.meta.from': 'Da',
|
||||||
'reservations.meta.to': 'A',
|
'reservations.meta.to': 'A',
|
||||||
|
'reservations.layover.route': 'Itinerario',
|
||||||
|
'reservations.layover.stop': 'Scalo',
|
||||||
|
'reservations.layover.addStop': 'Aggiungi scalo',
|
||||||
|
'reservations.layover.connection': 'Coincidenza',
|
||||||
|
'reservations.layover.layover': 'Sosta',
|
||||||
'reservations.needsReview': 'Verifica',
|
'reservations.needsReview': 'Verifica',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
"L'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.",
|
"L'aeroporto non è stato riconosciuto automaticamente — conferma la posizione.",
|
||||||
|
|||||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'予算項目のメンバーアイコンをクリックして緑にすると、支払い済みを示します。精算では、誰が誰にいくら支払うべきかを表示します。',
|
'予算項目のメンバーアイコンをクリックして緑にすると、支払い済みを示します。精算では、誰が誰にいくら支払うべきかを表示します。',
|
||||||
'budget.netBalances': '差引残高',
|
'budget.netBalances': '差引残高',
|
||||||
'budget.categoriesLabel': 'カテゴリ',
|
'budget.categoriesLabel': 'カテゴリ',
|
||||||
"costs.you": "You",
|
"costs.you": "自分",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Y",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "you",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "支払う額",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "他の人に支払うべき額",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "受け取る額",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "他の人があなたに支払うべき額",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "旅行の合計支出",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "全員の合計",
|
||||||
"costs.to": "To",
|
"costs.to": "支払先",
|
||||||
"costs.from": "From",
|
"costs.from": "支払元",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "すべて精算済みです",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "受け取る額はありません",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "あなたの負担分",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "あなたが支払った額",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "支出",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count}件",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "支出を検索…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "すべて",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "自分が支払った分",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "受け取る分",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "支出を追加",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "支出を編集",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "検索条件に一致する支出はありません。",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "支出はまだありません。最初の支出を追加しましょう。",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount}を支出",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "日付なし",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "まだ誰も支払っていません",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "{amount}を立て替えました",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "{amount}を借りています",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "精算する",
|
||||||
"costs.history": "History",
|
"costs.history": "履歴",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "全員が清算済みです",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "現在、未払いの支払いはありません。",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "支払う",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "支払う",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "精算",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "残高",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "カテゴリ別",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "支出はまだありません。",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "精算履歴",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "精算済みの支払いはまだありません。",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count}件の支払いを精算済み",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "支払済み",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "元に戻す",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "何の支出ですか?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "例:夕食、お土産、ガソリン…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "合計金額",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "通貨",
|
||||||
"costs.day": "Day",
|
"costs.day": "日",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} = {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "カテゴリ",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "誰が支払いましたか?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "均等に分割する相手",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "分割する相手を少なくとも1人選んでください。",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "{count}人で分割 · 各{amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "宿泊",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "飲食",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "食料品",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "交通",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "航空券",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "アクティビティ",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "観光",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "買い物",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "手数料・チケット",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "健康",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "チップ",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "その他",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count}日間",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count}人の旅行者",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "リアルタイムレート",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "すべて精算",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': '便名',
|
'reservations.meta.flightNumber': '便名',
|
||||||
'reservations.meta.from': '出発地',
|
'reservations.meta.from': '出発地',
|
||||||
'reservations.meta.to': '到着地',
|
'reservations.meta.to': '到着地',
|
||||||
|
'reservations.layover.route': '経路',
|
||||||
|
'reservations.layover.stop': '経由地',
|
||||||
|
'reservations.layover.addStop': '経由地を追加',
|
||||||
|
'reservations.layover.connection': '乗り継ぎ便',
|
||||||
|
'reservations.layover.layover': '乗り継ぎ',
|
||||||
'reservations.needsReview': '要確認',
|
'reservations.needsReview': '要確認',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'空港を自動で特定できませんでした。場所を確認してください。',
|
'空港を自動で特定できませんでした。場所を確認してください。',
|
||||||
|
|||||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'예산 항목의 멤버 아바타를 클릭하면 녹색으로 표시됩니다 — 해당 멤버가 지불했음을 의미합니다. 그러면 정산에서 누가 누구에게 얼마를 지불해야 하는지 보여줍니다.',
|
'예산 항목의 멤버 아바타를 클릭하면 녹색으로 표시됩니다 — 해당 멤버가 지불했음을 의미합니다. 그러면 정산에서 누가 누구에게 얼마를 지불해야 하는지 보여줍니다.',
|
||||||
'budget.netBalances': '순 잔액',
|
'budget.netBalances': '순 잔액',
|
||||||
'budget.categoriesLabel': '카테고리',
|
'budget.categoriesLabel': '카테고리',
|
||||||
"costs.you": "You",
|
"costs.you": "나",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Y",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "나",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "내가 줄 돈",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "다른 사람에게 지불해야 합니다",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "받을 돈",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "다른 사람이 나에게 지불해야 합니다",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "총 여행 지출",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "모든 여행자 합계",
|
||||||
"costs.to": "To",
|
"costs.to": "받는 사람",
|
||||||
"costs.from": "From",
|
"costs.from": "보낸 사람",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "모두 정산되었습니다",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "받을 돈이 없습니다",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "내 몫",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "내가 지불함",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "지출",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count}개 항목",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "지출 검색…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "전체",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "내가 지불",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "받을 돈",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "지출 추가",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "지출 편집",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "검색과 일치하는 지출이 없습니다.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "아직 지출이 없습니다. 첫 항목을 추가하세요.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} 지출",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "날짜 없음",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "아직 아무도 지불하지 않음",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "{amount} 빌려줌",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "{amount} 빌림",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "정산하기",
|
||||||
"costs.history": "History",
|
"costs.history": "내역",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "모두 정산 완료",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "현재 미결제 금액이 없습니다.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "지불",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "지불",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "정산",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "잔액",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "카테고리별",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "아직 지출이 없습니다.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "정산 내역",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "아직 정산된 결제가 없습니다.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count}건 정산됨",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "지불됨",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "실행 취소",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "무엇을 위한 것인가요?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "예: 저녁 식사, 기념품, 주유…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "총 금액",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "통화",
|
||||||
"costs.day": "Day",
|
"costs.day": "날짜",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} = {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "카테고리",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "누가 지불했나요?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "균등 분할 대상",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "분할할 사람을 한 명 이상 선택하세요.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "{count}명 분할 · 각 {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "숙박",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "식음료",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "식료품",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "교통",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "항공편",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "액티비티",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "관광",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "쇼핑",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "요금 및 입장권",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "건강",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "팁",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "기타",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count}일",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "여행자 {count}명",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "실시간 환율",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "전체 정산",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': '항공편 번호',
|
'reservations.meta.flightNumber': '항공편 번호',
|
||||||
'reservations.meta.from': '출발',
|
'reservations.meta.from': '출발',
|
||||||
'reservations.meta.to': '도착',
|
'reservations.meta.to': '도착',
|
||||||
|
'reservations.layover.route': '경로',
|
||||||
|
'reservations.layover.stop': '경유',
|
||||||
|
'reservations.layover.addStop': '경유지 추가',
|
||||||
|
'reservations.layover.connection': '연결편',
|
||||||
|
'reservations.layover.layover': '경유 대기',
|
||||||
'reservations.needsReview': '검토 필요',
|
'reservations.needsReview': '검토 필요',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'공항이 자동으로 매칭되지 않았습니다 — 위치를 확인해 주세요.',
|
'공항이 자동으로 매칭되지 않았습니다 — 위치를 확인해 주세요.',
|
||||||
|
|||||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
|||||||
'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
|
'Klik op de avatar van een lid bij een budgetpost om deze groen te markeren — dit betekent dat diegene heeft betaald. De afrekening toont vervolgens wie wie hoeveel verschuldigd is.',
|
||||||
'budget.netBalances': 'Nettosaldi',
|
'budget.netBalances': 'Nettosaldi',
|
||||||
'budget.categoriesLabel': 'categorieën',
|
'budget.categoriesLabel': 'categorieën',
|
||||||
"costs.you": "You",
|
"costs.you": "Jij",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "J",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "jij",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Jij bent verschuldigd",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Jij moet anderen betalen",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Jij krijgt nog",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Anderen moeten jou betalen",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Totale reisuitgaven",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Voor alle reizigers",
|
||||||
"costs.to": "To",
|
"costs.to": "Aan",
|
||||||
"costs.from": "From",
|
"costs.from": "Van",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Je bent helemaal afgerekend",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Niemand is jou iets verschuldigd",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Jouw aandeel",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Jij hebt betaald",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Uitgaven",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} invoeren",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Uitgaven zoeken…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Alles",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Door mij betaald",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Mij verschuldigd",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Uitgave toevoegen",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Uitgave bewerken",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Geen uitgaven komen overeen met je zoekopdracht.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Nog geen uitgaven. Voeg je eerste toe.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} uitgegeven",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Geen datum",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Nog niemand heeft betaald",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "je hebt {amount} voorgeschoten",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "je hebt {amount} geleend",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Afrekenen",
|
||||||
"costs.history": "History",
|
"costs.history": "Geschiedenis",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Iedereen is quitte",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Er staan op dit moment geen betalingen open.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "betaalt",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "betaalt",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Afrekenen",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Saldi",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Per categorie",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Nog geen uitgaven.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Afrekengeschiedenis",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Nog geen afgerekende betalingen.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} betalingen afgerekend",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "betaald",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Ongedaan maken",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Waar was het voor?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "bijv. Diner, souvenirs, benzine…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Totaalbedrag",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Valuta",
|
||||||
"costs.day": "Day",
|
"costs.day": "Dag",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} in {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Categorie",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Wie heeft betaald?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Gelijk verdelen over",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Kies minstens één persoon om mee te delen.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Verdeeld over {count} · {amount} elk",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Accommodatie",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Eten & drinken",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Boodschappen",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Vervoer",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Vluchten",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Activiteiten",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Bezienswaardigheden",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Winkelen",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Kosten & tickets",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Gezondheid",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Fooien",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Overig",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} dagen",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} reizigers",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "live koers",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Alles afrekenen",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Vluchtnr.',
|
'reservations.meta.flightNumber': 'Vluchtnr.',
|
||||||
'reservations.meta.from': 'Van',
|
'reservations.meta.from': 'Van',
|
||||||
'reservations.meta.to': 'Naar',
|
'reservations.meta.to': 'Naar',
|
||||||
|
'reservations.layover.route': 'Route',
|
||||||
|
'reservations.layover.stop': 'Tussenstop',
|
||||||
|
'reservations.layover.addStop': 'Tussenstop toevoegen',
|
||||||
|
'reservations.layover.connection': 'Aansluiting',
|
||||||
|
'reservations.layover.layover': 'Overstap',
|
||||||
'reservations.needsReview': 'Controleren',
|
'reservations.needsReview': 'Controleren',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
|
'Luchthaven kon niet automatisch worden herkend — bevestig de locatie.',
|
||||||
|
|||||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'budget.exportCsv': 'Eksportuj CSV',
|
'budget.exportCsv': 'Eksportuj CSV',
|
||||||
'budget.table.date': 'Data',
|
'budget.table.date': 'Data',
|
||||||
'budget.categoriesLabel': 'kategorie',
|
'budget.categoriesLabel': 'kategorie',
|
||||||
"costs.you": "You",
|
"costs.you": "Ty",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "T",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "ty",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Jesteś winien",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Powinieneś zapłacić innym",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Należy ci się",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Inni powinni zapłacić tobie",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Łączne wydatki na podróż",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Wszystkich podróżnych",
|
||||||
"costs.to": "To",
|
"costs.to": "Do",
|
||||||
"costs.from": "From",
|
"costs.from": "Od",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Wszystko rozliczone",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Nikt nie jest ci nic winien",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Twój udział",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Zapłaciłeś",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Wydatki",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "Wpisów: {count}",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Szukaj wydatków…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Wszystkie",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Opłacone przeze mnie",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Należy mi się",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Dodaj wydatek",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Edytuj wydatek",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Brak wydatków pasujących do wyszukiwania.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Brak wydatków. Dodaj pierwszy.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "wydano {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Brak daty",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Nikt jeszcze nie zapłacił",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "pożyczyłeś {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "pożyczyłeś od innych {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Rozlicz",
|
||||||
"costs.history": "History",
|
"costs.history": "Historia",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Wszyscy rozliczeni",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Brak nierozliczonych płatności.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "zapłać",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "płaci",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Rozlicz",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Salda",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Według kategorii",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Brak wydatków.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Historia rozliczeń",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Brak rozliczonych płatności.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "Rozliczonych płatności: {count}",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "zapłacono",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Cofnij",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Na co to było?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "np. kolacja, pamiątki, paliwo…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Łączna kwota",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Waluta",
|
||||||
"costs.day": "Day",
|
"costs.day": "Dzień",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} w {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Kategoria",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Kto zapłacił?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Podziel równo między",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Wybierz co najmniej jedną osobę do podziału.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Podział na {count} · {amount} na osobę",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Nocleg",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Jedzenie i napoje",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Zakupy spożywcze",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Transport",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Loty",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Atrakcje",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Zwiedzanie",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Zakupy",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Opłaty i bilety",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Zdrowie",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Napiwki",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Inne",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "Dni: {count}",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "Podróżnych: {count}",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "kurs na żywo",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Rozlicz wszystko",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Numer lotu',
|
'reservations.meta.flightNumber': 'Numer lotu',
|
||||||
'reservations.meta.from': 'Skąd',
|
'reservations.meta.from': 'Skąd',
|
||||||
'reservations.meta.to': 'Dokąd',
|
'reservations.meta.to': 'Dokąd',
|
||||||
|
'reservations.layover.route': 'Trasa',
|
||||||
|
'reservations.layover.stop': 'Przystanek',
|
||||||
|
'reservations.layover.addStop': 'Dodaj przystanek',
|
||||||
|
'reservations.layover.connection': 'Połączenie',
|
||||||
|
'reservations.layover.layover': 'Przesiadka',
|
||||||
'reservations.meta.trainNumber': 'Numer pociągu',
|
'reservations.meta.trainNumber': 'Numer pociągu',
|
||||||
'reservations.meta.platform': 'Peron',
|
'reservations.meta.platform': 'Peron',
|
||||||
'reservations.meta.seat': 'Miejsce',
|
'reservations.meta.seat': 'Miejsce',
|
||||||
|
|||||||
@@ -40,78 +40,78 @@ const budget: TranslationStrings = {
|
|||||||
'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
|
'Нажмите на аватар участника в строке бюджета, чтобы отметить его зелёным — это значит, что он заплатил. Взаиморасчёт покажет, кто кому и сколько должен.',
|
||||||
'budget.netBalances': 'Чистые балансы',
|
'budget.netBalances': 'Чистые балансы',
|
||||||
'budget.categoriesLabel': 'категорий',
|
'budget.categoriesLabel': 'категорий',
|
||||||
"costs.you": "You",
|
"costs.you": "Вы",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "В",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "вы",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Вы должны",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Вы должны заплатить другим",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Вам должны",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Другие должны заплатить вам",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Общие расходы поездки",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "По всем участникам",
|
||||||
"costs.to": "To",
|
"costs.to": "Кому",
|
||||||
"costs.from": "From",
|
"costs.from": "От",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "У вас всё рассчитано",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Вам ничего не должны",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Ваша доля",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Вы заплатили",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Расходы",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} записей",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Поиск расходов…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Все",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Оплачено мной",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Мне должны",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Добавить расход",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Изменить расход",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Нет расходов по вашему запросу.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Расходов пока нет. Добавьте первый.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "потрачено {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Без даты",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Пока никто не заплатил",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "вы одолжили {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "вы заняли {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Рассчитаться",
|
||||||
"costs.history": "History",
|
"costs.history": "История",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Все в расчёте",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Сейчас нет неоплаченных платежей.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "платит",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "платит",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Рассчитать",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Балансы",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "По категориям",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Расходов пока нет.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "История расчётов",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Расчётов пока нет.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "Рассчитано платежей: {count}",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "оплачено",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Отменить",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "За что это было?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "напр. ужин, сувениры, бензин…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Общая сумма",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Валюта",
|
||||||
"costs.day": "Day",
|
"costs.day": "День",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} в {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Категория",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Кто заплатил?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Поделить поровну между",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Выберите хотя бы одного человека для разделения.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Разделено на {count} · по {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Проживание",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Еда и напитки",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Продукты",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Транспорт",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Авиаперелёты",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Развлечения",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Достопримечательности",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Покупки",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Сборы и билеты",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Здоровье",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Чаевые",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Прочее",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} дней",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} путешественников",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "актуальный курс",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Рассчитать всё",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Номер рейса',
|
'reservations.meta.flightNumber': 'Номер рейса',
|
||||||
'reservations.meta.from': 'Откуда',
|
'reservations.meta.from': 'Откуда',
|
||||||
'reservations.meta.to': 'Куда',
|
'reservations.meta.to': 'Куда',
|
||||||
|
'reservations.layover.route': 'Маршрут',
|
||||||
|
'reservations.layover.stop': 'Остановка',
|
||||||
|
'reservations.layover.addStop': 'Добавить остановку',
|
||||||
|
'reservations.layover.connection': 'Стыковка',
|
||||||
|
'reservations.layover.layover': 'Пересадка',
|
||||||
'reservations.needsReview': 'Проверить',
|
'reservations.needsReview': 'Проверить',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
|
'Аэропорт не удалось определить автоматически — подтвердите местоположение.',
|
||||||
|
|||||||
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
|
|||||||
'Bir bütçe kalemindeki üye avatarına tıklayarak yeşil işaretleyin — bu ödedikleri anlamına gelir. Hesaplaşma kimin kime ne kadar borçlu olduğunu gösterir.',
|
'Bir bütçe kalemindeki üye avatarına tıklayarak yeşil işaretleyin — bu ödedikleri anlamına gelir. Hesaplaşma kimin kime ne kadar borçlu olduğunu gösterir.',
|
||||||
'budget.netBalances': 'Net Bakiyeler',
|
'budget.netBalances': 'Net Bakiyeler',
|
||||||
'budget.categoriesLabel': 'kategoriler',
|
'budget.categoriesLabel': 'kategoriler',
|
||||||
"costs.you": "You",
|
"costs.you": "Siz",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "S",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "siz",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Borcunuz",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Başkalarına ödemeniz gerekiyor",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Size borçlu",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Başkaları size ödemeli",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Toplam seyahat harcaması",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Tüm yolcular genelinde",
|
||||||
"costs.to": "To",
|
"costs.to": "Alıcı",
|
||||||
"costs.from": "From",
|
"costs.from": "Gönderen",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Tüm hesaplar kapandı",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Size borç yok",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Sizin payınız",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Siz ödediniz",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Harcamalar",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} kayıt",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Harcamalarda ara…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Tümü",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Benim ödediklerim",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Bana borçlu",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Harcama ekle",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Harcamayı düzenle",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Aramanızla eşleşen harcama yok.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Henüz harcama yok. İlkini ekleyin.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "{amount} harcandı",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Tarih yok",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Henüz kimse ödemedi",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "{amount} verdiniz",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "{amount} aldınız",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Hesaplaş",
|
||||||
"costs.history": "History",
|
"costs.history": "Geçmiş",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Herkesin hesabı kapalı",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Şu anda bekleyen ödeme yok.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "öde",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "ödüyor",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Hesaplaş",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Bakiyeler",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "Kategoriye göre",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Henüz harcama yok.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Hesaplaşma geçmişi",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Henüz kapatılmış ödeme yok.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "{count} ödeme kapatıldı",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "ödendi",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Geri al",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "Ne içindi?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "örn. Akşam yemeği, hediyelik, benzin…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Toplam tutar",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Para birimi",
|
||||||
"costs.day": "Day",
|
"costs.day": "Gün",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "{to} cinsinden 1 {from}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Kategori",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Kim ödedi?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Eşit olarak böl",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Paylaşmak için en az bir kişi seçin.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "{count} kişiye bölündü · her biri {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Konaklama",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Yiyecek & içecek",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Market alışverişi",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Ulaşım",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Uçuşlar",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Etkinlikler",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Gezi & turlar",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Alışveriş",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Ücretler & biletler",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Sağlık",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Bahşişler",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Diğer",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} gün",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} yolcu",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "anlık kur",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Tümünü hesaplaş",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Uçuş No.',
|
'reservations.meta.flightNumber': 'Uçuş No.',
|
||||||
'reservations.meta.from': 'İtibaren',
|
'reservations.meta.from': 'İtibaren',
|
||||||
'reservations.meta.to': 'İle',
|
'reservations.meta.to': 'İle',
|
||||||
|
'reservations.layover.route': 'Rota',
|
||||||
|
'reservations.layover.stop': 'Durak',
|
||||||
|
'reservations.layover.addStop': 'Durak ekle',
|
||||||
|
'reservations.layover.connection': 'Aktarma',
|
||||||
|
'reservations.layover.layover': 'Aktarma bekleme süresi',
|
||||||
'reservations.needsReview': 'Gözden geçirmek',
|
'reservations.needsReview': 'Gözden geçirmek',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Havaalanı otomatik olarak eşleştirilemedi; lütfen konumu onaylayın.',
|
'Havaalanı otomatik olarak eşleştirilemedi; lütfen konumu onaylayın.',
|
||||||
|
|||||||
@@ -39,78 +39,78 @@ const budget: TranslationStrings = {
|
|||||||
'Натисніть на аватар учасника в рядку бюджету, щоб відзначити його зеленим — це означає, що він заплатив. Взаєморозрахунок покаже, хто кому і скільки винен.',
|
'Натисніть на аватар учасника в рядку бюджету, щоб відзначити його зеленим — це означає, що він заплатив. Взаєморозрахунок покаже, хто кому і скільки винен.',
|
||||||
'budget.netBalances': 'Чисті баланси',
|
'budget.netBalances': 'Чисті баланси',
|
||||||
'budget.categoriesLabel': 'категорії',
|
'budget.categoriesLabel': 'категорії',
|
||||||
"costs.you": "You",
|
"costs.you": "Ви",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Ви",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "ви",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "Ви винні",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "Ви маєте заплатити іншим",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "Вам винні",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "Інші мають заплатити вам",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "Загальні витрати поїздки",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "Серед усіх мандрівників",
|
||||||
"costs.to": "To",
|
"costs.to": "Кому",
|
||||||
"costs.from": "From",
|
"costs.from": "Від",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "Усі розрахунки завершено",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "Вам нічого не винні",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "Ваша частка",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "Ви заплатили",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "Витрати",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} записів",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "Пошук витрат…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "Усі",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "Сплачено мною",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "Мені винні",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "Додати витрату",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "Редагувати витрату",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "Жодна витрата не відповідає пошуку.",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "Витрат ще немає. Додайте першу.",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "Витрачено {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "Без дати",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "Ще ніхто не заплатив",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "ви позичили {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "ви заборгували {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "Розрахуватися",
|
||||||
"costs.history": "History",
|
"costs.history": "Історія",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "Усі розрахувалися",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "Зараз немає непогашених платежів.",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "заплатити",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "платить",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "Розрахувати",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "Баланси",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "За категоріями",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "Витрат ще немає.",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "Історія розрахунків",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "Розрахованих платежів ще немає.",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "Розраховано платежів: {count}",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "сплачено",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "Скасувати",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "За що це було?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "напр. вечеря, сувеніри, пальне…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "Загальна сума",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "Валюта",
|
||||||
"costs.day": "Day",
|
"costs.day": "День",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} у {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "Категорія",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "Хто заплатив?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "Розділити порівну між",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "Виберіть хоча б одну особу для розподілу.",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "Розділено на {count} · по {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "Проживання",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "Їжа та напої",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "Продукти",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "Транспорт",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "Авіапереліт",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "Активності",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "Огляд пам’яток",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "Покупки",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "Збори та квитки",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "Здоров’я",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "Чайові",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "Інше",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} днів",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} мандрівників",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "поточний курс",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "Розрахувати все",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': 'Номер рейсу',
|
'reservations.meta.flightNumber': 'Номер рейсу',
|
||||||
'reservations.meta.from': 'Звідки',
|
'reservations.meta.from': 'Звідки',
|
||||||
'reservations.meta.to': 'Куди',
|
'reservations.meta.to': 'Куди',
|
||||||
|
'reservations.layover.route': 'Маршрут',
|
||||||
|
'reservations.layover.stop': 'Зупинка',
|
||||||
|
'reservations.layover.addStop': 'Додати зупинку',
|
||||||
|
'reservations.layover.connection': 'Пересадка',
|
||||||
|
'reservations.layover.layover': 'Очікування',
|
||||||
'reservations.needsReview': 'Перевірити',
|
'reservations.needsReview': 'Перевірити',
|
||||||
'reservations.needsReviewHint':
|
'reservations.needsReviewHint':
|
||||||
'Аеропорт не вдалося визначити автоматично — підтвердіть місцезнаходження.',
|
'Аеропорт не вдалося визначити автоматично — підтвердіть місцезнаходження.',
|
||||||
|
|||||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'點選預算專案上的成員頭像將其標記為綠色——表示該成員已付款。結算會顯示誰欠誰多少。',
|
'點選預算專案上的成員頭像將其標記為綠色——表示該成員已付款。結算會顯示誰欠誰多少。',
|
||||||
'budget.netBalances': '淨餘額',
|
'budget.netBalances': '淨餘額',
|
||||||
'budget.categoriesLabel': '類別',
|
'budget.categoriesLabel': '類別',
|
||||||
"costs.you": "You",
|
"costs.you": "你",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "Y",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "你",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "你欠款",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "你需付款給他人",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "他人欠你",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "他人需付款給你",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "旅程總支出",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "所有旅伴合計",
|
||||||
"costs.to": "To",
|
"costs.to": "給",
|
||||||
"costs.from": "From",
|
"costs.from": "來自",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "你已全部結清",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "沒有人欠你",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "你的分攤",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "你支付了",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "支出",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} 筆",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "搜尋支出…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "全部",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "我支付的",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "他人欠我",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "新增支出",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "編輯支出",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "沒有符合搜尋的支出。",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "尚無支出,新增第一筆吧。",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "支出 {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "無日期",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "尚無人付款",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "你借出 {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "你借入 {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "結清",
|
||||||
"costs.history": "History",
|
"costs.history": "歷史紀錄",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "大家都已結清",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "目前沒有待付款項。",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "支付",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "支付",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "結算",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "餘額",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "按分類",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "尚無支出。",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "結算紀錄",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "尚無已結清的款項。",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "已結清 {count} 筆款項",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "已付",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "復原",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "這筆是什麼支出?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "例如:晚餐、紀念品、油費…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "總金額",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "貨幣",
|
||||||
"costs.day": "Day",
|
"costs.day": "日期",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} 兌 {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "分類",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "誰付的款?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "平均分攤給",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "至少選擇一人來分攤。",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "分 {count} 份 · 每份 {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "住宿",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "餐飲",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "雜貨",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "交通",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "機票",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "活動",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "觀光",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "購物",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "費用與票券",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "健康",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "小費",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "其他",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} 天",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} 位旅伴",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "即時匯率",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "全部結清",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': '航班號',
|
'reservations.meta.flightNumber': '航班號',
|
||||||
'reservations.meta.from': '出發',
|
'reservations.meta.from': '出發',
|
||||||
'reservations.meta.to': '到達',
|
'reservations.meta.to': '到達',
|
||||||
|
'reservations.layover.route': '航線',
|
||||||
|
'reservations.layover.stop': '中轉站',
|
||||||
|
'reservations.layover.addStop': '新增中轉站',
|
||||||
|
'reservations.layover.connection': '轉乘航班',
|
||||||
|
'reservations.layover.layover': '轉機等候',
|
||||||
'reservations.needsReview': '待確認',
|
'reservations.needsReview': '待確認',
|
||||||
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
|
'reservations.needsReviewHint': '無法自動匹配機場 — 請確認位置。',
|
||||||
'reservations.searchLocation': '搜尋車站、港口、地址...',
|
'reservations.searchLocation': '搜尋車站、港口、地址...',
|
||||||
|
|||||||
@@ -38,78 +38,78 @@ const budget: TranslationStrings = {
|
|||||||
'点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
|
'点击预算项目上的成员头像将其标记为绿色——表示该成员已付款。结算会显示谁欠谁多少。',
|
||||||
'budget.netBalances': '净余额',
|
'budget.netBalances': '净余额',
|
||||||
'budget.categoriesLabel': '类别',
|
'budget.categoriesLabel': '类别',
|
||||||
"costs.you": "You",
|
"costs.you": "你",
|
||||||
"costs.youShort": "Y",
|
"costs.youShort": "你",
|
||||||
"costs.youLower": "you",
|
"costs.youLower": "你",
|
||||||
"costs.youOwe": "You owe",
|
"costs.youOwe": "你欠款",
|
||||||
"costs.youOweSub": "You should pay others",
|
"costs.youOweSub": "你需要付钱给其他人",
|
||||||
"costs.youreOwed": "You're owed",
|
"costs.youreOwed": "别人欠你",
|
||||||
"costs.youreOwedSub": "Others should pay you",
|
"costs.youreOwedSub": "其他人需要付钱给你",
|
||||||
"costs.totalSpend": "Total trip spend",
|
"costs.totalSpend": "行程总支出",
|
||||||
"costs.totalSpendSub": "Across all travelers",
|
"costs.totalSpendSub": "所有同行者合计",
|
||||||
"costs.to": "To",
|
"costs.to": "收款方",
|
||||||
"costs.from": "From",
|
"costs.from": "付款方",
|
||||||
"costs.allSettled": "You're all settled up",
|
"costs.allSettled": "你已全部结清",
|
||||||
"costs.nothingOwed": "Nothing owed to you",
|
"costs.nothingOwed": "没有人欠你",
|
||||||
"costs.yourShare": "Your share",
|
"costs.yourShare": "你的份额",
|
||||||
"costs.youPaid": "You paid",
|
"costs.youPaid": "你已支付",
|
||||||
"costs.expenses": "Expenses",
|
"costs.expenses": "支出",
|
||||||
"costs.entries": "{count} entries",
|
"costs.entries": "{count} 条记录",
|
||||||
"costs.searchPlaceholder": "Search expenses…",
|
"costs.searchPlaceholder": "搜索支出…",
|
||||||
"costs.filter.all": "All",
|
"costs.filter.all": "全部",
|
||||||
"costs.filter.mine": "Paid by me",
|
"costs.filter.mine": "我支付的",
|
||||||
"costs.filter.owed": "I'm owed",
|
"costs.filter.owed": "别人欠我",
|
||||||
"costs.addExpense": "Add expense",
|
"costs.addExpense": "添加支出",
|
||||||
"costs.editExpense": "Edit expense",
|
"costs.editExpense": "编辑支出",
|
||||||
"costs.noMatch": "No expenses match your search.",
|
"costs.noMatch": "没有符合搜索条件的支出。",
|
||||||
"costs.emptyText": "No expenses yet. Add your first one.",
|
"costs.emptyText": "暂无支出。添加第一笔吧。",
|
||||||
"costs.spent": "{amount} spent",
|
"costs.spent": "已花费 {amount}",
|
||||||
"costs.noDate": "No date",
|
"costs.noDate": "无日期",
|
||||||
"costs.noOnePaid": "No one paid yet",
|
"costs.noOnePaid": "尚无人支付",
|
||||||
"costs.youLent": "you lent {amount}",
|
"costs.youLent": "你垫付了 {amount}",
|
||||||
"costs.youBorrowed": "you borrowed {amount}",
|
"costs.youBorrowed": "你欠了 {amount}",
|
||||||
"costs.settleUp": "Settle up",
|
"costs.settleUp": "结算",
|
||||||
"costs.history": "History",
|
"costs.history": "历史记录",
|
||||||
"costs.everyoneSquare": "Everyone's square",
|
"costs.everyoneSquare": "大家已两清",
|
||||||
"costs.nothingOutstanding": "No payments outstanding right now.",
|
"costs.nothingOutstanding": "目前没有待结算的款项。",
|
||||||
"costs.pay": "pay",
|
"costs.pay": "支付",
|
||||||
"costs.pays": "pays",
|
"costs.pays": "支付",
|
||||||
"costs.settle": "Settle",
|
"costs.settle": "结算",
|
||||||
"costs.balances": "Balances",
|
"costs.balances": "余额",
|
||||||
"costs.byCategory": "By category",
|
"costs.byCategory": "按分类",
|
||||||
"costs.noCategories": "No expenses yet.",
|
"costs.noCategories": "暂无支出。",
|
||||||
"costs.settleHistory": "Settle history",
|
"costs.settleHistory": "结算历史",
|
||||||
"costs.noSettlements": "No settled payments yet.",
|
"costs.noSettlements": "暂无已结算的款项。",
|
||||||
"costs.paymentsSettled": "{count} payments settled",
|
"costs.paymentsSettled": "已结算 {count} 笔付款",
|
||||||
"costs.paid": "paid",
|
"costs.paid": "已支付",
|
||||||
"costs.undo": "Undo",
|
"costs.undo": "撤销",
|
||||||
"costs.whatFor": "What was it for?",
|
"costs.whatFor": "这笔花在哪了?",
|
||||||
"costs.namePlaceholder": "e.g. Dinner, souvenirs, gas…",
|
"costs.namePlaceholder": "例如:晚餐、纪念品、油费…",
|
||||||
"costs.totalAmount": "Total amount",
|
"costs.totalAmount": "总金额",
|
||||||
"costs.currency": "Currency",
|
"costs.currency": "货币",
|
||||||
"costs.day": "Day",
|
"costs.day": "日期",
|
||||||
"costs.rateLabel": "1 {from} in {to}",
|
"costs.rateLabel": "1 {from} = {to}",
|
||||||
"costs.category": "Category",
|
"costs.category": "分类",
|
||||||
"costs.whoPaid": "Who paid?",
|
"costs.whoPaid": "谁支付的?",
|
||||||
"costs.splitBetween": "Split equally between",
|
"costs.splitBetween": "平均分摊给",
|
||||||
"costs.pickSomeone": "Pick at least one person to split with.",
|
"costs.pickSomeone": "至少选择一人参与分摊。",
|
||||||
"costs.splitSummary": "Split {count} ways · {amount} each",
|
"costs.splitSummary": "分 {count} 份 · 每份 {amount}",
|
||||||
"costs.cat.accommodation": "Accommodation",
|
"costs.cat.accommodation": "住宿",
|
||||||
"costs.cat.food": "Food & drink",
|
"costs.cat.food": "餐饮",
|
||||||
"costs.cat.groceries": "Groceries",
|
"costs.cat.groceries": "采买",
|
||||||
"costs.cat.transport": "Transport",
|
"costs.cat.transport": "交通",
|
||||||
"costs.cat.flights": "Flights",
|
"costs.cat.flights": "机票",
|
||||||
"costs.cat.activities": "Activities",
|
"costs.cat.activities": "活动",
|
||||||
"costs.cat.sightseeing": "Sightseeing",
|
"costs.cat.sightseeing": "观光",
|
||||||
"costs.cat.shopping": "Shopping",
|
"costs.cat.shopping": "购物",
|
||||||
"costs.cat.fees": "Fees & tickets",
|
"costs.cat.fees": "费用与门票",
|
||||||
"costs.cat.health": "Health",
|
"costs.cat.health": "健康",
|
||||||
"costs.cat.tips": "Tips",
|
"costs.cat.tips": "小费",
|
||||||
"costs.cat.other": "Other",
|
"costs.cat.other": "其他",
|
||||||
"costs.daysCount": "{count} days",
|
"costs.daysCount": "{count} 天",
|
||||||
"costs.travelers": "{count} travelers",
|
"costs.travelers": "{count} 位同行者",
|
||||||
"costs.liveRate": "live rate",
|
"costs.liveRate": "实时汇率",
|
||||||
"costs.settleAll": "Settle all",
|
"costs.settleAll": "全部结算",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ const reservations: TranslationStrings = {
|
|||||||
'reservations.meta.flightNumber': '航班号',
|
'reservations.meta.flightNumber': '航班号',
|
||||||
'reservations.meta.from': '出发',
|
'reservations.meta.from': '出发',
|
||||||
'reservations.meta.to': '到达',
|
'reservations.meta.to': '到达',
|
||||||
|
'reservations.layover.route': '航线',
|
||||||
|
'reservations.layover.stop': '经停',
|
||||||
|
'reservations.layover.addStop': '添加经停',
|
||||||
|
'reservations.layover.connection': '转机',
|
||||||
|
'reservations.layover.layover': '中转停留',
|
||||||
'reservations.needsReview': '待确认',
|
'reservations.needsReview': '待确认',
|
||||||
'reservations.needsReviewHint': '无法自动匹配机场 — 请确认位置。',
|
'reservations.needsReviewHint': '无法自动匹配机场 — 请确认位置。',
|
||||||
'reservations.searchLocation': '搜索车站、港口、地址...',
|
'reservations.searchLocation': '搜索车站、港口、地址...',
|
||||||
|
|||||||
Reference in New Issue
Block a user