Compare commits

...

9 Commits

Author SHA1 Message Date
Maurice c15c89ca61 feat(costs): create an expense from a booking, fix editing total-only items
Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

Also fix the Costs editor so an expense with a recorded total but no payers
(transport-derived or pre-rework items) opens with its amount, lets you set the
currency, and saves - it previously showed 0 everywhere and could not be saved.
Legacy / localized categories now map to the fixed keys, and changing a booking's
type keeps its linked expense category in sync (unless it was manually set).

- shared: reservation_id on budget create, typeToCostCategory helper, i18n keys
- server: createBudgetItem stores reservation_id; keep total_price for payerless
  items; a booking update no longer wipes its linked expense and syncs the
  category on type change
- client: shared BookingCostsSection, exported ExpenseModal with prefill and an
  editable total, page-level save-then-open wiring
2026-06-17 22:11:56 +02:00
Maurice f98058a3af feat(backup): make the upload size limit configurable
The restore upload was capped at a hard-coded 500 MB, so instances whose
backup archive (uploads/ included) grew past that got a 413 "File too large"
with no way to raise it. Add a BACKUP_UPLOAD_LIMIT_MB env var (default 500,
invalid values warn and fall back), documented in .env.example.
2026-06-17 21:00:36 +02:00
Maurice 39a3ee7ce7 fix(collab): show poll option labels in the UI
The poll API formatted each option as { label, voters }, but the React poll
component renders opt.text - so every option button came out blank. Emit text
alongside label (kept for any other consumer) so options render again.
2026-06-17 21:00:19 +02:00
Maurice e09849d5b4 fix(oidc): keep dots in generated usernames
The OIDC username sanitizer stripped dots because they were missing from the
allowed character class, so a name claim like "first.last" became "firstlast".
Dots are valid usernames (the profile validator already allows
^[a-zA-Z0-9_.-]+$), so add the dot to the sanitizer.
2026-06-17 21:00:04 +02:00
Maurice b3fc5411ca fix(atlas): cursor-following tooltips and removing countries from search
Two related Atlas fixes:

- Country tooltips were bound with sticky:false, which anchors them at the
  feature's bounds centre. For countries with overseas territories (e.g.
  France) that centre sits far out in the ocean, so the tooltip popped up
  nowhere near the area being hovered. Make them sticky so they track the
  cursor.

- Selecting an already-visited country from the search bar always opened the
  "Mark / Bucket" dialog, with no way to remove it. Tiny countries like
  Vatican City or Singapore are hard to hit on the map, so search was the only
  way in. Mirror the map-click behaviour: a manually-marked country opens the
  Remove confirmation, a trip/place-backed one opens its detail.
2026-06-17 15:23:54 +02:00
Maurice f524909008 fix(dashboard): show the correct reservation date regardless of timezone
The upcoming-reservations widget built the date with new Date(reservation_time)
.toISOString(), which reinterprets the stored naive local time as UTC and can
roll the displayed day forward in non-UTC timezones (e.g. a 23:30 reservation
showing the next day). Read the date and time straight from the stored string
parts via splitReservationDateTime, and format the time with the shared
formatTime helper so it also honours the user's 12h/24h preference.
2026-06-17 15:23:35 +02:00
Maurice 264cf7d384 fix(vacay): keep the mode toolbar above the mobile bottom nav
The floating Vacation/Company toolbar was pinned at bottom-3 with z-30, so on
mobile it landed in the same band as the fixed bottom nav (z-60) and got hidden
behind it - and could scroll out of reach entirely. Pin it above the nav with
the shared --bottom-nav-h variable (0px on desktop, so nothing changes there)
and reserve matching space below the calendar grid so it never gets swallowed.
2026-06-17 15:23:23 +02:00
Maurice cb7ce7f229 fix(docker): ship the encryption-key migration script in the image
The production image only copied server/dist, so the documented rotation
command `node --import tsx scripts/migrate-encryption.ts` failed inside the
container with a module-not-found error - the raw .ts was never present. The
script runs via tsx straight from source and only pulls node builtins plus
better-sqlite3 (both prod deps), so copying the single file into
/app/server/scripts is enough to make the rotation work again.
2026-06-17 15:04:29 +02:00
Maurice d40c5ce7a6 fix(demo): skip first-run admin seed in demo mode
When DEMO_MODE is on, the demo seeder creates its own admin (admin@trek.app,
username "admin") right after the generic seeds run. The first-run admin
bootstrap was grabbing username "admin" first, so the demo seeder hit the
UNIQUE(username) constraint and aborted before the demo user was ever created
- which surfaced as a 500 "Demo user not found" on demo-login. Skip the
generic admin bootstrap when demo mode owns the admin account.
2026-06-17 15:01:41 +02:00
43 changed files with 1087 additions and 483 deletions
+4
View File
@@ -85,6 +85,10 @@ COPY --from=server-builder /app/server/dist ./server/dist
COPY --from=server-builder /app/server/assets ./server/assets
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
COPY server/tsconfig.json ./server/
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
# raw .ts source — it never enters dist, so it must be copied in explicitly or
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
+38 -10
View File
@@ -662,8 +662,15 @@ function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
}
// ── Add / edit expense modal ───────────────────────────────────────────────
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
export interface ExpensePrefill {
name?: string
category?: string
amount?: number
reservationId?: number
}
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
}) {
const { t, locale } = useTranslation()
const toast = useToast()
@@ -671,8 +678,8 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
const [name, setName] = useState(editing?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [payers, setPayers] = useState<Record<number, string>>(() => {
@@ -680,13 +687,23 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
return m
})
// Standalone total for "recorded amount, nobody has paid yet" expenses (created
// from a booking, or pre-rework items). Used only while no per-person amount is
// entered; once a payer has an amount, the total derives from the payers.
const [amount, setAmount] = useState<string>(() => {
if (editing && !(editing.payers && editing.payers.length > 0)) return editing.total_price ? String(editing.total_price) : ''
if (prefill?.amount != null) return String(prefill.amount)
return ''
})
const [split, setSplit] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [saving, setSaving] = useState(false)
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
const each = split.size > 0 ? payersTotal / split.size : 0
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
const hasPayers = payersTotal > 0
const total = hasPayers ? payersTotal : (parseFloat(amount) || 0)
const each = split.size > 0 ? total / split.size : 0
const valid = name.trim().length > 0 && total > 0 && (hasPayers ? split.size > 0 : true)
const save = async () => {
if (!valid) return
@@ -699,6 +716,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
currency,
payers: payerList, member_ids: [...split],
expense_date: day || null,
// No per-person amounts: record the typed total directly (the server keeps
// it instead of deriving 0 from the empty payer list).
...(payerList.length === 0 ? { total_price: parseFloat(amount) || 0 } : {}),
// Link a freshly-created expense to its booking (create-from-booking flow).
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
}
try {
if (editing) await updateBudgetItem(tripId, editing.id, data)
@@ -728,7 +750,13 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
<label className={labelCls}>{t('costs.totalAmount')}</label>
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
{hasPayers ? (
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
) : (
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
onChange={e => setAmount(e.target.value)}
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
)}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -744,11 +772,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
</div>
</div>
{currency !== base && payersTotal > 0 && (
{currency !== base && total > 0 && (
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>{formatMoney(payersTotal, currency, locale)}</span>
<span>{formatMoney(total, currency, locale)}</span>
<span className="text-content-faint"></span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(total, currency), base, locale)}</span>
<span className="text-content-faint">· {t('costs.liveRate')}</span>
</div>
)}
@@ -32,8 +32,32 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
/** Map any stored category (incl. legacy free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
return COST_CAT_META.other
/**
* Legacy / English free-text categories (and reservation type labels) mapped to
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
* which never matched the lowercase keys and fell through to `other`.
*/
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
transport: 'transport', transportation: 'transport',
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
grocery: 'groceries', groceries: 'groceries',
activity: 'activities', activities: 'activities',
sightseeing: 'sightseeing', sights: 'sightseeing',
shop: 'shopping', shopping: 'shopping',
fee: 'fees', fees: 'fees',
health: 'health', medical: 'health',
tip: 'tips', tips: 'tips',
other: 'other', misc: 'other',
}
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
if (!cat) return COST_CAT_META.other
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
}
@@ -0,0 +1,61 @@
import { Plus, Pencil, Trash2 } from 'lucide-react'
import { useTripStore } from '../../store/tripStore'
import { useSettingsStore } from '../../store/settingsStore'
import { useTranslation } from '../../i18n'
import { formatMoney } from '../../utils/formatters'
import { catMeta } from '../Budget/costsCategories'
import type { BudgetItem } from '../../types'
/**
* The Costs block inside a booking modal. Replaces the old inline price + budget
* category fields: when no expense is linked yet it offers a "create expense"
* button (the modal saves the booking first, then opens the full Costs editor);
* once linked it shows the expense with edit / remove actions.
*/
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
reservationId: number | null
onCreate: () => void
onEdit: (item: BudgetItem) => void
onRemove: (item: BudgetItem) => void
}) {
const { t, locale } = useTranslation()
const budgetItems = useTripStore(s => s.budgetItems)
const trip = useTripStore(s => s.trip)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
if (linked) {
const meta = catMeta(linked.category)
const Icon = meta.Icon
return (
<div>
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
</div>
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
</div>
</div>
)
}
return (
<div>
<label className={labelCls}>{t('reservations.costsLabel')}</label>
<button type="button" onClick={onCreate}
className="bg-surface-secondary border border-edge text-content"
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
<Plus size={15} /> {t('reservations.createExpense')}
</button>
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
</div>
)
}
@@ -0,0 +1,11 @@
import type { BudgetItem } from '../../types'
/**
* A request from a booking modal to open the Costs expense editor — either to
* edit the already-linked expense, or to create a new one prefilled from the
* booking (the modal saves the booking first so `reservationId` is known).
*/
export interface BookingExpenseRequest {
editItem?: BudgetItem
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
}
@@ -343,56 +343,51 @@ describe('ReservationModal', () => {
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '99.99');
expect((priceInput as HTMLInputElement).value).toBe('99.99');
});
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<ReservationModal {...defaultProps} />);
const priceInput = screen.getByPlaceholderText('0.00');
await userEvent.type(priceInput, '50');
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<ReservationModal {...defaultProps} onSave={onSave} />);
const onSave = vi.fn().mockResolvedValue({ id: 55 });
const onOpenExpense = vi.fn();
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
await waitFor(() =>
expect(onOpenExpense).toHaveBeenCalledWith(
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
)
);
});
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
],
});
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
});
// ── File upload ───────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
@@ -599,22 +594,6 @@ describe('ReservationModal', () => {
expect(filePickerItem).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Budget section is visible
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
@@ -632,31 +611,6 @@ describe('ReservationModal', () => {
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
});
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
seedStore(useTripStore, {
trip: buildTrip({ id: 1 }),
budgetItems: [
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
],
});
render(<ReservationModal {...defaultProps} />);
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
await userEvent.click(budgetCategoryBtn);
// Click the "Transport" category option
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
await userEvent.click(screen.getByText('Transport'));
// The select should now show "Transport"
expect(screen.getByText('Transport')).toBeInTheDocument();
});
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
render(<ReservationModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
@@ -11,7 +11,10 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import { typeToCostCategory } from '@trek/shared'
const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
@@ -60,9 +63,10 @@ interface ReservationModalProps {
onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[]
defaultAssignmentId?: number | null
onOpenExpense?: (req: BookingExpenseRequest) => void
}
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
const { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast()
@@ -70,18 +74,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
// Set right before submit when the user clicked create/edit expense (see TransportModal).
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
})
@@ -127,15 +127,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
} else {
setForm({
title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
})
@@ -167,8 +164,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
return endFull <= startFull
})()
const handleSubmit = async (e) => {
e.preventDefault()
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
e?.preventDefault?.()
if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
setIsSaving(true)
@@ -185,11 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const saveData: Record<string, any> & { title: string } = {
title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
@@ -202,11 +194,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endpoints: [],
needs_review: false,
}
if (isBudgetEnabled) {
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = {
place_id: form.hotel_place_id || null,
@@ -228,11 +215,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
await onFileUpload(fd)
}
}
// Open the Costs editor for the saved booking when the user asked to
// create/edit its linked expense (gated on saved?.id).
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
} finally {
setIsSaving(false)
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
const handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
@@ -610,38 +611,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div>
</div>
{/* Price + Budget Category */}
{/* Costs — create / view the expense linked to this booking */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
className={inputClass} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
<BookingCostsSection
reservationId={reservation?.id ?? null}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
)}
</form>
@@ -132,34 +132,37 @@ describe('TransportModal', () => {
// ── Budget addon ─────────────────────────────────────────────────────────────
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
render(<TransportModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
render(<TransportModal {...defaultProps} />);
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
});
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true,
});
const onSave = vi.fn().mockResolvedValue(undefined);
render(<TransportModal {...defaultProps} onSave={onSave} />);
const onSave = vi.fn().mockResolvedValue({ id: 42 });
const onOpenExpense = vi.fn();
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
await waitFor(() =>
expect(onOpenExpense).toHaveBeenCalledWith(
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
)
);
});
@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo, useRef } from 'react'
import { useState, useEffect, useRef } from 'react'
import { useParams } from 'react-router-dom'
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'
@@ -13,8 +13,11 @@ import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
import { BookingCostsSection } from './BookingCostsSection'
import type { BookingExpenseRequest } from './BookingCostsSection.types'
import { typeToCostCategory } from '@trek/shared'
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number]
@@ -105,8 +108,6 @@ const defaultForm = {
arrival_time: '',
confirmation_number: '',
notes: '',
price: '',
budget_category: '',
meta_airline: '',
meta_flight_number: '',
meta_train_number: '',
@@ -124,20 +125,20 @@ interface TransportModalProps {
files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void>
onOpenExpense?: (req: BookingExpenseRequest) => void
}
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
const { t, locale } = useTranslation()
const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
const loadFiles = useTripStore(s => s.loadFiles)
const budgetCategories = useMemo(() => {
const cats = new Set<string>()
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const { id: tripId } = useParams<{ id: string }>()
// Set right before submitting when the user clicked "create/edit expense", so
// the post-save handler knows to open the Costs editor for the saved booking.
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
const [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({})
@@ -177,8 +178,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
meta_train_number: meta.train_number || '',
meta_platform: meta.platform || '',
meta_seat: meta.seat || '',
price: meta.price || '',
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
})
if (type === 'flight') {
const orderedEps = orderedEndpoints(reservation)
@@ -229,8 +228,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!form.title.trim()) return
setIsSaving(true)
try {
@@ -289,11 +288,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
if (form.meta_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat
}
if (isBudgetEnabled) {
if (form.price) metadata.price = form.price
if (form.budget_category) metadata.budget_category = form.budget_category
}
const startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
@@ -334,11 +328,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints,
needs_review: false,
}
if (isBudgetEnabled) {
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
: { total_price: 0 }
}
const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) {
@@ -349,6 +338,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
await onFileUpload(fd)
}
}
// The user asked to create/edit the linked expense — open the Costs editor
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
const intent = expenseIntentRef.current
expenseIntentRef.current = null
if (intent && onOpenExpense && saved?.id) {
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
}
} catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally {
@@ -356,6 +353,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
}
}
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
const handleRemoveExpense = async (item: BudgetItem) => {
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
}
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -712,38 +715,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div>
</div>
{/* Price + Budget Category */}
{/* Costs — create / view the expense linked to this booking */}
{isBudgetEnabled && (
<>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.price')}</label>
<input type="text" inputMode="decimal" value={form.price}
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
placeholder="0.00"
className={inputClass} />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
<CustomSelect
value={form.budget_category}
onChange={v => set('budget_category', v)}
options={[
{ value: '', label: t('reservations.budgetCategoryAuto') },
...budgetCategories.map(c => ({ value: c, label: c })),
]}
placeholder={t('reservations.budgetCategoryAuto')}
size="sm"
/>
</div>
</div>
{form.price && parseFloat(form.price) > 0 && (
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
{t('reservations.budgetHint')}
</div>
)}
</>
<BookingCostsSection
reservationId={reservation?.id ?? null}
onCreate={handleCreateExpense}
onEdit={handleEditExpense}
onRemove={handleRemoveExpense}
/>
)}
</form>
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
return (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
{Array.from({ length: 12 }, (_, i) => (
<VacayMonthCard
key={i}
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
))}
</div>
{/* Floating toolbar */}
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
<button
onClick={() => setCompanyMode(false)}
+10 -4
View File
@@ -18,6 +18,8 @@ import {
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
LayoutGrid, List, Ticket, X,
} from 'lucide-react'
import { formatTime, splitReservationDateTime } from '../utils/formatters'
import { useSettingsStore } from '../store/settingsStore'
import '../styles/dashboard.css'
const GRADIENTS = [
@@ -602,6 +604,7 @@ function UpcomingTool({ items, locale, onOpen }: {
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
}): React.ReactElement {
const { t } = useTranslation()
const timeFormat = useSettingsStore(s => s.settings.time_format)
return (
<div className="tool">
<div className="tool-head">
@@ -612,10 +615,13 @@ function UpcomingTool({ items, locale, onOpen }: {
) : (
<div className="upc-list">
{items.map(r => {
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
const d = when ? new Date(when) : null
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
// Read the date/time straight from the stored string parts. Going through
// new Date(...).toISOString() reinterprets the naive local time as UTC and
// can roll the displayed day forward/back in non-UTC timezones.
const parsed = splitReservationDateTime(r.reservation_time)
const datePart = parsed.date || r.day_date || null
const dateStr = datePart ? splitDate(datePart, locale) : null
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
return (
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
+29 -3
View File
@@ -25,7 +25,9 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager'
import CostsPanel from '../components/Budget/CostsPanel'
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
import type { BudgetItem } from '../types'
import CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast'
@@ -212,6 +214,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
// page level so it has tripMembers / base currency / current user available.
const meId = useAuthStore(s => s.user?.id ?? -1)
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
const openBookingExpense = (req: BookingExpenseRequest) => {
if (req.editItem) setBookingExpense({ editing: req.editItem })
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
}
if (isLoading || !splashDone) {
return (
<div className="bg-surface" style={{
@@ -706,8 +720,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
{bookingExpense && (
<ExpenseModal
tripId={tripId}
base={costsBase}
people={tripMembers}
me={meId}
editing={bookingExpense.editing}
prefill={bookingExpense.prefill}
onClose={() => setBookingExpense(null)}
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
/>
)}
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog
+19 -2
View File
@@ -340,7 +340,10 @@ export function useAtlas() {
</div>
</div>`
layer.bindTooltip(tooltipHtml, {
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
// bounds centre, which for countries with overseas territories (e.g. France) lands
// far out in the ocean instead of over the area being hovered.
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => {
if (c.placeCount === 0 && c.tripCount === 0) {
@@ -363,7 +366,7 @@ export function useAtlas() {
country_layer_by_a2_ref.current[countryCode] = layer
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
})
layer.on('click', () => handleMarkCountry(countryCode, name))
layer.on('mouseover', (e) => {
@@ -552,6 +555,20 @@ export function useAtlas() {
} catch (e ) {
console.error('Error fitting bounds', e)
}
// Mirror the map-click behaviour so an already-visited country can be removed
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
// hit on the map, so search was the only way in — but it always opened the
// "Mark / Bucket" dialog with no Remove option.
const visited = data?.countries.find(c => c.code === country_code)
if (visited) {
if (visited.placeCount === 0 && visited.tripCount === 0) {
handleUnmarkCountry(country_code)
} else {
loadCountryDetailRef.current(country_code)
}
return
}
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
}
+2
View File
@@ -35,6 +35,8 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
DEMO_MODE=false # Demo mode - resets data hourly
# BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size).
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
+5
View File
@@ -18,6 +18,11 @@ function seedAdminAccount(db: Database.Database): void {
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
if (userCount > 0) return;
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
// Creating a first-run admin here would grab username 'admin' first and make the demo
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
if (process.env.DEMO_MODE?.toLowerCase() === 'true') return;
if (isOidcOnlyConfigured()) {
console.log('');
console.log('╔══════════════════════════════════════════════╗');
+1 -1
View File
@@ -114,7 +114,7 @@ export class BudgetController {
create(
@CurrentUser() user: User,
@Param('tripId') tripId: string,
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
@Headers('x-socket-id') socketId?: string,
) {
const trip = this.requireTrip(tripId, user);
@@ -5,6 +5,7 @@ import { checkPermission } from '../../services/permissions';
import type { User } from '../../types';
import * as svc from '../../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
import { typeToCostCategory } from '@trek/shared';
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
type BudgetEntry = { total_price?: number; category?: string } | undefined;
@@ -77,30 +78,51 @@ export class ReservationsService {
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
if (!entry || !entry.total_price) {
// When the booking type changes, keep a linked expense's category in sync —
// but only if it still carries the auto-derived category (so a manual pick in
// the Costs editor is preserved). Runs regardless of create_budget_entry.
if (type && currentType && type !== currentType) {
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
if (linked) {
const oldCat = typeToCostCategory(currentType);
const newCat = typeToCostCategory(type);
if (oldCat !== newCat && linked.category === oldCat) {
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
}
}
}
// No budget entry on the payload — the booking edit isn't touching its linked
// expense, so leave any linked item alone. Expenses are managed from the
// booking's Costs section / the Costs tab, not by re-saving the booking.
if (!entry) return;
if (!(Number(entry.total_price) > 0)) {
// Explicit clear (total_price 0/empty) — drop the linked item.
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linked) {
deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
}
return;
}
if (entry && Number(entry.total_price) > 0) {
try {
const itemName = title || currentTitle;
const category = entry.category || type || currentType || 'Other';
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
} else {
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
item.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item }, socketId);
}
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
try {
const itemName = title || currentTitle;
const category = entry.category || type || currentType || 'Other';
const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) {
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
} else {
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price });
db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
item.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item }, socketId);
}
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
}
}
+15 -1
View File
@@ -15,7 +15,21 @@ const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
// Compressed upload cap for restore archives. Defaults to 500 MB, raisable via
// BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow
// past that. Invalid values warn and fall back to the default.
const DEFAULT_BACKUP_UPLOAD_LIMIT_MB = 500;
const rawBackupUploadLimit = process.env.BACKUP_UPLOAD_LIMIT_MB?.trim();
let backupUploadLimitMb = DEFAULT_BACKUP_UPLOAD_LIMIT_MB;
if (rawBackupUploadLimit) {
const parsed = Number(rawBackupUploadLimit);
if (Number.isFinite(parsed) && parsed > 0) {
backupUploadLimitMb = parsed;
} else {
console.warn(`BACKUP_UPLOAD_LIMIT_MB="${rawBackupUploadLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_UPLOAD_LIMIT_MB} MB.`);
}
}
export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
// limit only caps the compressed bytes). Generous enough for any real backup.
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
+12 -2
View File
@@ -105,6 +105,7 @@ export function createBudgetItem(
currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
reservation_id?: number | null;
},
) {
const maxOrder = db.prepare(
@@ -128,7 +129,7 @@ export function createBudgetItem(
const total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
const result = db.prepare(
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
'INSERT INTO budget_items (trip_id, category, name, total_price, currency, exchange_rate, persons, days, note, sort_order, expense_date, reservation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
).run(
tripId,
cat,
@@ -141,6 +142,7 @@ export function createBudgetItem(
data.note || null,
sortOrder,
data.expense_date || null,
data.reservation_id != null ? data.reservation_id : null,
);
const itemId = result.lastInsertRowid as number;
@@ -208,7 +210,15 @@ export function updateBudgetItem(
);
// Optional inline payer/member replacement (the edit modal saves all at once).
if (data.payers !== undefined) writeItemPayers(id, data.payers);
if (data.payers !== undefined) {
writeItemPayers(id, data.payers);
// writeItemPayers derives total_price from the payer sum (0 for no payers).
// A "recorded total, nobody assigned" expense clears payers but still carries
// an explicit total_price — re-apply it so it isn't clobbered to 0.
if (data.payers.length === 0 && data.total_price !== undefined) {
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
}
}
if (data.member_ids !== undefined) {
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
+11 -6
View File
@@ -229,12 +229,17 @@ export function getPollWithVotes(pollId: number | bigint | string) {
WHERE v.poll_id = ?
`).all(pollId) as PollVoteRow[];
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
label: typeof label === 'string' ? label : label.label || label,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
}));
const formattedOptions = options.map((label: string | { label: string }, idx: number) => {
const text = typeof label === 'string' ? label : label.label || label;
return {
// The client renders `opt.text`; keep `label` too for any other consumer.
text,
label: text,
voters: votes
.filter(v => v.option_index === idx)
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
};
});
return {
...poll,
+4 -2
View File
@@ -417,8 +417,10 @@ export function findOrCreateUser(
const bcrypt = require('bcryptjs');
const hash = bcrypt.hashSync(randomPass, 10);
// Username: sanitize and avoid collisions
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
// Username: sanitize and avoid collisions. Keep dots — they are valid in
// usernames (see the ^[a-zA-Z0-9_.-]+$ validation in authService) and common
// in OIDC name claims like "first.last".
let username = name.replace(/[^a-zA-Z0-9_.-]/g, '').substring(0, 30) || 'user';
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
if (existing) username = `${username}_${Date.now() % 10000}`;
+77 -3
View File
@@ -382,7 +382,7 @@ describe('Reservation budget entry integration', () => {
expect(items[0].total_price).toBe(150);
});
it('RESV-014 — PUT without create_budget_entry removes existing linked budget item', async () => {
it('RESV-014 — PUT without create_budget_entry keeps the existing linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
@@ -398,24 +398,98 @@ describe('Reservation budget entry integration', () => {
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Verify budget item exists
const before = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(before).toBeDefined();
// Update without create_budget_entry — should delete the linked budget item
// Update WITHOUT create_budget_entry — the booking edit must NOT touch its
// linked expense (expenses are managed from the Costs section now).
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Taxi Updated' });
expect(updateRes.status).toBe(200);
const after = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(after).toBeDefined();
});
it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({
title: 'Taxi',
type: 'transport',
create_budget_entry: { total_price: 50, category: 'Transport' },
});
expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id;
// Explicit clear (total_price 0) still removes the linked item.
const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Taxi', create_budget_entry: { total_price: 0 } });
expect(updateRes.status).toBe(200);
const after = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId);
expect(after).toBeUndefined();
});
it('RESV-014c — changing the booking type updates the linked expense category', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
const resvId = createRes.body.reservation.id;
// Change the type other -> hotel (no create_budget_entry).
await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'hotel' });
const item = testDb
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId) as { category: string };
expect(item.category).toBe('accommodation');
});
it('RESV-014d — a manually-picked expense category survives a booking type change', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const createRes = await request(app)
.post(`/api/trips/${trip.id}/reservations`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'other', create_budget_entry: { total_price: 50, category: 'other' } });
const resvId = createRes.body.reservation.id;
// Simulate a manual category pick in the Costs editor.
testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId);
await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id))
.send({ title: 'Booking', type: 'hotel' });
const item = testDb
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId) as { category: string };
expect(item.category).toBe('fees');
});
});
describe('Reservation accommodation delete', () => {
+32
View File
@@ -49,6 +49,35 @@ export const COST_CATEGORIES = [
] as const;
export type CostCategory = (typeof COST_CATEGORIES)[number];
/**
* Maps a reservation `type` (flight, train, hotel, ) to one of the fixed Costs
* categories, so an expense created from a booking lands in the right bucket
* instead of a free-text/localized label. Unknown types fall back to `other`.
*/
const RESERVATION_TYPE_TO_COST_CATEGORY: Record<string, CostCategory> = {
flight: 'flights',
plane: 'flights',
train: 'transport',
bus: 'transport',
car: 'transport',
'car-rental': 'transport',
ferry: 'transport',
boat: 'transport',
taxi: 'transport',
transfer: 'transport',
transport: 'transport',
hotel: 'accommodation',
accommodation: 'accommodation',
lodging: 'accommodation',
restaurant: 'food',
activity: 'activities',
};
export function typeToCostCategory(type: string | null | undefined): CostCategory {
if (!type) return 'other';
return RESERVATION_TYPE_TO_COST_CATEGORY[type.trim().toLowerCase()] || 'other';
}
/**
* One payer of an expense a row of budget_item_payers. `amount` is in the
* expense's own currency (budget_items.currency). Several payers can split who
@@ -112,6 +141,9 @@ export const budgetCreateItemRequestSchema = z.object({
days: z.number().nullable().optional(),
note: z.string().nullable().optional(),
expense_date: z.string().nullable().optional(),
// Link this expense to a reservation (e.g. created from a booking's
// "add expense" flow). The server stores it on budget_items.reservation_id.
reservation_id: z.number().optional(),
});
export type BudgetCreateItemRequest = z.infer<
typeof budgetCreateItemRequestSchema
+26 -10
View File
@@ -127,35 +127,51 @@ const reservations: TranslationStrings = {
'reservations.import.cta': 'استيراد من ملف',
'reservations.import.dropHere': 'أسقط ملفات تأكيد الحجز هنا أو انقر للتحديد',
'reservations.import.dropActive': 'أسقط الملفات للاستيراد',
'reservations.import.acceptedFormats': 'المقبول: EML، PDF، PKPass، HTML، TXT (بحد أقصى 10 ميغابايت لكل ملف، حتى 5 ملفات)',
'reservations.import.acceptedFormats':
'المقبول: EML، PDF، PKPass، HTML، TXT (بحد أقصى 10 ميغابايت لكل ملف، حتى 5 ملفات)',
'reservations.import.parsing': 'جارٍ معالجة الملفات…',
'reservations.import.previewHeading': 'تم العثور على {count} حجز/حجوزات',
'reservations.import.previewEmpty': 'تعذّر استخراج أي حجوزات من الملفات المُحمَّلة.',
'reservations.import.previewEmpty':
'تعذّر استخراج أي حجوزات من الملفات المُحمَّلة.',
'reservations.import.removeItem': 'إزالة',
'reservations.import.confirm': 'استيراد {count} حجز/حجوزات',
'reservations.import.back': 'رجوع',
'reservations.import.success': 'تم استيراد {count} حجز/حجوزات',
'reservations.import.partialFailure': 'تم استيراد {created}، فشل {failed}',
'reservations.import.error': 'فشلت المعالجة. تأكد من أن الملف تأكيد حجز صالح.',
'reservations.import.unavailable': 'استيراد الحجوزات غير متاح على هذا الخادم.',
'reservations.import.unsupportedFormat': 'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.',
'reservations.import.error':
'فشلت المعالجة. تأكد من أن الملف تأكيد حجز صالح.',
'reservations.import.unavailable':
'استيراد الحجوزات غير متاح على هذا الخادم.',
'reservations.import.unsupportedFormat':
'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.',
'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.',
'reservations.airtrail.title': 'استيراد من AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.',
'reservations.airtrail.syncedHint':
'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.',
'reservations.airtrail.notSynced': 'غير متزامن',
'reservations.airtrail.notSyncedHint': 'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.',
'reservations.airtrail.notSyncedHint':
'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.',
'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.',
'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات',
'reservations.airtrail.skippedDuplicate': '{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها',
'reservations.airtrail.skippedDuplicate':
'{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها',
'reservations.airtrail.nothingImported': 'لا شيء لاستيراده.',
'reservations.airtrail.importError': 'فشل الاستيراد. يُرجى المحاولة مرة أخرى.',
'reservations.airtrail.importError':
'فشل الاستيراد. يُرجى المحاولة مرة أخرى.',
'reservations.airtrail.undo': 'استيراد من AirTrail',
'reservations.airtrail.alreadyImported': 'مُستورَد',
'reservations.airtrail.duringTrip': 'خلال هذه الرحلة',
'reservations.airtrail.otherFlights': 'رحلات أخرى',
'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
'reservations.airtrail.empty':
'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
'reservations.airtrail.importCta': 'استيراد {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -126,37 +126,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Adicionar reserva',
'reservations.import.title': 'Importar confirmações de reserva',
'reservations.import.cta': 'Importar de arquivo',
'reservations.import.dropHere': 'Solte os arquivos de confirmação de reserva aqui ou clique para selecionar',
'reservations.import.dropHere':
'Solte os arquivos de confirmação de reserva aqui ou clique para selecionar',
'reservations.import.dropActive': 'Solte os arquivos para importar',
'reservations.import.acceptedFormats': 'Aceitos: EML, PDF, PKPass, HTML, TXT (máx. 10 MB cada, até 5 arquivos)',
'reservations.import.acceptedFormats':
'Aceitos: EML, PDF, PKPass, HTML, TXT (máx. 10 MB cada, até 5 arquivos)',
'reservations.import.parsing': 'Analisando arquivos…',
'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)',
'reservations.import.previewEmpty': 'Nenhuma reserva pôde ser extraída dos arquivos enviados.',
'reservations.import.previewEmpty':
'Nenhuma reserva pôde ser extraída dos arquivos enviados.',
'reservations.import.removeItem': 'Remover',
'reservations.import.confirm': 'Importar {count} reserva(s)',
'reservations.import.back': 'Voltar',
'reservations.import.success': '{count} reserva(s) importada(s)',
'reservations.import.partialFailure': '{created} importada(s), {failed} falhou/falharam',
'reservations.import.error': 'Falha na análise. Verifique se o arquivo é uma confirmação de reserva válida.',
'reservations.import.unavailable': 'A importação de reservas não está disponível neste servidor.',
'reservations.import.unsupportedFormat': 'Formato de arquivo não suportado. Use EML, PDF, PKPass, HTML ou TXT.',
'reservations.import.fileTooLarge': 'O arquivo "{name}" excede o limite de 10 MB.',
'reservations.import.partialFailure':
'{created} importada(s), {failed} falhou/falharam',
'reservations.import.error':
'Falha na análise. Verifique se o arquivo é uma confirmação de reserva válida.',
'reservations.import.unavailable':
'A importação de reservas não está disponível neste servidor.',
'reservations.import.unsupportedFormat':
'Formato de arquivo não suportado. Use EML, PDF, PKPass, HTML ou TXT.',
'reservations.import.fileTooLarge':
'O arquivo "{name}" excede o limite de 10 MB.',
'reservations.airtrail.title': 'Importar do AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Sincronizado do AirTrail — as edições permanecem em sincronia nos dois sentidos.',
'reservations.airtrail.syncedHint':
'Sincronizado do AirTrail — as edições permanecem em sincronia nos dois sentidos.',
'reservations.airtrail.notSynced': 'Não sincronizado',
'reservations.airtrail.notSyncedHint': 'Este voo foi removido no AirTrail e não sincroniza mais.',
'reservations.airtrail.loadError': 'Não foi possível carregar seus voos do AirTrail.',
'reservations.airtrail.notSyncedHint':
'Este voo foi removido no AirTrail e não sincroniza mais.',
'reservations.airtrail.loadError':
'Não foi possível carregar seus voos do AirTrail.',
'reservations.airtrail.imported': '{count} voo(s) importado(s)',
'reservations.airtrail.skippedDuplicate': '{count} já nesta viagem, ignorado(s)',
'reservations.airtrail.skippedDuplicate':
'{count} já nesta viagem, ignorado(s)',
'reservations.airtrail.nothingImported': 'Nada para importar.',
'reservations.airtrail.importError': 'Falha na importação. Tente novamente.',
'reservations.airtrail.undo': 'Importar do AirTrail',
'reservations.airtrail.alreadyImported': 'Importado',
'reservations.airtrail.duringTrip': 'Durante esta viagem',
'reservations.airtrail.otherFlights': 'Outros voos',
'reservations.airtrail.empty': 'Nenhum voo encontrado na sua conta do AirTrail.',
'reservations.airtrail.empty':
'Nenhum voo encontrado na sua conta do AirTrail.',
'reservations.airtrail.importCta': 'Importar {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+30 -12
View File
@@ -125,37 +125,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Přidat rezervaci',
'reservations.import.title': 'Importovat potvrzení rezervace',
'reservations.import.cta': 'Importovat ze souboru',
'reservations.import.dropHere': 'Přetáhněte soubory s potvrzením rezervace sem nebo klikněte pro výběr',
'reservations.import.dropHere':
'Přetáhněte soubory s potvrzením rezervace sem nebo klikněte pro výběr',
'reservations.import.dropActive': 'Pusťte soubory pro import',
'reservations.import.acceptedFormats': 'Přijímané formáty: EML, PDF, PKPass, HTML, TXT (max. 10 MB každý, až 5 souborů)',
'reservations.import.acceptedFormats':
'Přijímané formáty: EML, PDF, PKPass, HTML, TXT (max. 10 MB každý, až 5 souborů)',
'reservations.import.parsing': 'Zpracování souborů…',
'reservations.import.previewHeading': 'Nalezeno {count} rezervace/í',
'reservations.import.previewEmpty': 'Z nahraných souborů se nepodařilo extrahovat žádné rezervace.',
'reservations.import.previewEmpty':
'Z nahraných souborů se nepodařilo extrahovat žádné rezervace.',
'reservations.import.removeItem': 'Odebrat',
'reservations.import.confirm': 'Importovat {count} rezervaci/í',
'reservations.import.back': 'Zpět',
'reservations.import.success': '{count} rezervace/í importováno',
'reservations.import.partialFailure': '{created} importováno, {failed} selhalo',
'reservations.import.error': 'Zpracování selhalo. Ujistěte se, že soubor je platným potvrzením rezervace.',
'reservations.import.unavailable': 'Import rezervací není na tomto serveru k dispozici.',
'reservations.import.unsupportedFormat': 'Nepodporovaný formát souboru. Použijte EML, PDF, PKPass, HTML nebo TXT.',
'reservations.import.partialFailure':
'{created} importováno, {failed} selhalo',
'reservations.import.error':
'Zpracování selhalo. Ujistěte se, že soubor je platným potvrzením rezervace.',
'reservations.import.unavailable':
'Import rezervací není na tomto serveru k dispozici.',
'reservations.import.unsupportedFormat':
'Nepodporovaný formát souboru. Použijte EML, PDF, PKPass, HTML nebo TXT.',
'reservations.import.fileTooLarge': 'Soubor „{name}" překračuje limit 10 MB.',
'reservations.airtrail.title': 'Import z AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Synchronizováno z AirTrail úpravy zůstávají synchronní v obou směrech.',
'reservations.airtrail.syncedHint':
'Synchronizováno z AirTrail úpravy zůstávají synchronní v obou směrech.',
'reservations.airtrail.notSynced': 'Nesynchronizováno',
'reservations.airtrail.notSyncedHint': 'Tento let byl v AirTrail odstraněn a již se nesynchronizuje.',
'reservations.airtrail.loadError': 'Vaše lety z AirTrail se nepodařilo načíst.',
'reservations.airtrail.notSyncedHint':
'Tento let byl v AirTrail odstraněn a již se nesynchronizuje.',
'reservations.airtrail.loadError':
'Vaše lety z AirTrail se nepodařilo načíst.',
'reservations.airtrail.imported': 'Importováno letů: {count}',
'reservations.airtrail.skippedDuplicate': 'Již v tomto výletu: {count}, přeskočeno',
'reservations.airtrail.skippedDuplicate':
'Již v tomto výletu: {count}, přeskočeno',
'reservations.airtrail.nothingImported': 'Není co importovat.',
'reservations.airtrail.importError': 'Import selhal. Zkuste to prosím znovu.',
'reservations.airtrail.undo': 'Import z AirTrail',
'reservations.airtrail.alreadyImported': 'Importováno',
'reservations.airtrail.duringTrip': 'Během tohoto výletu',
'reservations.airtrail.otherFlights': 'Ostatní lety',
'reservations.airtrail.empty': 'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.',
'reservations.airtrail.empty':
'Ve vašem účtu AirTrail nebyly nalezeny žádné lety.',
'reservations.airtrail.importCta': 'Importovat {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+34 -14
View File
@@ -127,37 +127,57 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Buchung hinzufügen',
'reservations.import.title': 'Buchungsbestätigungen importieren',
'reservations.import.cta': 'Aus Datei importieren',
'reservations.import.dropHere': 'Buchungsbestätigungsdateien hier ablegen oder klicken zum Auswählen',
'reservations.import.dropHere':
'Buchungsbestätigungsdateien hier ablegen oder klicken zum Auswählen',
'reservations.import.dropActive': 'Dateien zum Importieren ablegen',
'reservations.import.acceptedFormats': 'Akzeptiert: EML, PDF, PKPass, HTML, TXT (max. 10 MB pro Datei, bis zu 5 Dateien)',
'reservations.import.acceptedFormats':
'Akzeptiert: EML, PDF, PKPass, HTML, TXT (max. 10 MB pro Datei, bis zu 5 Dateien)',
'reservations.import.parsing': 'Dateien werden verarbeitet…',
'reservations.import.previewHeading': '{count} Reservierung(en) gefunden',
'reservations.import.previewEmpty': 'Aus den hochgeladenen Dateien konnten keine Reservierungen extrahiert werden.',
'reservations.import.previewEmpty':
'Aus den hochgeladenen Dateien konnten keine Reservierungen extrahiert werden.',
'reservations.import.removeItem': 'Entfernen',
'reservations.import.confirm': '{count} Reservierung(en) importieren',
'reservations.import.back': 'Zurück',
'reservations.import.success': '{count} Reservierung(en) importiert',
'reservations.import.partialFailure': '{created} importiert, {failed} fehlgeschlagen',
'reservations.import.error': 'Verarbeitung fehlgeschlagen. Stellen Sie sicher, dass die Datei eine gültige Buchungsbestätigung ist.',
'reservations.import.unavailable': 'Buchungsimport ist auf diesem Server nicht verfügbar.',
'reservations.import.unsupportedFormat': 'Nicht unterstütztes Dateiformat. Verwenden Sie EML, PDF, PKPass, HTML oder TXT.',
'reservations.import.fileTooLarge': 'Datei „{name}" überschreitet das 10-MB-Limit.',
'reservations.import.partialFailure':
'{created} importiert, {failed} fehlgeschlagen',
'reservations.import.error':
'Verarbeitung fehlgeschlagen. Stellen Sie sicher, dass die Datei eine gültige Buchungsbestätigung ist.',
'reservations.import.unavailable':
'Buchungsimport ist auf diesem Server nicht verfügbar.',
'reservations.import.unsupportedFormat':
'Nicht unterstütztes Dateiformat. Verwenden Sie EML, PDF, PKPass, HTML oder TXT.',
'reservations.import.fileTooLarge':
'Datei „{name}" überschreitet das 10-MB-Limit.',
'reservations.airtrail.title': 'Aus AirTrail importieren',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Aus AirTrail synchronisiert — Änderungen bleiben in beide Richtungen synchron.',
'reservations.airtrail.syncedHint':
'Aus AirTrail synchronisiert — Änderungen bleiben in beide Richtungen synchron.',
'reservations.airtrail.notSynced': 'Nicht synchronisiert',
'reservations.airtrail.notSyncedHint': 'Dieser Flug wurde in AirTrail gelöscht und wird nicht mehr synchronisiert.',
'reservations.airtrail.loadError': 'Ihre AirTrail-Flüge konnten nicht geladen werden.',
'reservations.airtrail.notSyncedHint':
'Dieser Flug wurde in AirTrail gelöscht und wird nicht mehr synchronisiert.',
'reservations.airtrail.loadError':
'Ihre AirTrail-Flüge konnten nicht geladen werden.',
'reservations.airtrail.imported': '{count} Flug/Flüge importiert',
'reservations.airtrail.skippedDuplicate': '{count} bereits in dieser Reise, übersprungen',
'reservations.airtrail.skippedDuplicate':
'{count} bereits in dieser Reise, übersprungen',
'reservations.airtrail.nothingImported': 'Nichts zu importieren.',
'reservations.airtrail.importError': 'Import fehlgeschlagen. Bitte erneut versuchen.',
'reservations.airtrail.importError':
'Import fehlgeschlagen. Bitte erneut versuchen.',
'reservations.airtrail.undo': 'Aus AirTrail importieren',
'reservations.airtrail.alreadyImported': 'Importiert',
'reservations.airtrail.duringTrip': 'Während dieser Reise',
'reservations.airtrail.otherFlights': 'Weitere Flüge',
'reservations.airtrail.empty': 'Keine Flüge in Ihrem AirTrail-Konto gefunden.',
'reservations.airtrail.empty':
'Keine Flüge in Ihrem AirTrail-Konto gefunden.',
'reservations.airtrail.importCta': '{count} importieren',
'reservations.costsLabel': 'Kosten',
'reservations.createExpense': 'Ausgabe erstellen',
'reservations.createExpenseHint':
'Speichert die Buchung und öffnet dann den Kosten-Editor.',
'reservations.linkedExpense': 'Verknüpfte Ausgabe',
'reservations.removeExpense': 'Ausgabe entfernen',
};
export default reservations;
+24 -9
View File
@@ -126,30 +126,39 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Add booking',
'reservations.import.title': 'Import booking confirmations',
'reservations.import.cta': 'Import from file',
'reservations.import.dropHere': 'Drop booking confirmation files here, or click to select',
'reservations.import.dropHere':
'Drop booking confirmation files here, or click to select',
'reservations.import.dropActive': 'Drop files to import',
'reservations.import.acceptedFormats': 'Accepted: EML, PDF, PKPass, HTML, TXT (max 10 MB each, up to 5 files)',
'reservations.import.acceptedFormats':
'Accepted: EML, PDF, PKPass, HTML, TXT (max 10 MB each, up to 5 files)',
'reservations.import.parsing': 'Parsing files…',
'reservations.import.previewHeading': '{count} reservation(s) found',
'reservations.import.previewEmpty': 'No reservations could be extracted from the uploaded files.',
'reservations.import.previewEmpty':
'No reservations could be extracted from the uploaded files.',
'reservations.import.removeItem': 'Remove',
'reservations.import.confirm': 'Import {count} reservation(s)',
'reservations.import.back': 'Back',
'reservations.import.success': '{count} reservation(s) imported',
'reservations.import.partialFailure': '{created} imported, {failed} failed',
'reservations.import.error': 'Parsing failed. Make sure the file is a valid booking confirmation.',
'reservations.import.unavailable': 'Booking import is not available on this server.',
'reservations.import.unsupportedFormat': 'Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.',
'reservations.import.error':
'Parsing failed. Make sure the file is a valid booking confirmation.',
'reservations.import.unavailable':
'Booking import is not available on this server.',
'reservations.import.unsupportedFormat':
'Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.',
'reservations.import.fileTooLarge': 'File "{name}" exceeds 10 MB limit.',
'reservations.airtrail.title': 'Import from AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Synced from AirTrail — edits stay in sync both ways.',
'reservations.airtrail.syncedHint':
'Synced from AirTrail — edits stay in sync both ways.',
'reservations.airtrail.notSynced': 'Not synced',
'reservations.airtrail.notSyncedHint': 'This flight was removed in AirTrail and no longer syncs.',
'reservations.airtrail.notSyncedHint':
'This flight was removed in AirTrail and no longer syncs.',
'reservations.airtrail.loadError': 'Could not load your AirTrail flights.',
'reservations.airtrail.imported': '{count} flight(s) imported',
'reservations.airtrail.skippedDuplicate': '{count} already in this trip, skipped',
'reservations.airtrail.skippedDuplicate':
'{count} already in this trip, skipped',
'reservations.airtrail.nothingImported': 'Nothing to import.',
'reservations.airtrail.importError': 'Import failed. Please try again.',
'reservations.airtrail.undo': 'Import from AirTrail',
@@ -158,5 +167,11 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Other flights',
'reservations.airtrail.empty': 'No flights found in your AirTrail account.',
'reservations.airtrail.importCta': 'Import {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -126,37 +126,56 @@ const reservations: TranslationStrings = {
'reservations.meta.selectDay': 'Seleccionar día',
'reservations.import.title': 'Importar confirmaciones de reserva',
'reservations.import.cta': 'Importar desde archivo',
'reservations.import.dropHere': 'Suelta los archivos de confirmación de reserva aquí o haz clic para seleccionar',
'reservations.import.dropHere':
'Suelta los archivos de confirmación de reserva aquí o haz clic para seleccionar',
'reservations.import.dropActive': 'Suelta los archivos para importar',
'reservations.import.acceptedFormats': 'Aceptados: EML, PDF, PKPass, HTML, TXT (máx. 10 MB por archivo, hasta 5 archivos)',
'reservations.import.acceptedFormats':
'Aceptados: EML, PDF, PKPass, HTML, TXT (máx. 10 MB por archivo, hasta 5 archivos)',
'reservations.import.parsing': 'Analizando archivos…',
'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)',
'reservations.import.previewEmpty': 'No se pudieron extraer reservas de los archivos subidos.',
'reservations.import.previewEmpty':
'No se pudieron extraer reservas de los archivos subidos.',
'reservations.import.removeItem': 'Eliminar',
'reservations.import.confirm': 'Importar {count} reserva(s)',
'reservations.import.back': 'Atrás',
'reservations.import.success': '{count} reserva(s) importada(s)',
'reservations.import.partialFailure': '{created} importada(s), {failed} fallida(s)',
'reservations.import.error': 'Error al analizar. Asegúrate de que el archivo sea una confirmación de reserva válida.',
'reservations.import.unavailable': 'La importación de reservas no está disponible en este servidor.',
'reservations.import.unsupportedFormat': 'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.',
'reservations.import.fileTooLarge': 'El archivo «{name}» supera el límite de 10 MB.',
'reservations.import.partialFailure':
'{created} importada(s), {failed} fallida(s)',
'reservations.import.error':
'Error al analizar. Asegúrate de que el archivo sea una confirmación de reserva válida.',
'reservations.import.unavailable':
'La importación de reservas no está disponible en este servidor.',
'reservations.import.unsupportedFormat':
'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.',
'reservations.import.fileTooLarge':
'El archivo «{name}» supera el límite de 10 MB.',
'reservations.airtrail.title': 'Importar desde AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Sincronizado desde AirTrail: las ediciones se mantienen sincronizadas en ambos sentidos.',
'reservations.airtrail.syncedHint':
'Sincronizado desde AirTrail: las ediciones se mantienen sincronizadas en ambos sentidos.',
'reservations.airtrail.notSynced': 'No sincronizado',
'reservations.airtrail.notSyncedHint': 'Este vuelo se eliminó en AirTrail y ya no se sincroniza.',
'reservations.airtrail.loadError': 'No se pudieron cargar tus vuelos de AirTrail.',
'reservations.airtrail.notSyncedHint':
'Este vuelo se eliminó en AirTrail y ya no se sincroniza.',
'reservations.airtrail.loadError':
'No se pudieron cargar tus vuelos de AirTrail.',
'reservations.airtrail.imported': '{count} vuelo(s) importado(s)',
'reservations.airtrail.skippedDuplicate': '{count} ya en este viaje, omitido(s)',
'reservations.airtrail.skippedDuplicate':
'{count} ya en este viaje, omitido(s)',
'reservations.airtrail.nothingImported': 'No hay nada que importar.',
'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.',
'reservations.airtrail.undo': 'Importar desde AirTrail',
'reservations.airtrail.alreadyImported': 'Importado',
'reservations.airtrail.duringTrip': 'Durante este viaje',
'reservations.airtrail.otherFlights': 'Otros vuelos',
'reservations.airtrail.empty': 'No se encontraron vuelos en tu cuenta de AirTrail.',
'reservations.airtrail.empty':
'No se encontraron vuelos en tu cuenta de AirTrail.',
'reservations.airtrail.importCta': 'Importar {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+30 -12
View File
@@ -127,37 +127,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Ajouter une réservation',
'reservations.import.title': 'Importer des confirmations de réservation',
'reservations.import.cta': 'Importer depuis un fichier',
'reservations.import.dropHere': 'Déposez les fichiers de confirmation de réservation ici ou cliquez pour sélectionner',
'reservations.import.dropHere':
'Déposez les fichiers de confirmation de réservation ici ou cliquez pour sélectionner',
'reservations.import.dropActive': 'Déposez les fichiers pour importer',
'reservations.import.acceptedFormats': "Acceptés : EML, PDF, PKPass, HTML, TXT (max. 10 Mo chacun, jusqu'à 5 fichiers)",
'reservations.import.acceptedFormats':
"Acceptés : EML, PDF, PKPass, HTML, TXT (max. 10 Mo chacun, jusqu'à 5 fichiers)",
'reservations.import.parsing': 'Analyse des fichiers…',
'reservations.import.previewHeading': '{count} réservation(s) trouvée(s)',
'reservations.import.previewEmpty': "Aucune réservation n'a pu être extraite des fichiers envoyés.",
'reservations.import.previewEmpty':
"Aucune réservation n'a pu être extraite des fichiers envoyés.",
'reservations.import.removeItem': 'Supprimer',
'reservations.import.confirm': 'Importer {count} réservation(s)',
'reservations.import.back': 'Retour',
'reservations.import.success': '{count} réservation(s) importée(s)',
'reservations.import.partialFailure': '{created} importée(s), {failed} échouée(s)',
'reservations.import.error': 'Analyse échouée. Assurez-vous que le fichier est une confirmation de réservation valide.',
'reservations.import.unavailable': "L'import de réservations n'est pas disponible sur ce serveur.",
'reservations.import.unsupportedFormat': 'Format de fichier non pris en charge. Utilisez EML, PDF, PKPass, HTML ou TXT.',
'reservations.import.fileTooLarge': 'Le fichier « {name} » dépasse la limite de 10 Mo.',
'reservations.import.partialFailure':
'{created} importée(s), {failed} échouée(s)',
'reservations.import.error':
'Analyse échouée. Assurez-vous que le fichier est une confirmation de réservation valide.',
'reservations.import.unavailable':
"L'import de réservations n'est pas disponible sur ce serveur.",
'reservations.import.unsupportedFormat':
'Format de fichier non pris en charge. Utilisez EML, PDF, PKPass, HTML ou TXT.',
'reservations.import.fileTooLarge':
'Le fichier « {name} » dépasse la limite de 10 Mo.',
'reservations.airtrail.title': 'Importer depuis AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Synchronisé depuis AirTrail — les modifications restent synchronisées dans les deux sens.',
'reservations.airtrail.syncedHint':
'Synchronisé depuis AirTrail — les modifications restent synchronisées dans les deux sens.',
'reservations.airtrail.notSynced': 'Non synchronisé',
'reservations.airtrail.notSyncedHint': "Ce vol a été supprimé dans AirTrail et n'est plus synchronisé.",
'reservations.airtrail.notSyncedHint':
"Ce vol a été supprimé dans AirTrail et n'est plus synchronisé.",
'reservations.airtrail.loadError': 'Impossible de charger vos vols AirTrail.',
'reservations.airtrail.imported': '{count} vol(s) importé(s)',
'reservations.airtrail.skippedDuplicate': '{count} déjà dans ce voyage, ignoré(s)',
'reservations.airtrail.skippedDuplicate':
'{count} déjà dans ce voyage, ignoré(s)',
'reservations.airtrail.nothingImported': 'Rien à importer.',
'reservations.airtrail.importError': "Échec de l'importation. Veuillez réessayer.",
'reservations.airtrail.importError':
"Échec de l'importation. Veuillez réessayer.",
'reservations.airtrail.undo': 'Importer depuis AirTrail',
'reservations.airtrail.alreadyImported': 'Importé',
'reservations.airtrail.duringTrip': 'Pendant ce voyage',
'reservations.airtrail.otherFlights': 'Autres vols',
'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.',
'reservations.airtrail.importCta': 'Importer {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -128,37 +128,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Προσθήκη κράτησης',
'reservations.import.title': 'Εισαγωγή επιβεβαιώσεων κράτησης',
'reservations.import.cta': 'Εισαγωγή από αρχείο',
'reservations.import.dropHere': 'Αποθέστε αρχεία επιβεβαίωσης κράτησης εδώ ή κάντε κλικ για επιλογή',
'reservations.import.dropHere':
'Αποθέστε αρχεία επιβεβαίωσης κράτησης εδώ ή κάντε κλικ για επιλογή',
'reservations.import.dropActive': 'Αποθέστε αρχεία για εισαγωγή',
'reservations.import.acceptedFormats': 'Αποδεκτά: EML, PDF, PKPass, HTML, TXT (μέγιστο 10 MB το καθένα, έως 5 αρχεία)',
'reservations.import.acceptedFormats':
'Αποδεκτά: EML, PDF, PKPass, HTML, TXT (μέγιστο 10 MB το καθένα, έως 5 αρχεία)',
'reservations.import.parsing': 'Επεξεργασία αρχείων…',
'reservations.import.previewHeading': 'Βρέθηκαν {count} κράτηση/κρατήσεις',
'reservations.import.previewEmpty': 'Δεν ήταν δυνατή η εξαγωγή κρατήσεων από τα μεταφορτωμένα αρχεία.',
'reservations.import.previewEmpty':
'Δεν ήταν δυνατή η εξαγωγή κρατήσεων από τα μεταφορτωμένα αρχεία.',
'reservations.import.removeItem': 'Αφαίρεση',
'reservations.import.confirm': 'Εισαγωγή {count} κράτησης/κρατήσεων',
'reservations.import.back': 'Πίσω',
'reservations.import.success': '{count} κράτηση/κρατήσεις εισήχθησαν',
'reservations.import.partialFailure': '{created} εισήχθησαν, {failed} απέτυχαν',
'reservations.import.error': 'Η επεξεργασία απέτυχε. Βεβαιωθείτε ότι το αρχείο είναι έγκυρη επιβεβαίωση κράτησης.',
'reservations.import.unavailable': 'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.',
'reservations.import.unsupportedFormat': 'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.',
'reservations.import.fileTooLarge': 'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.',
'reservations.import.partialFailure':
'{created} εισήχθησαν, {failed} απέτυχαν',
'reservations.import.error':
'Η επεξεργασία απέτυχε. Βεβαιωθείτε ότι το αρχείο είναι έγκυρη επιβεβαίωση κράτησης.',
'reservations.import.unavailable':
'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.',
'reservations.import.unsupportedFormat':
'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.',
'reservations.import.fileTooLarge':
'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.',
'reservations.airtrail.title': 'Εισαγωγή από το AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.',
'reservations.airtrail.syncedHint':
'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.',
'reservations.airtrail.notSynced': 'Μη συγχρονισμένο',
'reservations.airtrail.notSyncedHint': 'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.',
'reservations.airtrail.loadError': 'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.',
'reservations.airtrail.notSyncedHint':
'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.',
'reservations.airtrail.loadError':
'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.',
'reservations.airtrail.imported': '{count} πτήση/πτήσεις εισήχθησαν',
'reservations.airtrail.skippedDuplicate': '{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν',
'reservations.airtrail.skippedDuplicate':
'{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν',
'reservations.airtrail.nothingImported': 'Δεν υπάρχει τίποτα για εισαγωγή.',
'reservations.airtrail.importError': 'Η εισαγωγή απέτυχε. Δοκιμάστε ξανά.',
'reservations.airtrail.undo': 'Εισαγωγή από το AirTrail',
'reservations.airtrail.alreadyImported': 'Εισήχθη',
'reservations.airtrail.duringTrip': 'Κατά τη διάρκεια αυτού του ταξιδιού',
'reservations.airtrail.otherFlights': 'Άλλες πτήσεις',
'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
'reservations.airtrail.empty':
'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
'reservations.airtrail.importCta': 'Εισαγωγή {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Foglalás hozzáadása',
'reservations.import.title': 'Foglalási visszaigazolások importálása',
'reservations.import.cta': 'Importálás fájlból',
'reservations.import.dropHere': 'Dobja ide a foglalási visszaigazolás fájlokat, vagy kattintson a kiválasztáshoz',
'reservations.import.dropHere':
'Dobja ide a foglalási visszaigazolás fájlokat, vagy kattintson a kiválasztáshoz',
'reservations.import.dropActive': 'Dobja ide a fájlokat az importáláshoz',
'reservations.import.acceptedFormats': 'Elfogadott: EML, PDF, PKPass, HTML, TXT (max. 10 MB darabonként, legfeljebb 5 fájl)',
'reservations.import.acceptedFormats':
'Elfogadott: EML, PDF, PKPass, HTML, TXT (max. 10 MB darabonként, legfeljebb 5 fájl)',
'reservations.import.parsing': 'Fájlok feldolgozása…',
'reservations.import.previewHeading': '{count} foglalás találva',
'reservations.import.previewEmpty': 'A feltöltött fájlokból nem sikerült foglalásokat kinyerni.',
'reservations.import.previewEmpty':
'A feltöltött fájlokból nem sikerült foglalásokat kinyerni.',
'reservations.import.removeItem': 'Eltávolítás',
'reservations.import.confirm': '{count} foglalás importálása',
'reservations.import.back': 'Vissza',
'reservations.import.success': '{count} foglalás importálva',
'reservations.import.partialFailure': '{created} importálva, {failed} sikertelen',
'reservations.import.error': 'A feldolgozás sikertelen. Győződjön meg arról, hogy a fájl érvényes foglalási visszaigazolás.',
'reservations.import.unavailable': 'A foglalásimportálás nem érhető el ezen a kiszolgálón.',
'reservations.import.unsupportedFormat': 'Nem támogatott fájlformátum. Használjon EML, PDF, PKPass, HTML vagy TXT formátumot.',
'reservations.import.fileTooLarge': 'A(z) „{name}" fájl meghaladja a 10 MB-os korlátot.',
'reservations.import.partialFailure':
'{created} importálva, {failed} sikertelen',
'reservations.import.error':
'A feldolgozás sikertelen. Győződjön meg arról, hogy a fájl érvényes foglalási visszaigazolás.',
'reservations.import.unavailable':
'A foglalásimportálás nem érhető el ezen a kiszolgálón.',
'reservations.import.unsupportedFormat':
'Nem támogatott fájlformátum. Használjon EML, PDF, PKPass, HTML vagy TXT formátumot.',
'reservations.import.fileTooLarge':
'A(z) „{name}" fájl meghaladja a 10 MB-os korlátot.',
'reservations.airtrail.title': 'Importálás az AirTrailből',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Az AirTrailből szinkronizálva — a módosítások mindkét irányban szinkronban maradnak.',
'reservations.airtrail.syncedHint':
'Az AirTrailből szinkronizálva — a módosítások mindkét irányban szinkronban maradnak.',
'reservations.airtrail.notSynced': 'Nincs szinkronizálva',
'reservations.airtrail.notSyncedHint': 'Ezt a járatot eltávolították az AirTrailből, és többé nem szinkronizálódik.',
'reservations.airtrail.loadError': 'Nem sikerült betölteni az AirTrail-járataidat.',
'reservations.airtrail.notSyncedHint':
'Ezt a járatot eltávolították az AirTrailből, és többé nem szinkronizálódik.',
'reservations.airtrail.loadError':
'Nem sikerült betölteni az AirTrail-járataidat.',
'reservations.airtrail.imported': '{count} járat importálva',
'reservations.airtrail.skippedDuplicate': '{count} már szerepel ebben az utazásban, kihagyva',
'reservations.airtrail.skippedDuplicate':
'{count} már szerepel ebben az utazásban, kihagyva',
'reservations.airtrail.nothingImported': 'Nincs mit importálni.',
'reservations.airtrail.importError': 'Az importálás sikertelen. Kérjük, próbáld újra.',
'reservations.airtrail.importError':
'Az importálás sikertelen. Kérjük, próbáld újra.',
'reservations.airtrail.undo': 'Importálás az AirTrailből',
'reservations.airtrail.alreadyImported': 'Importálva',
'reservations.airtrail.duringTrip': 'Az utazás ideje alatt',
'reservations.airtrail.otherFlights': 'Egyéb járatok',
'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.',
'reservations.airtrail.importCta': '{count} importálása',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+30 -12
View File
@@ -126,37 +126,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Tambah pemesanan',
'reservations.import.title': 'Impor konfirmasi pemesanan',
'reservations.import.cta': 'Impor dari file',
'reservations.import.dropHere': 'Seret file konfirmasi pemesanan ke sini atau klik untuk memilih',
'reservations.import.dropHere':
'Seret file konfirmasi pemesanan ke sini atau klik untuk memilih',
'reservations.import.dropActive': 'Lepaskan file untuk mengimpor',
'reservations.import.acceptedFormats': 'Diterima: EML, PDF, PKPass, HTML, TXT (maks. 10 MB per file, hingga 5 file)',
'reservations.import.acceptedFormats':
'Diterima: EML, PDF, PKPass, HTML, TXT (maks. 10 MB per file, hingga 5 file)',
'reservations.import.parsing': 'Memproses file…',
'reservations.import.previewHeading': '{count} pemesanan ditemukan',
'reservations.import.previewEmpty': 'Tidak ada pemesanan yang dapat diekstrak dari file yang diunggah.',
'reservations.import.previewEmpty':
'Tidak ada pemesanan yang dapat diekstrak dari file yang diunggah.',
'reservations.import.removeItem': 'Hapus',
'reservations.import.confirm': 'Impor {count} pemesanan',
'reservations.import.back': 'Kembali',
'reservations.import.success': '{count} pemesanan berhasil diimpor',
'reservations.import.partialFailure': '{created} berhasil diimpor, {failed} gagal',
'reservations.import.error': 'Pemrosesan gagal. Pastikan file adalah konfirmasi pemesanan yang valid.',
'reservations.import.unavailable': 'Impor pemesanan tidak tersedia di server ini.',
'reservations.import.unsupportedFormat': 'Format file tidak didukung. Gunakan EML, PDF, PKPass, HTML, atau TXT.',
'reservations.import.partialFailure':
'{created} berhasil diimpor, {failed} gagal',
'reservations.import.error':
'Pemrosesan gagal. Pastikan file adalah konfirmasi pemesanan yang valid.',
'reservations.import.unavailable':
'Impor pemesanan tidak tersedia di server ini.',
'reservations.import.unsupportedFormat':
'Format file tidak didukung. Gunakan EML, PDF, PKPass, HTML, atau TXT.',
'reservations.import.fileTooLarge': 'File "{name}" melebihi batas 10 MB.',
'reservations.airtrail.title': 'Impor dari AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Tersinkron dari AirTrail — perubahan tetap sinkron di kedua arah.',
'reservations.airtrail.syncedHint':
'Tersinkron dari AirTrail — perubahan tetap sinkron di kedua arah.',
'reservations.airtrail.notSynced': 'Tidak tersinkron',
'reservations.airtrail.notSyncedHint': 'Penerbangan ini telah dihapus di AirTrail dan tidak lagi tersinkron.',
'reservations.airtrail.loadError': 'Tidak dapat memuat penerbangan AirTrail-mu.',
'reservations.airtrail.notSyncedHint':
'Penerbangan ini telah dihapus di AirTrail dan tidak lagi tersinkron.',
'reservations.airtrail.loadError':
'Tidak dapat memuat penerbangan AirTrail-mu.',
'reservations.airtrail.imported': '{count} penerbangan diimpor',
'reservations.airtrail.skippedDuplicate': '{count} sudah ada di perjalanan ini, dilewati',
'reservations.airtrail.skippedDuplicate':
'{count} sudah ada di perjalanan ini, dilewati',
'reservations.airtrail.nothingImported': 'Tidak ada yang dapat diimpor.',
'reservations.airtrail.importError': 'Impor gagal. Silakan coba lagi.',
'reservations.airtrail.undo': 'Impor dari AirTrail',
'reservations.airtrail.alreadyImported': 'Diimpor',
'reservations.airtrail.duringTrip': 'Selama perjalanan ini',
'reservations.airtrail.otherFlights': 'Penerbangan lain',
'reservations.airtrail.empty': 'Tidak ada penerbangan ditemukan di akun AirTrail-mu.',
'reservations.airtrail.empty':
'Tidak ada penerbangan ditemukan di akun AirTrail-mu.',
'reservations.airtrail.importCta': 'Impor {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -128,37 +128,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Aggiungi prenotazione',
'reservations.import.title': 'Importa conferme di prenotazione',
'reservations.import.cta': 'Importa da file',
'reservations.import.dropHere': 'Trascina i file di conferma prenotazione qui o clicca per selezionare',
'reservations.import.dropHere':
'Trascina i file di conferma prenotazione qui o clicca per selezionare',
'reservations.import.dropActive': 'Rilascia i file per importare',
'reservations.import.acceptedFormats': 'Accettati: EML, PDF, PKPass, HTML, TXT (max 10 MB ciascuno, fino a 5 file)',
'reservations.import.acceptedFormats':
'Accettati: EML, PDF, PKPass, HTML, TXT (max 10 MB ciascuno, fino a 5 file)',
'reservations.import.parsing': 'Analisi dei file in corso…',
'reservations.import.previewHeading': '{count} prenotazione/i trovata/e',
'reservations.import.previewEmpty': 'Nessuna prenotazione è stata estratta dai file caricati.',
'reservations.import.previewEmpty':
'Nessuna prenotazione è stata estratta dai file caricati.',
'reservations.import.removeItem': 'Rimuovi',
'reservations.import.confirm': 'Importa {count} prenotazione/i',
'reservations.import.back': 'Indietro',
'reservations.import.success': '{count} prenotazione/i importata/e',
'reservations.import.partialFailure': '{created} importata/e, {failed} fallita/e',
'reservations.import.error': "Analisi fallita. Assicurati che il file sia una conferma di prenotazione valida.",
'reservations.import.unavailable': "L'importazione di prenotazioni non è disponibile su questo server.",
'reservations.import.unsupportedFormat': 'Formato file non supportato. Usa EML, PDF, PKPass, HTML o TXT.',
'reservations.import.fileTooLarge': 'Il file "{name}" supera il limite di 10 MB.',
'reservations.import.partialFailure':
'{created} importata/e, {failed} fallita/e',
'reservations.import.error':
'Analisi fallita. Assicurati che il file sia una conferma di prenotazione valida.',
'reservations.import.unavailable':
"L'importazione di prenotazioni non è disponibile su questo server.",
'reservations.import.unsupportedFormat':
'Formato file non supportato. Usa EML, PDF, PKPass, HTML o TXT.',
'reservations.import.fileTooLarge':
'Il file "{name}" supera il limite di 10 MB.',
'reservations.airtrail.title': 'Importa da AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Sincronizzato da AirTrail — le modifiche restano sincronizzate in entrambe le direzioni.',
'reservations.airtrail.syncedHint':
'Sincronizzato da AirTrail — le modifiche restano sincronizzate in entrambe le direzioni.',
'reservations.airtrail.notSynced': 'Non sincronizzato',
'reservations.airtrail.notSyncedHint': 'Questo volo è stato rimosso in AirTrail e non si sincronizza più.',
'reservations.airtrail.loadError': 'Impossibile caricare i tuoi voli AirTrail.',
'reservations.airtrail.notSyncedHint':
'Questo volo è stato rimosso in AirTrail e non si sincronizza più.',
'reservations.airtrail.loadError':
'Impossibile caricare i tuoi voli AirTrail.',
'reservations.airtrail.imported': '{count} volo/i importato/i',
'reservations.airtrail.skippedDuplicate': '{count} già presente/i in questo viaggio, ignorato/i',
'reservations.airtrail.skippedDuplicate':
'{count} già presente/i in questo viaggio, ignorato/i',
'reservations.airtrail.nothingImported': 'Niente da importare.',
'reservations.airtrail.importError': 'Importazione fallita. Riprova.',
'reservations.airtrail.undo': 'Importa da AirTrail',
'reservations.airtrail.alreadyImported': 'Importato',
'reservations.airtrail.duringTrip': 'Durante questo viaggio',
'reservations.airtrail.otherFlights': 'Altri voli',
'reservations.airtrail.empty': 'Nessun volo trovato nel tuo account AirTrail.',
'reservations.airtrail.empty':
'Nessun volo trovato nel tuo account AirTrail.',
'reservations.airtrail.importCta': 'Importa {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+34 -14
View File
@@ -124,37 +124,57 @@ const reservations: TranslationStrings = {
'reservations.addBooking': '予約を追加',
'reservations.import.title': '予約確認書のインポート',
'reservations.import.cta': 'ファイルからインポート',
'reservations.import.dropHere': '予約確認ファイルをここにドロップするか、クリックして選択',
'reservations.import.dropHere':
'予約確認ファイルをここにドロップするか、クリックして選択',
'reservations.import.dropActive': 'ファイルをドロップしてインポート',
'reservations.import.acceptedFormats': '対応形式:EML、PDF、PKPass、HTML、TXT(各最大 10 MB、最大 5 ファイル)',
'reservations.import.acceptedFormats':
'対応形式:EML、PDF、PKPass、HTML、TXT(各最大 10 MB、最大 5 ファイル)',
'reservations.import.parsing': 'ファイルを解析中…',
'reservations.import.previewHeading': '{count} 件の予約が見つかりました',
'reservations.import.previewEmpty': 'アップロードされたファイルから予約を抽出できませんでした。',
'reservations.import.previewEmpty':
'アップロードされたファイルから予約を抽出できませんでした。',
'reservations.import.removeItem': '削除',
'reservations.import.confirm': '{count} 件の予約をインポート',
'reservations.import.back': '戻る',
'reservations.import.success': '{count} 件の予約をインポートしました',
'reservations.import.partialFailure': '{created} 件インポート済み、{failed} 件失敗',
'reservations.import.error': '解析に失敗しました。ファイルが有効な予約確認書であることを確認してください。',
'reservations.import.unavailable': 'このサーバーでは予約インポート機能が利用できません。',
'reservations.import.unsupportedFormat': '対応していないファイル形式です。EML、PDF、PKPass、HTML、または TXT を使用してください。',
'reservations.import.fileTooLarge': 'ファイル「{name}」は 10 MB の制限を超えています。',
'reservations.import.partialFailure':
'{created} 件インポート済み、{failed} 件失敗',
'reservations.import.error':
'解析に失敗しました。ファイルが有効な予約確認書であることを確認してください。',
'reservations.import.unavailable':
'このサーバーでは予約インポート機能が利用できません。',
'reservations.import.unsupportedFormat':
'対応していないファイル形式です。EML、PDF、PKPass、HTML、または TXT を使用してください。',
'reservations.import.fileTooLarge':
'ファイル「{name}」は 10 MB の制限を超えています。',
'reservations.airtrail.title': 'AirTrail からインポート',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'AirTrail と同期済み — 編集は双方向で同期されます。',
'reservations.airtrail.syncedHint':
'AirTrail と同期済み — 編集は双方向で同期されます。',
'reservations.airtrail.notSynced': '未同期',
'reservations.airtrail.notSyncedHint': 'このフライトは AirTrail で削除されたため、同期されなくなりました。',
'reservations.airtrail.loadError': 'AirTrail のフライトを読み込めませんでした。',
'reservations.airtrail.notSyncedHint':
'このフライトは AirTrail で削除されたため、同期されなくなりました。',
'reservations.airtrail.loadError':
'AirTrail のフライトを読み込めませんでした。',
'reservations.airtrail.imported': '{count} 件のフライトをインポートしました',
'reservations.airtrail.skippedDuplicate': '{count} 件はこの旅行に既に存在するためスキップしました',
'reservations.airtrail.skippedDuplicate':
'{count} 件はこの旅行に既に存在するためスキップしました',
'reservations.airtrail.nothingImported': 'インポートする項目がありません。',
'reservations.airtrail.importError': 'インポートに失敗しました。もう一度お試しください。',
'reservations.airtrail.importError':
'インポートに失敗しました。もう一度お試しください。',
'reservations.airtrail.undo': 'AirTrail からインポート',
'reservations.airtrail.alreadyImported': 'インポート済み',
'reservations.airtrail.duringTrip': 'この旅行の期間中',
'reservations.airtrail.otherFlights': 'その他のフライト',
'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。',
'reservations.airtrail.empty':
'AirTrail アカウントにフライトが見つかりませんでした。',
'reservations.airtrail.importCta': '{count} 件をインポート',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+28 -11
View File
@@ -124,37 +124,54 @@ const reservations: TranslationStrings = {
'reservations.addBooking': '예약 추가',
'reservations.import.title': '예약 확인서 가져오기',
'reservations.import.cta': '파일에서 가져오기',
'reservations.import.dropHere': '예약 확인 파일을 여기에 끌어다 놓거나 클릭하여 선택',
'reservations.import.dropHere':
'예약 확인 파일을 여기에 끌어다 놓거나 클릭하여 선택',
'reservations.import.dropActive': '가져올 파일을 여기에 놓으세요',
'reservations.import.acceptedFormats': '허용 형식: EML, PDF, PKPass, HTML, TXT (파일당 최대 10 MB, 최대 5개)',
'reservations.import.acceptedFormats':
'허용 형식: EML, PDF, PKPass, HTML, TXT (파일당 최대 10 MB, 최대 5개)',
'reservations.import.parsing': '파일 분석 중…',
'reservations.import.previewHeading': '{count}개 예약 발견',
'reservations.import.previewEmpty': '업로드된 파일에서 예약을 추출할 수 없었습니다.',
'reservations.import.previewEmpty':
'업로드된 파일에서 예약을 추출할 수 없었습니다.',
'reservations.import.removeItem': '제거',
'reservations.import.confirm': '{count}개 예약 가져오기',
'reservations.import.back': '뒤로',
'reservations.import.success': '{count}개 예약을 가져왔습니다',
'reservations.import.partialFailure': '{created}개 가져옴, {failed}개 실패',
'reservations.import.error': '분석 실패. 파일이 유효한 예약 확인서인지 확인하세요.',
'reservations.import.unavailable': '이 서버에서는 예약 가져오기를 사용할 수 없습니다.',
'reservations.import.unsupportedFormat': '지원하지 않는 파일 형식입니다. EML, PDF, PKPass, HTML 또는 TXT를 사용하세요.',
'reservations.import.fileTooLarge': '파일 "{name}"이(가) 10 MB 제한을 초과합니다.',
'reservations.import.error':
'분석 실패. 파일이 유효한 예약 확인서인지 확인하세요.',
'reservations.import.unavailable':
'이 서버에서는 예약 가져오기를 사용할 수 없습니다.',
'reservations.import.unsupportedFormat':
'지원하지 않는 파일 형식입니다. EML, PDF, PKPass, HTML 또는 TXT를 사용하세요.',
'reservations.import.fileTooLarge':
'파일 "{name}"이(가) 10 MB 제한을 초과합니다.',
'reservations.airtrail.title': 'AirTrail에서 가져오기',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'AirTrail에서 동기화됨 — 수정 사항이 양방향으로 동기화됩니다.',
'reservations.airtrail.syncedHint':
'AirTrail에서 동기화됨 — 수정 사항이 양방향으로 동기화됩니다.',
'reservations.airtrail.notSynced': '동기화되지 않음',
'reservations.airtrail.notSyncedHint': '이 항공편은 AirTrail에서 삭제되어 더 이상 동기화되지 않습니다.',
'reservations.airtrail.notSyncedHint':
'이 항공편은 AirTrail에서 삭제되어 더 이상 동기화되지 않습니다.',
'reservations.airtrail.loadError': 'AirTrail 항공편을 불러올 수 없습니다.',
'reservations.airtrail.imported': '{count}개 항공편을 가져왔습니다',
'reservations.airtrail.skippedDuplicate': '{count}개는 이미 이 여행에 있어 건너뛰었습니다',
'reservations.airtrail.skippedDuplicate':
'{count}개는 이미 이 여행에 있어 건너뛰었습니다',
'reservations.airtrail.nothingImported': '가져올 항목이 없습니다.',
'reservations.airtrail.importError': '가져오기에 실패했습니다. 다시 시도하세요.',
'reservations.airtrail.importError':
'가져오기에 실패했습니다. 다시 시도하세요.',
'reservations.airtrail.undo': 'AirTrail에서 가져오기',
'reservations.airtrail.alreadyImported': '가져옴',
'reservations.airtrail.duringTrip': '이 여행 기간',
'reservations.airtrail.otherFlights': '기타 항공편',
'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.',
'reservations.airtrail.importCta': '{count}개 가져오기',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+30 -12
View File
@@ -127,21 +127,29 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Boeking toevoegen',
'reservations.import.title': 'Boekingsbevestigingen importeren',
'reservations.import.cta': 'Importeren vanuit bestand',
'reservations.import.dropHere': 'Zet hier bevestigingsbestanden neer of klik om te selecteren',
'reservations.import.dropHere':
'Zet hier bevestigingsbestanden neer of klik om te selecteren',
'reservations.import.dropActive': 'Laat bestanden los om te importeren',
'reservations.import.acceptedFormats': 'Geaccepteerd: EML, PDF, PKPass, HTML, TXT (max. 10 MB per stuk, tot 5 bestanden)',
'reservations.import.acceptedFormats':
'Geaccepteerd: EML, PDF, PKPass, HTML, TXT (max. 10 MB per stuk, tot 5 bestanden)',
'reservations.import.parsing': 'Bestanden verwerken…',
'reservations.import.previewHeading': '{count} reservering(en) gevonden',
'reservations.import.previewEmpty': 'Er konden geen reserveringen worden geëxtraheerd uit de geüploade bestanden.',
'reservations.import.previewEmpty':
'Er konden geen reserveringen worden geëxtraheerd uit de geüploade bestanden.',
'reservations.import.removeItem': 'Verwijderen',
'reservations.import.confirm': '{count} reservering(en) importeren',
'reservations.import.back': 'Terug',
'reservations.import.success': '{count} reservering(en) geïmporteerd',
'reservations.import.partialFailure': '{created} geïmporteerd, {failed} mislukt',
'reservations.import.error': 'Verwerking mislukt. Zorg ervoor dat het bestand een geldige boekingsbevestiging is.',
'reservations.import.unavailable': 'Boeking importeren is niet beschikbaar op deze server.',
'reservations.import.unsupportedFormat': 'Niet-ondersteund bestandsformaat. Gebruik EML, PDF, PKPass, HTML of TXT.',
'reservations.import.fileTooLarge': 'Bestand "{name}" overschrijdt de limiet van 10 MB.',
'reservations.import.partialFailure':
'{created} geïmporteerd, {failed} mislukt',
'reservations.import.error':
'Verwerking mislukt. Zorg ervoor dat het bestand een geldige boekingsbevestiging is.',
'reservations.import.unavailable':
'Boeking importeren is niet beschikbaar op deze server.',
'reservations.import.unsupportedFormat':
'Niet-ondersteund bestandsformaat. Gebruik EML, PDF, PKPass, HTML of TXT.',
'reservations.import.fileTooLarge':
'Bestand "{name}" overschrijdt de limiet van 10 MB.',
'reservations.airtrail.title': 'Importeren uit AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
@@ -150,16 +158,26 @@ const reservations: TranslationStrings = {
'reservations.airtrail.notSynced': 'Niet gesynchroniseerd',
'reservations.airtrail.notSyncedHint':
'Deze vlucht is in AirTrail verwijderd en wordt niet meer gesynchroniseerd.',
'reservations.airtrail.loadError': 'Je AirTrail-vluchten konden niet worden geladen.',
'reservations.airtrail.loadError':
'Je AirTrail-vluchten konden niet worden geladen.',
'reservations.airtrail.imported': '{count} vlucht(en) geïmporteerd',
'reservations.airtrail.skippedDuplicate': '{count} al in deze reis, overgeslagen',
'reservations.airtrail.skippedDuplicate':
'{count} al in deze reis, overgeslagen',
'reservations.airtrail.nothingImported': 'Niets om te importeren.',
'reservations.airtrail.importError': 'Importeren mislukt. Probeer het opnieuw.',
'reservations.airtrail.importError':
'Importeren mislukt. Probeer het opnieuw.',
'reservations.airtrail.undo': 'Importeren uit AirTrail',
'reservations.airtrail.alreadyImported': 'Geïmporteerd',
'reservations.airtrail.duringTrip': 'Tijdens deze reis',
'reservations.airtrail.otherFlights': 'Andere vluchten',
'reservations.airtrail.empty': 'Geen vluchten gevonden in je AirTrail-account.',
'reservations.airtrail.empty':
'Geen vluchten gevonden in je AirTrail-account.',
'reservations.airtrail.importCta': '{count} importeren',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Dodaj rezerwację',
'reservations.import.title': 'Importuj potwierdzenia rezerwacji',
'reservations.import.cta': 'Importuj z pliku',
'reservations.import.dropHere': 'Upuść pliki potwierdzeń rezerwacji tutaj lub kliknij, aby wybrać',
'reservations.import.dropHere':
'Upuść pliki potwierdzeń rezerwacji tutaj lub kliknij, aby wybrać',
'reservations.import.dropActive': 'Upuść pliki, aby zaimportować',
'reservations.import.acceptedFormats': 'Akceptowane: EML, PDF, PKPass, HTML, TXT (maks. 10 MB każdy, do 5 plików)',
'reservations.import.acceptedFormats':
'Akceptowane: EML, PDF, PKPass, HTML, TXT (maks. 10 MB każdy, do 5 plików)',
'reservations.import.parsing': 'Przetwarzanie plików…',
'reservations.import.previewHeading': 'Znaleziono {count} rezerwację/rezerwacje',
'reservations.import.previewEmpty': 'Nie udało się wyodrębnić rezerwacji z przesłanych plików.',
'reservations.import.previewHeading':
'Znaleziono {count} rezerwację/rezerwacje',
'reservations.import.previewEmpty':
'Nie udało się wyodrębnić rezerwacji z przesłanych plików.',
'reservations.import.removeItem': 'Usuń',
'reservations.import.confirm': 'Importuj {count} rezerwację/rezerwacje',
'reservations.import.back': 'Wstecz',
'reservations.import.success': 'Zaimportowano {count} rezerwację/rezerwacje',
'reservations.import.partialFailure': '{created} zaimportowano, {failed} nieudane',
'reservations.import.error': 'Przetwarzanie nieudane. Upewnij się, że plik jest prawidłowym potwierdzeniem rezerwacji.',
'reservations.import.unavailable': 'Import rezerwacji nie jest dostępny na tym serwerze.',
'reservations.import.unsupportedFormat': 'Nieobsługiwany format pliku. Użyj EML, PDF, PKPass, HTML lub TXT.',
'reservations.import.partialFailure':
'{created} zaimportowano, {failed} nieudane',
'reservations.import.error':
'Przetwarzanie nieudane. Upewnij się, że plik jest prawidłowym potwierdzeniem rezerwacji.',
'reservations.import.unavailable':
'Import rezerwacji nie jest dostępny na tym serwerze.',
'reservations.import.unsupportedFormat':
'Nieobsługiwany format pliku. Użyj EML, PDF, PKPass, HTML lub TXT.',
'reservations.import.fileTooLarge': 'Plik „{name}" przekracza limit 10 MB.',
'reservations.airtrail.title': 'Importuj z AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Zsynchronizowano z AirTrail — zmiany są synchronizowane w obie strony.',
'reservations.airtrail.syncedHint':
'Zsynchronizowano z AirTrail — zmiany są synchronizowane w obie strony.',
'reservations.airtrail.notSynced': 'Niezsynchronizowane',
'reservations.airtrail.notSyncedHint': 'Ten lot został usunięty w AirTrail i nie jest już synchronizowany.',
'reservations.airtrail.loadError': 'Nie udało się wczytać Twoich lotów z AirTrail.',
'reservations.airtrail.notSyncedHint':
'Ten lot został usunięty w AirTrail i nie jest już synchronizowany.',
'reservations.airtrail.loadError':
'Nie udało się wczytać Twoich lotów z AirTrail.',
'reservations.airtrail.imported': 'Zaimportowano {count} lot(y/ów)',
'reservations.airtrail.skippedDuplicate': '{count} już w tej wyprawie, pominięto',
'reservations.airtrail.skippedDuplicate':
'{count} już w tej wyprawie, pominięto',
'reservations.airtrail.nothingImported': 'Nic do zaimportowania.',
'reservations.airtrail.importError': 'Import nieudany. Spróbuj ponownie.',
'reservations.airtrail.undo': 'Importuj z AirTrail',
'reservations.airtrail.alreadyImported': 'Zaimportowano',
'reservations.airtrail.duringTrip': 'Podczas tej wyprawy',
'reservations.airtrail.otherFlights': 'Inne loty',
'reservations.airtrail.empty': 'Nie znaleziono lotów na Twoim koncie AirTrail.',
'reservations.airtrail.empty':
'Nie znaleziono lotów na Twoim koncie AirTrail.',
'reservations.airtrail.importCta': 'Importuj {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Добавить бронирование',
'reservations.import.title': 'Импорт подтверждений бронирования',
'reservations.import.cta': 'Импортировать из файла',
'reservations.import.dropHere': 'Перетащите файлы подтверждений бронирования сюда или нажмите для выбора',
'reservations.import.dropHere':
'Перетащите файлы подтверждений бронирования сюда или нажмите для выбора',
'reservations.import.dropActive': 'Отпустите файлы для импорта',
'reservations.import.acceptedFormats': 'Принимаются: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ каждый, до 5 файлов)',
'reservations.import.acceptedFormats':
'Принимаются: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ каждый, до 5 файлов)',
'reservations.import.parsing': 'Обработка файлов…',
'reservations.import.previewHeading': 'Найдено {count} бронирование(й)',
'reservations.import.previewEmpty': 'Из загруженных файлов не удалось извлечь бронирования.',
'reservations.import.previewEmpty':
'Из загруженных файлов не удалось извлечь бронирования.',
'reservations.import.removeItem': 'Удалить',
'reservations.import.confirm': 'Импортировать {count} бронирование(й)',
'reservations.import.back': 'Назад',
'reservations.import.success': '{count} бронирование(й) импортировано',
'reservations.import.partialFailure': '{created} импортировано, {failed} не удалось',
'reservations.import.error': 'Обработка не удалась. Убедитесь, что файл является действительным подтверждением бронирования.',
'reservations.import.unavailable': 'Импорт бронирований недоступен на этом сервере.',
'reservations.import.unsupportedFormat': 'Неподдерживаемый формат файла. Используйте EML, PDF, PKPass, HTML или TXT.',
'reservations.import.fileTooLarge': 'Файл «{name}» превышает ограничение в 10 МБ.',
'reservations.import.partialFailure':
'{created} импортировано, {failed} не удалось',
'reservations.import.error':
'Обработка не удалась. Убедитесь, что файл является действительным подтверждением бронирования.',
'reservations.import.unavailable':
'Импорт бронирований недоступен на этом сервере.',
'reservations.import.unsupportedFormat':
'Неподдерживаемый формат файла. Используйте EML, PDF, PKPass, HTML или TXT.',
'reservations.import.fileTooLarge':
'Файл «{name}» превышает ограничение в 10 МБ.',
'reservations.airtrail.title': 'Импорт из AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Синхронизировано с AirTrail — изменения синхронизируются в обе стороны.',
'reservations.airtrail.syncedHint':
'Синхронизировано с AirTrail — изменения синхронизируются в обе стороны.',
'reservations.airtrail.notSynced': 'Не синхронизировано',
'reservations.airtrail.notSyncedHint': 'Этот рейс был удалён в AirTrail и больше не синхронизируется.',
'reservations.airtrail.loadError': 'Не удалось загрузить ваши рейсы из AirTrail.',
'reservations.airtrail.notSyncedHint':
'Этот рейс был удалён в AirTrail и больше не синхронизируется.',
'reservations.airtrail.loadError':
'Не удалось загрузить ваши рейсы из AirTrail.',
'reservations.airtrail.imported': 'Импортировано рейсов: {count}',
'reservations.airtrail.skippedDuplicate': '{count} уже в этой поездке, пропущено',
'reservations.airtrail.skippedDuplicate':
'{count} уже в этой поездке, пропущено',
'reservations.airtrail.nothingImported': 'Нечего импортировать.',
'reservations.airtrail.importError': 'Импорт не удался. Повторите попытку.',
'reservations.airtrail.undo': 'Импорт из AirTrail',
'reservations.airtrail.alreadyImported': 'Импортировано',
'reservations.airtrail.duringTrip': 'Во время этой поездки',
'reservations.airtrail.otherFlights': 'Другие рейсы',
'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.',
'reservations.airtrail.empty':
'В вашей учётной записи AirTrail не найдено рейсов.',
'reservations.airtrail.importCta': 'Импортировать {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+28 -12
View File
@@ -127,37 +127,53 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Rezervasyon ekle',
'reservations.import.title': 'Rezervasyon onaylarını içe aktar',
'reservations.import.cta': 'Dosyadan içe aktar',
'reservations.import.dropHere': 'Rezervasyon onay dosyalarını buraya sürükleyin veya seçmek için tıklayın',
'reservations.import.dropHere':
'Rezervasyon onay dosyalarını buraya sürükleyin veya seçmek için tıklayın',
'reservations.import.dropActive': 'İçe aktarmak için dosyaları bırakın',
'reservations.import.acceptedFormats': 'Kabul edilenler: EML, PDF, PKPass, HTML, TXT (her biri maks. 10 MB, en fazla 5 dosya)',
'reservations.import.acceptedFormats':
'Kabul edilenler: EML, PDF, PKPass, HTML, TXT (her biri maks. 10 MB, en fazla 5 dosya)',
'reservations.import.parsing': 'Dosyalar işleniyor…',
'reservations.import.previewHeading': '{count} rezervasyon bulundu',
'reservations.import.previewEmpty': 'Yüklenen dosyalardan hiçbir rezervasyon çıkarılamadı.',
'reservations.import.previewEmpty':
'Yüklenen dosyalardan hiçbir rezervasyon çıkarılamadı.',
'reservations.import.removeItem': 'Kaldır',
'reservations.import.confirm': '{count} rezervasyonu içe aktar',
'reservations.import.back': 'Geri',
'reservations.import.success': '{count} rezervasyon içe aktarıldı',
'reservations.import.partialFailure': '{created} içe aktarıldı, {failed} başarısız',
'reservations.import.error': 'İşlem başarısız. Dosyanın geçerli bir rezervasyon onayı olduğundan emin olun.',
'reservations.import.unavailable': 'Rezervasyon içe aktarma bu sunucuda mevcut değil.',
'reservations.import.unsupportedFormat': 'Desteklenmeyen dosya biçimi. EML, PDF, PKPass, HTML veya TXT kullanın.',
'reservations.import.partialFailure':
'{created} içe aktarıldı, {failed} başarısız',
'reservations.import.error':
'İşlem başarısız. Dosyanın geçerli bir rezervasyon onayı olduğundan emin olun.',
'reservations.import.unavailable':
'Rezervasyon içe aktarma bu sunucuda mevcut değil.',
'reservations.import.unsupportedFormat':
'Desteklenmeyen dosya biçimi. EML, PDF, PKPass, HTML veya TXT kullanın.',
'reservations.import.fileTooLarge': '"{name}" dosyası 10 MB sınırını aşıyor.',
'reservations.airtrail.title': 'AirTrail\'den içe aktar',
'reservations.airtrail.title': "AirTrail'den içe aktar",
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'AirTrail ile senkronize edildi — düzenlemeler iki yönlü olarak senkronize kalır.',
'reservations.airtrail.syncedHint':
'AirTrail ile senkronize edildi — düzenlemeler iki yönlü olarak senkronize kalır.',
'reservations.airtrail.notSynced': 'Senkronize değil',
'reservations.airtrail.notSyncedHint': 'Bu uçuş AirTrail\'de kaldırıldı ve artık senkronize edilmiyor.',
'reservations.airtrail.notSyncedHint':
"Bu uçuş AirTrail'de kaldırıldı ve artık senkronize edilmiyor.",
'reservations.airtrail.loadError': 'AirTrail uçuşlarınız yüklenemedi.',
'reservations.airtrail.imported': '{count} uçuş içe aktarıldı',
'reservations.airtrail.skippedDuplicate': '{count} zaten bu gezide, atlandı',
'reservations.airtrail.nothingImported': 'İçe aktarılacak bir şey yok.',
'reservations.airtrail.importError': 'İçe aktarma başarısız. Lütfen tekrar deneyin.',
'reservations.airtrail.undo': 'AirTrail\'den içe aktar',
'reservations.airtrail.importError':
'İçe aktarma başarısız. Lütfen tekrar deneyin.',
'reservations.airtrail.undo': "AirTrail'den içe aktar",
'reservations.airtrail.alreadyImported': 'İçe aktarıldı',
'reservations.airtrail.duringTrip': 'Bu gezi sırasında',
'reservations.airtrail.otherFlights': 'Diğer uçuşlar',
'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.',
'reservations.airtrail.importCta': '{count} içe aktar',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Добавить бронирование',
'reservations.import.title': 'Імпорт підтверджень бронювання',
'reservations.import.cta': 'Імпортувати з файлу',
'reservations.import.dropHere': 'Перетягніть файли підтверджень бронювання сюди або натисніть для вибору',
'reservations.import.dropHere':
'Перетягніть файли підтверджень бронювання сюди або натисніть для вибору',
'reservations.import.dropActive': 'Відпустіть файли для імпорту',
'reservations.import.acceptedFormats': 'Підтримуються: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ кожен, до 5 файлів)',
'reservations.import.acceptedFormats':
'Підтримуються: EML, PDF, PKPass, HTML, TXT (макс. 10 МБ кожен, до 5 файлів)',
'reservations.import.parsing': 'Обробка файлів…',
'reservations.import.previewHeading': 'Знайдено {count} бронювання(нь)',
'reservations.import.previewEmpty': 'З завантажених файлів не вдалося витягти бронювання.',
'reservations.import.previewEmpty':
'З завантажених файлів не вдалося витягти бронювання.',
'reservations.import.removeItem': 'Видалити',
'reservations.import.confirm': 'Імпортувати {count} бронювання(нь)',
'reservations.import.back': 'Назад',
'reservations.import.success': '{count} бронювання(нь) імпортовано',
'reservations.import.partialFailure': '{created} імпортовано, {failed} не вдалося',
'reservations.import.error': 'Обробка не вдалася. Переконайтесь, що файл є дійсним підтвердженням бронювання.',
'reservations.import.unavailable': 'Імпорт бронювань недоступний на цьому сервері.',
'reservations.import.unsupportedFormat': 'Непідтримуваний формат файлу. Використовуйте EML, PDF, PKPass, HTML або TXT.',
'reservations.import.fileTooLarge': 'Файл «{name}» перевищує обмеження в 10 МБ.',
'reservations.import.partialFailure':
'{created} імпортовано, {failed} не вдалося',
'reservations.import.error':
'Обробка не вдалася. Переконайтесь, що файл є дійсним підтвердженням бронювання.',
'reservations.import.unavailable':
'Імпорт бронювань недоступний на цьому сервері.',
'reservations.import.unsupportedFormat':
'Непідтримуваний формат файлу. Використовуйте EML, PDF, PKPass, HTML або TXT.',
'reservations.import.fileTooLarge':
'Файл «{name}» перевищує обмеження в 10 МБ.',
'reservations.airtrail.title': 'Імпорт з AirTrail',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Синхронізовано з AirTrail — зміни синхронізуються в обидва боки.',
'reservations.airtrail.syncedHint':
'Синхронізовано з AirTrail — зміни синхронізуються в обидва боки.',
'reservations.airtrail.notSynced': 'Не синхронізовано',
'reservations.airtrail.notSyncedHint': 'Цей рейс було видалено в AirTrail і він більше не синхронізується.',
'reservations.airtrail.loadError': 'Не вдалося завантажити ваші рейси з AirTrail.',
'reservations.airtrail.notSyncedHint':
'Цей рейс було видалено в AirTrail і він більше не синхронізується.',
'reservations.airtrail.loadError':
'Не вдалося завантажити ваші рейси з AirTrail.',
'reservations.airtrail.imported': '{count} рейс(ів) імпортовано',
'reservations.airtrail.skippedDuplicate': '{count} вже в цій подорожі, пропущено',
'reservations.airtrail.skippedDuplicate':
'{count} вже в цій подорожі, пропущено',
'reservations.airtrail.nothingImported': 'Немає чого імпортувати.',
'reservations.airtrail.importError': 'Імпорт не вдався. Спробуйте ще раз.',
'reservations.airtrail.undo': 'Імпорт з AirTrail',
'reservations.airtrail.alreadyImported': 'Імпортовано',
'reservations.airtrail.duringTrip': 'Під час цієї подорожі',
'reservations.airtrail.otherFlights': 'Інші рейси',
'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.',
'reservations.airtrail.empty':
'У вашому акаунті AirTrail не знайдено рейсів.',
'reservations.airtrail.importCta': 'Імпортувати {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+14 -4
View File
@@ -125,7 +125,8 @@ const reservations: TranslationStrings = {
'reservations.import.cta': '從檔案匯入',
'reservations.import.dropHere': '將訂位確認檔案拖放到此處,或點擊選擇',
'reservations.import.dropActive': '放開檔案以匯入',
'reservations.import.acceptedFormats': '支援格式:EML、PDF、PKPass、HTML、TXT(每個最大 10 MB,最多 5 個檔案)',
'reservations.import.acceptedFormats':
'支援格式:EML、PDF、PKPass、HTML、TXT(每個最大 10 MB,最多 5 個檔案)',
'reservations.import.parsing': '正在解析檔案…',
'reservations.import.previewHeading': '找到 {count} 筆預訂',
'reservations.import.previewEmpty': '無法從上傳的檔案中提取任何預訂資訊。',
@@ -136,14 +137,17 @@ const reservations: TranslationStrings = {
'reservations.import.partialFailure': '已匯入 {created} 筆,{failed} 筆失敗',
'reservations.import.error': '解析失敗。請確保檔案是有效的訂位確認。',
'reservations.import.unavailable': '此伺服器上的預訂匯入功能不可用。',
'reservations.import.unsupportedFormat': '不支援的檔案格式。請使用 EML、PDF、PKPass、HTML 或 TXT。',
'reservations.import.unsupportedFormat':
'不支援的檔案格式。請使用 EML、PDF、PKPass、HTML 或 TXT。',
'reservations.import.fileTooLarge': '檔案「{name}」超過 10 MB 限制。',
'reservations.airtrail.title': '從 AirTrail 匯入',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': '已從 AirTrail 同步——編輯會雙向保持同步。',
'reservations.airtrail.syncedHint':
'已從 AirTrail 同步——編輯會雙向保持同步。',
'reservations.airtrail.notSynced': '未同步',
'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中移除,不再同步。',
'reservations.airtrail.notSyncedHint':
'此航班已在 AirTrail 中移除,不再同步。',
'reservations.airtrail.loadError': '無法載入你的 AirTrail 航班。',
'reservations.airtrail.imported': '已匯入 {count} 筆航班',
'reservations.airtrail.skippedDuplicate': '{count} 筆已在此行程中,已略過',
@@ -155,5 +159,11 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。',
'reservations.airtrail.importCta': '匯入 {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;
+14 -4
View File
@@ -125,7 +125,8 @@ const reservations: TranslationStrings = {
'reservations.import.cta': '从文件导入',
'reservations.import.dropHere': '将预订确认文件拖放到此处,或点击选择',
'reservations.import.dropActive': '松开文件以导入',
'reservations.import.acceptedFormats': '支持格式:EML、PDF、PKPass、HTML、TXT(每个最大 10 MB,最多 5 个文件)',
'reservations.import.acceptedFormats':
'支持格式:EML、PDF、PKPass、HTML、TXT(每个最大 10 MB,最多 5 个文件)',
'reservations.import.parsing': '正在解析文件…',
'reservations.import.previewHeading': '找到 {count} 个预订',
'reservations.import.previewEmpty': '无法从上传的文件中提取任何预订信息。',
@@ -136,14 +137,17 @@ const reservations: TranslationStrings = {
'reservations.import.partialFailure': '已导入 {created} 个,{failed} 个失败',
'reservations.import.error': '解析失败。请确保文件是有效的预订确认。',
'reservations.import.unavailable': '此服务器上的预订导入功能不可用。',
'reservations.import.unsupportedFormat': '不支持的文件格式。请使用 EML、PDF、PKPass、HTML 或 TXT。',
'reservations.import.unsupportedFormat':
'不支持的文件格式。请使用 EML、PDF、PKPass、HTML 或 TXT。',
'reservations.import.fileTooLarge': '文件"{name}"超过 10 MB 限制。',
'reservations.airtrail.title': '从 AirTrail 导入',
'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': '已从 AirTrail 同步——编辑会双向保持同步。',
'reservations.airtrail.syncedHint':
'已从 AirTrail 同步——编辑会双向保持同步。',
'reservations.airtrail.notSynced': '未同步',
'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中删除,不再同步。',
'reservations.airtrail.notSyncedHint':
'此航班已在 AirTrail 中删除,不再同步。',
'reservations.airtrail.loadError': '无法加载您的 AirTrail 航班。',
'reservations.airtrail.imported': '已导入 {count} 个航班',
'reservations.airtrail.skippedDuplicate': '{count} 个已在此行程中,已跳过',
@@ -155,5 +159,11 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。',
'reservations.airtrail.importCta': '导入 {count}',
'reservations.costsLabel': 'Costs',
'reservations.createExpense': 'Create expense',
'reservations.createExpenseHint':
'Saves the booking, then opens the Costs editor.',
'reservations.linkedExpense': 'Linked expense',
'reservations.removeExpense': 'Remove expense',
};
export default reservations;