diff --git a/client/src/components/Budget/CostsPanel.tsx b/client/src/components/Budget/CostsPanel.tsx index 8663bedb..bc3f778c 100644 --- a/client/src/components/Budget/CostsPanel.tsx +++ b/client/src/components/Budget/CostsPanel.tsx @@ -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(editing ? catMeta(editing.category).key : 'food') + const [name, setName] = useState(editing?.name || prefill?.name || '') + const [cat, setCat] = useState(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>(() => { @@ -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(() => { + 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>(() => 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 }: {
{sym(currency)} - {payersTotal.toFixed(2)} + {hasPayers ? ( + {payersTotal.toFixed(2)} + ) : ( + setAmount(e.target.value)} + className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} /> + )}
@@ -744,11 +772,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
- {currency !== base && payersTotal > 0 && ( + {currency !== base && total > 0 && (
- {formatMoney(payersTotal, currency, locale)} + {formatMoney(total, currency, locale)} - {formatMoney(convert(payersTotal, currency), base, locale)} + {formatMoney(convert(total, currency), base, locale)} · {t('costs.liveRate')}
)} diff --git a/client/src/components/Budget/costsCategories.tsx b/client/src/components/Budget/costsCategories.tsx index 1a6a619f..be649f85 100644 --- a/client/src/components/Budget/costsCategories.tsx +++ b/client/src/components/Budget/costsCategories.tsx @@ -32,8 +32,32 @@ export const COST_CAT_META: Record = { 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 = { + 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 } diff --git a/client/src/components/Planner/BookingCostsSection.tsx b/client/src/components/Planner/BookingCostsSection.tsx new file mode 100644 index 00000000..1683b895 --- /dev/null +++ b/client/src/components/Planner/BookingCostsSection.tsx @@ -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 ( +
+ +
+ +
+
{linked.name}
+
{t(meta.labelKey)}
+
+ {formatMoney(linked.total_price, linked.currency || base, locale)} + + +
+
+ ) + } + + return ( +
+ + +
{t('reservations.createExpenseHint')}
+
+ ) +} diff --git a/client/src/components/Planner/BookingCostsSection.types.ts b/client/src/components/Planner/BookingCostsSection.types.ts new file mode 100644 index 00000000..a4805e4a --- /dev/null +++ b/client/src/components/Planner/BookingCostsSection.types.ts @@ -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 } +} diff --git a/client/src/components/Planner/ReservationModal.test.tsx b/client/src/components/Planner/ReservationModal.test.tsx index c0403f5d..a4d1209c 100644 --- a/client/src/components/Planner/ReservationModal.test.tsx +++ b/client/src/components/Planner/ReservationModal.test.tsx @@ -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(); - 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(); - 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(); - 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(); + const onSave = vi.fn().mockResolvedValue({ id: 55 }); + const onOpenExpense = vi.fn(); + render(); 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(); + 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(); - // Budget section is visible - expect(screen.getByText(/Budget category/i)).toBeInTheDocument(); - }); - it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => { render(); 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(); - - // 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(); const attachBtn = screen.getByRole('button', { name: /Attach file/i }); diff --git a/client/src/components/Planner/ReservationModal.tsx b/client/src/components/Planner/ReservationModal.tsx index 11cc923f..10c320ec 100644 --- a/client/src/components/Planner/ReservationModal.tsx +++ b/client/src/components/Planner/ReservationModal.tsx @@ -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 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() - 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 & { 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 - {/* Price + Budget Category */} + {/* Costs — create / view the expense linked to this booking */} {isBudgetEnabled && ( - <> -
-
- - { 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} /> -
-
- - set('budget_category', v)} - options={[ - { value: '', label: t('reservations.budgetCategoryAuto') }, - ...budgetCategories.map(c => ({ value: c, label: c })), - ]} - placeholder={t('reservations.budgetCategoryAuto')} - size="sm" - /> -
-
- {form.price && parseFloat(form.price) > 0 && ( -
- {t('reservations.budgetHint')} -
- )} - + )} diff --git a/client/src/components/Planner/TransportModal.test.tsx b/client/src/components/Planner/TransportModal.test.tsx index 71829e72..bdeb0c09 100644 --- a/client/src/components/Planner/TransportModal.test.tsx +++ b/client/src/components/Planner/TransportModal.test.tsx @@ -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(); - 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(); - 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(); + const onSave = vi.fn().mockResolvedValue({ id: 42 }); + const onOpenExpense = vi.fn(); + render(); 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 }) }) + ) ); }); diff --git a/client/src/components/Planner/TransportModal.tsx b/client/src/components/Planner/TransportModal.tsx index ba755cc4..74d3ab48 100644 --- a/client/src/components/Planner/TransportModal.tsx +++ b/client/src/components/Planner/TransportModal.tsx @@ -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 onFileDelete?: (fileId: number) => Promise + 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() - 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({}) @@ -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[] = [] @@ -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) => { const file = e.target.files?.[0] if (!file) return @@ -712,38 +715,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel - {/* Price + Budget Category */} + {/* Costs — create / view the expense linked to this booking */} {isBudgetEnabled && ( - <> -
-
- - { 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} /> -
-
- - set('budget_category', v)} - options={[ - { value: '', label: t('reservations.budgetCategoryAuto') }, - ...budgetCategories.map(c => ({ value: c, label: c })), - ]} - placeholder={t('reservations.budgetCategoryAuto')} - size="sm" - /> -
-
- {form.price && parseFloat(form.price) > 0 && ( -
- {t('reservations.budgetHint')} -
- )} - + )} diff --git a/client/src/pages/TripPlannerPage.tsx b/client/src/pages/TripPlannerPage.tsx index bbf63c28..f9283079 100644 --- a/client/src/pages/TripPlannerPage.tsx +++ b/client/src/pages/TripPlannerPage.tsx @@ -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(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 (
{ 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)} /> setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} /> setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} /> - { 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 && { 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)} />} + { 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 && { 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 && ( + setBookingExpense(null)} + onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }} + /> + )} setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} /> setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} /> >; type BudgetEntry = { total_price?: number; category?: string } | undefined; @@ -77,30 +78,51 @@ export class ReservationsService { /** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */ syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void { - if (!entry || !entry.total_price) { + // When the booking type changes, keep a linked expense's category in sync — + // but only if it still carries the auto-derived category (so a manual pick in + // the Costs editor is preserved). Runs regardless of create_budget_entry. + if (type && currentType && type !== currentType) { + const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined; + if (linked) { + const oldCat = typeToCostCategory(currentType); + const newCat = typeToCostCategory(type); + if (oldCat !== newCat && linked.category === oldCat) { + const updated = updateBudgetItem(linked.id, tripId, { category: newCat }); + broadcast(tripId, 'budget:updated', { item: updated }, socketId); + } + } + } + + // No budget entry on the payload — the booking edit isn't touching its linked + // expense, so leave any linked item alone. Expenses are managed from the + // booking's Costs section / the Costs tab, not by re-saving the booking. + if (!entry) return; + + if (!(Number(entry.total_price) > 0)) { + // Explicit clear (total_price 0/empty) — drop the linked item. const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; if (linked) { deleteBudgetItem(linked.id, tripId); broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId); } + return; } - if (entry && Number(entry.total_price) > 0) { - try { - const itemName = title || currentTitle; - const category = entry.category || type || currentType || 'Other'; - const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; - if (existing) { - const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); - broadcast(tripId, 'budget:updated', { item: updated }, socketId); - } else { - const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); - db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id); - item.reservation_id = Number(id); - broadcast(tripId, 'budget:created', { item }, socketId); - } - } catch (err) { - console.error('[reservations] Failed to create/update budget entry:', err); + + try { + const itemName = title || currentTitle; + const category = entry.category || type || currentType || 'Other'; + const existing = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined; + if (existing) { + const updated = updateBudgetItem(existing.id, tripId, { name: itemName, category, total_price: entry.total_price }); + broadcast(tripId, 'budget:updated', { item: updated }, socketId); + } else { + const item = createBudgetItem(tripId, { name: itemName, category, total_price: entry.total_price }); + db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(id, item.id); + item.reservation_id = Number(id); + broadcast(tripId, 'budget:created', { item }, socketId); } + } catch (err) { + console.error('[reservations] Failed to create/update budget entry:', err); } } diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts index 8bffe934..682772a7 100644 --- a/server/src/services/budgetService.ts +++ b/server/src/services/budgetService.ts @@ -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)'); diff --git a/server/tests/integration/reservations.test.ts b/server/tests/integration/reservations.test.ts index 2e8dc0e3..b86a28d5 100644 --- a/server/tests/integration/reservations.test.ts +++ b/server/tests/integration/reservations.test.ts @@ -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', () => { diff --git a/shared/src/budget/budget.schema.ts b/shared/src/budget/budget.schema.ts index 717915bf..87701a0d 100644 --- a/shared/src/budget/budget.schema.ts +++ b/shared/src/budget/budget.schema.ts @@ -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 = { + 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 diff --git a/shared/src/i18n/ar/reservations.ts b/shared/src/i18n/ar/reservations.ts index 779cb8f1..3e645a82 100644 --- a/shared/src/i18n/ar/reservations.ts +++ b/shared/src/i18n/ar/reservations.ts @@ -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; diff --git a/shared/src/i18n/br/reservations.ts b/shared/src/i18n/br/reservations.ts index f64891a8..9891b9af 100644 --- a/shared/src/i18n/br/reservations.ts +++ b/shared/src/i18n/br/reservations.ts @@ -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; diff --git a/shared/src/i18n/cs/reservations.ts b/shared/src/i18n/cs/reservations.ts index 49737aef..7ea7eebb 100644 --- a/shared/src/i18n/cs/reservations.ts +++ b/shared/src/i18n/cs/reservations.ts @@ -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; diff --git a/shared/src/i18n/de/reservations.ts b/shared/src/i18n/de/reservations.ts index 499d0cc4..21591b11 100644 --- a/shared/src/i18n/de/reservations.ts +++ b/shared/src/i18n/de/reservations.ts @@ -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; diff --git a/shared/src/i18n/en/reservations.ts b/shared/src/i18n/en/reservations.ts index aaa36236..a1bc63eb 100644 --- a/shared/src/i18n/en/reservations.ts +++ b/shared/src/i18n/en/reservations.ts @@ -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; diff --git a/shared/src/i18n/es/reservations.ts b/shared/src/i18n/es/reservations.ts index b0222160..9621b64c 100644 --- a/shared/src/i18n/es/reservations.ts +++ b/shared/src/i18n/es/reservations.ts @@ -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; diff --git a/shared/src/i18n/fr/reservations.ts b/shared/src/i18n/fr/reservations.ts index e69a09fc..0f6a10d1 100644 --- a/shared/src/i18n/fr/reservations.ts +++ b/shared/src/i18n/fr/reservations.ts @@ -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; diff --git a/shared/src/i18n/gr/reservations.ts b/shared/src/i18n/gr/reservations.ts index 2f56a769..194d217e 100644 --- a/shared/src/i18n/gr/reservations.ts +++ b/shared/src/i18n/gr/reservations.ts @@ -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; diff --git a/shared/src/i18n/hu/reservations.ts b/shared/src/i18n/hu/reservations.ts index a60eb475..838c038d 100644 --- a/shared/src/i18n/hu/reservations.ts +++ b/shared/src/i18n/hu/reservations.ts @@ -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; diff --git a/shared/src/i18n/id/reservations.ts b/shared/src/i18n/id/reservations.ts index 5a3ca2fd..5246f80e 100644 --- a/shared/src/i18n/id/reservations.ts +++ b/shared/src/i18n/id/reservations.ts @@ -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; diff --git a/shared/src/i18n/it/reservations.ts b/shared/src/i18n/it/reservations.ts index b38efd37..b55b8ee9 100644 --- a/shared/src/i18n/it/reservations.ts +++ b/shared/src/i18n/it/reservations.ts @@ -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; diff --git a/shared/src/i18n/ja/reservations.ts b/shared/src/i18n/ja/reservations.ts index 73e630f0..906dd01f 100644 --- a/shared/src/i18n/ja/reservations.ts +++ b/shared/src/i18n/ja/reservations.ts @@ -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; diff --git a/shared/src/i18n/ko/reservations.ts b/shared/src/i18n/ko/reservations.ts index 544c8a8d..e99f0134 100644 --- a/shared/src/i18n/ko/reservations.ts +++ b/shared/src/i18n/ko/reservations.ts @@ -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; diff --git a/shared/src/i18n/nl/reservations.ts b/shared/src/i18n/nl/reservations.ts index 40615f14..d39bb2a8 100644 --- a/shared/src/i18n/nl/reservations.ts +++ b/shared/src/i18n/nl/reservations.ts @@ -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; diff --git a/shared/src/i18n/pl/reservations.ts b/shared/src/i18n/pl/reservations.ts index 3a20a7a3..e280d7e5 100644 --- a/shared/src/i18n/pl/reservations.ts +++ b/shared/src/i18n/pl/reservations.ts @@ -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; diff --git a/shared/src/i18n/ru/reservations.ts b/shared/src/i18n/ru/reservations.ts index dc0aceda..be6af868 100644 --- a/shared/src/i18n/ru/reservations.ts +++ b/shared/src/i18n/ru/reservations.ts @@ -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; diff --git a/shared/src/i18n/tr/reservations.ts b/shared/src/i18n/tr/reservations.ts index ba661801..f4974e3b 100644 --- a/shared/src/i18n/tr/reservations.ts +++ b/shared/src/i18n/tr/reservations.ts @@ -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; diff --git a/shared/src/i18n/uk/reservations.ts b/shared/src/i18n/uk/reservations.ts index 9cc10173..7074c77a 100644 --- a/shared/src/i18n/uk/reservations.ts +++ b/shared/src/i18n/uk/reservations.ts @@ -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; diff --git a/shared/src/i18n/zh-TW/reservations.ts b/shared/src/i18n/zh-TW/reservations.ts index 638524a8..78913708 100644 --- a/shared/src/i18n/zh-TW/reservations.ts +++ b/shared/src/i18n/zh-TW/reservations.ts @@ -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; diff --git a/shared/src/i18n/zh/reservations.ts b/shared/src/i18n/zh/reservations.ts index 85b91a64..14497b4f 100644 --- a/shared/src/i18n/zh/reservations.ts +++ b/shared/src/i18n/zh/reservations.ts @@ -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;