mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 06:56:35 +00:00
fix(budget): add back the multi payer selection
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitCents, payerSum, payersBalanced, rebalancePayers } from './CostsPanel.helpers'
|
||||
|
||||
describe('splitCents', () => {
|
||||
it('splits evenly when it divides cleanly', () => {
|
||||
expect(splitCents(90, 3)).toEqual([30, 30, 30])
|
||||
})
|
||||
|
||||
it('distributes the remainder cents so the parts sum back exactly', () => {
|
||||
const parts = splitCents(100.01, 3)
|
||||
expect(parts).toEqual([33.34, 33.34, 33.33])
|
||||
expect(parts.reduce((a, b) => a + b, 0)).toBeCloseTo(100.01, 2)
|
||||
})
|
||||
|
||||
it('returns an empty list for a non-positive count', () => {
|
||||
expect(splitCents(50, 0)).toEqual([])
|
||||
})
|
||||
|
||||
it('floors a negative amount at zero rather than inventing debt', () => {
|
||||
expect(splitCents(-10, 2)).toEqual([0, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('payerSum', () => {
|
||||
it('sums only the selected payers', () => {
|
||||
const amounts = { 1: '45', 2: '45', 3: '99' }
|
||||
expect(payerSum(amounts, new Set([1, 2]))).toBeCloseTo(90, 2)
|
||||
})
|
||||
|
||||
it('treats blank and unparseable amounts as zero', () => {
|
||||
expect(payerSum({ 1: '', 2: 'abc' }, new Set([1, 2]))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('payersBalanced', () => {
|
||||
it('is true when the payer amounts add up to the total', () => {
|
||||
expect(payersBalanced({ 1: '45', 2: '45' }, new Set([1, 2]), 90)).toBe(true)
|
||||
})
|
||||
|
||||
it('is false when they do not', () => {
|
||||
expect(payersBalanced({ 1: '45', 2: '40' }, new Set([1, 2]), 90)).toBe(false)
|
||||
})
|
||||
|
||||
it('compares to the cent, tolerating float dust', () => {
|
||||
expect(payersBalanced({ 1: '33.34', 2: '33.34', 3: '33.33' }, new Set([1, 2, 3]), 100.01)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('rebalancePayers', () => {
|
||||
it('spreads the total across payers when none are pinned', () => {
|
||||
const next = rebalancePayers({}, new Set(), new Set([1, 2]), 90)
|
||||
expect(next).toEqual({ 1: '45.00', 2: '45.00' })
|
||||
})
|
||||
|
||||
it('leaves pinned payers alone and lets the rest absorb the remainder', () => {
|
||||
// Alice pinned at 70 of a 100 bill → Bob must absorb 30.
|
||||
const next = rebalancePayers({ 1: '70' }, new Set([1]), new Set([1, 2]), 100)
|
||||
expect(next[1]).toBe('70')
|
||||
expect(next[2]).toBe('30.00')
|
||||
})
|
||||
|
||||
it('returns the amounts untouched when every payer is pinned', () => {
|
||||
const amounts = { 1: '70', 2: '20' }
|
||||
const next = rebalancePayers(amounts, new Set([1, 2]), new Set([1, 2]), 100)
|
||||
expect(next).toEqual(amounts)
|
||||
})
|
||||
|
||||
it('blanks a free payer whose share works out to zero', () => {
|
||||
// Alice pinned at the full total → Bob is a payer with nothing left to pay.
|
||||
const next = rebalancePayers({ 1: '100' }, new Set([1]), new Set([1, 2]), 100)
|
||||
expect(next[2]).toBe('')
|
||||
})
|
||||
|
||||
it('keeps the result balanced after rebalancing', () => {
|
||||
const next = rebalancePayers({ 1: '33.33' }, new Set([1]), new Set([1, 2, 3]), 100)
|
||||
expect(payersBalanced(next, new Set([1, 2, 3]), 100)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Pure payer math for the Costs expense modal.
|
||||
*
|
||||
* An expense's payers must always sum to its total. The server re-derives
|
||||
* budget_items.total_price from the payer sum (budgetService.createItem), so an
|
||||
* unbalanced payer list would silently rewrite the expense total — and in custom
|
||||
* split mode the member debits, balanced against the old total, would stop
|
||||
* cancelling the payer credits. rebalancePayers keeps the payers the user hasn't
|
||||
* touched absorbing the remainder as they type; payersBalanced gates the save.
|
||||
*
|
||||
* Amounts are the raw input strings, parsed on use (same as customAmounts).
|
||||
*/
|
||||
|
||||
/** Spread `amount` across `n` payers in whole cents so the parts sum back exactly. */
|
||||
export function splitCents(amount: number, n: number): number[] {
|
||||
if (n <= 0) return []
|
||||
const cents = Math.max(0, Math.round(amount * 100))
|
||||
const base = Math.floor(cents / n)
|
||||
const rem = cents - base * n
|
||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
||||
}
|
||||
|
||||
/** Sum the amounts of the selected payers. */
|
||||
export function payerSum(amounts: Record<number, string>, ids: Set<number>): number {
|
||||
return [...ids].reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
|
||||
}
|
||||
|
||||
/** True when the payer amounts add up to the expense total, to the cent. */
|
||||
export function payersBalanced(amounts: Record<number, string>, ids: Set<number>, total: number): boolean {
|
||||
return Math.round(payerSum(amounts, ids) * 100) === Math.round(total * 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute the payers the user has not explicitly edited (everyone not in
|
||||
* `pinned`) so the whole list sums to `total`. Pinned amounts are left as typed.
|
||||
*/
|
||||
export function rebalancePayers(
|
||||
amounts: Record<number, string>,
|
||||
pinned: Set<number>,
|
||||
ids: Set<number>,
|
||||
total: number,
|
||||
): Record<number, string> {
|
||||
const all = [...ids]
|
||||
const free = all.filter(id => !pinned.has(id))
|
||||
if (free.length === 0) return amounts
|
||||
const pinnedSum = all
|
||||
.filter(id => pinned.has(id))
|
||||
.reduce((a, id) => a + (parseFloat(amounts[id]) || 0), 0)
|
||||
const shares = splitCents(total - pinnedSum, free.length)
|
||||
const next = { ...amounts }
|
||||
free.forEach((id, i) => { next[id] = shares[i] ? shares[i].toFixed(2) : '' })
|
||||
return next
|
||||
}
|
||||
@@ -270,6 +270,118 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(await screen.findByRole('button', { name: 'You' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// ── Multi-payer (#1426 regression) ─────────────────────────────────────────
|
||||
// 3.2.0 collapsed payers[] to a single payer, so a bill fronted by two people
|
||||
// credited all of it to one and skewed settle-up. The ledger always supported N
|
||||
// payers; only the form could no longer send them.
|
||||
|
||||
it('records an expense paid by two people with their own amounts', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
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: 'Dinner' }), id: 11 } })
|
||||
}),
|
||||
)
|
||||
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…'), 'Dinner')
|
||||
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'])
|
||||
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
await user.click(addBtns[addBtns.length - 1])
|
||||
|
||||
await waitFor(() => expect(posted).toBeTruthy())
|
||||
expect(posted!.total_price).toBe(90)
|
||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
||||
{ user_id: 1, amount: 45 },
|
||||
{ user_id: 2, amount: 45 },
|
||||
]))
|
||||
expect(posted!.payers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('blocks saving when the payer amounts do not add up to the total', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
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: 'Dinner' }) })
|
||||
}),
|
||||
)
|
||||
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…'), 'Dinner')
|
||||
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])
|
||||
|
||||
// Pin both payers at 20 of a 90 bill, so nobody is left to absorb the rest.
|
||||
const amounts = () => screen.getAllByTestId('payer-amount') as HTMLInputElement[]
|
||||
await user.clear(amounts()[0])
|
||||
await user.type(amounts()[0], '20')
|
||||
await user.clear(amounts()[1])
|
||||
await user.type(amounts()[1], '20')
|
||||
|
||||
// An unbalanced payer list would make the server re-derive total_price as 40.
|
||||
expect(screen.getByText(/must add up to/i)).toBeInTheDocument()
|
||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||
expect(addBtns[addBtns.length - 1]).toBeDisabled()
|
||||
expect(posted).toBeNull()
|
||||
})
|
||||
|
||||
it('reopens a two-payer expense with both payers intact', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
let put: Record<string, unknown> | null = null
|
||||
const item = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
|
||||
id: 7,
|
||||
total_price: 90,
|
||||
payers: [{ user_id: 1, amount: 45, username: 'alice' }, { user_id: 2, amount: 45, username: 'bob' }],
|
||||
members: [{ user_id: 1, username: 'alice', paid: 0 }, { user_id: 2, username: 'bob', paid: 0 }],
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
http.put('/api/trips/1/budget/7', async ({ request }) => {
|
||||
put = await request.json() as Record<string, unknown>
|
||||
return HttpResponse.json({ item })
|
||||
}),
|
||||
)
|
||||
const { default: userEvent } = await import('@testing-library/user-event')
|
||||
const user = userEvent.setup()
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
await screen.findByText('Dinner')
|
||||
await user.click(screen.getByTitle('Edit'))
|
||||
|
||||
// Loading used to be payers.find(...), which silently dropped the second payer.
|
||||
const amounts = await screen.findAllByTestId('payer-amount')
|
||||
expect(amounts.map(i => (i as HTMLInputElement).value)).toEqual(['45', '45'])
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(put).toBeTruthy())
|
||||
expect(put!.payers).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('exports the expenses as a CSV download (#1500)', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
|
||||
@@ -15,6 +15,7 @@ import Modal from '../shared/Modal'
|
||||
import CustomSelect from '../shared/CustomSelect'
|
||||
import { CustomDatePicker } from '../shared/CustomDateTimePicker'
|
||||
import { SYMBOLS, currenciesWith, SPLIT_COLORS } from './BudgetPanel.constants'
|
||||
import { payersBalanced, rebalancePayers } from './CostsPanel.helpers'
|
||||
import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
||||
import type { BudgetItem } from '../../types'
|
||||
import type { TripMember } from './BudgetPanelMemberChips'
|
||||
@@ -989,13 +990,29 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||
|
||||
// Payer state: 0 represents "Nobody (planning entry)". On an existing expense a
|
||||
// missing payer is a deliberate choice, so only a brand-new one defaults to me.
|
||||
// Payer state. An expense can be fronted by several people, each with their own
|
||||
// amount (budget_item_payers) — a shared card, or "I got this round, you get the
|
||||
// next". The single-payer dropdown stays the default path; multiPayer swaps in a
|
||||
// per-person amount editor. 0 represents "Nobody (planning entry)"; on an
|
||||
// existing expense a missing payer is a deliberate choice, so only a brand-new
|
||||
// one defaults to me.
|
||||
const initialPayers = (editing?.payers || []).filter(p => p.amount > 0)
|
||||
|
||||
const [payerId, setPayerId] = useState<number>(() => {
|
||||
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
|
||||
const existingPayer = initialPayers[0]
|
||||
if (existingPayer) return existingPayer.user_id
|
||||
return editing ? 0 : me
|
||||
})
|
||||
const [multiPayer, setMultiPayer] = useState(() => initialPayers.length > 1)
|
||||
const [payerIds, setPayerIds] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
const [payerAmounts, setPayerAmounts] = useState<Record<number, string>>(() => {
|
||||
const m: Record<number, string> = {}
|
||||
for (const p of initialPayers) m[p.user_id] = String(p.amount)
|
||||
return m
|
||||
})
|
||||
// Payers the user typed an amount for: rebalance leaves these alone and makes
|
||||
// the others absorb the remainder.
|
||||
const [pinnedPayers, setPinnedPayers] = useState<Set<number>>(() => new Set(initialPayers.map(p => p.user_id)))
|
||||
|
||||
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||
@@ -1066,7 +1083,8 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
}, [totalNum, participants, customAmounts, editing])
|
||||
|
||||
const ticketValid = ticketItems.length > 0 && ticketItems.every(item => item.name.trim().length > 0 && (parseFloat(item.price) || 0) > 0 && item.participants.size > 0)
|
||||
const valid = name.trim().length > 0 && (
|
||||
const payersOk = !multiPayer || (payerIds.size > 0 && payersBalanced(payerAmounts, payerIds, totalNum))
|
||||
const valid = name.trim().length > 0 && payersOk && (
|
||||
isTicketMode
|
||||
? ticketValid
|
||||
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||
@@ -1076,6 +1094,52 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
setTotal(v.replace(',', '.'))
|
||||
}
|
||||
|
||||
// Keep the payer amounts summing to the total as it changes — including in ticket
|
||||
// mode, where the total is derived from the ticket items rather than typed.
|
||||
useEffect(() => {
|
||||
if (!multiPayer) return
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinnedPayers, payerIds, totalNum))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [totalNum])
|
||||
|
||||
const enableMultiPayer = () => {
|
||||
const seed = payerIds.size > 0 ? new Set(payerIds) : new Set<number>([payerId > 0 ? payerId : me])
|
||||
const pinned = new Set<number>()
|
||||
setPayerIds(seed)
|
||||
setPinnedPayers(pinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, pinned, seed, totalNum))
|
||||
setMultiPayer(true)
|
||||
}
|
||||
|
||||
const disableMultiPayer = () => {
|
||||
// Collapsing back keeps the first payer; their amount becomes the whole total.
|
||||
const [first] = [...payerIds]
|
||||
setPayerId(first ?? me)
|
||||
setMultiPayer(false)
|
||||
}
|
||||
|
||||
const togglePayer = (id: number) => {
|
||||
const nextIds = new Set(payerIds)
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
if (nextIds.has(id)) {
|
||||
nextIds.delete(id)
|
||||
nextPinned.delete(id)
|
||||
} else {
|
||||
nextIds.add(id)
|
||||
}
|
||||
setPayerIds(nextIds)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers(prev, nextPinned, nextIds, totalNum))
|
||||
}
|
||||
|
||||
const onPayerAmountChange = (id: number, v: string) => {
|
||||
const val = v.replace(',', '.')
|
||||
const nextPinned = new Set(pinnedPayers)
|
||||
nextPinned.add(id)
|
||||
setPinnedPayers(nextPinned)
|
||||
setPayerAmounts(prev => rebalancePayers({ ...prev, [id]: val }, nextPinned, payerIds, totalNum))
|
||||
}
|
||||
|
||||
const handleCustomAmountChange = (id: number, val: string) => {
|
||||
val = val.replace(',', '.')
|
||||
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||
@@ -1140,7 +1204,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
const save = async () => {
|
||||
if (!valid) return
|
||||
setSaving(true)
|
||||
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const payerList = multiPayer
|
||||
? [...payerIds]
|
||||
.map(id => ({ user_id: id, amount: parseFloat(payerAmounts[id]) || 0 }))
|
||||
.filter(p => p.amount > 0)
|
||||
: (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
|
||||
const memberList = [...participants].map(id => ({
|
||||
user_id: id,
|
||||
amount: splitMode === 'custom'
|
||||
@@ -1245,13 +1313,66 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
|
||||
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
]}
|
||||
style={{ width: '100%' }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<label className={labelCls} style={{ marginBottom: 0 }}>{t('costs.whoPaid')}</label>
|
||||
<button type="button" onClick={() => (multiPayer ? disableMultiPayer() : enableMultiPayer())}
|
||||
className="text-content-muted"
|
||||
style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))', fontWeight: 600, textDecoration: 'underline' }}>
|
||||
{multiPayer ? t('costs.singlePayer') : t('costs.multiplePayers')}
|
||||
</button>
|
||||
</div>
|
||||
{!multiPayer ? (
|
||||
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
|
||||
options={[
|
||||
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
|
||||
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||
]}
|
||||
style={{ width: '100%' }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{people.map((p, idx) => {
|
||||
const on = payerIds.has(p.id)
|
||||
return (
|
||||
<div key={p.id} className="bg-surface-secondary border border-edge"
|
||||
style={{ display: 'grid', gridTemplateColumns: '1fr 130px', gap: 10, alignItems: 'center', padding: '8px 11px', borderRadius: 10, opacity: on ? 1 : 0.5 }}>
|
||||
<button type="button" onClick={() => togglePayer(p.id)} data-testid="payer-toggle"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||
{p.avatar_url
|
||||
? <img src={p.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%', objectFit: 'cover', display: 'block', flexShrink: 0, opacity: on ? 1 : 0.45 }} />
|
||||
: <span style={{ width: 22, height: 22, borderRadius: '50%', background: SPLIT_COLORS[idx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 9, fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>
|
||||
{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}
|
||||
</span>}
|
||||
<span className="text-content" style={{ fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{p.id === me ? t('costs.you') : p.username}
|
||||
</span>
|
||||
</button>
|
||||
{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] || ''}
|
||||
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' }} />
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" onClick={() => togglePayer(p.id)} className="text-content-faint"
|
||||
style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-caption, 1))', textAlign: 'right' }}>
|
||||
{t('costs.tapToInclude')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{!payersOk && (
|
||||
<div style={{ marginTop: 8, fontSize: 'calc(12.5px * var(--fs-scale-caption, 1))', color: '#d97706' }}>
|
||||
{t('costs.payersUnbalanced', { amount: formatMoney(totalNum, currency, locale) })}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user