fix(budget): match the add-expense input decimal separator to the list (#1645)

This commit is contained in:
Konstantinos Thermos
2026-07-21 21:36:47 +03:00
committed by GitHub
parent b8728078c4
commit 88cb7f22dd
5 changed files with 54 additions and 26 deletions
@@ -108,12 +108,12 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
const nums = () => screen.getAllByPlaceholderText('0,00') as HTMLInputElement[]
await user.type(nums()[0], '100') // total = 100
await user.click(screen.getByRole('button', { name: /Custom/i }))
const customInputs = screen.getAllByPlaceholderText('50.00')
const customInputs = screen.getAllByPlaceholderText('50,00')
await user.type(customInputs[0], '30')
await user.type(customInputs[1], '70')
@@ -146,7 +146,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'AirTags')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '39,99') // comma → normalized to 39.99
await user.type(screen.getAllByPlaceholderText('0,00')[0], '39,99') // comma → normalized to 39.99
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1]) // footer submit
@@ -203,7 +203,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Hotel')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '120') // total only, paid on-site later
await user.type(screen.getAllByPlaceholderText('0,00')[0], '120') // total only, paid on-site later
// Deselect everyone — the cost is recorded without a split (the bug: this was blocked).
// The participant toggles are buttons; the same names also appear as plain text in
@@ -292,14 +292,14 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '90')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
// Alice (me) is seeded as the sole payer; including Bob rebalances to 45/45.
await user.click(screen.getAllByTestId('payer-toggle')[1])
expect(screen.getAllByTestId('payer-amount').map(i => (i as HTMLInputElement).value))
.toEqual(['45.00', '45.00'])
.toEqual(['45,00', '45,00'])
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
await user.click(addBtns[addBtns.length - 1])
@@ -330,7 +330,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
await user.type(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
await user.type(screen.getAllByPlaceholderText('0.00')[0], '90')
await user.type(screen.getAllByPlaceholderText('0,00')[0], '90')
await user.click(screen.getByRole('button', { name: 'Multiple people paid' }))
await user.click(screen.getAllByTestId('payer-toggle')[1])
@@ -435,7 +435,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.click(addBtn)
const itemNames = screen.getAllByPlaceholderText('Item name')
const itemPrices = screen.getAllByPlaceholderText('0.00')
const itemPrices = screen.getAllByPlaceholderText('0,00')
await user.type(itemNames[0], 'Apples')
await user.type(itemPrices[1], '10')
@@ -448,7 +448,7 @@ describe('CostsPanel — settlements in the ledger', () => {
await user.type(itemNames[2], 'Milk')
await user.type(itemPrices[3], '40')
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
expect(screen.getByDisplayValue('100,00')).toBeDisabled()
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
+7 -7
View File
@@ -10,7 +10,7 @@ import { useTranslation } from '../../i18n'
import { budgetApi } from '../../api/client'
import { useExchangeRates } from '../../hooks/useExchangeRates'
import { useIsMobile } from '../../hooks/useIsMobile'
import { formatMoney, currencyDecimals, currencyLocale } from '../../utils/formatters'
import { formatMoney, currencyDecimals, currencyLocale, localizeAmountInput } from '../../utils/formatters'
import Modal from '../shared/Modal'
import CustomSelect from '../shared/CustomSelect'
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
@@ -1295,7 +1295,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<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', opacity: isTicketMode ? 0.6 : 1 }}>
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
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%' }} />
@@ -1378,8 +1378,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
{on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
<NumericInput mode="decimal" placeholder="0.00" data-testid="payer-amount"
value={payerAmounts[p.id] || ''}
<NumericInput mode="decimal" placeholder={localizeAmountInput('0.00', currency)} data-testid="payer-amount"
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
@@ -1443,8 +1443,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
<NumericInput
mode="decimal"
placeholder="0.00"
value={item.price}
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(item.price, currency)}
onValueChange={v => handleUpdateItemPrice(item.id, v)}
className="text-content"
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
@@ -1526,7 +1526,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
on ? (
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
<input type="text" inputMode="decimal" placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)} value={localizeAmountInput(customAmounts[p.id] || '', currency)}
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
</div>
@@ -9,7 +9,7 @@ import { useTranslation } from '../../../../i18n'
import { useToast } from '../../../../components/shared/Toast'
import { useTripStore } from '../../../../store/tripStore'
import { useExchangeRates } from '../../../../hooks/useExchangeRates'
import { formatMoney } from '../../../../utils/formatters'
import { formatMoney, localizeAmountInput } from '../../../../utils/formatters'
import { SYMBOLS, SPLIT_COLORS, currenciesWith } from '../../../../components/Budget/BudgetPanel.constants'
import { COST_CATEGORY_LIST, catMeta } from '../../../../components/Budget/costsCategories'
import { calculateTicketShares, splitEqualShares, type TicketItem, type ExpensePrefill } from '../../../../components/Budget/CostsPanel'
@@ -350,8 +350,8 @@ 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="decimal"
placeholder="0.00"
value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(isTicketMode ? ticketInfo.total.toFixed(2) : total, currency)}
onValueChange={onTotalChange}
disabled={isTicketMode}
className="min-w-0 flex-1 border-0 bg-transparent text-[0.84375rem] font-semibold text-m-ink outline-none [font-variant-numeric:tabular-nums] placeholder:text-m-faint"
@@ -443,8 +443,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<span className="text-[0.75rem] text-m-faint">{sym(currency)}</span>
<NumericInput
mode="decimal"
placeholder="0.00"
value={payerAmounts[p.id] || ''}
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(payerAmounts[p.id] || '', currency)}
onValueChange={v => onPayerAmountChange(p.id, v)}
className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none"
/>
@@ -497,8 +497,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<span className="text-[0.75rem] text-m-faint">{sym(currency)}</span>
<NumericInput
mode="decimal"
placeholder="0.00"
value={item.price}
placeholder={localizeAmountInput('0.00', currency)}
value={localizeAmountInput(item.price, currency)}
onValueChange={v => handleUpdateItemPrice(item.id, v)}
className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none"
/>
@@ -575,8 +575,8 @@ export default function MCostSheet({ tripId, base, people, me, editing, prefill,
<input
type="text"
inputMode="decimal"
placeholder={(placeholderShares[p.id] || 0).toFixed(2)}
value={customAmounts[p.id] || ''}
placeholder={localizeAmountInput((placeholderShares[p.id] || 0).toFixed(2), currency)}
value={localizeAmountInput(customAmounts[p.id] || '', currency)}
onChange={e => handleCustomAmountChange(p.id, e.target.value)}
className="w-full border-0 bg-transparent py-[7px] text-right text-[0.8125rem] font-semibold text-m-ink outline-none placeholder:text-m-faint"
/>
+15 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals } from './formatters'
import { splitReservationDateTime, resolveDayId, formatMoney, formatMoneySum, currencyDecimals, localizeAmountInput } from './formatters'
import { CURRENCIES, SYMBOLS, currenciesWith } from '../components/Budget/BudgetPanel.constants'
import type { Day } from '../types'
@@ -26,6 +26,20 @@ describe('resolveDayId', () => {
})
})
describe('localizeAmountInput (#1624)', () => {
it('shows the amount with the currency comma separator so the field matches the list', () => {
expect(localizeAmountInput('12.5', 'EUR')).toBe('12,5')
expect(localizeAmountInput('0.00', 'EUR')).toBe('0,00')
})
it('keeps the dot for dot-separator currencies', () => {
expect(localizeAmountInput('12.5', 'USD')).toBe('12.5')
})
it('passes an empty/nullish value through unchanged', () => {
expect(localizeAmountInput('', 'EUR')).toBe('')
expect(localizeAmountInput(null, 'EUR')).toBe('')
})
})
describe('KGS currency (#1400)', () => {
it('is selectable everywhere the shared currency list feeds', () => {
expect(CURRENCIES).toContain('KGS')
+14
View File
@@ -71,6 +71,20 @@ export function currencyLocale(currency: string): string {
return CURRENCY_LOCALE[(currency || '').toUpperCase()] || 'en-US'
}
// The decimal separator formatMoney renders a currency with (',' for EUR, '.' for
// USD) — same home-locale convention, so an amount input can match the displayed list.
export function currencyDecimalSeparator(currency: string): string {
return (0.1).toLocaleString(currencyLocale(currency)).replace(/\d/g, '') || '.'
}
// Show a dot-normalized amount string in an input using the currency's decimal
// separator, so the field reads the same way as the list. State stays dot-normalized;
// callers still normalize typed input back to a dot on change.
export function localizeAmountInput(value: string | number | null | undefined, currency: string): string {
const s = String(value ?? '')
return currencyDecimalSeparator(currency) === ',' ? s.replace('.', ',') : s
}
/**
* Locale- and currency-correct money formatting via Intl: the symbol position,
* thousands/decimal separators and decimal count all follow the user's locale