mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-18 19:36:02 +00:00
fix(budget): accept comma decimal separator in expense amounts
The expense Total Amount, per-person "Who paid", and settlement amount
inputs used type="number" with a bare parseFloat. On desktop the number
input normalized comma→dot for free, but mobile keyboards drop the comma
before onChange fires, so parseFloat("39,99") silently became 39.
Switch the three inputs to type="text" inputMode="decimal" and normalize
comma→dot in their onChange handlers, matching the pattern already used
by the other budget inputs (BudgetPanelInlineEditCell, BudgetPanelAddItemRow,
DashboardPage). Both comma and dot now work on every device.
Closes #1256
This commit is contained in:
@@ -107,7 +107,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
|
|
||||||
await user.click(await screen.findByRole('button', { name: 'Add expense' }))
|
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(await screen.findByPlaceholderText('e.g. Dinner, souvenirs, gas…'), 'Dinner')
|
||||||
const nums = () => screen.getAllByRole('spinbutton') as HTMLInputElement[]
|
const nums = () => screen.getAllByPlaceholderText('0.00') as HTMLInputElement[]
|
||||||
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
await user.type(nums()[0], '100') // total → auto equal-split across the 2 participants
|
||||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
||||||
expect(nums()[2].value).toBe('50')
|
expect(nums()[2].value).toBe('50')
|
||||||
@@ -125,6 +125,30 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
]))
|
]))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts a comma as the decimal separator in the total amount (#1256)', async () => {
|
||||||
|
let posted: Record<string, unknown> | null = null
|
||||||
|
server.use(
|
||||||
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
|
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||||
|
http.post('/api/trips/1/budget', async ({ request }) => {
|
||||||
|
posted = await request.json() as Record<string, unknown>
|
||||||
|
return HttpResponse.json({ item: { ...buildBudgetItem({ trip_id: 1, name: 'AirTags' }), id: 6 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const { default: userEvent } = await import('@testing-library/user-event')
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
|
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||||
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
|
expect(posted!.total_price).toBe(39.99)
|
||||||
|
})
|
||||||
|
|
||||||
it('marks an expense with no payer as Unfinished', async () => {
|
it('marks an expense with no payer as Unfinished', async () => {
|
||||||
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
const item = { ...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Hotel' }), total_price: 90, payers: [], members: [{ user_id: 1, username: 'alice', paid: 0 }] }
|
||||||
server.use(
|
server.use(
|
||||||
|
|||||||
@@ -754,8 +754,8 @@ function SettlementModal({ tripId, people, me, editing, onClose, onSaved }: {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.amount')}</label>
|
<label className={labelCls}>{t('costs.amount')}</label>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={amount}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={amount}
|
||||||
onChange={e => setAmount(e.target.value)} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
onChange={e => setAmount(e.target.value.replace(',', '.'))} className={inputCls} style={{ borderRadius: 10, padding: '11px 13px', fontSize: 14, outline: 'none', fontWeight: 600 }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -833,10 +833,12 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onTotalChange = (v: string) => {
|
const onTotalChange = (v: string) => {
|
||||||
|
v = v.replace(',', '.')
|
||||||
setTotal(v)
|
setTotal(v)
|
||||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
||||||
}
|
}
|
||||||
const onPaidChange = (id: number, v: string) => {
|
const onPaidChange = (id: number, v: string) => {
|
||||||
|
v = v.replace(',', '.')
|
||||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
const nextDirty = new Set(dirty); nextDirty.add(id)
|
||||||
setDirty(nextDirty)
|
setDirty(nextDirty)
|
||||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
||||||
@@ -896,7 +898,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
<label className={labelCls}>{t('costs.totalAmount')}</label>
|
<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' }}>
|
<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>
|
<span className="text-content-faint" style={{ fontSize: 15 }}>{sym(currency)}</span>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={total}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
||||||
onChange={e => onTotalChange(e.target.value)}
|
onChange={e => onTotalChange(e.target.value)}
|
||||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 15, fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -956,7 +958,7 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
{on ? (
|
{on ? (
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
<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>
|
<span className="text-content-faint" style={{ fontSize: 13 }}>{sym(currency)}</span>
|
||||||
<input type="number" inputMode="decimal" min="0" step="0.01" placeholder="0.00" value={paid[p.id] || ''}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
||||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
onChange={e => onPaidChange(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' }} />
|
className="text-content" style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 14, fontWeight: 600, padding: '8px 0', textAlign: 'right' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user