mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +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 />
|
||||
|
||||
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': 'اضغط للتضمين',
|
||||
'costs.amount': 'المبلغ',
|
||||
'costs.split': 'التقسيم',
|
||||
'costs.splitChipLabel': '{name}: {amount}',
|
||||
'costs.splitEqually': 'بالتساوي',
|
||||
'costs.splitCustom': 'مخصص',
|
||||
'costs.splitTicket': 'الإيصال',
|
||||
|
||||
@@ -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ə',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -151,6 +151,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': 'Πατήστε για προσθήκη',
|
||||
'costs.amount': 'Ποσό',
|
||||
'costs.split': 'Διαμοιρασμός',
|
||||
'costs.splitChipLabel': '{name}: {amount}',
|
||||
'costs.splitEqually': 'Εξίσου',
|
||||
'costs.splitCustom': 'Προσαρμογή',
|
||||
'costs.splitTicket': 'Απόδειξη',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': 'タップして追加',
|
||||
'costs.amount': '金額',
|
||||
'costs.split': '分け方',
|
||||
'costs.splitChipLabel': '{name}:{amount}',
|
||||
'costs.splitEqually': '均等',
|
||||
'costs.splitCustom': 'カスタム',
|
||||
'costs.splitTicket': 'レシート',
|
||||
|
||||
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': '탭하여 포함',
|
||||
'costs.amount': '금액',
|
||||
'costs.split': '나누기',
|
||||
'costs.splitChipLabel': '{name}: {amount}',
|
||||
'costs.splitEqually': '균등하게',
|
||||
'costs.splitCustom': '직접 설정',
|
||||
'costs.splitTicket': '영수증',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': 'Нажмите, чтобы добавить',
|
||||
'costs.amount': 'Сумма',
|
||||
'costs.split': 'Разделение',
|
||||
'costs.splitChipLabel': '{name}: {amount}',
|
||||
'costs.splitEqually': 'Поровну',
|
||||
'costs.splitCustom': 'Вручную',
|
||||
'costs.splitTicket': 'Чек',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -122,6 +122,7 @@ const budget: TranslationStrings = {
|
||||
'costs.payersUnbalanced': 'จำนวนเงินของผู้ชำระจะต้องรวมกันไม่เกิน {amount}',
|
||||
'costs.splitBetween': 'แบ่งเท่าๆ กันระหว่าง',
|
||||
'costs.split': 'แยก',
|
||||
'costs.splitChipLabel': '{name}: {amount}',
|
||||
'costs.splitEqually': 'เท่าๆ กัน',
|
||||
'costs.splitCustom': 'กำหนดเอง',
|
||||
'costs.splitTicket': 'ตั๋ว',
|
||||
|
||||
@@ -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ş',
|
||||
|
||||
@@ -150,6 +150,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': 'Натисніть, щоб додати',
|
||||
'costs.amount': 'Сума',
|
||||
'costs.split': 'Розподіл',
|
||||
'costs.splitChipLabel': '{name}: {amount}',
|
||||
'costs.splitEqually': 'Порівну',
|
||||
'costs.splitCustom': 'Вручну',
|
||||
'costs.splitTicket': 'Чек',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -149,6 +149,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': '點按以加入',
|
||||
'costs.amount': '金額',
|
||||
'costs.split': '分攤',
|
||||
'costs.splitChipLabel': '{name}:{amount}',
|
||||
'costs.splitEqually': '平均',
|
||||
'costs.splitCustom': '自訂',
|
||||
'costs.splitTicket': '收據',
|
||||
|
||||
@@ -149,6 +149,7 @@ const budget: TranslationStrings = {
|
||||
'costs.tapToInclude': '点按以加入',
|
||||
'costs.amount': '金额',
|
||||
'costs.split': '分摊',
|
||||
'costs.splitChipLabel': '{name}:{amount}',
|
||||
'costs.splitEqually': '平均分摊',
|
||||
'costs.splitCustom': '自定义金额',
|
||||
'costs.splitTicket': '按账单明细',
|
||||
|
||||
Reference in New Issue
Block a user