mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(budget): match the add-expense input decimal separator to the list (#1645)
This commit is contained in:
committed by
GitHub
parent
b8728078c4
commit
88cb7f22dd
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user