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:
Maurice
2026-10-01 09:34:52 +02:00
parent b4f0a6dea3
commit 104f0eef38
29 changed files with 116 additions and 12 deletions
@@ -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()
})
+48 -10
View File
@@ -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 />
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'اضغط للتضمين',
'costs.amount': 'المبلغ',
'costs.split': 'التقسيم',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'بالتساوي',
'costs.splitCustom': 'مخصص',
'costs.splitTicket': 'الإيصال',
+1
View File
@@ -141,6 +141,7 @@ const budget: TranslationStrings = {
'costs.splitBetween': 'Bərabər bölüşdürüləcək şəxslər',
'costs.split': 'Bölüşdür',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Bərabər',
'costs.splitCustom': 'Fərdi',
'costs.splitTicket': 'Qəbz üzrə',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Toque para incluir',
'costs.amount': 'Valor',
'costs.split': 'Dividir',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Igualmente',
'costs.splitCustom': 'Personalizado',
'costs.splitTicket': 'Recibo',
+1
View File
@@ -118,6 +118,7 @@ const budget: TranslationStrings = {
'costs.whoPaid': 'Qui ha pagat?',
'costs.splitBetween': 'Dividir a parts iguals entre',
'costs.split': 'Dividir',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'A parts iguals',
'costs.splitCustom': 'Personalitzat',
'costs.splitTicket': 'Tiquet',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Klepnutím zahrnout',
'costs.amount': 'Částka',
'costs.split': 'Rozdělení',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Rovným dílem',
'costs.splitCustom': 'Vlastní',
'costs.splitTicket': 'Účtenka',
+1
View File
@@ -153,6 +153,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Zum Einbeziehen tippen',
'costs.amount': 'Betrag',
'costs.split': 'Aufteilen',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Gleichmäßig',
'costs.splitCustom': 'Individuell',
'costs.splitTicket': 'Beleg',
+1
View File
@@ -126,6 +126,7 @@ const budget: TranslationStrings = {
'costs.payersUnbalanced': 'Payer amounts must add up to {amount}',
'costs.splitBetween': 'Split equally between',
'costs.split': 'Split',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Equally',
'costs.splitCustom': 'Custom',
'costs.splitTicket': 'Ticket',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Toca para incluir',
'costs.amount': 'Importe',
'costs.split': 'Dividir',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'A partes iguales',
'costs.splitCustom': 'Personalizado',
'costs.splitTicket': 'Recibo',
+1
View File
@@ -126,6 +126,7 @@ const budget: TranslationStrings = {
'costs.payersUnbalanced': 'Maksjate summad peavad kokku andma {amount}',
'costs.splitBetween': 'Jaga võrdselt järgmiste vahel',
'costs.split': 'Jaotus',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Võrdselt',
'costs.splitCustom': 'Kohandatud',
'costs.splitTicket': 'Tšekk',
+1
View File
@@ -151,6 +151,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Toucher pour inclure',
'costs.amount': 'Montant',
'costs.split': 'Répartition',
'costs.splitChipLabel': '{name} : {amount}',
'costs.splitEqually': 'À parts égales',
'costs.splitCustom': 'Personnalisé',
'costs.splitTicket': 'Reçu',
+1
View File
@@ -151,6 +151,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Πατήστε για προσθήκη',
'costs.amount': 'Ποσό',
'costs.split': 'Διαμοιρασμός',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Εξίσου',
'costs.splitCustom': 'Προσαρμογή',
'costs.splitTicket': 'Απόδειξη',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Koppintson a hozzáadáshoz',
'costs.amount': 'Összeg',
'costs.split': 'Felosztás',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Egyenlően',
'costs.splitCustom': 'Egyéni',
'costs.splitTicket': 'Blokk',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Ketuk untuk menyertakan',
'costs.amount': 'Jumlah',
'costs.split': 'Pembagian',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Rata',
'costs.splitCustom': 'Kustom',
'costs.splitTicket': 'Struk',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Tocca per includere',
'costs.amount': 'Importo',
'costs.split': 'Suddivisione',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'In parti uguali',
'costs.splitCustom': 'Personalizzato',
'costs.splitTicket': 'Scontrino',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'タップして追加',
'costs.amount': '金額',
'costs.split': '分け方',
'costs.splitChipLabel': '{name}:{amount}',
'costs.splitEqually': '均等',
'costs.splitCustom': 'カスタム',
'costs.splitTicket': 'レシート',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': '탭하여 포함',
'costs.amount': '금액',
'costs.split': '나누기',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': '균등하게',
'costs.splitCustom': '직접 설정',
'costs.splitTicket': '영수증',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Tik om toe te voegen',
'costs.amount': 'Bedrag',
'costs.split': 'Verdelen',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Gelijk',
'costs.splitCustom': 'Aangepast',
'costs.splitTicket': 'Bon',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Dotknij, aby dodać',
'costs.amount': 'Kwota',
'costs.split': 'Podział',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Po równo',
'costs.splitCustom': 'Własny',
'costs.splitTicket': 'Paragon',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Нажмите, чтобы добавить',
'costs.amount': 'Сумма',
'costs.split': 'Разделение',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Поровну',
'costs.splitCustom': 'Вручную',
'costs.splitTicket': 'Чек',
+1
View File
@@ -126,6 +126,7 @@ const budget: TranslationStrings = {
'costs.payersUnbalanced': 'Súčet súm platcov musí byť {amount}',
'costs.splitBetween': 'Rozdeliť rovným dielom medzi',
'costs.split': 'Rozdeliť',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Rovnakým dielom',
'costs.splitCustom': 'Vlastné',
'costs.splitTicket': 'Účtenka',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Tryck för att inkludera',
'costs.amount': 'Summa',
'costs.split': 'Fördelning',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Lika',
'costs.splitCustom': 'Anpassat',
'costs.splitTicket': 'Kvitto',
+1
View File
@@ -122,6 +122,7 @@ const budget: TranslationStrings = {
'costs.payersUnbalanced': 'จำนวนเงินของผู้ชำระจะต้องรวมกันไม่เกิน {amount}',
'costs.splitBetween': 'แบ่งเท่าๆ กันระหว่าง',
'costs.split': 'แยก',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'เท่าๆ กัน',
'costs.splitCustom': 'กำหนดเอง',
'costs.splitTicket': 'ตั๋ว',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Eklemek için dokun',
'costs.amount': 'Tutar',
'costs.split': 'Paylaşım',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Eşit',
'costs.splitCustom': 'Özel',
'costs.splitTicket': 'Fiş',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Натисніть, щоб додати',
'costs.amount': 'Сума',
'costs.split': 'Розподіл',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Порівну',
'costs.splitCustom': 'Вручну',
'costs.splitTicket': 'Чек',
+1
View File
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': 'Nhấn để bao gồm',
'costs.amount': 'Số lượng',
'costs.split': 'Chia tiền',
'costs.splitChipLabel': '{name}: {amount}',
'costs.splitEqually': 'Chia đều',
'costs.splitCustom': 'Tùy chỉnh',
'costs.splitTicket': 'Hóa đơn',
+1
View File
@@ -149,6 +149,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': '點按以加入',
'costs.amount': '金額',
'costs.split': '分攤',
'costs.splitChipLabel': '{name}:{amount}',
'costs.splitEqually': '平均',
'costs.splitCustom': '自訂',
'costs.splitTicket': '收據',
+1
View File
@@ -149,6 +149,7 @@ const budget: TranslationStrings = {
'costs.tapToInclude': '点按以加入',
'costs.amount': '金额',
'costs.split': '分摊',
'costs.splitChipLabel': '{name}:{amount}',
'costs.splitEqually': '平均分摊',
'costs.splitCustom': '自定义金额',
'costs.splitTicket': '按账单明细',