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
This commit is contained in:
Maurice
2026-06-17 22:11:56 +02:00
parent f98058a3af
commit c15c89ca61
34 changed files with 1014 additions and 465 deletions
+38 -10
View File
@@ -662,8 +662,15 @@ function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
} }
// ── Add / edit expense modal ─────────────────────────────────────────────── // ── Add / edit expense modal ───────────────────────────────────────────────
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: { export interface ExpensePrefill {
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void 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 { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
@@ -671,8 +678,8 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
const { convert } = useExchangeRates(base) const { convert } = useExchangeRates(base)
const sym = (c: string) => SYMBOLS[c] || (c + ' ') const sym = (c: string) => SYMBOLS[c] || (c + ' ')
const [name, setName] = useState(editing?.name || '') const [name, setName] = useState(editing?.name || prefill?.name || '')
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food') const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase()) const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10)) const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
const [payers, setPayers] = useState<Record<number, string>>(() => { 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) for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
return m 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>>(() => const [split, setSplit] = useState<Set<number>>(() =>
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id))) editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0) const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
const each = split.size > 0 ? payersTotal / split.size : 0 const hasPayers = payersTotal > 0
const valid = name.trim().length > 0 && split.size > 0 && 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 () => { const save = async () => {
if (!valid) return if (!valid) return
@@ -699,6 +716,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
currency, currency,
payers: payerList, member_ids: [...split], payers: payerList, member_ids: [...split],
expense_date: day || null, 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 { try {
if (editing) await updateBudgetItem(tripId, editing.id, data) 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> <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' }}> <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-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> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
@@ -744,11 +772,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
</div> </div>
</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' }}> <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-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> <span className="text-content-faint">· {t('costs.liveRate')}</span>
</div> </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]) 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 { * Legacy / English free-text categories (and reservation type labels) mapped to
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory] * the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
return COST_CAT_META.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 ───────────────────────────────────────────────────────────── // ── 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, { seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true, loaded: true,
}); });
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
expect(screen.getByText(/Budget category/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, { seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true, loaded: true,
}); });
render(<ReservationModal {...defaultProps} />); const onSave = vi.fn().mockResolvedValue({ id: 55 });
const priceInput = screen.getByPlaceholderText('0.00'); const onOpenExpense = vi.fn();
await userEvent.type(priceInput, '99.99'); render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
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} />);
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris'); 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: /Create expense/i }));
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith( expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) }) 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 ─────────────────────────────────────────────────────────────── // ── File upload ───────────────────────────────────────────────────────────────
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => { 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(); 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 () => { it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i })); await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
@@ -632,31 +611,6 @@ describe('ReservationModal', () => {
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' }))); 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 () => { it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
render(<ReservationModal {...defaultProps} />); render(<ReservationModal {...defaultProps} />);
const attachBtn = screen.getByRole('button', { name: /Attach file/i }); const attachBtn = screen.getByRole('button', { name: /Attach file/i });
@@ -11,7 +11,10 @@ import { useTranslation } from '../../i18n'
import { CustomDatePicker } from '../shared/CustomDateTimePicker' import { CustomDatePicker } from '../shared/CustomDateTimePicker'
import CustomTimePicker from '../shared/CustomTimePicker' import CustomTimePicker from '../shared/CustomTimePicker'
import { openFile } from '../../utils/fileDownload' 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 = [ const TYPE_OPTIONS = [
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel }, { value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
@@ -60,9 +63,10 @@ interface ReservationModalProps {
onFileDelete: (fileId: number) => Promise<void> onFileDelete: (fileId: number) => Promise<void>
accommodations?: Accommodation[] accommodations?: Accommodation[]
defaultAssignmentId?: number | null 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 { id: tripId } = useParams<{ id: string }>()
const loadFiles = useTripStore(s => s.loadFiles) const loadFiles = useTripStore(s => s.loadFiles)
const toast = useToast() const toast = useToast()
@@ -70,18 +74,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
const fileInputRef = useRef(null) const fileInputRef = useRef(null)
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems) const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
const budgetCategories = useMemo(() => { // Set right before submit when the user clicked create/edit expense (see TransportModal).
const cats = new Set<string>() const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
return Array.from(cats).sort()
}, [budgetItems])
const [form, setForm] = useState({ const [form, setForm] = useState({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | 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: '', 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, 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_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_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 || '' })(), 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 { } else {
setForm({ setForm({
title: '', type: 'other', status: 'pending', title: '', type: 'other', status: 'pending',
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '', reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '', notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
price: '', budget_category: '',
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '', meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '', hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
}) })
@@ -167,8 +164,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
return endFull <= startFull return endFull <= startFull
})() })()
const handleSubmit = async (e) => { const handleSubmit = async (e?: { preventDefault?: () => void }) => {
e.preventDefault() e?.preventDefault?.()
if (!form.title.trim()) return if (!form.title.trim()) return
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return } if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
setIsSaving(true) setIsSaving(true)
@@ -185,11 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
} else if (form.reservation_end_time && form.reservation_time) { } else if (form.reservation_end_time && form.reservation_time) {
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_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 } = { const saveData: Record<string, any> & { title: string } = {
title: form.title, type: form.type, status: form.status, title: form.title, type: form.type, status: form.status,
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null), reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
@@ -202,11 +194,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
endpoints: [], endpoints: [],
needs_review: false, 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) { if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
saveData.create_accommodation = { saveData.create_accommodation = {
place_id: form.hotel_place_id || null, place_id: form.hotel_place_id || null,
@@ -228,11 +215,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
await onFileUpload(fd) 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 { } finally {
setIsSaving(false) 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 handleFileChange = async (e) => {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return if (!file) return
@@ -610,38 +611,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
</div> </div>
</div> </div>
{/* Price + Budget Category */} {/* Costs — create / view the expense linked to this booking */}
{isBudgetEnabled && ( {isBudgetEnabled && (
<> <BookingCostsSection
<div style={{ display: 'flex', gap: 8 }}> reservationId={reservation?.id ?? null}
<div style={{ flex: 1, minWidth: 0 }}> onCreate={handleCreateExpense}
<label className={labelClass}>{t('reservations.price')}</label> onEdit={handleEditExpense}
<input type="text" inputMode="decimal" value={form.price} onRemove={handleRemoveExpense}
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>
)}
</>
)} )}
</form> </form>
@@ -132,34 +132,37 @@ describe('TransportModal', () => {
// ── Budget addon ───────────────────────────────────────────────────────────── // ── 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, { seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true, loaded: true,
}); });
render(<TransportModal {...defaultProps} />); render(<TransportModal {...defaultProps} />);
expect(screen.getByText(/^Price$/i)).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
expect(screen.getByText(/Budget category/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} />); 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, { seedStore(useAddonStore, {
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }], addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
loaded: true, loaded: true,
}); });
const onSave = vi.fn().mockResolvedValue(undefined); const onSave = vi.fn().mockResolvedValue({ id: 42 });
render(<TransportModal {...defaultProps} onSave={onSave} />); 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(/e\.g\. Lufthansa/i), 'ICE Train');
await userEvent.type(screen.getByPlaceholderText('0.00'), '85'); await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalled()); await waitFor(() => expect(onSave).toHaveBeenCalled());
expect(onSave).toHaveBeenCalledWith( // The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) }) 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 { 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 { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
import Modal from '../shared/Modal' import Modal from '../shared/Modal'
@@ -13,8 +13,11 @@ import { useAddonStore } from '../../store/addonStore'
import { formatDate, splitReservationDateTime } from '../../utils/formatters' import { formatDate, splitReservationDateTime } from '../../utils/formatters'
import { openFile } from '../../utils/fileDownload' import { openFile } from '../../utils/fileDownload'
import apiClient from '../../api/client' import apiClient from '../../api/client'
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types' import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs' 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 const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
type TransportType = typeof TRANSPORT_TYPES[number] type TransportType = typeof TRANSPORT_TYPES[number]
@@ -105,8 +108,6 @@ const defaultForm = {
arrival_time: '', arrival_time: '',
confirmation_number: '', confirmation_number: '',
notes: '', notes: '',
price: '',
budget_category: '',
meta_airline: '', meta_airline: '',
meta_flight_number: '', meta_flight_number: '',
meta_train_number: '', meta_train_number: '',
@@ -124,20 +125,20 @@ interface TransportModalProps {
files?: TripFile[] files?: TripFile[]
onFileUpload?: (fd: FormData) => Promise<unknown> onFileUpload?: (fd: FormData) => Promise<unknown>
onFileDelete?: (fileId: number) => Promise<void> 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 { t, locale } = useTranslation()
const toast = useToast() const toast = useToast()
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget')) const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
const budgetItems = useTripStore(s => s.budgetItems) const budgetItems = useTripStore(s => s.budgetItems)
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
const loadFiles = useTripStore(s => s.loadFiles) 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 }>() 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 [form, setForm] = useState({ ...defaultForm })
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [fromPick, setFromPick] = useState<EndpointPick>({}) 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_train_number: meta.train_number || '',
meta_platform: meta.platform || '', meta_platform: meta.platform || '',
meta_seat: meta.seat || '', 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') { if (type === 'flight') {
const orderedEps = orderedEndpoints(reservation) 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 set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e?: React.FormEvent) => {
e.preventDefault() e?.preventDefault()
if (!form.title.trim()) return if (!form.title.trim()) return
setIsSaving(true) setIsSaving(true)
try { 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_platform) metadata.platform = form.meta_platform
if (form.meta_seat) metadata.seat = form.meta_seat 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 startDate = startDay?.date ?? null
const endDate = (endDay ?? startDay)?.date ?? null const endDate = (endDay ?? startDay)?.date ?? null
const endpoints: ReturnType<typeof endpointFromAirport>[] = [] const endpoints: ReturnType<typeof endpointFromAirport>[] = []
@@ -334,11 +328,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
endpoints, endpoints,
needs_review: false, 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) const saved = await onSave(payload)
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) { if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
for (const file of pendingFiles) { for (const file of pendingFiles) {
@@ -349,6 +338,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
await onFileUpload(fd) 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) { } catch (err: unknown) {
toast.error(err instanceof Error ? err.message : t('common.unknownError')) toast.error(err instanceof Error ? err.message : t('common.unknownError'))
} finally { } 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 handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0]
if (!file) return if (!file) return
@@ -712,38 +715,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
</div> </div>
</div> </div>
{/* Price + Budget Category */} {/* Costs — create / view the expense linked to this booking */}
{isBudgetEnabled && ( {isBudgetEnabled && (
<> <BookingCostsSection
<div style={{ display: 'flex', gap: 8 }}> reservationId={reservation?.id ?? null}
<div style={{ flex: 1, minWidth: 0 }}> onCreate={handleCreateExpense}
<label className={labelClass}>{t('reservations.price')}</label> onEdit={handleEditExpense}
<input type="text" inputMode="decimal" value={form.price} onRemove={handleRemoveExpense}
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>
)}
</>
)} )}
</form> </form>
+29 -3
View File
@@ -25,7 +25,9 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton' import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
import TodoListPanel from '../components/Todo/TodoListPanel' import TodoListPanel from '../components/Todo/TodoListPanel'
import FileManager from '../components/Files/FileManager' 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 CollabPanel from '../components/Collab/CollabPanel'
import Navbar from '../components/Layout/Navbar' import Navbar from '../components/Layout/Navbar'
import { useToast } from '../components/shared/Toast' 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 [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false 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) { if (isLoading || !splashDone) {
return ( return (
<div className="bg-surface" style={{ <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)} /> <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} /> <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} /> <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} /> <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)} />} {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} /> <BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} /> <AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
<ConfirmDialog <ConfirmDialog
+1 -1
View File
@@ -114,7 +114,7 @@ export class BudgetController {
create( create(
@CurrentUser() user: User, @CurrentUser() user: User,
@Param('tripId') tripId: string, @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, @Headers('x-socket-id') socketId?: string,
) { ) {
const trip = this.requireTrip(tripId, user); const trip = this.requireTrip(tripId, user);
@@ -5,6 +5,7 @@ import { checkPermission } from '../../services/permissions';
import type { User } from '../../types'; import type { User } from '../../types';
import * as svc from '../../services/reservationService'; import * as svc from '../../services/reservationService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService'; import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
import { typeToCostCategory } from '@trek/shared';
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>; type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
type BudgetEntry = { total_price?: number; category?: string } | undefined; 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. */ /** 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 { 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; const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (linked) { if (linked) {
deleteBudgetItem(linked.id, tripId); deleteBudgetItem(linked.id, tripId);
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId); broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
} }
return;
} }
if (entry && Number(entry.total_price) > 0) {
try { try {
const itemName = title || currentTitle; const itemName = title || currentTitle;
const category = entry.category || type || currentType || 'Other'; 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; const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
if (existing) { if (existing) {
const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price });
broadcast(tripId, 'budget:updated', { item: updated }, socketId); broadcast(tripId, 'budget:updated', { item: updated }, socketId);
} else { } else {
const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); 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); db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id);
item.reservation_id = Number(id); item.reservation_id = Number(id);
broadcast(tripId, 'budget:created', { item }, socketId); broadcast(tripId, 'budget:created', { item }, socketId);
}
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
} }
} catch (err) {
console.error('[reservations] Failed to create/update budget entry:', err);
} }
} }
+12 -2
View File
@@ -105,6 +105,7 @@ export function createBudgetItem(
currency?: string | null; exchange_rate?: number; currency?: string | null; exchange_rate?: number;
payers?: { user_id: number; amount: number }[]; member_ids?: number[]; payers?: { user_id: number; amount: number }[]; member_ids?: number[];
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
reservation_id?: number | null;
}, },
) { ) {
const maxOrder = db.prepare( 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 total = data.payers && data.payers.length > 0 ? payerTotal : (data.total_price || 0);
const result = db.prepare( 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( ).run(
tripId, tripId,
cat, cat,
@@ -141,6 +142,7 @@ export function createBudgetItem(
data.note || null, data.note || null,
sortOrder, sortOrder,
data.expense_date || null, data.expense_date || null,
data.reservation_id != null ? data.reservation_id : null,
); );
const itemId = result.lastInsertRowid as number; 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). // 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) { if (data.member_ids !== undefined) {
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id); 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)'); const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
+77 -3
View File
@@ -382,7 +382,7 @@ describe('Reservation budget entry integration', () => {
expect(items[0].total_price).toBe(150); 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 { user } = createUser(testDb);
const trip = createTrip(testDb, user.id); const trip = createTrip(testDb, user.id);
@@ -398,24 +398,98 @@ describe('Reservation budget entry integration', () => {
expect(createRes.status).toBe(201); expect(createRes.status).toBe(201);
const resvId = createRes.body.reservation.id; const resvId = createRes.body.reservation.id;
// Verify budget item exists
const before = testDb const before = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?') .prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId); .get(trip.id, resvId);
expect(before).toBeDefined(); 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) const updateRes = await request(app)
.put(`/api/trips/${trip.id}/reservations/${resvId}`) .put(`/api/trips/${trip.id}/reservations/${resvId}`)
.set('Cookie', authCookie(user.id)) .set('Cookie', authCookie(user.id))
.send({ title: 'Taxi Updated' }); .send({ title: 'Taxi Updated' });
expect(updateRes.status).toBe(200); 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 const after = testDb
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?') .prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
.get(trip.id, resvId); .get(trip.id, resvId);
expect(after).toBeUndefined(); 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', () => { describe('Reservation accommodation delete', () => {
+32
View File
@@ -49,6 +49,35 @@ export const COST_CATEGORIES = [
] as const; ] as const;
export type CostCategory = (typeof COST_CATEGORIES)[number]; 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 * 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 * 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(), days: z.number().nullable().optional(),
note: z.string().nullable().optional(), note: z.string().nullable().optional(),
expense_date: 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< export type BudgetCreateItemRequest = z.infer<
typeof budgetCreateItemRequestSchema typeof budgetCreateItemRequestSchema
+26 -10
View File
@@ -127,35 +127,51 @@ const reservations: TranslationStrings = {
'reservations.import.cta': 'استيراد من ملف', 'reservations.import.cta': 'استيراد من ملف',
'reservations.import.dropHere': 'أسقط ملفات تأكيد الحجز هنا أو انقر للتحديد', 'reservations.import.dropHere': 'أسقط ملفات تأكيد الحجز هنا أو انقر للتحديد',
'reservations.import.dropActive': 'أسقط الملفات للاستيراد', '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.parsing': 'جارٍ معالجة الملفات…',
'reservations.import.previewHeading': 'تم العثور على {count} حجز/حجوزات', 'reservations.import.previewHeading': 'تم العثور على {count} حجز/حجوزات',
'reservations.import.previewEmpty': 'تعذّر استخراج أي حجوزات من الملفات المُحمَّلة.', 'reservations.import.previewEmpty':
'تعذّر استخراج أي حجوزات من الملفات المُحمَّلة.',
'reservations.import.removeItem': 'إزالة', 'reservations.import.removeItem': 'إزالة',
'reservations.import.confirm': 'استيراد {count} حجز/حجوزات', 'reservations.import.confirm': 'استيراد {count} حجز/حجوزات',
'reservations.import.back': 'رجوع', 'reservations.import.back': 'رجوع',
'reservations.import.success': 'تم استيراد {count} حجز/حجوزات', 'reservations.import.success': 'تم استيراد {count} حجز/حجوزات',
'reservations.import.partialFailure': 'تم استيراد {created}، فشل {failed}', 'reservations.import.partialFailure': 'تم استيراد {created}، فشل {failed}',
'reservations.import.error': 'فشلت المعالجة. تأكد من أن الملف تأكيد حجز صالح.', 'reservations.import.error':
'reservations.import.unavailable': 'استيراد الحجوزات غير متاح على هذا الخادم.', 'فشلت المعالجة. تأكد من أن الملف تأكيد حجز صالح.',
'reservations.import.unsupportedFormat': 'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.', 'reservations.import.unavailable':
'استيراد الحجوزات غير متاح على هذا الخادم.',
'reservations.import.unsupportedFormat':
'صيغة ملف غير مدعومة. استخدم EML أو PDF أو PKPass أو HTML أو TXT.',
'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.', 'reservations.import.fileTooLarge': 'الملف "{name}" يتجاوز حد 10 ميغابايت.',
'reservations.airtrail.title': 'استيراد من AirTrail', 'reservations.airtrail.title': 'استيراد من AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.', 'reservations.airtrail.syncedHint':
'متزامن من AirTrail — تبقى التعديلات متزامنة في الاتجاهين.',
'reservations.airtrail.notSynced': 'غير متزامن', 'reservations.airtrail.notSynced': 'غير متزامن',
'reservations.airtrail.notSyncedHint': 'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.', 'reservations.airtrail.notSyncedHint':
'تمت إزالة هذه الرحلة في AirTrail ولم تعد متزامنة.',
'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.', 'reservations.airtrail.loadError': 'تعذّر تحميل رحلاتك من AirTrail.',
'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات', 'reservations.airtrail.imported': 'تم استيراد {count} رحلة/رحلات',
'reservations.airtrail.skippedDuplicate': '{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها', 'reservations.airtrail.skippedDuplicate':
'{count} موجودة بالفعل في هذه الرحلة، تم تخطّيها',
'reservations.airtrail.nothingImported': 'لا شيء لاستيراده.', 'reservations.airtrail.nothingImported': 'لا شيء لاستيراده.',
'reservations.airtrail.importError': 'فشل الاستيراد. يُرجى المحاولة مرة أخرى.', 'reservations.airtrail.importError':
'فشل الاستيراد. يُرجى المحاولة مرة أخرى.',
'reservations.airtrail.undo': 'استيراد من AirTrail', 'reservations.airtrail.undo': 'استيراد من AirTrail',
'reservations.airtrail.alreadyImported': 'مُستورَد', 'reservations.airtrail.alreadyImported': 'مُستورَد',
'reservations.airtrail.duringTrip': 'خلال هذه الرحلة', 'reservations.airtrail.duringTrip': 'خلال هذه الرحلة',
'reservations.airtrail.otherFlights': 'رحلات أخرى', 'reservations.airtrail.otherFlights': 'رحلات أخرى',
'reservations.airtrail.empty': 'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.', 'reservations.airtrail.empty':
'لم يتم العثور على أي رحلات في حساب AirTrail الخاص بك.',
'reservations.airtrail.importCta': 'استيراد {count}', '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; export default reservations;
+32 -13
View File
@@ -126,37 +126,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Adicionar reserva', 'reservations.addBooking': 'Adicionar reserva',
'reservations.import.title': 'Importar confirmações de reserva', 'reservations.import.title': 'Importar confirmações de reserva',
'reservations.import.cta': 'Importar de arquivo', '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.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.parsing': 'Analisando arquivos…',
'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)', '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.removeItem': 'Remover',
'reservations.import.confirm': 'Importar {count} reserva(s)', 'reservations.import.confirm': 'Importar {count} reserva(s)',
'reservations.import.back': 'Voltar', 'reservations.import.back': 'Voltar',
'reservations.import.success': '{count} reserva(s) importada(s)', 'reservations.import.success': '{count} reserva(s) importada(s)',
'reservations.import.partialFailure': '{created} importada(s), {failed} falhou/falharam', 'reservations.import.partialFailure':
'reservations.import.error': 'Falha na análise. Verifique se o arquivo é uma confirmação de reserva válida.', '{created} importada(s), {failed} falhou/falharam',
'reservations.import.unavailable': 'A importação de reservas não está disponível neste servidor.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Formato de arquivo não suportado. Use EML, PDF, PKPass, HTML ou TXT.', 'Falha na análise. Verifique se o arquivo é uma confirmação de reserva válida.',
'reservations.import.fileTooLarge': 'O arquivo "{name}" excede o limite de 10 MB.', '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.title': 'Importar do AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'Não sincronizado',
'reservations.airtrail.notSyncedHint': 'Este voo foi removido no AirTrail e não sincroniza mais.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Não foi possível carregar seus voos do AirTrail.', '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.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.nothingImported': 'Nada para importar.',
'reservations.airtrail.importError': 'Falha na importação. Tente novamente.', 'reservations.airtrail.importError': 'Falha na importação. Tente novamente.',
'reservations.airtrail.undo': 'Importar do AirTrail', 'reservations.airtrail.undo': 'Importar do AirTrail',
'reservations.airtrail.alreadyImported': 'Importado', 'reservations.airtrail.alreadyImported': 'Importado',
'reservations.airtrail.duringTrip': 'Durante esta viagem', 'reservations.airtrail.duringTrip': 'Durante esta viagem',
'reservations.airtrail.otherFlights': 'Outros voos', '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.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; export default reservations;
+30 -12
View File
@@ -125,37 +125,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Přidat rezervaci', 'reservations.addBooking': 'Přidat rezervaci',
'reservations.import.title': 'Importovat potvrzení rezervace', 'reservations.import.title': 'Importovat potvrzení rezervace',
'reservations.import.cta': 'Importovat ze souboru', '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.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.parsing': 'Zpracování souborů…',
'reservations.import.previewHeading': 'Nalezeno {count} rezervace/í', '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.removeItem': 'Odebrat',
'reservations.import.confirm': 'Importovat {count} rezervaci/í', 'reservations.import.confirm': 'Importovat {count} rezervaci/í',
'reservations.import.back': 'Zpět', 'reservations.import.back': 'Zpět',
'reservations.import.success': '{count} rezervace/í importováno', 'reservations.import.success': '{count} rezervace/í importováno',
'reservations.import.partialFailure': '{created} importováno, {failed} selhalo', 'reservations.import.partialFailure':
'reservations.import.error': 'Zpracování selhalo. Ujistěte se, že soubor je platným potvrzením rezervace.', '{created} importováno, {failed} selhalo',
'reservations.import.unavailable': 'Import rezervací není na tomto serveru k dispozici.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Nepodporovaný formát souboru. Použijte EML, PDF, PKPass, HTML nebo TXT.', '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.import.fileTooLarge': 'Soubor „{name}" překračuje limit 10 MB.',
'reservations.airtrail.title': 'Import z AirTrail', 'reservations.airtrail.title': 'Import z AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'Nesynchronizováno',
'reservations.airtrail.notSyncedHint': 'Tento let byl v AirTrail odstraněn a již se nesynchronizuje.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Vaše lety z AirTrail se nepodařilo načíst.', '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.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.nothingImported': 'Není co importovat.',
'reservations.airtrail.importError': 'Import selhal. Zkuste to prosím znovu.', 'reservations.airtrail.importError': 'Import selhal. Zkuste to prosím znovu.',
'reservations.airtrail.undo': 'Import z AirTrail', 'reservations.airtrail.undo': 'Import z AirTrail',
'reservations.airtrail.alreadyImported': 'Importováno', 'reservations.airtrail.alreadyImported': 'Importováno',
'reservations.airtrail.duringTrip': 'Během tohoto výletu', 'reservations.airtrail.duringTrip': 'Během tohoto výletu',
'reservations.airtrail.otherFlights': 'Ostatní lety', '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.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; export default reservations;
+34 -14
View File
@@ -127,37 +127,57 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Buchung hinzufügen', 'reservations.addBooking': 'Buchung hinzufügen',
'reservations.import.title': 'Buchungsbestätigungen importieren', 'reservations.import.title': 'Buchungsbestätigungen importieren',
'reservations.import.cta': 'Aus Datei 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.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.parsing': 'Dateien werden verarbeitet…',
'reservations.import.previewHeading': '{count} Reservierung(en) gefunden', '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.removeItem': 'Entfernen',
'reservations.import.confirm': '{count} Reservierung(en) importieren', 'reservations.import.confirm': '{count} Reservierung(en) importieren',
'reservations.import.back': 'Zurück', 'reservations.import.back': 'Zurück',
'reservations.import.success': '{count} Reservierung(en) importiert', 'reservations.import.success': '{count} Reservierung(en) importiert',
'reservations.import.partialFailure': '{created} importiert, {failed} fehlgeschlagen', 'reservations.import.partialFailure':
'reservations.import.error': 'Verarbeitung fehlgeschlagen. Stellen Sie sicher, dass die Datei eine gültige Buchungsbestätigung ist.', '{created} importiert, {failed} fehlgeschlagen',
'reservations.import.unavailable': 'Buchungsimport ist auf diesem Server nicht verfügbar.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Nicht unterstütztes Dateiformat. Verwenden Sie EML, PDF, PKPass, HTML oder TXT.', 'Verarbeitung fehlgeschlagen. Stellen Sie sicher, dass die Datei eine gültige Buchungsbestätigung ist.',
'reservations.import.fileTooLarge': 'Datei „{name}" überschreitet das 10-MB-Limit.', '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.title': 'Aus AirTrail importieren',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'Nicht synchronisiert',
'reservations.airtrail.notSyncedHint': 'Dieser Flug wurde in AirTrail gelöscht und wird nicht mehr synchronisiert.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Ihre AirTrail-Flüge konnten nicht geladen werden.', '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.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.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.undo': 'Aus AirTrail importieren',
'reservations.airtrail.alreadyImported': 'Importiert', 'reservations.airtrail.alreadyImported': 'Importiert',
'reservations.airtrail.duringTrip': 'Während dieser Reise', 'reservations.airtrail.duringTrip': 'Während dieser Reise',
'reservations.airtrail.otherFlights': 'Weitere Flüge', '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.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; export default reservations;
+24 -9
View File
@@ -126,30 +126,39 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Add booking', 'reservations.addBooking': 'Add booking',
'reservations.import.title': 'Import booking confirmations', 'reservations.import.title': 'Import booking confirmations',
'reservations.import.cta': 'Import from file', '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.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.parsing': 'Parsing files…',
'reservations.import.previewHeading': '{count} reservation(s) found', '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.removeItem': 'Remove',
'reservations.import.confirm': 'Import {count} reservation(s)', 'reservations.import.confirm': 'Import {count} reservation(s)',
'reservations.import.back': 'Back', 'reservations.import.back': 'Back',
'reservations.import.success': '{count} reservation(s) imported', 'reservations.import.success': '{count} reservation(s) imported',
'reservations.import.partialFailure': '{created} imported, {failed} failed', 'reservations.import.partialFailure': '{created} imported, {failed} failed',
'reservations.import.error': 'Parsing failed. Make sure the file is a valid booking confirmation.', 'reservations.import.error':
'reservations.import.unavailable': 'Booking import is not available on this server.', 'Parsing failed. Make sure the file is a valid booking confirmation.',
'reservations.import.unsupportedFormat': 'Unsupported file format. Use EML, PDF, PKPass, HTML, or TXT.', '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.import.fileTooLarge': 'File "{name}" exceeds 10 MB limit.',
'reservations.airtrail.title': 'Import from AirTrail', 'reservations.airtrail.title': 'Import from AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.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.loadError': 'Could not load your AirTrail flights.',
'reservations.airtrail.imported': '{count} flight(s) imported', '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.nothingImported': 'Nothing to import.',
'reservations.airtrail.importError': 'Import failed. Please try again.', 'reservations.airtrail.importError': 'Import failed. Please try again.',
'reservations.airtrail.undo': 'Import from AirTrail', 'reservations.airtrail.undo': 'Import from AirTrail',
@@ -158,5 +167,11 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': 'Other flights', 'reservations.airtrail.otherFlights': 'Other flights',
'reservations.airtrail.empty': 'No flights found in your AirTrail account.', 'reservations.airtrail.empty': 'No flights found in your AirTrail account.',
'reservations.airtrail.importCta': 'Import {count}', '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; export default reservations;
+32 -13
View File
@@ -126,37 +126,56 @@ const reservations: TranslationStrings = {
'reservations.meta.selectDay': 'Seleccionar día', 'reservations.meta.selectDay': 'Seleccionar día',
'reservations.import.title': 'Importar confirmaciones de reserva', 'reservations.import.title': 'Importar confirmaciones de reserva',
'reservations.import.cta': 'Importar desde archivo', '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.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.parsing': 'Analizando archivos…',
'reservations.import.previewHeading': '{count} reserva(s) encontrada(s)', '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.removeItem': 'Eliminar',
'reservations.import.confirm': 'Importar {count} reserva(s)', 'reservations.import.confirm': 'Importar {count} reserva(s)',
'reservations.import.back': 'Atrás', 'reservations.import.back': 'Atrás',
'reservations.import.success': '{count} reserva(s) importada(s)', 'reservations.import.success': '{count} reserva(s) importada(s)',
'reservations.import.partialFailure': '{created} importada(s), {failed} fallida(s)', 'reservations.import.partialFailure':
'reservations.import.error': 'Error al analizar. Asegúrate de que el archivo sea una confirmación de reserva válida.', '{created} importada(s), {failed} fallida(s)',
'reservations.import.unavailable': 'La importación de reservas no está disponible en este servidor.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Formato de archivo no compatible. Usa EML, PDF, PKPass, HTML o TXT.', 'Error al analizar. Asegúrate de que el archivo sea una confirmación de reserva válida.',
'reservations.import.fileTooLarge': 'El archivo «{name}» supera el límite de 10 MB.', '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.title': 'Importar desde AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'No sincronizado',
'reservations.airtrail.notSyncedHint': 'Este vuelo se eliminó en AirTrail y ya no se sincroniza.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'No se pudieron cargar tus vuelos de AirTrail.', '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.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.nothingImported': 'No hay nada que importar.',
'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.', 'reservations.airtrail.importError': 'Error al importar. Inténtalo de nuevo.',
'reservations.airtrail.undo': 'Importar desde AirTrail', 'reservations.airtrail.undo': 'Importar desde AirTrail',
'reservations.airtrail.alreadyImported': 'Importado', 'reservations.airtrail.alreadyImported': 'Importado',
'reservations.airtrail.duringTrip': 'Durante este viaje', 'reservations.airtrail.duringTrip': 'Durante este viaje',
'reservations.airtrail.otherFlights': 'Otros vuelos', '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.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; export default reservations;
+30 -12
View File
@@ -127,37 +127,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Ajouter une réservation', 'reservations.addBooking': 'Ajouter une réservation',
'reservations.import.title': 'Importer des confirmations de réservation', 'reservations.import.title': 'Importer des confirmations de réservation',
'reservations.import.cta': 'Importer depuis un fichier', '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.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.parsing': 'Analyse des fichiers…',
'reservations.import.previewHeading': '{count} réservation(s) trouvée(s)', '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.removeItem': 'Supprimer',
'reservations.import.confirm': 'Importer {count} réservation(s)', 'reservations.import.confirm': 'Importer {count} réservation(s)',
'reservations.import.back': 'Retour', 'reservations.import.back': 'Retour',
'reservations.import.success': '{count} réservation(s) importée(s)', 'reservations.import.success': '{count} réservation(s) importée(s)',
'reservations.import.partialFailure': '{created} importée(s), {failed} échouée(s)', 'reservations.import.partialFailure':
'reservations.import.error': 'Analyse échouée. Assurez-vous que le fichier est une confirmation de réservation valide.', '{created} importée(s), {failed} échouée(s)',
'reservations.import.unavailable': "L'import de réservations n'est pas disponible sur ce serveur.", 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Format de fichier non pris en charge. Utilisez EML, PDF, PKPass, HTML ou TXT.', 'Analyse échouée. Assurez-vous que le fichier est une confirmation de réservation valide.',
'reservations.import.fileTooLarge': 'Le fichier « {name} » dépasse la limite de 10 Mo.', '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.title': 'Importer depuis AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.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.loadError': 'Impossible de charger vos vols AirTrail.',
'reservations.airtrail.imported': '{count} vol(s) importé(s)', '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.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.undo': 'Importer depuis AirTrail',
'reservations.airtrail.alreadyImported': 'Importé', 'reservations.airtrail.alreadyImported': 'Importé',
'reservations.airtrail.duringTrip': 'Pendant ce voyage', 'reservations.airtrail.duringTrip': 'Pendant ce voyage',
'reservations.airtrail.otherFlights': 'Autres vols', 'reservations.airtrail.otherFlights': 'Autres vols',
'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.', 'reservations.airtrail.empty': 'Aucun vol trouvé dans votre compte AirTrail.',
'reservations.airtrail.importCta': 'Importer {count}', '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; export default reservations;
+32 -13
View File
@@ -128,37 +128,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Προσθήκη κράτησης', 'reservations.addBooking': 'Προσθήκη κράτησης',
'reservations.import.title': 'Εισαγωγή επιβεβαιώσεων κράτησης', 'reservations.import.title': 'Εισαγωγή επιβεβαιώσεων κράτησης',
'reservations.import.cta': 'Εισαγωγή από αρχείο', 'reservations.import.cta': 'Εισαγωγή από αρχείο',
'reservations.import.dropHere': 'Αποθέστε αρχεία επιβεβαίωσης κράτησης εδώ ή κάντε κλικ για επιλογή', 'reservations.import.dropHere':
'Αποθέστε αρχεία επιβεβαίωσης κράτησης εδώ ή κάντε κλικ για επιλογή',
'reservations.import.dropActive': 'Αποθέστε αρχεία για εισαγωγή', '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.parsing': 'Επεξεργασία αρχείων…',
'reservations.import.previewHeading': 'Βρέθηκαν {count} κράτηση/κρατήσεις', 'reservations.import.previewHeading': 'Βρέθηκαν {count} κράτηση/κρατήσεις',
'reservations.import.previewEmpty': 'Δεν ήταν δυνατή η εξαγωγή κρατήσεων από τα μεταφορτωμένα αρχεία.', 'reservations.import.previewEmpty':
'Δεν ήταν δυνατή η εξαγωγή κρατήσεων από τα μεταφορτωμένα αρχεία.',
'reservations.import.removeItem': 'Αφαίρεση', 'reservations.import.removeItem': 'Αφαίρεση',
'reservations.import.confirm': 'Εισαγωγή {count} κράτησης/κρατήσεων', 'reservations.import.confirm': 'Εισαγωγή {count} κράτησης/κρατήσεων',
'reservations.import.back': 'Πίσω', 'reservations.import.back': 'Πίσω',
'reservations.import.success': '{count} κράτηση/κρατήσεις εισήχθησαν', 'reservations.import.success': '{count} κράτηση/κρατήσεις εισήχθησαν',
'reservations.import.partialFailure': '{created} εισήχθησαν, {failed} απέτυχαν', 'reservations.import.partialFailure':
'reservations.import.error': 'Η επεξεργασία απέτυχε. Βεβαιωθείτε ότι το αρχείο είναι έγκυρη επιβεβαίωση κράτησης.', '{created} εισήχθησαν, {failed} απέτυχαν',
'reservations.import.unavailable': 'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.', 'Η επεξεργασία απέτυχε. Βεβαιωθείτε ότι το αρχείο είναι έγκυρη επιβεβαίωση κράτησης.',
'reservations.import.fileTooLarge': 'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.', 'reservations.import.unavailable':
'Η εισαγωγή κρατήσεων δεν είναι διαθέσιμη σε αυτόν τον διακομιστή.',
'reservations.import.unsupportedFormat':
'Μη υποστηριζόμενη μορφή αρχείου. Χρησιμοποιήστε EML, PDF, PKPass, HTML ή TXT.',
'reservations.import.fileTooLarge':
'Το αρχείο «{name}» υπερβαίνει το όριο των 10 MB.',
'reservations.airtrail.title': 'Εισαγωγή από το AirTrail', 'reservations.airtrail.title': 'Εισαγωγή από το AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.', 'reservations.airtrail.syncedHint':
'Συγχρονισμένο από το AirTrail — οι αλλαγές συγχρονίζονται και προς τις δύο κατευθύνσεις.',
'reservations.airtrail.notSynced': 'Μη συγχρονισμένο', 'reservations.airtrail.notSynced': 'Μη συγχρονισμένο',
'reservations.airtrail.notSyncedHint': 'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.', 'Αυτή η πτήση αφαιρέθηκε στο AirTrail και δεν συγχρονίζεται πλέον.',
'reservations.airtrail.loadError':
'Δεν ήταν δυνατή η φόρτωση των πτήσεών σας από το AirTrail.',
'reservations.airtrail.imported': '{count} πτήση/πτήσεις εισήχθησαν', 'reservations.airtrail.imported': '{count} πτήση/πτήσεις εισήχθησαν',
'reservations.airtrail.skippedDuplicate': '{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν', 'reservations.airtrail.skippedDuplicate':
'{count} υπάρχουν ήδη σε αυτό το ταξίδι, παραλείφθηκαν',
'reservations.airtrail.nothingImported': 'Δεν υπάρχει τίποτα για εισαγωγή.', 'reservations.airtrail.nothingImported': 'Δεν υπάρχει τίποτα για εισαγωγή.',
'reservations.airtrail.importError': 'Η εισαγωγή απέτυχε. Δοκιμάστε ξανά.', 'reservations.airtrail.importError': 'Η εισαγωγή απέτυχε. Δοκιμάστε ξανά.',
'reservations.airtrail.undo': 'Εισαγωγή από το AirTrail', 'reservations.airtrail.undo': 'Εισαγωγή από το AirTrail',
'reservations.airtrail.alreadyImported': 'Εισήχθη', 'reservations.airtrail.alreadyImported': 'Εισήχθη',
'reservations.airtrail.duringTrip': 'Κατά τη διάρκεια αυτού του ταξιδιού', 'reservations.airtrail.duringTrip': 'Κατά τη διάρκεια αυτού του ταξιδιού',
'reservations.airtrail.otherFlights': 'Άλλες πτήσεις', 'reservations.airtrail.otherFlights': 'Άλλες πτήσεις',
'reservations.airtrail.empty': 'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.', 'reservations.airtrail.empty':
'Δεν βρέθηκαν πτήσεις στον λογαριασμό σας στο AirTrail.',
'reservations.airtrail.importCta': 'Εισαγωγή {count}', '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; export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Foglalás hozzáadása', 'reservations.addBooking': 'Foglalás hozzáadása',
'reservations.import.title': 'Foglalási visszaigazolások importálása', 'reservations.import.title': 'Foglalási visszaigazolások importálása',
'reservations.import.cta': 'Importálás fájlból', '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.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.parsing': 'Fájlok feldolgozása…',
'reservations.import.previewHeading': '{count} foglalás találva', '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.removeItem': 'Eltávolítás',
'reservations.import.confirm': '{count} foglalás importálása', 'reservations.import.confirm': '{count} foglalás importálása',
'reservations.import.back': 'Vissza', 'reservations.import.back': 'Vissza',
'reservations.import.success': '{count} foglalás importálva', 'reservations.import.success': '{count} foglalás importálva',
'reservations.import.partialFailure': '{created} importálva, {failed} sikertelen', 'reservations.import.partialFailure':
'reservations.import.error': 'A feldolgozás sikertelen. Győződjön meg arról, hogy a fájl érvényes foglalási visszaigazolás.', '{created} importálva, {failed} sikertelen',
'reservations.import.unavailable': 'A foglalásimportálás nem érhető el ezen a kiszolgálón.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Nem támogatott fájlformátum. Használjon EML, PDF, PKPass, HTML vagy TXT formátumot.', 'A feldolgozás sikertelen. Győződjön meg arról, hogy a fájl érvényes foglalási visszaigazolás.',
'reservations.import.fileTooLarge': 'A(z) „{name}" fájl meghaladja a 10 MB-os korlátot.', '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.title': 'Importálás az AirTrailből',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.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.notSyncedHint':
'reservations.airtrail.loadError': 'Nem sikerült betölteni az AirTrail-járataidat.', '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.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.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.undo': 'Importálás az AirTrailből',
'reservations.airtrail.alreadyImported': 'Importálva', 'reservations.airtrail.alreadyImported': 'Importálva',
'reservations.airtrail.duringTrip': 'Az utazás ideje alatt', 'reservations.airtrail.duringTrip': 'Az utazás ideje alatt',
'reservations.airtrail.otherFlights': 'Egyéb járatok', 'reservations.airtrail.otherFlights': 'Egyéb járatok',
'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.', 'reservations.airtrail.empty': 'Nem található járat az AirTrail-fiókodban.',
'reservations.airtrail.importCta': '{count} importálása', '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; export default reservations;
+30 -12
View File
@@ -126,37 +126,55 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Tambah pemesanan', 'reservations.addBooking': 'Tambah pemesanan',
'reservations.import.title': 'Impor konfirmasi pemesanan', 'reservations.import.title': 'Impor konfirmasi pemesanan',
'reservations.import.cta': 'Impor dari file', '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.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.parsing': 'Memproses file…',
'reservations.import.previewHeading': '{count} pemesanan ditemukan', '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.removeItem': 'Hapus',
'reservations.import.confirm': 'Impor {count} pemesanan', 'reservations.import.confirm': 'Impor {count} pemesanan',
'reservations.import.back': 'Kembali', 'reservations.import.back': 'Kembali',
'reservations.import.success': '{count} pemesanan berhasil diimpor', 'reservations.import.success': '{count} pemesanan berhasil diimpor',
'reservations.import.partialFailure': '{created} berhasil diimpor, {failed} gagal', 'reservations.import.partialFailure':
'reservations.import.error': 'Pemrosesan gagal. Pastikan file adalah konfirmasi pemesanan yang valid.', '{created} berhasil diimpor, {failed} gagal',
'reservations.import.unavailable': 'Impor pemesanan tidak tersedia di server ini.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Format file tidak didukung. Gunakan EML, PDF, PKPass, HTML, atau TXT.', '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.import.fileTooLarge': 'File "{name}" melebihi batas 10 MB.',
'reservations.airtrail.title': 'Impor dari AirTrail', 'reservations.airtrail.title': 'Impor dari AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'Tidak tersinkron',
'reservations.airtrail.notSyncedHint': 'Penerbangan ini telah dihapus di AirTrail dan tidak lagi tersinkron.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Tidak dapat memuat penerbangan AirTrail-mu.', '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.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.nothingImported': 'Tidak ada yang dapat diimpor.',
'reservations.airtrail.importError': 'Impor gagal. Silakan coba lagi.', 'reservations.airtrail.importError': 'Impor gagal. Silakan coba lagi.',
'reservations.airtrail.undo': 'Impor dari AirTrail', 'reservations.airtrail.undo': 'Impor dari AirTrail',
'reservations.airtrail.alreadyImported': 'Diimpor', 'reservations.airtrail.alreadyImported': 'Diimpor',
'reservations.airtrail.duringTrip': 'Selama perjalanan ini', 'reservations.airtrail.duringTrip': 'Selama perjalanan ini',
'reservations.airtrail.otherFlights': 'Penerbangan lain', '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.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; export default reservations;
+32 -13
View File
@@ -128,37 +128,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Aggiungi prenotazione', 'reservations.addBooking': 'Aggiungi prenotazione',
'reservations.import.title': 'Importa conferme di prenotazione', 'reservations.import.title': 'Importa conferme di prenotazione',
'reservations.import.cta': 'Importa da file', '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.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.parsing': 'Analisi dei file in corso…',
'reservations.import.previewHeading': '{count} prenotazione/i trovata/e', '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.removeItem': 'Rimuovi',
'reservations.import.confirm': 'Importa {count} prenotazione/i', 'reservations.import.confirm': 'Importa {count} prenotazione/i',
'reservations.import.back': 'Indietro', 'reservations.import.back': 'Indietro',
'reservations.import.success': '{count} prenotazione/i importata/e', 'reservations.import.success': '{count} prenotazione/i importata/e',
'reservations.import.partialFailure': '{created} importata/e, {failed} fallita/e', 'reservations.import.partialFailure':
'reservations.import.error': "Analisi fallita. Assicurati che il file sia una conferma di prenotazione valida.", '{created} importata/e, {failed} fallita/e',
'reservations.import.unavailable': "L'importazione di prenotazioni non è disponibile su questo server.", 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Formato file non supportato. Usa EML, PDF, PKPass, HTML o TXT.', 'Analisi fallita. Assicurati che il file sia una conferma di prenotazione valida.',
'reservations.import.fileTooLarge': 'Il file "{name}" supera il limite di 10 MB.', '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.title': 'Importa da AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'Non sincronizzato',
'reservations.airtrail.notSyncedHint': 'Questo volo è stato rimosso in AirTrail e non si sincronizza più.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Impossibile caricare i tuoi voli AirTrail.', '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.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.nothingImported': 'Niente da importare.',
'reservations.airtrail.importError': 'Importazione fallita. Riprova.', 'reservations.airtrail.importError': 'Importazione fallita. Riprova.',
'reservations.airtrail.undo': 'Importa da AirTrail', 'reservations.airtrail.undo': 'Importa da AirTrail',
'reservations.airtrail.alreadyImported': 'Importato', 'reservations.airtrail.alreadyImported': 'Importato',
'reservations.airtrail.duringTrip': 'Durante questo viaggio', 'reservations.airtrail.duringTrip': 'Durante questo viaggio',
'reservations.airtrail.otherFlights': 'Altri voli', '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.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; export default reservations;
+34 -14
View File
@@ -124,37 +124,57 @@ const reservations: TranslationStrings = {
'reservations.addBooking': '予約を追加', 'reservations.addBooking': '予約を追加',
'reservations.import.title': '予約確認書のインポート', 'reservations.import.title': '予約確認書のインポート',
'reservations.import.cta': 'ファイルからインポート', 'reservations.import.cta': 'ファイルからインポート',
'reservations.import.dropHere': '予約確認ファイルをここにドロップするか、クリックして選択', 'reservations.import.dropHere':
'予約確認ファイルをここにドロップするか、クリックして選択',
'reservations.import.dropActive': 'ファイルをドロップしてインポート', '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.parsing': 'ファイルを解析中…',
'reservations.import.previewHeading': '{count} 件の予約が見つかりました', 'reservations.import.previewHeading': '{count} 件の予約が見つかりました',
'reservations.import.previewEmpty': 'アップロードされたファイルから予約を抽出できませんでした。', 'reservations.import.previewEmpty':
'アップロードされたファイルから予約を抽出できませんでした。',
'reservations.import.removeItem': '削除', 'reservations.import.removeItem': '削除',
'reservations.import.confirm': '{count} 件の予約をインポート', 'reservations.import.confirm': '{count} 件の予約をインポート',
'reservations.import.back': '戻る', 'reservations.import.back': '戻る',
'reservations.import.success': '{count} 件の予約をインポートしました', 'reservations.import.success': '{count} 件の予約をインポートしました',
'reservations.import.partialFailure': '{created} 件インポート済み、{failed} 件失敗', 'reservations.import.partialFailure':
'reservations.import.error': '解析に失敗しました。ファイルが有効な予約確認書であることを確認してください。', '{created} 件インポート済み、{failed} 件失敗',
'reservations.import.unavailable': 'このサーバーでは予約インポート機能が利用できません。', 'reservations.import.error':
'reservations.import.unsupportedFormat': '対応していないファイル形式です。EML、PDF、PKPass、HTML、または TXT を使用してください。', '解析に失敗しました。ファイルが有効な予約確認書であることを確認してください。',
'reservations.import.fileTooLarge': 'ファイル「{name}」は 10 MB の制限を超えています。', 'reservations.import.unavailable':
'このサーバーでは予約インポート機能が利用できません。',
'reservations.import.unsupportedFormat':
'対応していないファイル形式です。EML、PDF、PKPass、HTML、または TXT を使用してください。',
'reservations.import.fileTooLarge':
'ファイル「{name}」は 10 MB の制限を超えています。',
'reservations.airtrail.title': 'AirTrail からインポート', 'reservations.airtrail.title': 'AirTrail からインポート',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'AirTrail と同期済み — 編集は双方向で同期されます。', 'reservations.airtrail.syncedHint':
'AirTrail と同期済み — 編集は双方向で同期されます。',
'reservations.airtrail.notSynced': '未同期', 'reservations.airtrail.notSynced': '未同期',
'reservations.airtrail.notSyncedHint': 'このフライトは AirTrail で削除されたため、同期されなくなりました。', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'AirTrail のフライトを読み込めませんでした。', 'このフライトは AirTrail で削除されたため、同期されなくなりました。',
'reservations.airtrail.loadError':
'AirTrail のフライトを読み込めませんでした。',
'reservations.airtrail.imported': '{count} 件のフライトをインポートしました', 'reservations.airtrail.imported': '{count} 件のフライトをインポートしました',
'reservations.airtrail.skippedDuplicate': '{count} 件はこの旅行に既に存在するためスキップしました', 'reservations.airtrail.skippedDuplicate':
'{count} 件はこの旅行に既に存在するためスキップしました',
'reservations.airtrail.nothingImported': 'インポートする項目がありません。', 'reservations.airtrail.nothingImported': 'インポートする項目がありません。',
'reservations.airtrail.importError': 'インポートに失敗しました。もう一度お試しください。', 'reservations.airtrail.importError':
'インポートに失敗しました。もう一度お試しください。',
'reservations.airtrail.undo': 'AirTrail からインポート', 'reservations.airtrail.undo': 'AirTrail からインポート',
'reservations.airtrail.alreadyImported': 'インポート済み', 'reservations.airtrail.alreadyImported': 'インポート済み',
'reservations.airtrail.duringTrip': 'この旅行の期間中', 'reservations.airtrail.duringTrip': 'この旅行の期間中',
'reservations.airtrail.otherFlights': 'その他のフライト', 'reservations.airtrail.otherFlights': 'その他のフライト',
'reservations.airtrail.empty': 'AirTrail アカウントにフライトが見つかりませんでした。', 'reservations.airtrail.empty':
'AirTrail アカウントにフライトが見つかりませんでした。',
'reservations.airtrail.importCta': '{count} 件をインポート', '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; export default reservations;
+28 -11
View File
@@ -124,37 +124,54 @@ const reservations: TranslationStrings = {
'reservations.addBooking': '예약 추가', 'reservations.addBooking': '예약 추가',
'reservations.import.title': '예약 확인서 가져오기', 'reservations.import.title': '예약 확인서 가져오기',
'reservations.import.cta': '파일에서 가져오기', 'reservations.import.cta': '파일에서 가져오기',
'reservations.import.dropHere': '예약 확인 파일을 여기에 끌어다 놓거나 클릭하여 선택', 'reservations.import.dropHere':
'예약 확인 파일을 여기에 끌어다 놓거나 클릭하여 선택',
'reservations.import.dropActive': '가져올 파일을 여기에 놓으세요', '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.parsing': '파일 분석 중…',
'reservations.import.previewHeading': '{count}개 예약 발견', 'reservations.import.previewHeading': '{count}개 예약 발견',
'reservations.import.previewEmpty': '업로드된 파일에서 예약을 추출할 수 없었습니다.', 'reservations.import.previewEmpty':
'업로드된 파일에서 예약을 추출할 수 없었습니다.',
'reservations.import.removeItem': '제거', 'reservations.import.removeItem': '제거',
'reservations.import.confirm': '{count}개 예약 가져오기', 'reservations.import.confirm': '{count}개 예약 가져오기',
'reservations.import.back': '뒤로', 'reservations.import.back': '뒤로',
'reservations.import.success': '{count}개 예약을 가져왔습니다', 'reservations.import.success': '{count}개 예약을 가져왔습니다',
'reservations.import.partialFailure': '{created}개 가져옴, {failed}개 실패', 'reservations.import.partialFailure': '{created}개 가져옴, {failed}개 실패',
'reservations.import.error': '분석 실패. 파일이 유효한 예약 확인서인지 확인하세요.', 'reservations.import.error':
'reservations.import.unavailable': '이 서버에서는 예약 가져오기를 사용할 수 없습니다.', '분석 실패. 파일이 유효한 예약 확인서인지 확인하세요.',
'reservations.import.unsupportedFormat': '지원하지 않는 파일 형식입니다. EML, PDF, PKPass, HTML 또는 TXT를 사용하세요.', 'reservations.import.unavailable':
'reservations.import.fileTooLarge': '파일 "{name}"이(가) 10 MB 제한을 초과합니다.', '이 서버에서는 예약 가져오기를 사용할 수 없습니다.',
'reservations.import.unsupportedFormat':
'지원하지 않는 파일 형식입니다. EML, PDF, PKPass, HTML 또는 TXT를 사용하세요.',
'reservations.import.fileTooLarge':
'파일 "{name}"이(가) 10 MB 제한을 초과합니다.',
'reservations.airtrail.title': 'AirTrail에서 가져오기', 'reservations.airtrail.title': 'AirTrail에서 가져오기',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'AirTrail에서 동기화됨 — 수정 사항이 양방향으로 동기화됩니다.', 'reservations.airtrail.syncedHint':
'AirTrail에서 동기화됨 — 수정 사항이 양방향으로 동기화됩니다.',
'reservations.airtrail.notSynced': '동기화되지 않음', 'reservations.airtrail.notSynced': '동기화되지 않음',
'reservations.airtrail.notSyncedHint': '이 항공편은 AirTrail에서 삭제되어 더 이상 동기화되지 않습니다.', 'reservations.airtrail.notSyncedHint':
'이 항공편은 AirTrail에서 삭제되어 더 이상 동기화되지 않습니다.',
'reservations.airtrail.loadError': 'AirTrail 항공편을 불러올 수 없습니다.', 'reservations.airtrail.loadError': 'AirTrail 항공편을 불러올 수 없습니다.',
'reservations.airtrail.imported': '{count}개 항공편을 가져왔습니다', 'reservations.airtrail.imported': '{count}개 항공편을 가져왔습니다',
'reservations.airtrail.skippedDuplicate': '{count}개는 이미 이 여행에 있어 건너뛰었습니다', 'reservations.airtrail.skippedDuplicate':
'{count}개는 이미 이 여행에 있어 건너뛰었습니다',
'reservations.airtrail.nothingImported': '가져올 항목이 없습니다.', 'reservations.airtrail.nothingImported': '가져올 항목이 없습니다.',
'reservations.airtrail.importError': '가져오기에 실패했습니다. 다시 시도하세요.', 'reservations.airtrail.importError':
'가져오기에 실패했습니다. 다시 시도하세요.',
'reservations.airtrail.undo': 'AirTrail에서 가져오기', 'reservations.airtrail.undo': 'AirTrail에서 가져오기',
'reservations.airtrail.alreadyImported': '가져옴', 'reservations.airtrail.alreadyImported': '가져옴',
'reservations.airtrail.duringTrip': '이 여행 기간', 'reservations.airtrail.duringTrip': '이 여행 기간',
'reservations.airtrail.otherFlights': '기타 항공편', 'reservations.airtrail.otherFlights': '기타 항공편',
'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.', 'reservations.airtrail.empty': 'AirTrail 계정에서 항공편을 찾을 수 없습니다.',
'reservations.airtrail.importCta': '{count}개 가져오기', '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; export default reservations;
+30 -12
View File
@@ -127,21 +127,29 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Boeking toevoegen', 'reservations.addBooking': 'Boeking toevoegen',
'reservations.import.title': 'Boekingsbevestigingen importeren', 'reservations.import.title': 'Boekingsbevestigingen importeren',
'reservations.import.cta': 'Importeren vanuit bestand', '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.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.parsing': 'Bestanden verwerken…',
'reservations.import.previewHeading': '{count} reservering(en) gevonden', '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.removeItem': 'Verwijderen',
'reservations.import.confirm': '{count} reservering(en) importeren', 'reservations.import.confirm': '{count} reservering(en) importeren',
'reservations.import.back': 'Terug', 'reservations.import.back': 'Terug',
'reservations.import.success': '{count} reservering(en) geïmporteerd', 'reservations.import.success': '{count} reservering(en) geïmporteerd',
'reservations.import.partialFailure': '{created} geïmporteerd, {failed} mislukt', 'reservations.import.partialFailure':
'reservations.import.error': 'Verwerking mislukt. Zorg ervoor dat het bestand een geldige boekingsbevestiging is.', '{created} geïmporteerd, {failed} mislukt',
'reservations.import.unavailable': 'Boeking importeren is niet beschikbaar op deze server.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Niet-ondersteund bestandsformaat. Gebruik EML, PDF, PKPass, HTML of TXT.', 'Verwerking mislukt. Zorg ervoor dat het bestand een geldige boekingsbevestiging is.',
'reservations.import.fileTooLarge': 'Bestand "{name}" overschrijdt de limiet van 10 MB.', '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.title': 'Importeren uit AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
@@ -150,16 +158,26 @@ const reservations: TranslationStrings = {
'reservations.airtrail.notSynced': 'Niet gesynchroniseerd', 'reservations.airtrail.notSynced': 'Niet gesynchroniseerd',
'reservations.airtrail.notSyncedHint': 'reservations.airtrail.notSyncedHint':
'Deze vlucht is in AirTrail verwijderd en wordt niet meer gesynchroniseerd.', '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.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.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.undo': 'Importeren uit AirTrail',
'reservations.airtrail.alreadyImported': 'Geïmporteerd', 'reservations.airtrail.alreadyImported': 'Geïmporteerd',
'reservations.airtrail.duringTrip': 'Tijdens deze reis', 'reservations.airtrail.duringTrip': 'Tijdens deze reis',
'reservations.airtrail.otherFlights': 'Andere vluchten', '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.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; export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Dodaj rezerwację', 'reservations.addBooking': 'Dodaj rezerwację',
'reservations.import.title': 'Importuj potwierdzenia rezerwacji', 'reservations.import.title': 'Importuj potwierdzenia rezerwacji',
'reservations.import.cta': 'Importuj z pliku', '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.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.parsing': 'Przetwarzanie plików…',
'reservations.import.previewHeading': 'Znaleziono {count} rezerwację/rezerwacje', 'reservations.import.previewHeading':
'reservations.import.previewEmpty': 'Nie udało się wyodrębnić rezerwacji z przesłanych plików.', '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.removeItem': 'Usuń',
'reservations.import.confirm': 'Importuj {count} rezerwację/rezerwacje', 'reservations.import.confirm': 'Importuj {count} rezerwację/rezerwacje',
'reservations.import.back': 'Wstecz', 'reservations.import.back': 'Wstecz',
'reservations.import.success': 'Zaimportowano {count} rezerwację/rezerwacje', 'reservations.import.success': 'Zaimportowano {count} rezerwację/rezerwacje',
'reservations.import.partialFailure': '{created} zaimportowano, {failed} nieudane', 'reservations.import.partialFailure':
'reservations.import.error': 'Przetwarzanie nieudane. Upewnij się, że plik jest prawidłowym potwierdzeniem rezerwacji.', '{created} zaimportowano, {failed} nieudane',
'reservations.import.unavailable': 'Import rezerwacji nie jest dostępny na tym serwerze.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Nieobsługiwany format pliku. Użyj EML, PDF, PKPass, HTML lub TXT.', '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.import.fileTooLarge': 'Plik „{name}" przekracza limit 10 MB.',
'reservations.airtrail.title': 'Importuj z AirTrail', 'reservations.airtrail.title': 'Importuj z AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': '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.notSynced': 'Niezsynchronizowane',
'reservations.airtrail.notSyncedHint': 'Ten lot został usunięty w AirTrail i nie jest już synchronizowany.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Nie udało się wczytać Twoich lotów z AirTrail.', '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.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.nothingImported': 'Nic do zaimportowania.',
'reservations.airtrail.importError': 'Import nieudany. Spróbuj ponownie.', 'reservations.airtrail.importError': 'Import nieudany. Spróbuj ponownie.',
'reservations.airtrail.undo': 'Importuj z AirTrail', 'reservations.airtrail.undo': 'Importuj z AirTrail',
'reservations.airtrail.alreadyImported': 'Zaimportowano', 'reservations.airtrail.alreadyImported': 'Zaimportowano',
'reservations.airtrail.duringTrip': 'Podczas tej wyprawy', 'reservations.airtrail.duringTrip': 'Podczas tej wyprawy',
'reservations.airtrail.otherFlights': 'Inne loty', '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.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; export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Добавить бронирование', 'reservations.addBooking': 'Добавить бронирование',
'reservations.import.title': 'Импорт подтверждений бронирования', 'reservations.import.title': 'Импорт подтверждений бронирования',
'reservations.import.cta': 'Импортировать из файла', 'reservations.import.cta': 'Импортировать из файла',
'reservations.import.dropHere': 'Перетащите файлы подтверждений бронирования сюда или нажмите для выбора', 'reservations.import.dropHere':
'Перетащите файлы подтверждений бронирования сюда или нажмите для выбора',
'reservations.import.dropActive': 'Отпустите файлы для импорта', '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.parsing': 'Обработка файлов…',
'reservations.import.previewHeading': 'Найдено {count} бронирование(й)', 'reservations.import.previewHeading': 'Найдено {count} бронирование(й)',
'reservations.import.previewEmpty': 'Из загруженных файлов не удалось извлечь бронирования.', 'reservations.import.previewEmpty':
'Из загруженных файлов не удалось извлечь бронирования.',
'reservations.import.removeItem': 'Удалить', 'reservations.import.removeItem': 'Удалить',
'reservations.import.confirm': 'Импортировать {count} бронирование(й)', 'reservations.import.confirm': 'Импортировать {count} бронирование(й)',
'reservations.import.back': 'Назад', 'reservations.import.back': 'Назад',
'reservations.import.success': '{count} бронирование(й) импортировано', 'reservations.import.success': '{count} бронирование(й) импортировано',
'reservations.import.partialFailure': '{created} импортировано, {failed} не удалось', 'reservations.import.partialFailure':
'reservations.import.error': 'Обработка не удалась. Убедитесь, что файл является действительным подтверждением бронирования.', '{created} импортировано, {failed} не удалось',
'reservations.import.unavailable': 'Импорт бронирований недоступен на этом сервере.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Неподдерживаемый формат файла. Используйте EML, PDF, PKPass, HTML или TXT.', 'Обработка не удалась. Убедитесь, что файл является действительным подтверждением бронирования.',
'reservations.import.fileTooLarge': 'Файл «{name}» превышает ограничение в 10 МБ.', 'reservations.import.unavailable':
'Импорт бронирований недоступен на этом сервере.',
'reservations.import.unsupportedFormat':
'Неподдерживаемый формат файла. Используйте EML, PDF, PKPass, HTML или TXT.',
'reservations.import.fileTooLarge':
'Файл «{name}» превышает ограничение в 10 МБ.',
'reservations.airtrail.title': 'Импорт из AirTrail', 'reservations.airtrail.title': 'Импорт из AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Синхронизировано с AirTrail — изменения синхронизируются в обе стороны.', 'reservations.airtrail.syncedHint':
'Синхронизировано с AirTrail — изменения синхронизируются в обе стороны.',
'reservations.airtrail.notSynced': 'Не синхронизировано', 'reservations.airtrail.notSynced': 'Не синхронизировано',
'reservations.airtrail.notSyncedHint': 'Этот рейс был удалён в AirTrail и больше не синхронизируется.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Не удалось загрузить ваши рейсы из AirTrail.', 'Этот рейс был удалён в AirTrail и больше не синхронизируется.',
'reservations.airtrail.loadError':
'Не удалось загрузить ваши рейсы из AirTrail.',
'reservations.airtrail.imported': 'Импортировано рейсов: {count}', 'reservations.airtrail.imported': 'Импортировано рейсов: {count}',
'reservations.airtrail.skippedDuplicate': '{count} уже в этой поездке, пропущено', 'reservations.airtrail.skippedDuplicate':
'{count} уже в этой поездке, пропущено',
'reservations.airtrail.nothingImported': 'Нечего импортировать.', 'reservations.airtrail.nothingImported': 'Нечего импортировать.',
'reservations.airtrail.importError': 'Импорт не удался. Повторите попытку.', 'reservations.airtrail.importError': 'Импорт не удался. Повторите попытку.',
'reservations.airtrail.undo': 'Импорт из AirTrail', 'reservations.airtrail.undo': 'Импорт из AirTrail',
'reservations.airtrail.alreadyImported': 'Импортировано', 'reservations.airtrail.alreadyImported': 'Импортировано',
'reservations.airtrail.duringTrip': 'Во время этой поездки', 'reservations.airtrail.duringTrip': 'Во время этой поездки',
'reservations.airtrail.otherFlights': 'Другие рейсы', 'reservations.airtrail.otherFlights': 'Другие рейсы',
'reservations.airtrail.empty': 'В вашей учётной записи AirTrail не найдено рейсов.', 'reservations.airtrail.empty':
'В вашей учётной записи AirTrail не найдено рейсов.',
'reservations.airtrail.importCta': 'Импортировать {count}', '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; export default reservations;
+28 -12
View File
@@ -127,37 +127,53 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Rezervasyon ekle', 'reservations.addBooking': 'Rezervasyon ekle',
'reservations.import.title': 'Rezervasyon onaylarını içe aktar', 'reservations.import.title': 'Rezervasyon onaylarını içe aktar',
'reservations.import.cta': 'Dosyadan 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.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.parsing': 'Dosyalar işleniyor…',
'reservations.import.previewHeading': '{count} rezervasyon bulundu', '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.removeItem': 'Kaldır',
'reservations.import.confirm': '{count} rezervasyonu içe aktar', 'reservations.import.confirm': '{count} rezervasyonu içe aktar',
'reservations.import.back': 'Geri', 'reservations.import.back': 'Geri',
'reservations.import.success': '{count} rezervasyon içe aktarıldı', 'reservations.import.success': '{count} rezervasyon içe aktarıldı',
'reservations.import.partialFailure': '{created} içe aktarıldı, {failed} başarısız', 'reservations.import.partialFailure':
'reservations.import.error': 'İşlem başarısız. Dosyanın geçerli bir rezervasyon onayı olduğundan emin olun.', '{created} içe aktarıldı, {failed} başarısız',
'reservations.import.unavailable': 'Rezervasyon içe aktarma bu sunucuda mevcut değil.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Desteklenmeyen dosya biçimi. EML, PDF, PKPass, HTML veya TXT kullanın.', 'İş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.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.cta': 'AirTrail',
'reservations.airtrail.synced': '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.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.loadError': 'AirTrail uçuşlarınız yüklenemedi.',
'reservations.airtrail.imported': '{count} uçuş içe aktarıldı', 'reservations.airtrail.imported': '{count} uçuş içe aktarıldı',
'reservations.airtrail.skippedDuplicate': '{count} zaten bu gezide, atlandı', 'reservations.airtrail.skippedDuplicate': '{count} zaten bu gezide, atlandı',
'reservations.airtrail.nothingImported': 'İçe aktarılacak bir şey yok.', 'reservations.airtrail.nothingImported': 'İçe aktarılacak bir şey yok.',
'reservations.airtrail.importError': 'İçe aktarma başarısız. Lütfen tekrar deneyin.', 'reservations.airtrail.importError':
'reservations.airtrail.undo': 'AirTrail\'den içe aktar', 'İç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.alreadyImported': 'İçe aktarıldı',
'reservations.airtrail.duringTrip': 'Bu gezi sırasında', 'reservations.airtrail.duringTrip': 'Bu gezi sırasında',
'reservations.airtrail.otherFlights': 'Diğer uçuşlar', 'reservations.airtrail.otherFlights': 'Diğer uçuşlar',
'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.', 'reservations.airtrail.empty': 'AirTrail hesabınızda uçuş bulunamadı.',
'reservations.airtrail.importCta': '{count} içe aktar', '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; export default reservations;
+32 -13
View File
@@ -127,37 +127,56 @@ const reservations: TranslationStrings = {
'reservations.addBooking': 'Добавить бронирование', 'reservations.addBooking': 'Добавить бронирование',
'reservations.import.title': 'Імпорт підтверджень бронювання', 'reservations.import.title': 'Імпорт підтверджень бронювання',
'reservations.import.cta': 'Імпортувати з файлу', 'reservations.import.cta': 'Імпортувати з файлу',
'reservations.import.dropHere': 'Перетягніть файли підтверджень бронювання сюди або натисніть для вибору', 'reservations.import.dropHere':
'Перетягніть файли підтверджень бронювання сюди або натисніть для вибору',
'reservations.import.dropActive': 'Відпустіть файли для імпорту', '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.parsing': 'Обробка файлів…',
'reservations.import.previewHeading': 'Знайдено {count} бронювання(нь)', 'reservations.import.previewHeading': 'Знайдено {count} бронювання(нь)',
'reservations.import.previewEmpty': 'З завантажених файлів не вдалося витягти бронювання.', 'reservations.import.previewEmpty':
'З завантажених файлів не вдалося витягти бронювання.',
'reservations.import.removeItem': 'Видалити', 'reservations.import.removeItem': 'Видалити',
'reservations.import.confirm': 'Імпортувати {count} бронювання(нь)', 'reservations.import.confirm': 'Імпортувати {count} бронювання(нь)',
'reservations.import.back': 'Назад', 'reservations.import.back': 'Назад',
'reservations.import.success': '{count} бронювання(нь) імпортовано', 'reservations.import.success': '{count} бронювання(нь) імпортовано',
'reservations.import.partialFailure': '{created} імпортовано, {failed} не вдалося', 'reservations.import.partialFailure':
'reservations.import.error': 'Обробка не вдалася. Переконайтесь, що файл є дійсним підтвердженням бронювання.', '{created} імпортовано, {failed} не вдалося',
'reservations.import.unavailable': 'Імпорт бронювань недоступний на цьому сервері.', 'reservations.import.error':
'reservations.import.unsupportedFormat': 'Непідтримуваний формат файлу. Використовуйте EML, PDF, PKPass, HTML або TXT.', 'Обробка не вдалася. Переконайтесь, що файл є дійсним підтвердженням бронювання.',
'reservations.import.fileTooLarge': 'Файл «{name}» перевищує обмеження в 10 МБ.', 'reservations.import.unavailable':
'Імпорт бронювань недоступний на цьому сервері.',
'reservations.import.unsupportedFormat':
'Непідтримуваний формат файлу. Використовуйте EML, PDF, PKPass, HTML або TXT.',
'reservations.import.fileTooLarge':
'Файл «{name}» перевищує обмеження в 10 МБ.',
'reservations.airtrail.title': 'Імпорт з AirTrail', 'reservations.airtrail.title': 'Імпорт з AirTrail',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': 'Синхронізовано з AirTrail — зміни синхронізуються в обидва боки.', 'reservations.airtrail.syncedHint':
'Синхронізовано з AirTrail — зміни синхронізуються в обидва боки.',
'reservations.airtrail.notSynced': 'Не синхронізовано', 'reservations.airtrail.notSynced': 'Не синхронізовано',
'reservations.airtrail.notSyncedHint': 'Цей рейс було видалено в AirTrail і він більше не синхронізується.', 'reservations.airtrail.notSyncedHint':
'reservations.airtrail.loadError': 'Не вдалося завантажити ваші рейси з AirTrail.', 'Цей рейс було видалено в AirTrail і він більше не синхронізується.',
'reservations.airtrail.loadError':
'Не вдалося завантажити ваші рейси з AirTrail.',
'reservations.airtrail.imported': '{count} рейс(ів) імпортовано', 'reservations.airtrail.imported': '{count} рейс(ів) імпортовано',
'reservations.airtrail.skippedDuplicate': '{count} вже в цій подорожі, пропущено', 'reservations.airtrail.skippedDuplicate':
'{count} вже в цій подорожі, пропущено',
'reservations.airtrail.nothingImported': 'Немає чого імпортувати.', 'reservations.airtrail.nothingImported': 'Немає чого імпортувати.',
'reservations.airtrail.importError': 'Імпорт не вдався. Спробуйте ще раз.', 'reservations.airtrail.importError': 'Імпорт не вдався. Спробуйте ще раз.',
'reservations.airtrail.undo': 'Імпорт з AirTrail', 'reservations.airtrail.undo': 'Імпорт з AirTrail',
'reservations.airtrail.alreadyImported': 'Імпортовано', 'reservations.airtrail.alreadyImported': 'Імпортовано',
'reservations.airtrail.duringTrip': 'Під час цієї подорожі', 'reservations.airtrail.duringTrip': 'Під час цієї подорожі',
'reservations.airtrail.otherFlights': 'Інші рейси', 'reservations.airtrail.otherFlights': 'Інші рейси',
'reservations.airtrail.empty': 'У вашому акаунті AirTrail не знайдено рейсів.', 'reservations.airtrail.empty':
'У вашому акаунті AirTrail не знайдено рейсів.',
'reservations.airtrail.importCta': 'Імпортувати {count}', '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; export default reservations;
+14 -4
View File
@@ -125,7 +125,8 @@ const reservations: TranslationStrings = {
'reservations.import.cta': '從檔案匯入', 'reservations.import.cta': '從檔案匯入',
'reservations.import.dropHere': '將訂位確認檔案拖放到此處,或點擊選擇', 'reservations.import.dropHere': '將訂位確認檔案拖放到此處,或點擊選擇',
'reservations.import.dropActive': '放開檔案以匯入', '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.parsing': '正在解析檔案…',
'reservations.import.previewHeading': '找到 {count} 筆預訂', 'reservations.import.previewHeading': '找到 {count} 筆預訂',
'reservations.import.previewEmpty': '無法從上傳的檔案中提取任何預訂資訊。', 'reservations.import.previewEmpty': '無法從上傳的檔案中提取任何預訂資訊。',
@@ -136,14 +137,17 @@ const reservations: TranslationStrings = {
'reservations.import.partialFailure': '已匯入 {created} 筆,{failed} 筆失敗', 'reservations.import.partialFailure': '已匯入 {created} 筆,{failed} 筆失敗',
'reservations.import.error': '解析失敗。請確保檔案是有效的訂位確認。', 'reservations.import.error': '解析失敗。請確保檔案是有效的訂位確認。',
'reservations.import.unavailable': '此伺服器上的預訂匯入功能不可用。', '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.import.fileTooLarge': '檔案「{name}」超過 10 MB 限制。',
'reservations.airtrail.title': '從 AirTrail 匯入', 'reservations.airtrail.title': '從 AirTrail 匯入',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': '已從 AirTrail 同步——編輯會雙向保持同步。', 'reservations.airtrail.syncedHint':
'已從 AirTrail 同步——編輯會雙向保持同步。',
'reservations.airtrail.notSynced': '未同步', 'reservations.airtrail.notSynced': '未同步',
'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中移除,不再同步。', 'reservations.airtrail.notSyncedHint':
'此航班已在 AirTrail 中移除,不再同步。',
'reservations.airtrail.loadError': '無法載入你的 AirTrail 航班。', 'reservations.airtrail.loadError': '無法載入你的 AirTrail 航班。',
'reservations.airtrail.imported': '已匯入 {count} 筆航班', 'reservations.airtrail.imported': '已匯入 {count} 筆航班',
'reservations.airtrail.skippedDuplicate': '{count} 筆已在此行程中,已略過', 'reservations.airtrail.skippedDuplicate': '{count} 筆已在此行程中,已略過',
@@ -155,5 +159,11 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '其他航班', 'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。', 'reservations.airtrail.empty': '在你的 AirTrail 帳戶中找不到任何航班。',
'reservations.airtrail.importCta': '匯入 {count}', '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; export default reservations;
+14 -4
View File
@@ -125,7 +125,8 @@ const reservations: TranslationStrings = {
'reservations.import.cta': '从文件导入', 'reservations.import.cta': '从文件导入',
'reservations.import.dropHere': '将预订确认文件拖放到此处,或点击选择', 'reservations.import.dropHere': '将预订确认文件拖放到此处,或点击选择',
'reservations.import.dropActive': '松开文件以导入', '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.parsing': '正在解析文件…',
'reservations.import.previewHeading': '找到 {count} 个预订', 'reservations.import.previewHeading': '找到 {count} 个预订',
'reservations.import.previewEmpty': '无法从上传的文件中提取任何预订信息。', 'reservations.import.previewEmpty': '无法从上传的文件中提取任何预订信息。',
@@ -136,14 +137,17 @@ const reservations: TranslationStrings = {
'reservations.import.partialFailure': '已导入 {created} 个,{failed} 个失败', 'reservations.import.partialFailure': '已导入 {created} 个,{failed} 个失败',
'reservations.import.error': '解析失败。请确保文件是有效的预订确认。', 'reservations.import.error': '解析失败。请确保文件是有效的预订确认。',
'reservations.import.unavailable': '此服务器上的预订导入功能不可用。', '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.import.fileTooLarge': '文件"{name}"超过 10 MB 限制。',
'reservations.airtrail.title': '从 AirTrail 导入', 'reservations.airtrail.title': '从 AirTrail 导入',
'reservations.airtrail.cta': 'AirTrail', 'reservations.airtrail.cta': 'AirTrail',
'reservations.airtrail.synced': 'AirTrail', 'reservations.airtrail.synced': 'AirTrail',
'reservations.airtrail.syncedHint': '已从 AirTrail 同步——编辑会双向保持同步。', 'reservations.airtrail.syncedHint':
'已从 AirTrail 同步——编辑会双向保持同步。',
'reservations.airtrail.notSynced': '未同步', 'reservations.airtrail.notSynced': '未同步',
'reservations.airtrail.notSyncedHint': '此航班已在 AirTrail 中删除,不再同步。', 'reservations.airtrail.notSyncedHint':
'此航班已在 AirTrail 中删除,不再同步。',
'reservations.airtrail.loadError': '无法加载您的 AirTrail 航班。', 'reservations.airtrail.loadError': '无法加载您的 AirTrail 航班。',
'reservations.airtrail.imported': '已导入 {count} 个航班', 'reservations.airtrail.imported': '已导入 {count} 个航班',
'reservations.airtrail.skippedDuplicate': '{count} 个已在此行程中,已跳过', 'reservations.airtrail.skippedDuplicate': '{count} 个已在此行程中,已跳过',
@@ -155,5 +159,11 @@ const reservations: TranslationStrings = {
'reservations.airtrail.otherFlights': '其他航班', 'reservations.airtrail.otherFlights': '其他航班',
'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。', 'reservations.airtrail.empty': '您的 AirTrail 账户中未找到航班。',
'reservations.airtrail.importCta': '导入 {count}', '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; export default reservations;