mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Compare commits
9 Commits
2d79254c33
...
c15c89ca61
| Author | SHA1 | Date | |
|---|---|---|---|
| c15c89ca61 | |||
| f98058a3af | |||
| 39a3ee7ce7 | |||
| e09849d5b4 | |||
| b3fc5411ca | |||
| f524909008 | |||
| 264cf7d384 | |||
| cb7ce7f229 | |||
| d40c5ce7a6 |
@@ -85,6 +85,10 @@ COPY --from=server-builder /app/server/dist ./server/dist
|
||||
COPY --from=server-builder /app/server/assets ./server/assets
|
||||
# tsconfig-paths/register reads this at runtime to resolve MCP SDK paths.
|
||||
COPY server/tsconfig.json ./server/
|
||||
# Encryption-key rotation is run on demand via tsx (a prod dep) straight from the
|
||||
# raw .ts source — it never enters dist, so it must be copied in explicitly or
|
||||
# `node --import tsx scripts/migrate-encryption.ts` fails with module-not-found.
|
||||
COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
@@ -662,8 +662,15 @@ function SettleHistory({ settlements, fmt, Avatar, name, onUndo, canEdit }: {
|
||||
}
|
||||
|
||||
// ── Add / edit expense modal ───────────────────────────────────────────────
|
||||
function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; onClose: () => void; onSaved: () => void
|
||||
export interface ExpensePrefill {
|
||||
name?: string
|
||||
category?: string
|
||||
amount?: number
|
||||
reservationId?: number
|
||||
}
|
||||
|
||||
export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClose, onSaved }: {
|
||||
tripId: number; base: string; people: TripMember[]; me: number; editing: BudgetItem | null; prefill?: ExpensePrefill; onClose: () => void; onSaved: () => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
@@ -671,8 +678,8 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
const { convert } = useExchangeRates(base)
|
||||
const sym = (c: string) => SYMBOLS[c] || (c + ' ')
|
||||
|
||||
const [name, setName] = useState(editing?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : 'food')
|
||||
const [name, setName] = useState(editing?.name || prefill?.name || '')
|
||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||
const [payers, setPayers] = useState<Record<number, string>>(() => {
|
||||
@@ -680,13 +687,23 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
for (const p of editing?.payers || []) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
// Standalone total for "recorded amount, nobody has paid yet" expenses (created
|
||||
// from a booking, or pre-rework items). Used only while no per-person amount is
|
||||
// entered; once a payer has an amount, the total derives from the payers.
|
||||
const [amount, setAmount] = useState<string>(() => {
|
||||
if (editing && !(editing.payers && editing.payers.length > 0)) return editing.total_price ? String(editing.total_price) : ''
|
||||
if (prefill?.amount != null) return String(prefill.amount)
|
||||
return ''
|
||||
})
|
||||
const [split, setSplit] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const payersTotal = Object.values(payers).reduce((a, v) => a + (parseFloat(v) || 0), 0)
|
||||
const each = split.size > 0 ? payersTotal / split.size : 0
|
||||
const valid = name.trim().length > 0 && split.size > 0 && payersTotal > 0
|
||||
const hasPayers = payersTotal > 0
|
||||
const total = hasPayers ? payersTotal : (parseFloat(amount) || 0)
|
||||
const each = split.size > 0 ? total / split.size : 0
|
||||
const valid = name.trim().length > 0 && total > 0 && (hasPayers ? split.size > 0 : true)
|
||||
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
@@ -699,6 +716,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
currency,
|
||||
payers: payerList, member_ids: [...split],
|
||||
expense_date: day || null,
|
||||
// No per-person amounts: record the typed total directly (the server keeps
|
||||
// it instead of deriving 0 from the empty payer list).
|
||||
...(payerList.length === 0 ? { total_price: parseFloat(amount) || 0 } : {}),
|
||||
// Link a freshly-created expense to its booking (create-from-booking flow).
|
||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||
}
|
||||
try {
|
||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||
@@ -728,7 +750,13 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
||||
<div className="bg-surface-input border border-edge" style={{ height: FIELD_H, boxSizing: 'border-box', display: 'flex', alignItems: 'center', borderRadius: 10, padding: '0 12px' }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||
{hasPayers ? (
|
||||
<span className="text-content" style={{ flex: 1, fontSize: 15, fontWeight: 600, paddingLeft: 6 }}>{payersTotal.toFixed(2)}</span>
|
||||
) : (
|
||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
||||
onChange={e => setAmount(e.target.value)}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
@@ -744,11 +772,11 @@ function ExpenseModal({ tripId, base, people, me, editing, onClose, onSaved }: {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currency !== base && payersTotal > 0 && (
|
||||
{currency !== base && total > 0 && (
|
||||
<div className="bg-surface-secondary border border-edge text-content-muted" style={{ borderRadius: 10, padding: '10px 12px', fontSize: 12.5, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
|
||||
<span>{formatMoney(payersTotal, currency, locale)}</span>
|
||||
<span>{formatMoney(total, currency, locale)}</span>
|
||||
<span className="text-content-faint">≈</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(payersTotal, currency), base, locale)}</span>
|
||||
<span className="text-content" style={{ fontWeight: 600 }}>{formatMoney(convert(total, currency), base, locale)}</span>
|
||||
<span className="text-content-faint">· {t('costs.liveRate')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -32,8 +32,32 @@ export const COST_CAT_META: Record<CostCategory, CostCategoryMeta> = {
|
||||
|
||||
export const COST_CATEGORY_LIST: CostCategoryMeta[] = COST_CATEGORIES.map(k => COST_CAT_META[k])
|
||||
|
||||
/** Map any stored category (incl. legacy free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (cat && cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
return COST_CAT_META.other
|
||||
/**
|
||||
* Legacy / English free-text categories (and reservation type labels) mapped to
|
||||
* the fixed keys. Bookings used to store labels like "Flight"/"Train"/"Other",
|
||||
* which never matched the lowercase keys and fell through to `other`.
|
||||
*/
|
||||
const LEGACY_CATEGORY_MAP: Record<string, CostCategory> = {
|
||||
flight: 'flights', flights: 'flights', plane: 'flights', flug: 'flights',
|
||||
train: 'transport', bus: 'transport', car: 'transport', 'car rental': 'transport',
|
||||
ferry: 'transport', boat: 'transport', taxi: 'transport', transfer: 'transport',
|
||||
transport: 'transport', transportation: 'transport',
|
||||
hotel: 'accommodation', accommodation: 'accommodation', lodging: 'accommodation', hostel: 'accommodation',
|
||||
restaurant: 'food', food: 'food', dining: 'food', meal: 'food', meals: 'food',
|
||||
grocery: 'groceries', groceries: 'groceries',
|
||||
activity: 'activities', activities: 'activities',
|
||||
sightseeing: 'sightseeing', sights: 'sightseeing',
|
||||
shop: 'shopping', shopping: 'shopping',
|
||||
fee: 'fees', fees: 'fees',
|
||||
health: 'health', medical: 'health',
|
||||
tip: 'tips', tips: 'tips',
|
||||
other: 'other', misc: 'other',
|
||||
}
|
||||
|
||||
/** Map any stored category (incl. legacy/localized free-text values) to a known meta. */
|
||||
export function catMeta(cat: string | null | undefined): CostCategoryMeta {
|
||||
if (!cat) return COST_CAT_META.other
|
||||
if (cat in COST_CAT_META) return COST_CAT_META[cat as CostCategory]
|
||||
const mapped = LEGACY_CATEGORY_MAP[cat.trim().toLowerCase()]
|
||||
return mapped ? COST_CAT_META[mapped] : COST_CAT_META.other
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react'
|
||||
import { useTripStore } from '../../store/tripStore'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useTranslation } from '../../i18n'
|
||||
import { formatMoney } from '../../utils/formatters'
|
||||
import { catMeta } from '../Budget/costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* The Costs block inside a booking modal. Replaces the old inline price + budget
|
||||
* category fields: when no expense is linked yet it offers a "create expense"
|
||||
* button (the modal saves the booking first, then opens the full Costs editor);
|
||||
* once linked it shows the expense with edit / remove actions.
|
||||
*/
|
||||
export function BookingCostsSection({ reservationId, onCreate, onEdit, onRemove }: {
|
||||
reservationId: number | null
|
||||
onCreate: () => void
|
||||
onEdit: (item: BudgetItem) => void
|
||||
onRemove: (item: BudgetItem) => void
|
||||
}) {
|
||||
const { t, locale } = useTranslation()
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const trip = useTripStore(s => s.trip)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const base = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const linked = reservationId ? budgetItems.find(i => i.reservation_id === reservationId) : null
|
||||
|
||||
const labelCls = 'block text-[11px] font-semibold uppercase tracking-[0.08em] text-content-faint mb-[6px]'
|
||||
|
||||
if (linked) {
|
||||
const meta = catMeta(linked.category)
|
||||
const Icon = meta.Icon
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.linkedExpense')}</label>
|
||||
<div className="bg-surface-secondary border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 12px', borderRadius: 10 }}>
|
||||
<span style={{ width: 26, height: 26, borderRadius: 7, display: 'grid', placeItems: 'center', background: meta.color + '22', color: meta.color, flexShrink: 0 }}><Icon size={14} /></span>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="text-content" style={{ fontSize: 14, fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{linked.name}</div>
|
||||
<div className="text-content-faint" style={{ fontSize: 12 }}>{t(meta.labelKey)}</div>
|
||||
</div>
|
||||
<span className="text-content" style={{ fontSize: 14, fontWeight: 700, flexShrink: 0 }}>{formatMoney(linked.total_price, linked.currency || base, locale)}</span>
|
||||
<button type="button" onClick={() => onEdit(linked)} title={t('common.edit')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Pencil size={13} /></button>
|
||||
<button type="button" onClick={() => onRemove(linked)} title={t('reservations.removeExpense')} className="text-content-muted border border-edge bg-surface-card" style={{ display: 'inline-flex', padding: 7, borderRadius: 8, cursor: 'pointer' }}><Trash2 size={13} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className={labelCls}>{t('reservations.costsLabel')}</label>
|
||||
<button type="button" onClick={onCreate}
|
||||
className="bg-surface-secondary border border-edge text-content"
|
||||
style={{ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 8, padding: '11px 13px', borderRadius: 10, fontSize: 13.5, fontWeight: 600, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||
<Plus size={15} /> {t('reservations.createExpense')}
|
||||
</button>
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: 6 }}>{t('reservations.createExpenseHint')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { BudgetItem } from '../../types'
|
||||
|
||||
/**
|
||||
* A request from a booking modal to open the Costs expense editor — either to
|
||||
* edit the already-linked expense, or to create a new one prefilled from the
|
||||
* booking (the modal saves the booking first so `reservationId` is known).
|
||||
*/
|
||||
export interface BookingExpenseRequest {
|
||||
editItem?: BudgetItem
|
||||
prefill?: { reservationId?: number; name?: string; category?: string; amount?: number }
|
||||
}
|
||||
@@ -343,56 +343,51 @@ describe('ReservationModal', () => {
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-024: budget section visible when budget addon is enabled', () => {
|
||||
it('FE-PLANNER-RESMODAL-024: costs section (create expense) visible when budget addon is enabled', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-025: budget price input accepts valid decimal', async () => {
|
||||
it('FE-PLANNER-RESMODAL-025: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const priceInput = screen.getByPlaceholderText('0.00');
|
||||
await userEvent.type(priceInput, '99.99');
|
||||
expect((priceInput as HTMLInputElement).value).toBe('99.99');
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-026: budget hint shown when price > 0', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const priceInput = screen.getByPlaceholderText('0.00');
|
||||
await userEvent.type(priceInput, '50');
|
||||
expect(screen.getByText(/budget entry will be created/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-027: budget fields included in onSave when price is set', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} />);
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 55 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<ReservationModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'Hotel Paris');
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '120');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 120 }) })
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 55 }) })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-026: linked expense summary shown for a booking with a linked cost', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 7, trip_id: 1, name: 'Hotel deposit', total_price: 120, currency: 'EUR', category: 'accommodation', reservation_id: 9, members: [], payers: [], persons: 1, expense_date: null, paid_by_user_id: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} reservation={buildReservation({ id: 9, type: 'hotel', title: 'Hotel Paris' })} />);
|
||||
expect(screen.getByText('Hotel deposit')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── File upload ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-RESMODAL-028: pending file added for new reservation on file input change', async () => {
|
||||
@@ -599,22 +594,6 @@ describe('ReservationModal', () => {
|
||||
expect(filePickerItem).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-044: budget category dropdown options include existing categories', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 1, trip_id: 1, name: 'Flight ticket', total_price: 300, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
// Budget section is visible
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-045: tour type shows time pickers', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Tour$/i }));
|
||||
@@ -632,31 +611,6 @@ describe('ReservationModal', () => {
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ type: 'other' })));
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-047: clicking budget category select changes the value', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
seedStore(useTripStore, {
|
||||
trip: buildTrip({ id: 1 }),
|
||||
budgetItems: [
|
||||
{ id: 1, trip_id: 1, name: 'Ticket', total_price: 100, category: 'Transport', paid_by_user_id: null, persons: 1, members: [], expense_date: null },
|
||||
],
|
||||
});
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
|
||||
// Open the budget category CustomSelect (shows placeholder "Auto (from booking type)")
|
||||
const budgetCategoryBtn = screen.getByText(/Auto \(from booking type\)/i).closest('button')!;
|
||||
await userEvent.click(budgetCategoryBtn);
|
||||
|
||||
// Click the "Transport" category option
|
||||
await waitFor(() => expect(screen.getByText('Transport')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByText('Transport'));
|
||||
|
||||
// The select should now show "Transport"
|
||||
expect(screen.getByText('Transport')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-RESMODAL-048: clicking attach file button triggers file input', async () => {
|
||||
render(<ReservationModal {...defaultProps} />);
|
||||
const attachBtn = screen.getByRole('button', { name: /Attach file/i });
|
||||
|
||||
@@ -11,7 +11,10 @@ import { useTranslation } from '../../i18n'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import CustomTimePicker from '../shared/CustomTimePicker'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation } from '../../types'
|
||||
import type { Day, Place, Reservation, TripFile, AssignmentsMap, Accommodation, BudgetItem } from '../../types'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: 'hotel', labelKey: 'reservations.type.hotel', Icon: Hotel },
|
||||
@@ -60,9 +63,10 @@ interface ReservationModalProps {
|
||||
onFileDelete: (fileId: number) => Promise<void>
|
||||
accommodations?: Accommodation[]
|
||||
defaultAssignmentId?: number | null
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
}
|
||||
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null }: ReservationModalProps) {
|
||||
export function ReservationModal({ isOpen, onClose, onSave, reservation, days, places, assignments, selectedDayId, files = [], onFileUpload, onFileDelete, accommodations = [], defaultAssignmentId = null, onOpenExpense }: ReservationModalProps) {
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const toast = useToast()
|
||||
@@ -70,18 +74,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
// Set right before submit when the user clicked create/edit expense (see TransportModal).
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
|
||||
const [form, setForm] = useState({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: '' as string | number, accommodation_id: '' as string | number,
|
||||
price: '', budget_category: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '' as string | number, hotel_start_day: '' as string | number, hotel_end_day: '' as string | number,
|
||||
})
|
||||
@@ -127,15 +127,12 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
hotel_place_id: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.place_id || '' })(),
|
||||
hotel_start_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.start_day_id || '' })(),
|
||||
hotel_end_day: (() => { const acc = accommodations.find(a => a.id == reservation.accommodation_id); return acc?.end_day_id || '' })(),
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
} else {
|
||||
setForm({
|
||||
title: '', type: 'other', status: 'pending',
|
||||
reservation_time: '', reservation_end_time: '', end_date: '', location: '', confirmation_number: '',
|
||||
notes: '', assignment_id: defaultAssignmentId ?? '', accommodation_id: '',
|
||||
price: '', budget_category: '',
|
||||
meta_check_in_time: '', meta_check_in_end_time: '', meta_check_out_time: '',
|
||||
hotel_place_id: '', hotel_start_day: '', hotel_end_day: '',
|
||||
})
|
||||
@@ -167,8 +164,8 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
return endFull <= startFull
|
||||
})()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e?: { preventDefault?: () => void }) => {
|
||||
e?.preventDefault?.()
|
||||
if (!form.title.trim()) return
|
||||
if (isEndBeforeStart) { toast.error(t('reservations.validation.endBeforeStart')); return }
|
||||
setIsSaving(true)
|
||||
@@ -185,11 +182,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
} else if (form.reservation_end_time && form.reservation_time) {
|
||||
combinedEndTime = `${form.reservation_time.split('T')[0]}T${form.reservation_end_time}`
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const saveData: Record<string, any> & { title: string } = {
|
||||
title: form.title, type: form.type, status: form.status,
|
||||
reservation_time: form.type === 'hotel' ? null : (form.reservation_time || null),
|
||||
@@ -202,11 +194,6 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
endpoints: [],
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
saveData.create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
if (form.type === 'hotel' && form.hotel_start_day && form.hotel_end_day) {
|
||||
saveData.create_accommodation = {
|
||||
place_id: form.hotel_place_id || null,
|
||||
@@ -228,11 +215,25 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
// Open the Costs editor for the saved booking when the user asked to
|
||||
// create/edit its linked expense (gated on saved?.id).
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
if (intent && onOpenExpense && saved?.id) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleFileChange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
@@ -610,38 +611,14 @@ export function ReservationModal({ isOpen, onClose, onSave, reservation, days, p
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -132,34 +132,37 @@ describe('TransportModal', () => {
|
||||
|
||||
// ── Budget addon ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-011: budget section visible when addon is enabled', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-011: costs section (create expense) visible when budget addon is enabled', () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.getByText(/^Price$/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Budget category/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Create expense/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-012: budget section not shown when addon is disabled', () => {
|
||||
it('FE-PLANNER-TRANSMODAL-012: costs section not shown when budget addon is disabled', () => {
|
||||
render(<TransportModal {...defaultProps} />);
|
||||
expect(screen.queryByPlaceholderText('0.00')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Create expense/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-TRANSMODAL-013: budget fields included in onSave when price is set', async () => {
|
||||
it('FE-PLANNER-TRANSMODAL-013: create-expense saves the booking (no create_budget_entry) then opens the Costs editor', async () => {
|
||||
seedStore(useAddonStore, {
|
||||
addons: [{ id: 'budget', name: 'Budget', type: 'budget', icon: '', enabled: true }],
|
||||
loaded: true,
|
||||
});
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} />);
|
||||
const onSave = vi.fn().mockResolvedValue({ id: 42 });
|
||||
const onOpenExpense = vi.fn();
|
||||
render(<TransportModal {...defaultProps} onSave={onSave} onOpenExpense={onOpenExpense} />);
|
||||
await userEvent.type(screen.getByPlaceholderText(/e\.g\. Lufthansa/i), 'ICE Train');
|
||||
await userEvent.type(screen.getByPlaceholderText('0.00'), '85');
|
||||
await userEvent.click(screen.getByRole('button', { name: /^Add$/i }));
|
||||
await userEvent.click(screen.getByRole('button', { name: /Create expense/i }));
|
||||
await waitFor(() => expect(onSave).toHaveBeenCalled());
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ create_budget_entry: expect.objectContaining({ total_price: 85 }) })
|
||||
// The legacy auto-budget mechanism is gone; the expense is created via the editor instead.
|
||||
expect(onSave).not.toHaveBeenCalledWith(expect.objectContaining({ create_budget_entry: expect.anything() }));
|
||||
await waitFor(() =>
|
||||
expect(onOpenExpense).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ prefill: expect.objectContaining({ reservationId: 42 }) })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Plane, Train, Car, Ship, Bus, Sailboat, Bike, CarTaxiFront, Route, Paperclip, FileText, X, ExternalLink, Link2, Plus, Trash2 } from 'lucide-react'
|
||||
import Modal from '../shared/Modal'
|
||||
@@ -13,8 +13,11 @@ import { useAddonStore } from '../../store/addonStore'
|
||||
import { formatDate, splitReservationDateTime } from '../../utils/formatters'
|
||||
import { openFile } from '../../utils/fileDownload'
|
||||
import apiClient from '../../api/client'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile } from '../../types'
|
||||
import type { Day, Reservation, ReservationEndpoint, TripFile, BudgetItem } from '../../types'
|
||||
import { parseReservationMetadata, orderedEndpoints } from '../../utils/flightLegs'
|
||||
import { BookingCostsSection } from './BookingCostsSection'
|
||||
import type { BookingExpenseRequest } from './BookingCostsSection.types'
|
||||
import { typeToCostCategory } from '@trek/shared'
|
||||
|
||||
const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const
|
||||
type TransportType = typeof TRANSPORT_TYPES[number]
|
||||
@@ -105,8 +108,6 @@ const defaultForm = {
|
||||
arrival_time: '',
|
||||
confirmation_number: '',
|
||||
notes: '',
|
||||
price: '',
|
||||
budget_category: '',
|
||||
meta_airline: '',
|
||||
meta_flight_number: '',
|
||||
meta_train_number: '',
|
||||
@@ -124,20 +125,20 @@ interface TransportModalProps {
|
||||
files?: TripFile[]
|
||||
onFileUpload?: (fd: FormData) => Promise<unknown>
|
||||
onFileDelete?: (fileId: number) => Promise<void>
|
||||
onOpenExpense?: (req: BookingExpenseRequest) => void
|
||||
}
|
||||
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete }: TransportModalProps) {
|
||||
export function TransportModal({ isOpen, onClose, onSave, reservation, days, selectedDayId, files = [], onFileUpload, onFileDelete, onOpenExpense }: TransportModalProps) {
|
||||
const { t, locale } = useTranslation()
|
||||
const toast = useToast()
|
||||
const isBudgetEnabled = useAddonStore(s => s.isEnabled('budget'))
|
||||
const budgetItems = useTripStore(s => s.budgetItems)
|
||||
const deleteBudgetItem = useTripStore(s => s.deleteBudgetItem)
|
||||
const loadFiles = useTripStore(s => s.loadFiles)
|
||||
const budgetCategories = useMemo(() => {
|
||||
const cats = new Set<string>()
|
||||
budgetItems.forEach(i => { if (i.category) cats.add(i.category) })
|
||||
return Array.from(cats).sort()
|
||||
}, [budgetItems])
|
||||
const { id: tripId } = useParams<{ id: string }>()
|
||||
// Set right before submitting when the user clicked "create/edit expense", so
|
||||
// the post-save handler knows to open the Costs editor for the saved booking.
|
||||
const expenseIntentRef = useRef<{ editItem?: BudgetItem; create?: boolean } | null>(null)
|
||||
const [form, setForm] = useState({ ...defaultForm })
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [fromPick, setFromPick] = useState<EndpointPick>({})
|
||||
@@ -177,8 +178,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
meta_train_number: meta.train_number || '',
|
||||
meta_platform: meta.platform || '',
|
||||
meta_seat: meta.seat || '',
|
||||
price: meta.price || '',
|
||||
budget_category: (meta.budget_category && budgetItems.some(i => i.category === meta.budget_category)) ? meta.budget_category : '',
|
||||
})
|
||||
if (type === 'flight') {
|
||||
const orderedEps = orderedEndpoints(reservation)
|
||||
@@ -229,8 +228,8 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
|
||||
const set = (field: string, value: any) => setForm(prev => ({ ...prev, [field]: value }))
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault()
|
||||
if (!form.title.trim()) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
@@ -289,11 +288,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
if (form.meta_platform) metadata.platform = form.meta_platform
|
||||
if (form.meta_seat) metadata.seat = form.meta_seat
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
if (form.price) metadata.price = form.price
|
||||
if (form.budget_category) metadata.budget_category = form.budget_category
|
||||
}
|
||||
|
||||
const startDate = startDay?.date ?? null
|
||||
const endDate = (endDay ?? startDay)?.date ?? null
|
||||
const endpoints: ReturnType<typeof endpointFromAirport>[] = []
|
||||
@@ -334,11 +328,6 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
endpoints,
|
||||
needs_review: false,
|
||||
}
|
||||
if (isBudgetEnabled) {
|
||||
(payload as any).create_budget_entry = form.price && parseFloat(form.price) > 0
|
||||
? { total_price: parseFloat(form.price), category: form.budget_category || t(`reservations.type.${form.type}`) || 'Other' }
|
||||
: { total_price: 0 }
|
||||
}
|
||||
const saved = await onSave(payload)
|
||||
if (!reservation?.id && saved?.id && pendingFiles.length > 0 && onFileUpload) {
|
||||
for (const file of pendingFiles) {
|
||||
@@ -349,6 +338,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
await onFileUpload(fd)
|
||||
}
|
||||
}
|
||||
// The user asked to create/edit the linked expense — open the Costs editor
|
||||
// for the now-saved booking. Gated on saved?.id so a failed save doesn't.
|
||||
const intent = expenseIntentRef.current
|
||||
expenseIntentRef.current = null
|
||||
if (intent && onOpenExpense && saved?.id) {
|
||||
if (intent.editItem) onOpenExpense({ editItem: intent.editItem })
|
||||
else onOpenExpense({ prefill: { reservationId: saved.id, name: form.title, category: typeToCostCategory(form.type) } })
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
toast.error(err instanceof Error ? err.message : t('common.unknownError'))
|
||||
} finally {
|
||||
@@ -356,6 +353,12 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateExpense = () => { expenseIntentRef.current = { create: true }; handleSubmit() }
|
||||
const handleEditExpense = (item: BudgetItem) => { expenseIntentRef.current = { editItem: item }; handleSubmit() }
|
||||
const handleRemoveExpense = async (item: BudgetItem) => {
|
||||
try { await deleteBudgetItem(Number(tripId), item.id) } catch { toast.error(t('common.unknownError')) }
|
||||
}
|
||||
|
||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
@@ -712,38 +715,14 @@ export function TransportModal({ isOpen, onClose, onSave, reservation, days, sel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Budget Category */}
|
||||
{/* Costs — create / view the expense linked to this booking */}
|
||||
{isBudgetEnabled && (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.price')}</label>
|
||||
<input type="text" inputMode="decimal" value={form.price}
|
||||
onChange={e => { const v = e.target.value; if (v === '' || /^\d*[.,]?\d{0,2}$/.test(v)) set('price', v.replace(',', '.')) }}
|
||||
onPaste={e => { e.preventDefault(); let txt = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = txt.lastIndexOf(','), ld = txt.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { txt = txt.substring(0, dp).replace(/[.,]/g, '') + '.' + txt.substring(dp + 1) } else { txt = txt.replace(/[.,]/g, '') } set('price', txt) }}
|
||||
placeholder="0.00"
|
||||
className={inputClass} />
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<label className={labelClass}>{t('reservations.budgetCategory')}</label>
|
||||
<CustomSelect
|
||||
value={form.budget_category}
|
||||
onChange={v => set('budget_category', v)}
|
||||
options={[
|
||||
{ value: '', label: t('reservations.budgetCategoryAuto') },
|
||||
...budgetCategories.map(c => ({ value: c, label: c })),
|
||||
]}
|
||||
placeholder={t('reservations.budgetCategoryAuto')}
|
||||
size="sm"
|
||||
<BookingCostsSection
|
||||
reservationId={reservation?.id ?? null}
|
||||
onCreate={handleCreateExpense}
|
||||
onEdit={handleEditExpense}
|
||||
onRemove={handleRemoveExpense}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{form.price && parseFloat(form.price) > 0 && (
|
||||
<div className="text-content-faint" style={{ fontSize: 11, marginTop: -4 }}>
|
||||
{t('reservations.budgetHint')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
</form>
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function VacayCalendar() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 pb-14">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3" style={{ paddingBottom: 'calc(var(--bottom-nav-h, 0px) + 80px)' }}>
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<VacayMonthCard
|
||||
key={i}
|
||||
@@ -89,8 +89,8 @@ export default function VacayCalendar() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Floating toolbar */}
|
||||
<div className="sticky bottom-3 sm:bottom-4 mt-3 sm:mt-4 flex items-center justify-center z-30 px-2">
|
||||
{/* Floating toolbar — lift above the mobile bottom nav (z-60). On desktop --bottom-nav-h is 0px. */}
|
||||
<div className="sticky mt-3 sm:mt-4 flex items-center justify-center px-2" style={{ bottom: 'calc(var(--bottom-nav-h, 0px) + 12px)', zIndex: 61 }}>
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2 sm:px-3 py-1.5 sm:py-2 rounded-xl border bg-surface-card border-edge" style={{ boxShadow: '0 8px 32px rgba(0,0,0,0.12)' }}>
|
||||
<button
|
||||
onClick={() => setCompanyMode(false)}
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
Plane, Hotel, Utensils, Clock, RefreshCw, ArrowRightLeft, Calendar,
|
||||
LayoutGrid, List, Ticket, X,
|
||||
} from 'lucide-react'
|
||||
import { formatTime, splitReservationDateTime } from '../utils/formatters'
|
||||
import { useSettingsStore } from '../store/settingsStore'
|
||||
import '../styles/dashboard.css'
|
||||
|
||||
const GRADIENTS = [
|
||||
@@ -602,6 +604,7 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
items: UpcomingReservation[]; locale: string; onOpen: (tripId: number) => void
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation()
|
||||
const timeFormat = useSettingsStore(s => s.settings.time_format)
|
||||
return (
|
||||
<div className="tool">
|
||||
<div className="tool-head">
|
||||
@@ -612,10 +615,13 @@ function UpcomingTool({ items, locale, onOpen }: {
|
||||
) : (
|
||||
<div className="upc-list">
|
||||
{items.map(r => {
|
||||
const when = r.reservation_time || (r.day_date ? r.day_date + 'T00:00:00' : null)
|
||||
const d = when ? new Date(when) : null
|
||||
const dateStr = d ? splitDate(d.toISOString().slice(0, 10), locale) : null
|
||||
const timeStr = r.reservation_time ? new Date(r.reservation_time).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }) : null
|
||||
// Read the date/time straight from the stored string parts. Going through
|
||||
// new Date(...).toISOString() reinterprets the naive local time as UTC and
|
||||
// can roll the displayed day forward/back in non-UTC timezones.
|
||||
const parsed = splitReservationDateTime(r.reservation_time)
|
||||
const datePart = parsed.date || r.day_date || null
|
||||
const dateStr = datePart ? splitDate(datePart, locale) : null
|
||||
const timeStr = parsed.time ? formatTime(parsed.time, locale, timeFormat) : null
|
||||
const typeClass = RES_TYPE_CLASS[r.type] || 'other'
|
||||
return (
|
||||
<div className="upc-item" key={r.id} onClick={() => onOpen(r.trip_id)}>
|
||||
|
||||
@@ -25,7 +25,9 @@ import PackingListPanel from '../components/Packing/PackingListPanel'
|
||||
import ApplyTemplateButton from '../components/Packing/ApplyTemplateButton'
|
||||
import TodoListPanel from '../components/Todo/TodoListPanel'
|
||||
import FileManager from '../components/Files/FileManager'
|
||||
import CostsPanel from '../components/Budget/CostsPanel'
|
||||
import CostsPanel, { ExpenseModal, type ExpensePrefill } from '../components/Budget/CostsPanel'
|
||||
import type { BookingExpenseRequest } from '../components/Planner/BookingCostsSection.types'
|
||||
import type { BudgetItem } from '../types'
|
||||
import CollabPanel from '../components/Collab/CollabPanel'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import { useToast } from '../components/shared/Toast'
|
||||
@@ -212,6 +214,18 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
const [glMap, setGlMap] = useState<import('mapbox-gl').Map | null>(null)
|
||||
const poiPillEnabled = useSettingsStore(s => s.settings.map_poi_pill_enabled) !== false
|
||||
|
||||
// Costs expense editor opened from a booking modal (save-then-open). Lives at the
|
||||
// page level so it has tripMembers / base currency / current user available.
|
||||
const meId = useAuthStore(s => s.user?.id ?? -1)
|
||||
const displayCurrency = useSettingsStore(s => s.settings.default_currency)
|
||||
const costsBase = (displayCurrency || trip?.currency || 'EUR').toUpperCase()
|
||||
const loadBudgetItems = useTripStore(s => s.loadBudgetItems)
|
||||
const [bookingExpense, setBookingExpense] = useState<{ editing: BudgetItem | null; prefill?: ExpensePrefill } | null>(null)
|
||||
const openBookingExpense = (req: BookingExpenseRequest) => {
|
||||
if (req.editItem) setBookingExpense({ editing: req.editItem })
|
||||
else if (req.prefill) setBookingExpense({ editing: null, prefill: req.prefill })
|
||||
}
|
||||
|
||||
if (isLoading || !splashDone) {
|
||||
return (
|
||||
<div className="bg-surface" style={{
|
||||
@@ -706,8 +720,20 @@ export default function TripPlannerPage(): React.ReactElement | null {
|
||||
<PlaceFormModal isOpen={showPlaceForm} onClose={() => { setShowPlaceForm(false); setEditingPlace(null); setEditingAssignmentId(null); setPrefillCoords(null) }} onSave={handleSavePlace} place={editingPlace} prefillCoords={prefillCoords} assignmentId={editingAssignmentId} dayAssignments={editingAssignmentId ? Object.values(assignments).flat() : []} tripId={tripId} categories={categories} onCategoryCreated={cat => tripActions.addCategory?.(cat)} />
|
||||
<TripFormModal isOpen={showTripForm} onClose={() => setShowTripForm(false)} onSave={async (data) => { await tripActions.updateTrip(tripId, data); toast.success(t('trip.toast.tripUpdated')) }} trip={trip} />
|
||||
<TripMembersModal isOpen={showMembersModal} onClose={() => setShowMembersModal(false)} tripId={tripId} tripTitle={trip?.title} />
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} />}
|
||||
<ReservationModal isOpen={showReservationModal} onClose={() => { setShowReservationModal(false); setEditingReservation(null); setBookingForAssignmentId(null) }} onSave={handleSaveReservation} reservation={editingReservation} days={days} places={places} assignments={assignments} selectedDayId={selectedDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} accommodations={tripAccommodations} defaultAssignmentId={bookingForAssignmentId} onOpenExpense={openBookingExpense} />
|
||||
{showTransportModal && <TransportModal isOpen={showTransportModal} onClose={() => { setShowTransportModal(false); setEditingTransport(null); setTransportModalDayId(null) }} onSave={handleSaveTransport} reservation={editingTransport} days={days} selectedDayId={transportModalDayId} files={files} onFileUpload={canUploadFiles ? (fd) => tripActions.addFile(tripId, fd) : undefined} onFileDelete={(id) => tripActions.deleteFile(tripId, id)} onOpenExpense={openBookingExpense} />}
|
||||
{bookingExpense && (
|
||||
<ExpenseModal
|
||||
tripId={tripId}
|
||||
base={costsBase}
|
||||
people={tripMembers}
|
||||
me={meId}
|
||||
editing={bookingExpense.editing}
|
||||
prefill={bookingExpense.prefill}
|
||||
onClose={() => setBookingExpense(null)}
|
||||
onSaved={() => { setBookingExpense(null); loadBudgetItems(tripId) }}
|
||||
/>
|
||||
)}
|
||||
<BookingImportModal isOpen={showBookingImport} onClose={() => setShowBookingImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<AirTrailImportModal isOpen={showAirTrailImport} onClose={() => setShowAirTrailImport(false)} tripId={tripId} pushUndo={pushUndo} />
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -340,7 +340,10 @@ export function useAtlas() {
|
||||
</div>
|
||||
</div>`
|
||||
layer.bindTooltip(tooltipHtml, {
|
||||
sticky: false, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
// sticky so the tooltip tracks the cursor; non-sticky anchors it at the feature's
|
||||
// bounds centre, which for countries with overseas territories (e.g. France) lands
|
||||
// far out in the ocean instead of over the area being hovered.
|
||||
sticky: true, permanent: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
})
|
||||
layer.on('click', () => {
|
||||
if (c.placeCount === 0 && c.tripCount === 0) {
|
||||
@@ -363,7 +366,7 @@ export function useAtlas() {
|
||||
country_layer_by_a2_ref.current[countryCode] = layer
|
||||
const name = feature.properties?.NAME || feature.properties?.ADMIN || resolveName(countryCode)
|
||||
layer.bindTooltip(`<div style="font-size:12px;font-weight:600">${name}</div>`, {
|
||||
sticky: false, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
sticky: true, className: 'atlas-tooltip', direction: 'top', offset: [0, -10], opacity: 1
|
||||
})
|
||||
layer.on('click', () => handleMarkCountry(countryCode, name))
|
||||
layer.on('mouseover', (e) => {
|
||||
@@ -552,6 +555,20 @@ export function useAtlas() {
|
||||
} catch (e ) {
|
||||
console.error('Error fitting bounds', e)
|
||||
}
|
||||
|
||||
// Mirror the map-click behaviour so an already-visited country can be removed
|
||||
// straight from search. Tiny countries (Vatican City, Singapore) are hard to
|
||||
// hit on the map, so search was the only way in — but it always opened the
|
||||
// "Mark / Bucket" dialog with no Remove option.
|
||||
const visited = data?.countries.find(c => c.code === country_code)
|
||||
if (visited) {
|
||||
if (visited.placeCount === 0 && visited.tripCount === 0) {
|
||||
handleUnmarkCountry(country_code)
|
||||
} else {
|
||||
loadCountryDetailRef.current(country_code)
|
||||
}
|
||||
return
|
||||
}
|
||||
setConfirmAction({ type: 'choose', code: country_code, name: country_label })
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,8 @@ OIDC_SCOPE=openid email profile # Fully overrides the default. Add extra scopes
|
||||
|
||||
DEMO_MODE=false # Demo mode - resets data hourly
|
||||
|
||||
# BACKUP_UPLOAD_LIMIT_MB=500 # Max size (MB) of a backup archive you can upload when restoring. Raise it if your backup exceeds 500 MB. If you sit behind a reverse proxy, raise its upload limit too (e.g. nginx client_max_body_size).
|
||||
|
||||
# MCP_RATE_LIMIT=300 # Max MCP API requests per user per minute (default: 300)
|
||||
# MCP_MAX_SESSION_PER_USER=20 # Max concurrent MCP sessions per user (default: 20)
|
||||
|
||||
|
||||
@@ -18,6 +18,11 @@ function seedAdminAccount(db: Database.Database): void {
|
||||
const userCount = (db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number }).count;
|
||||
if (userCount > 0) return;
|
||||
|
||||
// Demo mode seeds its own admin (admin@trek.app, username 'admin') right after this.
|
||||
// Creating a first-run admin here would grab username 'admin' first and make the demo
|
||||
// seeder fail on the UNIQUE(username) constraint, leaving the demo user uncreated.
|
||||
if (process.env.DEMO_MODE?.toLowerCase() === 'true') return;
|
||||
|
||||
if (isOidcOnlyConfigured()) {
|
||||
console.log('');
|
||||
console.log('╔══════════════════════════════════════════════╗');
|
||||
|
||||
@@ -114,7 +114,7 @@ export class BudgetController {
|
||||
create(
|
||||
@CurrentUser() user: User,
|
||||
@Param('tripId') tripId: string,
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null },
|
||||
@Body() body: { name?: string; category?: string; total_price?: number; persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null; reservation_id?: number },
|
||||
@Headers('x-socket-id') socketId?: string,
|
||||
) {
|
||||
const trip = this.requireTrip(tripId, user);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { checkPermission } from '../../services/permissions';
|
||||
import type { User } from '../../types';
|
||||
import * as svc from '../../services/reservationService';
|
||||
import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../../services/budgetService';
|
||||
import { typeToCostCategory } from '@trek/shared';
|
||||
|
||||
type Trip = NonNullable<ReturnType<typeof svc.verifyTripAccess>>;
|
||||
type BudgetEntry = { total_price?: number; category?: string } | undefined;
|
||||
@@ -77,14 +78,36 @@ export class ReservationsService {
|
||||
|
||||
/** PUT side effect: drop the linked budget item when the price is cleared, else create/update it. */
|
||||
syncBudgetOnUpdate(tripId: string, id: string, title: string, type: string | undefined, currentTitle: string, currentType: string | undefined, entry: BudgetEntry, socketId: string | undefined): void {
|
||||
if (!entry || !entry.total_price) {
|
||||
// When the booking type changes, keep a linked expense's category in sync —
|
||||
// but only if it still carries the auto-derived category (so a manual pick in
|
||||
// the Costs editor is preserved). Runs regardless of create_budget_entry.
|
||||
if (type && currentType && type !== currentType) {
|
||||
const linked = db.prepare('SELECT id, category FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number; category: string } | undefined;
|
||||
if (linked) {
|
||||
const oldCat = typeToCostCategory(currentType);
|
||||
const newCat = typeToCostCategory(type);
|
||||
if (oldCat !== newCat && linked.category === oldCat) {
|
||||
const updated = updateBudgetItem(linked.id, tripId, { category: newCat });
|
||||
broadcast(tripId, 'budget:updated', { item: updated }, socketId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No budget entry on the payload — the booking edit isn't touching its linked
|
||||
// expense, so leave any linked item alone. Expenses are managed from the
|
||||
// booking's Costs section / the Costs tab, not by re-saving the booking.
|
||||
if (!entry) return;
|
||||
|
||||
if (!(Number(entry.total_price) > 0)) {
|
||||
// Explicit clear (total_price 0/empty) — drop the linked item.
|
||||
const linked = db.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?').get(tripId, id) as { id: number } | undefined;
|
||||
if (linked) {
|
||||
deleteBudgetItem(linked.id, tripId);
|
||||
broadcast(tripId, 'budget:deleted', { itemId: linked.id }, socketId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (entry && Number(entry.total_price) > 0) {
|
||||
|
||||
try {
|
||||
const itemName = title || currentTitle;
|
||||
const category = entry.category || type || currentType || 'Other';
|
||||
@@ -102,7 +125,6 @@ export class ReservationsService {
|
||||
console.error('[reservations] Failed to create/update budget entry:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget booking-change notification, mirroring the legacy dynamic import. */
|
||||
notifyBookingChange(tripId: string, actor: User, booking: string, type: string): void {
|
||||
|
||||
@@ -15,7 +15,21 @@ const dataDir = path.join(__dirname, '../../data');
|
||||
const backupsDir = path.join(dataDir, 'backups');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads');
|
||||
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
|
||||
// Compressed upload cap for restore archives. Defaults to 500 MB, raisable via
|
||||
// BACKUP_UPLOAD_LIMIT_MB for instances whose backups (uploads/ included) grow
|
||||
// past that. Invalid values warn and fall back to the default.
|
||||
const DEFAULT_BACKUP_UPLOAD_LIMIT_MB = 500;
|
||||
const rawBackupUploadLimit = process.env.BACKUP_UPLOAD_LIMIT_MB?.trim();
|
||||
let backupUploadLimitMb = DEFAULT_BACKUP_UPLOAD_LIMIT_MB;
|
||||
if (rawBackupUploadLimit) {
|
||||
const parsed = Number(rawBackupUploadLimit);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
backupUploadLimitMb = parsed;
|
||||
} else {
|
||||
console.warn(`BACKUP_UPLOAD_LIMIT_MB="${rawBackupUploadLimit}" is not a positive number. Falling back to ${DEFAULT_BACKUP_UPLOAD_LIMIT_MB} MB.`);
|
||||
}
|
||||
}
|
||||
export const MAX_BACKUP_UPLOAD_SIZE = backupUploadLimitMb * 1024 * 1024; // compressed
|
||||
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
|
||||
// limit only caps the compressed bytes). Generous enough for any real backup.
|
||||
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -229,12 +229,17 @@ export function getPollWithVotes(pollId: number | bigint | string) {
|
||||
WHERE v.poll_id = ?
|
||||
`).all(pollId) as PollVoteRow[];
|
||||
|
||||
const formattedOptions = options.map((label: string | { label: string }, idx: number) => ({
|
||||
label: typeof label === 'string' ? label : label.label || label,
|
||||
const formattedOptions = options.map((label: string | { label: string }, idx: number) => {
|
||||
const text = typeof label === 'string' ? label : label.label || label;
|
||||
return {
|
||||
// The client renders `opt.text`; keep `label` too for any other consumer.
|
||||
text,
|
||||
label: text,
|
||||
voters: votes
|
||||
.filter(v => v.option_index === idx)
|
||||
.map(v => ({ id: v.user_id, user_id: v.user_id, username: v.username, avatar: v.avatar, avatar_url: avatarUrl(v) })),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...poll,
|
||||
|
||||
@@ -417,8 +417,10 @@ export function findOrCreateUser(
|
||||
const bcrypt = require('bcryptjs');
|
||||
const hash = bcrypt.hashSync(randomPass, 10);
|
||||
|
||||
// Username: sanitize and avoid collisions
|
||||
let username = name.replace(/[^a-zA-Z0-9_-]/g, '').substring(0, 30) || 'user';
|
||||
// Username: sanitize and avoid collisions. Keep dots — they are valid in
|
||||
// usernames (see the ^[a-zA-Z0-9_.-]+$ validation in authService) and common
|
||||
// in OIDC name claims like "first.last".
|
||||
let username = name.replace(/[^a-zA-Z0-9_.-]/g, '').substring(0, 30) || 'user';
|
||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -49,6 +49,35 @@ export const COST_CATEGORIES = [
|
||||
] as const;
|
||||
export type CostCategory = (typeof COST_CATEGORIES)[number];
|
||||
|
||||
/**
|
||||
* Maps a reservation `type` (flight, train, hotel, …) to one of the fixed Costs
|
||||
* categories, so an expense created from a booking lands in the right bucket
|
||||
* instead of a free-text/localized label. Unknown types fall back to `other`.
|
||||
*/
|
||||
const RESERVATION_TYPE_TO_COST_CATEGORY: Record<string, CostCategory> = {
|
||||
flight: 'flights',
|
||||
plane: 'flights',
|
||||
train: 'transport',
|
||||
bus: 'transport',
|
||||
car: 'transport',
|
||||
'car-rental': 'transport',
|
||||
ferry: 'transport',
|
||||
boat: 'transport',
|
||||
taxi: 'transport',
|
||||
transfer: 'transport',
|
||||
transport: 'transport',
|
||||
hotel: 'accommodation',
|
||||
accommodation: 'accommodation',
|
||||
lodging: 'accommodation',
|
||||
restaurant: 'food',
|
||||
activity: 'activities',
|
||||
};
|
||||
|
||||
export function typeToCostCategory(type: string | null | undefined): CostCategory {
|
||||
if (!type) return 'other';
|
||||
return RESERVATION_TYPE_TO_COST_CATEGORY[type.trim().toLowerCase()] || 'other';
|
||||
}
|
||||
|
||||
/**
|
||||
* One payer of an expense — a row of budget_item_payers. `amount` is in the
|
||||
* expense's own currency (budget_items.currency). Several payers can split who
|
||||
@@ -112,6 +141,9 @@ export const budgetCreateItemRequestSchema = z.object({
|
||||
days: z.number().nullable().optional(),
|
||||
note: z.string().nullable().optional(),
|
||||
expense_date: z.string().nullable().optional(),
|
||||
// Link this expense to a reservation (e.g. created from a booking's
|
||||
// "add expense" flow). The server stores it on budget_items.reservation_id.
|
||||
reservation_id: z.number().optional(),
|
||||
});
|
||||
export type BudgetCreateItemRequest = z.infer<
|
||||
typeof budgetCreateItemRequestSchema
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user