mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(costs): each expense row shows who it is split between, with their part
Under the payers, a Split line lists every member of the expense with their share and a tick once they marked it paid, so checking that the right people are in no longer means opening each expense. More than six fold into a +N chip with the rest in its tooltip. (#1763)
This commit is contained in:
@@ -278,6 +278,43 @@ describe('CostsPanel — settlements in the ledger', () => {
|
||||
expect(within(hotelRow).queryByText(/you lent|you borrowed/)).toBeNull()
|
||||
})
|
||||
|
||||
it('lists who an expense is split between, with each part, in the row (#1763)', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ id: 1, username: 'alice' }), isAuthenticated: true })
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
const custom = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'food', name: 'Dinner' }),
|
||||
total_price: 90,
|
||||
payers: [{ user_id: 1, amount: 90, username: 'alice' }],
|
||||
members: [{ user_id: 1, username: 'alice', paid: 1, amount: 60 }, { user_id: 2, username: 'bob', paid: 0, amount: 30 }],
|
||||
}
|
||||
const crowd = {
|
||||
...buildBudgetItem({ trip_id: 1, category: 'activity', name: 'Boat' }),
|
||||
total_price: 80,
|
||||
payers: [{ user_id: 1, amount: 80, username: 'alice' }],
|
||||
members: Array.from({ length: 8 }, (_, i) => ({ user_id: i + 1, username: `p${i + 1}`, paid: 0 })),
|
||||
}
|
||||
server.use(
|
||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [custom, crowd] })),
|
||||
http.get('/api/trips/1/budget/settlement', () => HttpResponse.json({ balances: [], flows: [], settlements: [] })),
|
||||
)
|
||||
render(<CostsPanel tripId={1} tripMembers={tripMembers} />)
|
||||
|
||||
const dinnerRow = (await screen.findByText('Dinner')).closest('.exp-row') as HTMLElement
|
||||
const chips = within(dinnerRow).getAllByTestId('split-chip')
|
||||
expect(chips).toHaveLength(2)
|
||||
expect(chips[0]).toHaveTextContent('60')
|
||||
expect(chips[1]).toHaveTextContent('30')
|
||||
// alice marked her part as paid; bob has not.
|
||||
expect(within(chips[0]).getByLabelText('paid')).toBeInTheDocument()
|
||||
expect(within(chips[1]).queryByLabelText('paid')).toBeNull()
|
||||
|
||||
// Eight people fold into five chips and a "+3".
|
||||
const boatRow = (await screen.findByText('Boat')).closest('.exp-row') as HTMLElement
|
||||
expect(within(boatRow).getAllByTestId('split-chip')).toHaveLength(5)
|
||||
expect(within(boatRow).getByText('+3')).toBeInTheDocument()
|
||||
expect(within(boatRow).getAllByTestId('split-chip')[0]).toHaveTextContent('10')
|
||||
})
|
||||
|
||||
it('sums only unfinished expenses in the Outstanding amount card', async () => {
|
||||
// Display in the trip's own currency so FX conversion is an identity — keeps the asserted sum deterministic.
|
||||
seedStore(useSettingsStore, { settings: { ...useSettingsStore.getState().settings, default_currency: 'EUR' } })
|
||||
@@ -2463,8 +2500,10 @@ describe('CostsPanel: a bill entered in the display currency (#2525)', () => {
|
||||
|
||||
await screen.findByText('Aparthotel Silver')
|
||||
expect(screen.getByText(/\$400\.88 → 342,63\s€$/)).toBeInTheDocument()
|
||||
expect(screen.getByText('$395.77')).toBeInTheDocument()
|
||||
expect(screen.getByText('$30.00')).toBeInTheDocument()
|
||||
// The hotel's split chips can carry the same figure, so look past them to the payment.
|
||||
const outsideChips = (text: string) => screen.queryAllByText(text).filter(el => !el.closest('[data-testid="split-chip"]'))
|
||||
expect(outsideChips('$395.77')).toHaveLength(1)
|
||||
expect(outsideChips('$30.00')).toHaveLength(1)
|
||||
expect(screen.queryByText('$34.65')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ import { CountPill, EYEBROW } from '../Planner/bookings/bookingParts'
|
||||
import CostsToolbar, { type CostsView } from './CostsToolbar'
|
||||
import CostsTable, { CostsTableSummary } from './CostsTable'
|
||||
|
||||
/** Split chips a row shows before the rest fold into "+N" (#1763). */
|
||||
const SPLIT_CHIP_MAX = 6
|
||||
|
||||
interface CostsPanelProps {
|
||||
tripId: number
|
||||
tripMembers?: TripMember[]
|
||||
@@ -195,19 +198,20 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
// total but stays out of settlements until who-paid is filled in. A negative
|
||||
// total (a refund, #2176) is just as unfinished until its recipient is named.
|
||||
const isUnfinished = (e: BudgetItem) => baseTotal(e) !== 0 && (e.payers || []).filter(p => p.amount !== 0).length === 0
|
||||
const myShareOf = (e: BudgetItem) => {
|
||||
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
|
||||
// counting them here left the tile contradicting the balances right beside it.
|
||||
if (isUnfinished(e)) return 0
|
||||
const myMember = (e.members || []).find(m => m.user_id === me)
|
||||
if (!myMember) return 0
|
||||
if (myMember.amount !== null && myMember.amount !== undefined) {
|
||||
return booked(myMember.amount, e)
|
||||
// A member's part of an expense: the custom amount when one was set, else the
|
||||
// equal split the server settles with, in the display currency.
|
||||
const shareOf = (e: BudgetItem, userId: number) => {
|
||||
const member = (e.members || []).find(m => m.user_id === userId)
|
||||
if (!member) return 0
|
||||
if (member.amount !== null && member.amount !== undefined) {
|
||||
return booked(member.amount, e)
|
||||
}
|
||||
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
|
||||
const myShare = shares[me] || 0
|
||||
return booked(myShare, e)
|
||||
return booked(shares[userId] || 0, e)
|
||||
}
|
||||
// Nobody paid, so nobody owes: the ledger skips these entirely (#2225), and
|
||||
// counting them here left the tile contradicting the balances right beside it.
|
||||
const myShareOf = (e: BudgetItem) => (isUnfinished(e) ? 0 : shareOf(e, me))
|
||||
|
||||
// `booked` carries the rates. They can land after the expenses, and without it in the
|
||||
// deps the cards kept the sums they were first added up with while the rows moved on.
|
||||
@@ -889,6 +893,7 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{SplitChips({ e })}
|
||||
</div>
|
||||
|
||||
{/* The note, marked by a rule in the category colour instead of a box, so
|
||||
@@ -912,6 +917,39 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
||||
)
|
||||
}
|
||||
|
||||
// Who an expense is split between and each one's part (#1763), so checking that
|
||||
// the right people are in no longer means opening every expense. Quieter than
|
||||
// the payer chips above, and capped so a large group cannot crowd the row.
|
||||
function SplitChips({ e }: { e: BudgetItem }) {
|
||||
const members = e.members || []
|
||||
if (members.length === 0) return null
|
||||
const shown = members.length > SPLIT_CHIP_MAX ? members.slice(0, SPLIT_CHIP_MAX - 1) : members
|
||||
const rest = members.slice(shown.length)
|
||||
const chip = 'inline-flex items-center gap-[5px] rounded-full bg-surface-tertiary text-content-secondary'
|
||||
return (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 5, marginTop: 6 }}>
|
||||
<span className="text-content-faint" style={{ fontSize: 'calc(10px * var(--fs-scale-caption, 1))', fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', marginRight: 2 }}>{t('costs.split')}</span>
|
||||
{shown.map(m => {
|
||||
const share = fmt(shareOf(e, m.user_id))
|
||||
return (
|
||||
<Tooltip key={m.user_id} label={t('costs.splitChipLabel', { name: personName(m.user_id), amount: share })}>
|
||||
<span className={chip} data-testid="split-chip" style={{ padding: '2px 8px 2px 2px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))' }}>
|
||||
<Avatar id={m.user_id} size={16} />
|
||||
<span style={{ fontWeight: 600, fontVariantNumeric: 'tabular-nums' }}>{share}</span>
|
||||
{m.paid ? <Check size={11} strokeWidth={2.6} className="text-success" aria-label={t('costs.paid')} /> : null}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{rest.length > 0 && (
|
||||
<Tooltip label={rest.map(m => t('costs.splitChipLabel', { name: personName(m.user_id), amount: fmt(shareOf(e, m.user_id)) })).join(', ')}>
|
||||
<span className={chip} style={{ padding: '2px 8px', fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700 }}>+{rest.length}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A row's note as a pill that opens in place; an empty one keeps the column.
|
||||
function NoteCell({ note, noteKey, color }: { note: string; noteKey: string; color: string }) {
|
||||
if (!note) return <span />
|
||||
|
||||
Reference in New Issue
Block a user