mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-08-14 08:26:39 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)');
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user