feat(costs): create an expense from a booking, fix editing total-only items

Replace the inline price + budget-category fields in the Transport and
Reservation booking modals with a "Create expense" flow: the modal saves the
booking, then opens the full Costs editor prefilled (name + category mapped from
the booking type) and linked to the reservation. A booking with a linked expense
shows it inline with edit / remove.

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

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