fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)

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:
Maurice
2026-09-02 19:00:49 +02:00
committed by Maurice
parent c907e0d5e5
commit 7c857173cf
29 changed files with 199 additions and 34 deletions
@@ -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', () => {
+9 -4
View File
@@ -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
+53 -24
View File
@@ -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)}
+1
View File
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': 'التقسيم يطابق الإجمالي',
'costs.splitSumUnder': 'مجموع الحصص: {sum} من {total} (ناقص {diff})',
'costs.splitSumOver': 'مجموع الحصص: {sum} من {total} (زائد {diff})',
'costs.toggleSign': 'التبديل بين المصروف والاسترداد',
};
export default budget;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -147,5 +147,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': 'Ο διαμοιρασμός ταιριάζει με το σύνολο',
'costs.splitSumUnder': 'Άθροισμα μεριδίων: {sum} από {total} (λείπουν {diff})',
'costs.splitSumOver': 'Άθροισμα μεριδίων: {sum} από {total} ({diff} παραπάνω)',
'costs.toggleSign': 'Εναλλαγή μεταξύ εξόδου και επιστροφής',
};
export default budget;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': '分割は合計と一致しています',
'costs.splitSumUnder': '分割の合計: {total} のうち {sum}({diff} 不足)',
'costs.splitSumOver': '分割の合計: {total} のうち {sum}({diff} 超過)',
'costs.toggleSign': '支出と返金を切り替える',
};
export default budget;
+1
View File
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': '분할 금액이 합계와 일치합니다',
'costs.splitSumUnder': '분할 합계: {total} 중 {sum} ({diff} 부족)',
'costs.splitSumOver': '분할 합계: {total} 중 {sum} ({diff} 초과)',
'costs.toggleSign': '지출과 환불 전환',
};
export default budget;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': 'Разделение совпадает с итогом',
'costs.splitSumUnder': 'Сумма долей: {sum} из {total} (не хватает {diff})',
'costs.splitSumOver': 'Сумма долей: {sum} из {total} (больше на {diff})',
'costs.toggleSign': 'Переключить между расходом и возвратом',
};
export default budget;
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -146,5 +146,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': 'Розподіл збігається із сумою',
'costs.splitSumUnder': 'Сума часток: {sum} з {total} (бракує {diff})',
'costs.splitSumOver': 'Сума часток: {sum} з {total} (більше на {diff})',
'costs.toggleSign': 'Перемкнути між витратою та поверненням',
};
export default budget;
+1
View File
@@ -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;
+1
View File
@@ -145,5 +145,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': '分攤金額與總額相符',
'costs.splitSumUnder': '分攤合計:{total} 中的 {sum}(少 {diff})',
'costs.splitSumOver': '分攤合計:{total} 中的 {sum}(多 {diff})',
'costs.toggleSign': '在支出與退款之間切換',
};
export default budget;
+1
View File
@@ -145,5 +145,6 @@ const budget: TranslationStrings = {
'costs.splitBalanced': '分摊金额与总额相符',
'costs.splitSumUnder': '分摊合计:{total} 中的 {sum}(少 {diff})',
'costs.splitSumOver': '分摊合计:{total} 中的 {sum}(多 {diff})',
'costs.toggleSign': '在支出和退款之间切换',
};
export default budget;