mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
This commit is contained in:
@@ -11,6 +11,16 @@
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
import { currencyDecimals } from '../../utils/formatters'
|
||||
|
||||
// The split and receipt fields guard their own precision on every keystroke, so the
|
||||
// guard has to follow the currency: a three-decimal one (KWD, BHD, …) seeds three
|
||||
// places, and the old fixed two rejected every keystroke after that, leaving the field
|
||||
// unusable until a digit was deleted (#2175). Never stricter than two places, so no
|
||||
// value a field accepts today can become uneditable.
|
||||
export const amountPattern = (currency: string, signed: boolean) =>
|
||||
new RegExp(`^${signed ? '-?' : ''}\\d*\\.?\\d{0,${Math.max(2, currencyDecimals(currency))}}$`)
|
||||
|
||||
/**
|
||||
* Spread `amount` across `n` payers in whole cents so the parts sum back exactly.
|
||||
* Floor-based, so a negative total (a refund, #2176) splits just as exactly:
|
||||
|
||||
@@ -1890,6 +1890,29 @@ describe('CostsPanel — decimal padding on edit (#2175)', () => {
|
||||
await user.click(screen.getAllByTitle('Edit')[1])
|
||||
expect(await screen.findByDisplayValue('5,00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-071: a three-decimal currency seeds three places and the split field still takes input', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="KWD" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 340, name: 'Museum', category: 'activities', currency: 'KWD', total_price: 3,
|
||||
payers: [{ user_id: 1, amount: 3 }],
|
||||
members: [{ user_id: 1, username: 'alice', amount: 1.5 }, { user_id: 2, username: 'bob', amount: 1.5 }],
|
||||
})} />)
|
||||
|
||||
expect(await screen.findByDisplayValue('3.000')).toBeInTheDocument()
|
||||
const shares = screen.getAllByDisplayValue('1.500') as HTMLInputElement[]
|
||||
|
||||
// The seed is three decimals, so a two-decimal guard would reject every further
|
||||
// keystroke, including one typed in front of the decimal point.
|
||||
await user.type(shares[0], '2', { initialSelectionStart: 0, initialSelectionEnd: 0 })
|
||||
expect(shares[0].value).toBe('21.500')
|
||||
|
||||
// A fourth decimal is still not a fils.
|
||||
await user.clear(shares[1])
|
||||
await user.type(shares[1], '1.5005')
|
||||
expect(shares[1].value).toBe('1.500')
|
||||
})
|
||||
})
|
||||
|
||||
// ── #2176: negative amounts record partial reimbursements ───────────────────
|
||||
@@ -1977,6 +2000,38 @@ describe('CostsPanel — negative amounts (#2176)', () => {
|
||||
expect(within(breakdown).getByText('60 €')).toBeInTheDocument()
|
||||
expect(within(breakdown).getByText(/[-−]20\s*€/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-072: the custom-split hint reads under and over the right way round on a refund', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} onClose={() => {}} onSaved={vi.fn()}
|
||||
editing={expense({
|
||||
id: 341, name: 'Hotel refund', category: 'accommodation', total_price: -100,
|
||||
payers: [{ user_id: 1, amount: -100 }],
|
||||
members: [{ user_id: 1, username: 'alice', amount: -70 }, { user_id: 2, username: 'bob' }],
|
||||
})} />)
|
||||
|
||||
// -70 of -100: 30 still to hand out, not 30 too much.
|
||||
expect(await screen.findByText('Sum of splits: €-70.00 of €-100.00 (under by €30.00)')).toBeInTheDocument()
|
||||
|
||||
await user.type(screen.getByPlaceholderText('-30,00'), '-40')
|
||||
expect(screen.getByText('Sum of splits: €-110.00 of €-100.00 (over by €10.00)')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-W5COSTS-073: the total can be turned into a refund without a minus key', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ExpenseModal tripId={1} base="EUR" people={tripMembers} me={1} editing={null}
|
||||
onClose={() => {}} onSaved={vi.fn()} />)
|
||||
|
||||
const total = (await screen.findAllByPlaceholderText('0,00'))[0] as HTMLInputElement
|
||||
await user.type(total, '100')
|
||||
|
||||
// The iOS decimal pad has no minus key, so the sign has to be reachable without one.
|
||||
const toggle = screen.getByRole('button', { name: 'Switch between expense and refund' })
|
||||
await user.click(toggle)
|
||||
|
||||
expect(total.value).toBe('-100')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('CostsPanel — split maths', () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { localToday } from '../Planner/today'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, payersBalanced, readTicketItems, readUserNote, rebalancePayers, splitEqualShares, writeTicketItems, type TicketItem } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
@@ -1130,6 +1130,10 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const totalNum = isTicketMode ? ticketInfo.total : (Number.parseFloat(total) || 0)
|
||||
const splitSum = [...participants].reduce((sum, id) => sum + (Number.parseFloat(customAmounts[id]) || 0), 0)
|
||||
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||
// How much is still to be handed out, read on the total's own side: on a refund
|
||||
// (#2176) the shares run negative, so a plain total minus sum flips under and
|
||||
// over around and sends the user the wrong way.
|
||||
const splitShortfall = totalNum < 0 ? splitSum - totalNum : totalNum - splitSum
|
||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||
const equalShares = useMemo(() => {
|
||||
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
|
||||
@@ -1212,7 +1216,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^-?\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||
if (val === '' || amountPattern(currency, true).test(val)) {
|
||||
setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
}
|
||||
@@ -1235,7 +1239,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
|
||||
if (price === '' || amountPattern(currency, false).test(price)) {
|
||||
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
}
|
||||
@@ -1352,6 +1356,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||
<NumericInput mode="signed-decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
disabled={isTicketMode}
|
||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||
</div>
|
||||
@@ -1607,7 +1612,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||
{customBalanced
|
||||
? t('costs.splitBalanced')
|
||||
: t(totalNum - splitSum > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
|
||||
: t(splitShortfall > 0 ? 'costs.splitSumUnder' : 'costs.splitSumOver', {
|
||||
sum: sym(currency) + splitSum.toFixed(2),
|
||||
total: sym(currency) + totalNum.toFixed(2),
|
||||
diff: sym(currency) + Math.abs(totalNum - splitSum).toFixed(2),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-008
|
||||
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-014
|
||||
//
|
||||
// #1513: tapping a pre-populated numeric field put the caret at the end, so the first
|
||||
// digit typed was appended rather than replacing the value — quantity 1 + "6" = "16".
|
||||
@@ -16,9 +16,9 @@ beforeEach(() => {
|
||||
})
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
function Harness({ initial, mode }: { initial: string; mode?: NumericMode }) {
|
||||
function Harness({ initial, mode, signToggleLabel }: { initial: string; mode?: NumericMode; signToggleLabel?: string }) {
|
||||
const [v, setV] = useState(initial)
|
||||
return <NumericInput value={v} onValueChange={setV} mode={mode} aria-label="field" />
|
||||
return <NumericInput value={v} onValueChange={setV} mode={mode} signToggleLabel={signToggleLabel} aria-label="field" />
|
||||
}
|
||||
|
||||
describe('NumericInput', () => {
|
||||
@@ -115,6 +115,47 @@ describe('NumericInput', () => {
|
||||
expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-012: the sign toggle flips the sign of what is already there (#2176)', () => {
|
||||
// iOS renders inputMode="decimal" as a pad of digits, separator and backspace, with
|
||||
// no minus key, so a refund is untypeable on a phone without this button.
|
||||
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
const toggle = screen.getByRole('button', { name: 'Toggle sign' })
|
||||
|
||||
fireEvent.change(input, { target: { value: '12,50' } })
|
||||
fireEvent.click(toggle)
|
||||
expect(input.value).toBe('-12,50')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(input.value).toBe('12,50')
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false')
|
||||
|
||||
// The button must not take the focus, or the phone keypad closes mid-entry.
|
||||
expect(fireEvent.mouseDown(toggle)).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-013: an empty field toggles to a bare minus so the digits follow', () => {
|
||||
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
|
||||
const input = screen.getByLabelText('field') as HTMLInputElement
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle sign' }))
|
||||
expect(input.value).toBe('-')
|
||||
|
||||
// The caret stays in the field, so the next keystroke appends to the sign.
|
||||
fireEvent.change(input, { target: { value: '-1' } })
|
||||
expect(input.value).toBe('-1')
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-014: there is no toggle without a label, and none on an unsigned field', () => {
|
||||
const { unmount } = render(<Harness initial="" mode="signed-decimal" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
unmount()
|
||||
|
||||
render(<Harness initial="" mode="decimal" signToggleLabel="Toggle sign" />)
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-COMP-NUMINPUT-009: typing inside the one-frame window is not swallowed by the deferred select', async () => {
|
||||
// The deferred select() exists for WebKit, but it must not fire *after* the user has
|
||||
// started typing: it would select the half-typed value and the next character would
|
||||
|
||||
@@ -24,6 +24,8 @@ const SANITIZERS: Record<NumericMode, (raw: string) => string> = {
|
||||
},
|
||||
}
|
||||
|
||||
const flipSign = (raw: string) => (raw.startsWith('-') ? raw.slice(1) : `-${raw}`)
|
||||
|
||||
type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'value'> & {
|
||||
value: string | number | null | undefined
|
||||
/** Receives the sanitized raw string. Callers keep their own state and commit logic. */
|
||||
@@ -31,6 +33,13 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
|
||||
mode?: NumericMode
|
||||
/** Escape hatch for a field that must not steal the caret (none today). */
|
||||
selectOnFocus?: boolean
|
||||
/**
|
||||
* Accessible name for a sign toggle rendered beside the field. Set it on a signed
|
||||
* money field: iOS draws inputMode="decimal" as a pad with digits, separator and
|
||||
* backspace only, so a refund (#2176) has no minus key to type. Ignored on the
|
||||
* unsigned modes, where there is no sign to flip.
|
||||
*/
|
||||
signToggleLabel?: string
|
||||
ref?: Ref<HTMLInputElement>
|
||||
}
|
||||
|
||||
@@ -62,34 +71,54 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
|
||||
* others on blur. This owns only the part that was uniformly broken.
|
||||
*/
|
||||
export function NumericInput({
|
||||
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ref, ...rest
|
||||
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, signToggleLabel, ref, ...rest
|
||||
}: Props) {
|
||||
// Set while a deferred select() is queued; any input in that window cancels it.
|
||||
const selectPending = useRef(false)
|
||||
const raw = String(value ?? '')
|
||||
const signable = mode === 'signed' || mode === 'signed-decimal'
|
||||
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
selectPending.current = false
|
||||
onValueChange(SANITIZERS[mode](e.target.value))
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (selectOnFocus) {
|
||||
const el = e.currentTarget
|
||||
el.select()
|
||||
selectPending.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (selectPending.current && document.activeElement === el) el.select()
|
||||
selectPending.current = false
|
||||
})
|
||||
}
|
||||
onFocus?.(e)
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<input
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="text"
|
||||
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
|
||||
value={value ?? ''}
|
||||
onChange={e => {
|
||||
selectPending.current = false
|
||||
onValueChange(SANITIZERS[mode](e.target.value))
|
||||
}}
|
||||
onFocus={e => {
|
||||
if (selectOnFocus) {
|
||||
const el = e.currentTarget
|
||||
el.select()
|
||||
selectPending.current = true
|
||||
requestAnimationFrame(() => {
|
||||
if (selectPending.current && document.activeElement === el) el.select()
|
||||
selectPending.current = false
|
||||
})
|
||||
}
|
||||
onFocus?.(e)
|
||||
}}
|
||||
/>
|
||||
{signToggleLabel && signable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={signToggleLabel}
|
||||
aria-pressed={raw.startsWith('-')}
|
||||
disabled={rest.disabled}
|
||||
// Keeps the caret (and on a phone the keypad) in the field, so the sign can
|
||||
// be flipped mid-entry and typing carries on where it left off.
|
||||
onMouseDown={e => e.preventDefault()}
|
||||
onClick={() => onValueChange(SANITIZERS[mode](flipSign(raw)))}
|
||||
className="text-content-faint"
|
||||
style={{ border: 0, background: 'none', padding: '0 2px', font: 'inherit', lineHeight: 1, cursor: 'pointer' }}
|
||||
>
|
||||
±
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { formatMoney, localizeAmountInput, amountToInputString } from '../../../
|
||||
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
|
||||
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
|
||||
import { localToday } from '../../../../components/Planner/today'
|
||||
import { calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import { amountPattern, calculateTicketShares, hasTicketSplit, NOTE_MAX, readTicketItems, readUserNote, splitEqualShares, writeTicketItems, type TicketItem } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import type { ExpensePrefill } from '../../../../components/Budget/CostsPanel'
|
||||
import { payersBalanced, rebalancePayers } from '../../../../components/Budget/CostsPanel.helpers'
|
||||
import GuestBadge from '../../../../components/shared/GuestBadge'
|
||||
@@ -203,7 +203,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^-?\d*\.?\d{0,2}$/.test(val) || val === '') setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
if (val === '' || amountPattern(currency, true).test(val)) setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||
}
|
||||
|
||||
const handleAddEmptyItem = () => {
|
||||
@@ -215,7 +215,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
const handleUpdateItemName = (id: string, itemName: string) => setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name: itemName } : item))
|
||||
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||
price = price.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
if (price === '' || amountPattern(currency, false).test(price)) setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||
}
|
||||
const handleRemoveItem = (id: string) => setTicketItems(prev => prev.filter(item => item.id !== id))
|
||||
const handleToggleItemParticipant = (itemId: string, userId: number) => {
|
||||
@@ -346,6 +346,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<span className="text-[0.84375rem] font-medium text-m-faint">{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="signed-decimal"
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
|
||||
onValueChange={onTotalChange}
|
||||
@@ -465,6 +466,7 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
|
||||
<span className="text-[0.75rem] text-m-faint">{sym(currency)}</span>
|
||||
<NumericInput
|
||||
mode="signed-decimal"
|
||||
signToggleLabel={t('costs.toggleSign')}
|
||||
placeholder={localizeAmountInput('0.00', currency)}
|
||||
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
|
||||
onValueChange={v => onPayerAmountChange(p.id, v)}
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'التقسيم يطابق الإجمالي',
|
||||
'costs.splitSumUnder': 'مجموع الحصص: {sum} من {total} (ناقص {diff})',
|
||||
'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})',
|
||||
'costs.toggleSign': 'التبديل بين المصروف والاسترداد',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'A divisão bate com o total',
|
||||
'costs.splitSumUnder': 'Soma das partes: {sum} de {total} (faltam {diff})',
|
||||
'costs.splitSumOver': 'Soma das partes: {sum} de {total} (sobram {diff})',
|
||||
'costs.toggleSign': 'Alternar entre despesa e reembolso',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,6 +146,7 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'El repartiment quadra amb el total',
|
||||
'costs.splitSumUnder': 'Suma de les parts: {sum} de {total} (en falten {diff})',
|
||||
'costs.splitSumOver': 'Suma de les parts: {sum} de {total} ({diff} de més)',
|
||||
'costs.toggleSign': 'Alterna entre despesa i devolució',
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Rozdělení sedí s celkem',
|
||||
'costs.splitSumUnder': 'Součet podílů: {sum} z {total} (chybí {diff})',
|
||||
'costs.splitSumOver': 'Součet podílů: {sum} z {total} (o {diff} více)',
|
||||
'costs.toggleSign': 'Přepnout mezi výdajem a vratkou',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Aufteilung passt zur Summe',
|
||||
'costs.splitSumUnder': 'Summe der Anteile: {sum} von {total} (es fehlen {diff})',
|
||||
'costs.splitSumOver': 'Summe der Anteile: {sum} von {total} ({diff} zu viel)',
|
||||
'costs.toggleSign': 'Zwischen Ausgabe und Erstattung wechseln',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,6 +146,7 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Split matches total',
|
||||
'costs.splitSumUnder': 'Sum of splits: {sum} of {total} (under by {diff})',
|
||||
'costs.splitSumOver': 'Sum of splits: {sum} of {total} (over by {diff})',
|
||||
'costs.toggleSign': 'Switch between expense and refund',
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'El reparto cuadra con el total',
|
||||
'costs.splitSumUnder': 'Suma de las partes: {sum} de {total} (faltan {diff})',
|
||||
'costs.splitSumOver': 'Suma de las partes: {sum} de {total} (sobran {diff})',
|
||||
'costs.toggleSign': 'Cambiar entre gasto y reembolso',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'La répartition correspond au total',
|
||||
'costs.splitSumUnder': 'Somme des parts : {sum} sur {total} (il manque {diff})',
|
||||
'costs.splitSumOver': 'Somme des parts : {sum} sur {total} ({diff} de trop)',
|
||||
'costs.toggleSign': 'Basculer entre dépense et remboursement',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -147,5 +147,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Ο διαμοιρασμός ταιριάζει με το σύνολο',
|
||||
'costs.splitSumUnder': 'Άθροισμα μεριδίων: {sum} από {total} (λείπουν {diff})',
|
||||
'costs.splitSumOver': 'Άθροισμα μεριδίων: {sum} από {total} ({diff} παραπάνω)',
|
||||
'costs.toggleSign': 'Εναλλαγή μεταξύ εξόδου και επιστροφής',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'A felosztás megegyezik a végösszeggel',
|
||||
'costs.splitSumUnder': 'A részek összege: {sum} / {total} (hiányzik {diff})',
|
||||
'costs.splitSumOver': 'A részek összege: {sum} / {total} ({diff} a többlet)',
|
||||
'costs.toggleSign': 'Váltás kiadás és visszatérítés között',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Pembagian sudah pas dengan total',
|
||||
'costs.splitSumUnder': 'Jumlah bagian: {sum} dari {total} (kurang {diff})',
|
||||
'costs.splitSumOver': 'Jumlah bagian: {sum} dari {total} (lebih {diff})',
|
||||
'costs.toggleSign': 'Beralih antara pengeluaran dan pengembalian dana',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'La divisione corrisponde al totale',
|
||||
'costs.splitSumUnder': 'Somma delle quote: {sum} di {total} (mancano {diff})',
|
||||
'costs.splitSumOver': 'Somma delle quote: {sum} di {total} ({diff} in più)',
|
||||
'costs.toggleSign': 'Passa da spesa a rimborso',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': '分割は合計と一致しています',
|
||||
'costs.splitSumUnder': '分割の合計: {total} のうち {sum}({diff} 不足)',
|
||||
'costs.splitSumOver': '分割の合計: {total} のうち {sum}({diff} 超過)',
|
||||
'costs.toggleSign': '支出と返金を切り替える',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': '분할 금액이 합계와 일치합니다',
|
||||
'costs.splitSumUnder': '분할 합계: {total} 중 {sum} ({diff} 부족)',
|
||||
'costs.splitSumOver': '분할 합계: {total} 중 {sum} ({diff} 초과)',
|
||||
'costs.toggleSign': '지출과 환불 전환',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Verdeling klopt met het totaal',
|
||||
'costs.splitSumUnder': 'Som van de delen: {sum} van {total} ({diff} te weinig)',
|
||||
'costs.splitSumOver': 'Som van de delen: {sum} van {total} ({diff} te veel)',
|
||||
'costs.toggleSign': 'Wisselen tussen uitgave en terugbetaling',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Podział zgadza się z sumą',
|
||||
'costs.splitSumUnder': 'Suma podziałów: {sum} z {total} (brakuje {diff})',
|
||||
'costs.splitSumOver': 'Suma podziałów: {sum} z {total} (o {diff} za dużo)',
|
||||
'costs.toggleSign': 'Przełącz między wydatkiem a zwrotem',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Разделение совпадает с итогом',
|
||||
'costs.splitSumUnder': 'Сумма долей: {sum} из {total} (не хватает {diff})',
|
||||
'costs.splitSumOver': 'Сумма долей: {sum} из {total} (больше на {diff})',
|
||||
'costs.toggleSign': 'Переключить между расходом и возвратом',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Fördelningen stämmer med totalen',
|
||||
'costs.splitSumUnder': 'Summan av delarna: {sum} av {total} ({diff} saknas)',
|
||||
'costs.splitSumOver': 'Summan av delarna: {sum} av {total} ({diff} för mycket)',
|
||||
'costs.toggleSign': 'Växla mellan utgift och återbetalning',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Bölüşüm toplamla eşleşiyor',
|
||||
'costs.splitSumUnder': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} eksik)',
|
||||
'costs.splitSumOver': 'Payların toplamı: {total} tutarın {sum} kadarı ({diff} fazla)',
|
||||
'costs.toggleSign': 'Gider ve iade arasında geçiş yap',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Розподіл збігається із сумою',
|
||||
'costs.splitSumUnder': 'Сума часток: {sum} з {total} (бракує {diff})',
|
||||
'costs.splitSumOver': 'Сума часток: {sum} з {total} (більше на {diff})',
|
||||
'costs.toggleSign': 'Перемкнути між витратою та поверненням',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -146,6 +146,7 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': 'Phần chia khớp với tổng',
|
||||
'costs.splitSumUnder': 'Tổng các phần: {sum} trên {total} (thiếu {diff})',
|
||||
'costs.splitSumOver': 'Tổng các phần: {sum} trên {total} (thừa {diff})',
|
||||
'costs.toggleSign': 'Chuyển giữa khoản chi và khoản hoàn',
|
||||
};
|
||||
|
||||
export default budget;
|
||||
|
||||
@@ -145,5 +145,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': '分攤金額與總額相符',
|
||||
'costs.splitSumUnder': '分攤合計:{total} 中的 {sum}(少 {diff})',
|
||||
'costs.splitSumOver': '分攤合計:{total} 中的 {sum}(多 {diff})',
|
||||
'costs.toggleSign': '在支出與退款之間切換',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
@@ -145,5 +145,6 @@ const budget: TranslationStrings = {
|
||||
'costs.splitBalanced': '分摊金额与总额相符',
|
||||
'costs.splitSumUnder': '分摊合计:{total} 中的 {sum}(少 {diff})',
|
||||
'costs.splitSumOver': '分摊合计:{total} 中的 {sum}(多 {diff})',
|
||||
'costs.toggleSign': '在支出和退款之间切换',
|
||||
};
|
||||
export default budget;
|
||||
|
||||
Reference in New Issue
Block a user