mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-07-09 15:05:59 +00:00
feat(costs): Splitwise-like cost splitting
Add per-payer and per-member custom split amounts with Equally, Custom and Ticket split modes on top of the existing equal split, keep legacy "paid by" expenses working, and document the modes in the Budget Tracking wiki page.
This commit is contained in:
@@ -91,7 +91,7 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
expect(screen.getByText('Dinner')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('auto-splits the total across participants and rebalances a pinned amount on save', async () => {
|
it('supports custom split amounts on save', async () => {
|
||||||
let posted: Record<string, unknown> | null = null
|
let posted: Record<string, unknown> | null = null
|
||||||
server.use(
|
server.use(
|
||||||
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
|
||||||
@@ -108,18 +108,22 @@ 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.getAllByPlaceholderText('0.00') 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 = 100
|
||||||
await waitFor(() => expect(nums()[1].value).toBe('50'))
|
|
||||||
expect(nums()[2].value).toBe('50')
|
await user.click(screen.getByRole('button', { name: /Custom/i }))
|
||||||
// Pin the first participant to 30 → the other non-pinned field rebalances to 70.
|
|
||||||
await user.clear(nums()[1]); await user.type(nums()[1], '30')
|
const customInputs = screen.getAllByPlaceholderText('50.00')
|
||||||
await waitFor(() => expect(nums()[2].value).toBe('70'))
|
await user.type(customInputs[0], '30')
|
||||||
|
await user.type(customInputs[1], '70')
|
||||||
|
|
||||||
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
const addBtns = screen.getAllByRole('button', { name: 'Add expense' })
|
||||||
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
await user.click(addBtns[addBtns.length - 1]) // footer submit
|
||||||
await waitFor(() => expect(posted).toBeTruthy())
|
await waitFor(() => expect(posted).toBeTruthy())
|
||||||
expect(posted!.total_price).toBe(100)
|
expect(posted!.total_price).toBe(100)
|
||||||
expect(posted!.payers).toEqual(expect.arrayContaining([
|
expect(posted!.payers).toEqual([
|
||||||
|
expect.objectContaining({ amount: 100 })
|
||||||
|
])
|
||||||
|
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||||
expect.objectContaining({ user_id: 1, amount: 30 }),
|
expect.objectContaining({ user_id: 1, amount: 30 }),
|
||||||
expect.objectContaining({ user_id: 2, amount: 70 }),
|
expect.objectContaining({ user_id: 2, amount: 70 }),
|
||||||
]))
|
]))
|
||||||
@@ -194,4 +198,60 @@ describe('CostsPanel — settlements in the ledger', () => {
|
|||||||
expect(posted!.member_ids).toEqual([])
|
expect(posted!.member_ids).toEqual([])
|
||||||
expect(posted!.payers).toEqual([])
|
expect(posted!.payers).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('supports itemized receipt ticket manual entry and split assignment', 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: 'Dinner' }), id: 10 } })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
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.click(screen.getByRole('button', { name: 'Ticket' }))
|
||||||
|
|
||||||
|
const addBtn = screen.getByRole('button', { name: /Add item/i })
|
||||||
|
await user.click(addBtn)
|
||||||
|
await user.click(addBtn)
|
||||||
|
await user.click(addBtn)
|
||||||
|
|
||||||
|
const itemNames = screen.getAllByPlaceholderText('Item name')
|
||||||
|
const itemPrices = screen.getAllByPlaceholderText('0.00')
|
||||||
|
|
||||||
|
await user.type(itemNames[0], 'Apples')
|
||||||
|
await user.type(itemPrices[1], '10')
|
||||||
|
|
||||||
|
await user.type(itemNames[1], 'chocolate cake')
|
||||||
|
await user.type(itemPrices[2], '50')
|
||||||
|
const bobButtons = screen.getAllByRole('button', { name: /bob/i })
|
||||||
|
await user.click(bobButtons[1])
|
||||||
|
|
||||||
|
await user.type(itemNames[2], 'Milk')
|
||||||
|
await user.type(itemPrices[3], '40')
|
||||||
|
|
||||||
|
expect(screen.getByDisplayValue('100.00')).toBeDisabled()
|
||||||
|
|
||||||
|
expect(screen.getByText('Individual Shares Summary')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/75\.00/)).toBeInTheDocument()
|
||||||
|
expect(screen.getByText(/25\.00/)).toBeInTheDocument()
|
||||||
|
|
||||||
|
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(100)
|
||||||
|
expect(posted!.members).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ user_id: 1, amount: 75 }),
|
||||||
|
expect.objectContaining({ user_id: 2, amount: 25 }),
|
||||||
|
]))
|
||||||
|
expect(posted!.note).toContain('TICKETJSON:')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -19,6 +19,68 @@ import { COST_CATEGORY_LIST, catMeta } from './costsCategories'
|
|||||||
import type { BudgetItem } from '../../types'
|
import type { BudgetItem } from '../../types'
|
||||||
import type { TripMember } from './BudgetPanelMemberChips'
|
import type { TripMember } from './BudgetPanelMemberChips'
|
||||||
|
|
||||||
|
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||||
|
const n = members.length
|
||||||
|
if (n === 0) return {}
|
||||||
|
|
||||||
|
const totalCents = Math.round(total * 100)
|
||||||
|
const baseCents = Math.floor(totalCents / n)
|
||||||
|
const remainder = totalCents % n
|
||||||
|
|
||||||
|
const shares: Record<number, number> = {}
|
||||||
|
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id)
|
||||||
|
const startIndex = itemId % n
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const member = sortedMembers[i]
|
||||||
|
const hasExtraCent = ((i - startIndex + n) % n) < remainder
|
||||||
|
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return shares
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
participants: Set<number>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateTicketShares(items: TicketItem[]): { shares: Record<number, number>; total: number } {
|
||||||
|
const shares: Record<number, number> = {}
|
||||||
|
let totalCents = 0
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const priceNum = parseFloat(item.price) || 0
|
||||||
|
const priceCents = Math.round(priceNum * 100)
|
||||||
|
totalCents += priceCents
|
||||||
|
|
||||||
|
const partIds = [...item.participants]
|
||||||
|
const n = partIds.length
|
||||||
|
if (n === 0) continue
|
||||||
|
|
||||||
|
const baseCents = Math.floor(priceCents / n)
|
||||||
|
const remainder = priceCents % n
|
||||||
|
|
||||||
|
const sortedPartIds = [...partIds].sort((a, b) => a - b)
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const id = sortedPartIds[i]
|
||||||
|
const hasExtraCent = i < remainder
|
||||||
|
const shareCents = baseCents + (hasExtraCent ? 1 : 0)
|
||||||
|
shares[id] = (shares[id] || 0) + shareCents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalShares: Record<number, number> = {}
|
||||||
|
for (const id of Object.keys(shares)) {
|
||||||
|
finalShares[Number(id)] = shares[Number(id)] / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return { shares: finalShares, total: totalCents / 100 }
|
||||||
|
}
|
||||||
|
|
||||||
interface CostsPanelProps {
|
interface CostsPanelProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
tripMembers?: TripMember[]
|
tripMembers?: TripMember[]
|
||||||
@@ -105,9 +167,14 @@ export default function CostsPanel({ tripId, tripMembers = [] }: CostsPanelProps
|
|||||||
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
const baseTotal = (e: BudgetItem) => convert(e.total_price || 0, curOf(e))
|
||||||
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
const myPaidOf = (e: BudgetItem) => (e.payers || []).filter(p => p.user_id === me).reduce((a, p) => a + convert(p.amount, curOf(e)), 0)
|
||||||
const myShareOf = (e: BudgetItem) => {
|
const myShareOf = (e: BudgetItem) => {
|
||||||
const n = (e.members || []).length
|
const myMember = (e.members || []).find(m => m.user_id === me)
|
||||||
if (!n || !(e.members || []).some(m => m.user_id === me)) return 0
|
if (!myMember) return 0
|
||||||
return baseTotal(e) / n
|
if (myMember.amount !== null && myMember.amount !== undefined) {
|
||||||
|
return convert(myMember.amount, curOf(e))
|
||||||
|
}
|
||||||
|
const shares = splitEqualShares(e.total_price || 0, e.members || [], e.id)
|
||||||
|
const myShare = shares[me] || 0
|
||||||
|
return convert(myShare, curOf(e))
|
||||||
}
|
}
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
@@ -790,11 +857,6 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
const [cat, setCat] = useState<string>(editing ? catMeta(editing.category).key : (prefill?.category || 'food'))
|
||||||
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
const [currency, setCurrency] = useState((editing?.currency || base).toUpperCase())
|
||||||
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
const [day, setDay] = useState(editing?.expense_date || new Date().toISOString().slice(0, 10))
|
||||||
// One participant list: a person is "in" the split and may have paid an amount.
|
|
||||||
// Entering the total auto-distributes it equally across the non-pinned participants;
|
|
||||||
// touching an amount pins it and the rest rebalance so the paid amounts always sum
|
|
||||||
// back to the total. Leaving every amount blank = an unfinished expense (counts
|
|
||||||
// toward the trip total only, never settlements, until who-paid is filled in).
|
|
||||||
const [total, setTotal] = useState<string>(() => {
|
const [total, setTotal] = useState<string>(() => {
|
||||||
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
if (editing) return editing.total_price ? String(editing.total_price) : ''
|
||||||
if (prefill?.amount != null) return String(prefill.amount)
|
if (prefill?.amount != null) return String(prefill.amount)
|
||||||
@@ -802,89 +864,192 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
})
|
})
|
||||||
const [participants, setParticipants] = useState<Set<number>>(() =>
|
const [participants, setParticipants] = useState<Set<number>>(() =>
|
||||||
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
editing ? new Set((editing.members || []).map(m => m.user_id)) : new Set(people.map(p => p.id)))
|
||||||
const [paid, setPaid] = useState<Record<number, string>>(() => {
|
|
||||||
|
// Payer state: 0 represents "Nobody (planning entry)"
|
||||||
|
const [payerId, setPayerId] = useState<number>(() => {
|
||||||
|
const existingPayer = (editing?.payers || []).find(p => p.amount > 0)
|
||||||
|
return existingPayer ? existingPayer.user_id : me
|
||||||
|
})
|
||||||
|
|
||||||
|
const [splitMode, setSplitMode] = useState<'equally' | 'custom' | 'ticket'>(() => {
|
||||||
|
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||||
|
return 'ticket'
|
||||||
|
}
|
||||||
|
if (editing && editing.members && editing.members.length > 0) {
|
||||||
|
const hasCustom = editing.members.some(m => m.amount !== null && m.amount !== undefined)
|
||||||
|
return hasCustom ? 'custom' : 'equally'
|
||||||
|
}
|
||||||
|
return 'equally'
|
||||||
|
})
|
||||||
|
|
||||||
|
const [ticketItems, setTicketItems] = useState<TicketItem[]>(() => {
|
||||||
|
if (editing?.note && editing.note.startsWith('TICKETJSON:')) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(editing.note.slice(11))
|
||||||
|
return (parsed.items || []).map((item: any) => ({
|
||||||
|
id: String(Math.random()),
|
||||||
|
name: item.name,
|
||||||
|
price: String(item.price),
|
||||||
|
participants: new Set(item.parts || [])
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
const [customAmounts, setCustomAmounts] = useState<Record<number, string>>(() => {
|
||||||
const m: Record<number, string> = {}
|
const m: Record<number, string> = {}
|
||||||
for (const p of editing?.payers || []) if (p.amount > 0) m[p.user_id] = String(p.amount)
|
if (editing && editing.members) {
|
||||||
|
for (const member of editing.members) {
|
||||||
|
if (member.amount !== null && member.amount !== undefined) {
|
||||||
|
m[member.user_id] = String(member.amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
})
|
})
|
||||||
// Amounts the user pinned by typing — kept out of the auto-rebalance. Existing
|
|
||||||
// payer amounts load as pinned so opening an expense never reshuffles them.
|
|
||||||
const [dirty, setDirty] = useState<Set<number>>(() =>
|
|
||||||
new Set((editing?.payers || []).filter(p => p.amount > 0).map(p => p.user_id)))
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|
||||||
const totalNum = parseFloat(total) || 0
|
const isTicketMode = splitMode === 'ticket'
|
||||||
const paidSum = round2([...participants].reduce((a, id) => a + (parseFloat(paid[id]) || 0), 0))
|
|
||||||
const paidEntered = paidSum > 0
|
|
||||||
const balanced = Math.abs(paidSum - totalNum) < 0.01
|
|
||||||
const each = participants.size > 0 ? totalNum / participants.size : 0
|
|
||||||
// No participants = a recorded total with nobody to split with (e.g. a booking
|
|
||||||
// paid on-site later). It saves as an "unfinished" expense (#1286); selecting
|
|
||||||
// people only adds the who-owes-whom split on top.
|
|
||||||
const valid = name.trim().length > 0 && totalNum > 0 && (!paidEntered || balanced)
|
|
||||||
|
|
||||||
// Spread `amount` across `n` people in whole cents so the parts sum back exactly.
|
const ticketInfo = useMemo(() => {
|
||||||
const splitCents = (amount: number, n: number): number[] => {
|
return calculateTicketShares(ticketItems)
|
||||||
if (n <= 0) return []
|
}, [ticketItems])
|
||||||
const cents = Math.max(0, Math.round(amount * 100))
|
|
||||||
const base = Math.floor(cents / n), rem = cents - base * n
|
const totalNum = isTicketMode ? ticketInfo.total : (parseFloat(total) || 0)
|
||||||
return Array.from({ length: n }, (_, i) => (base + (i < rem ? 1 : 0)) / 100)
|
const splitSum = [...participants].reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
|
||||||
}
|
const customBalanced = Math.round(splitSum * 100) === Math.round(totalNum * 100)
|
||||||
// Recompute the non-pinned participants so every paid amount sums to the total.
|
const each = participants.size > 0 ? totalNum / participants.size : 0
|
||||||
const rebalance = (paidMap: Record<number, string>, dirtySet: Set<number>, parts: Set<number>, totalVal: number): Record<number, string> => {
|
const equalShares = useMemo(() => {
|
||||||
const ids = [...parts]
|
return splitEqualShares(totalNum, [...participants].map(id => ({ user_id: id })), editing?.id || 0)
|
||||||
const free = ids.filter(id => !dirtySet.has(id))
|
}, [totalNum, participants, editing])
|
||||||
if (free.length === 0) return paidMap
|
|
||||||
const pinnedSum = ids.filter(id => dirtySet.has(id)).reduce((a, id) => a + (parseFloat(paidMap[id]) || 0), 0)
|
const placeholderShares = useMemo(() => {
|
||||||
const shares = splitCents(totalVal - pinnedSum, free.length)
|
const emptyParts = [...participants].filter(id => !customAmounts[id])
|
||||||
const next = { ...paidMap }
|
if (emptyParts.length === 0) return {}
|
||||||
free.forEach((id, i) => { next[id] = shares[i] ? String(shares[i]) : '' })
|
|
||||||
return next
|
const enteredSum = [...participants]
|
||||||
}
|
.filter(id => customAmounts[id])
|
||||||
|
.reduce((sum, id) => sum + (parseFloat(customAmounts[id]) || 0), 0)
|
||||||
|
const remaining = Math.max(0, totalNum - enteredSum)
|
||||||
|
|
||||||
|
return splitEqualShares(remaining, emptyParts.map(id => ({ user_id: id })), editing?.id || 0)
|
||||||
|
}, [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 && (
|
||||||
|
isTicketMode
|
||||||
|
? ticketValid
|
||||||
|
: totalNum > 0 && (participants.size === 0 || splitMode === 'equally' || customBalanced)
|
||||||
|
)
|
||||||
|
|
||||||
const onTotalChange = (v: string) => {
|
const onTotalChange = (v: string) => {
|
||||||
v = v.replace(',', '.')
|
setTotal(v.replace(',', '.'))
|
||||||
setTotal(v)
|
|
||||||
setPaid(prev => rebalance(prev, dirty, participants, parseFloat(v) || 0))
|
|
||||||
}
|
}
|
||||||
const onPaidChange = (id: number, v: string) => {
|
|
||||||
v = v.replace(',', '.')
|
const handleCustomAmountChange = (id: number, val: string) => {
|
||||||
const nextDirty = new Set(dirty); nextDirty.add(id)
|
val = val.replace(',', '.')
|
||||||
setDirty(nextDirty)
|
if (/^\d*\.?\d{0,2}$/.test(val) || val === '') {
|
||||||
setPaid(prev => rebalance({ ...prev, [id]: v }, nextDirty, participants, totalNum))
|
setCustomAmounts(prev => ({ ...prev, [id]: val }))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleAddEmptyItem = () => {
|
||||||
|
setTicketItems(prev => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: String(Date.now() + Math.random()),
|
||||||
|
name: '',
|
||||||
|
price: '',
|
||||||
|
participants: new Set(people.map(p => p.id))
|
||||||
|
}
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateItemName = (id: string, name: string) => {
|
||||||
|
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, name } : item))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdateItemPrice = (id: string, price: string) => {
|
||||||
|
price = price.replace(',', '.')
|
||||||
|
if (/^\d*\.?\d{0,2}$/.test(price) || price === '') {
|
||||||
|
setTicketItems(prev => prev.map(item => item.id === id ? { ...item, price } : item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemoveItem = (id: string) => {
|
||||||
|
setTicketItems(prev => prev.filter(item => item.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleItemParticipant = (itemId: string, userId: number) => {
|
||||||
|
setTicketItems(prev => prev.map(item => {
|
||||||
|
if (item.id === itemId) {
|
||||||
|
const nextParts = new Set(item.participants)
|
||||||
|
if (nextParts.has(userId)) nextParts.delete(userId)
|
||||||
|
else nextParts.add(userId)
|
||||||
|
return { ...item, participants: nextParts }
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const toggleParticipant = (id: number) => {
|
const toggleParticipant = (id: number) => {
|
||||||
const nextParts = new Set(participants), nextDirty = new Set(dirty), nextPaid = { ...paid }
|
const nextParts = new Set(participants)
|
||||||
if (nextParts.has(id)) { nextParts.delete(id); nextDirty.delete(id); delete nextPaid[id] }
|
if (nextParts.has(id)) {
|
||||||
else nextParts.add(id)
|
nextParts.delete(id)
|
||||||
setParticipants(nextParts); setDirty(nextDirty)
|
setCustomAmounts(prev => {
|
||||||
setPaid(rebalance(nextPaid, nextDirty, nextParts, totalNum))
|
const copy = { ...prev }
|
||||||
|
delete copy[id]
|
||||||
|
return copy
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
nextParts.add(id)
|
||||||
|
}
|
||||||
|
setParticipants(nextParts)
|
||||||
}
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
const payerList = [...participants]
|
const payerList = (payerId > 0 && participants.size > 0) ? [{ user_id: payerId, amount: totalNum }] : []
|
||||||
.map(id => ({ user_id: id, amount: parseFloat(paid[id]) || 0 }))
|
const memberList = [...participants].map(id => ({
|
||||||
.filter(p => p.amount > 0)
|
user_id: id,
|
||||||
|
amount: splitMode === 'custom'
|
||||||
|
? (parseFloat(customAmounts[id]) || 0)
|
||||||
|
: splitMode === 'ticket'
|
||||||
|
? (ticketInfo.shares[id] || 0)
|
||||||
|
: null
|
||||||
|
}))
|
||||||
const data = {
|
const data = {
|
||||||
name: name.trim(), category: cat,
|
name: name.trim(),
|
||||||
// Store the actual currency the amounts were entered in; conversion to the
|
category: cat,
|
||||||
// viewer's display currency happens live (real rates), no manual rate.
|
|
||||||
currency,
|
currency,
|
||||||
payers: payerList, member_ids: [...participants],
|
payers: payerList,
|
||||||
|
members: memberList,
|
||||||
|
member_ids: [...participants],
|
||||||
expense_date: day || null,
|
expense_date: day || null,
|
||||||
// Always record the entered total: the server keeps it as-is for an unfinished
|
|
||||||
// expense (no payers) and otherwise re-derives it from the payer sum (== total).
|
|
||||||
total_price: totalNum,
|
total_price: totalNum,
|
||||||
// Link a freshly-created expense to its booking (create-from-booking flow).
|
note: splitMode === 'ticket' ? 'TICKETJSON:' + JSON.stringify({
|
||||||
|
items: ticketItems.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
price: item.price,
|
||||||
|
parts: [...item.participants]
|
||||||
|
}))
|
||||||
|
}) : null,
|
||||||
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
...(!editing && prefill?.reservationId ? { reservation_id: prefill.reservationId } : {}),
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
if (editing) await updateBudgetItem(tripId, editing.id, data)
|
||||||
else await addBudgetItem(tripId, data)
|
else await addBudgetItem(tripId, data)
|
||||||
onSaved()
|
onSaved()
|
||||||
} catch { toast.error(t('common.unknownError')) } finally { setSaving(false) }
|
} catch {
|
||||||
|
toast.error(t('common.unknownError'))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
const inputCls = 'w-full bg-surface-input border border-edge text-content'
|
||||||
@@ -906,10 +1071,11 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<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', opacity: isTicketMode ? 0.6 : 1 }}>
|
||||||
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
<span className="text-content-faint" style={{ fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))' }}>{sym(currency)}</span>
|
||||||
<input type="text" inputMode="decimal" placeholder="0.00" value={total}
|
<input type="text" inputMode="decimal" placeholder="0.00" value={isTicketMode ? ticketInfo.total.toFixed(2) : total}
|
||||||
onChange={e => onTotalChange(e.target.value)}
|
onChange={e => onTotalChange(e.target.value)}
|
||||||
|
disabled={isTicketMode}
|
||||||
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
className="text-content" style={{ flex: 1, border: 0, background: 'none', outline: 'none', fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))', fontWeight: 600, paddingLeft: 6, width: '100%' }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -954,39 +1120,164 @@ export function ExpenseModal({ tripId, base, people, me, editing, prefill, onClo
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
<label className={labelCls}>{t('costs.whoPaid')}</label>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
<CustomSelect value={String(payerId)} onChange={v => setPayerId(Number(v))}
|
||||||
{people.map((p, idx) => {
|
options={[
|
||||||
const on = participants.has(p.id)
|
{ value: '0', label: t('costs.noOnePaid') || 'Nobody (planning entry)' },
|
||||||
return (
|
...people.map(p => ({ value: String(p.id), label: p.id === me ? t('costs.you') : p.username }))
|
||||||
<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 onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
style={{ width: '100%' }} />
|
||||||
{p.avatar_url
|
</div>
|
||||||
? <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: 'calc(9px * var(--fs-scale-caption, 1))', fontWeight: 700, flexShrink: 0, opacity: on ? 1 : 0.45 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
<div>
|
||||||
<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>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||||
</button>
|
<label className={labelCls}>{t('costs.split') || 'Split'}</label>
|
||||||
{on ? (
|
<div className="bg-surface-secondary" style={{ display: 'flex', borderRadius: 8, padding: 2 }}>
|
||||||
<div className="bg-surface-input border border-edge" style={{ display: 'flex', alignItems: 'center', gap: 4, borderRadius: 8, padding: '0 10px' }}>
|
<button type="button" onClick={() => setSplitMode('equally')}
|
||||||
<span className="text-content-faint" style={{ fontSize: 'calc(13px * var(--fs-scale-body, 1))' }}>{sym(currency)}</span>
|
className={splitMode === 'equally' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||||
<input type="text" inputMode="decimal" placeholder="0.00" value={paid[p.id] || ''}
|
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
onChange={e => onPaidChange(p.id, e.target.value)}
|
{t('costs.splitEqually') || 'Equally'}
|
||||||
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' }} />
|
</button>
|
||||||
|
<button type="button" onClick={() => setSplitMode('custom')}
|
||||||
|
className={splitMode === 'custom' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
{t('costs.splitCustom') || 'Custom'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setSplitMode('ticket')}
|
||||||
|
className={splitMode === 'ticket' ? 'bg-surface-card text-content' : 'text-content-muted'}
|
||||||
|
style={{ padding: '4px 10px', fontSize: 11.5, borderRadius: 6, fontWeight: 600, border: 0, cursor: 'pointer', fontFamily: 'inherit' }}>
|
||||||
|
{t('costs.splitTicket') || 'Ticket'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{splitMode === 'ticket' ? (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{ticketItems.map((item, itemIdx) => (
|
||||||
|
<div key={item.id} className="bg-surface-secondary border border-edge" style={{ padding: 10, borderRadius: 10, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Item name"
|
||||||
|
value={item.name}
|
||||||
|
onChange={e => handleUpdateItemName(item.id, e.target.value)}
|
||||||
|
className="bg-surface-input border border-edge text-content"
|
||||||
|
style={{ flex: 2, padding: '6px 10px', borderRadius: 8, fontSize: 13, border: '1px solid var(--border-color)', outline: 'none' }}
|
||||||
|
/>
|
||||||
|
<div className="bg-surface-input border border-edge" style={{ flex: 1, display: 'flex', alignItems: 'center', padding: '0 8px', borderRadius: 8 }}>
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 12 }}>{sym(currency)}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="0.00"
|
||||||
|
value={item.price}
|
||||||
|
onChange={e => handleUpdateItemPrice(item.id, e.target.value)}
|
||||||
|
className="text-content"
|
||||||
|
style={{ width: '100%', border: 0, background: 'none', outline: 'none', fontSize: 13, fontWeight: 600, textAlign: 'right', padding: '6px 0' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => handleRemoveItem(item.id)} className="text-content-muted" style={{ background: 'none', border: 0, cursor: 'pointer', padding: 4 }}>
|
||||||
|
<Trash2 size={15} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<button onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 'calc(12px * var(--fs-scale-body, 1))', textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 5, alignItems: 'center' }}>
|
||||||
)}
|
<span className="text-content-faint" style={{ fontSize: 10.5, fontWeight: 600, textTransform: 'uppercase', marginRight: 4 }}>Splitting:</span>
|
||||||
|
{people.map((p, pIdx) => {
|
||||||
|
const active = item.participants.has(p.id)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => handleToggleItemParticipant(item.id, p.id)}
|
||||||
|
className={active ? 'bg-surface-card text-content border' : 'bg-surface-secondary text-content-muted border border-edge'}
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 8px', borderRadius: 999, fontSize: 11, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', border: active ? '1px solid var(--text-primary)' : undefined }}
|
||||||
|
>
|
||||||
|
{p.avatar_url
|
||||||
|
? <img src={p.avatar_url} alt="" style={{ width: 14, height: 14, borderRadius: '50%', objectFit: 'cover' }} />
|
||||||
|
: <span style={{ width: 14, height: 14, borderRadius: '50%', background: SPLIT_COLORS[pIdx % SPLIT_COLORS.length].gradient, color: '#fff', display: 'grid', placeItems: 'center', fontSize: 7, fontWeight: 700 }}>{(p.id === me ? t('costs.youShort') : p.username.charAt(0)).toUpperCase()}</span>}
|
||||||
|
<span>{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" onClick={handleAddEmptyItem} className="border border-dashed border-edge text-content-muted" style={{ padding: '8px 12px', borderRadius: 10, background: 'none', fontSize: 13, fontWeight: 500, cursor: 'pointer', fontFamily: 'inherit', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6 }}>
|
||||||
|
<Plus size={14} /> Add item
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{ticketItems.length > 0 && (
|
||||||
|
<div className="bg-surface-secondary border border-edge" style={{ padding: 12, borderRadius: 10 }}>
|
||||||
|
<div className="text-content" style={{ fontSize: 12, fontWeight: 600, marginBottom: 8 }}>Individual Shares Summary</div>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
|
{people.map(p => {
|
||||||
|
const share = ticketInfo.shares[p.id] || 0
|
||||||
|
return (
|
||||||
|
<div key={p.id} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13 }}>
|
||||||
|
<span className="text-content-muted">{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
|
<span className="text-content" style={{ fontWeight: 600 }}>{sym(currency)}{share.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
})}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
<div style={{ marginTop: 10, fontSize: 'calc(12.5px * var(--fs-scale-body, 1))', display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
<>
|
||||||
<span className="text-content-faint">
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||||
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
{people.map((p, idx) => {
|
||||||
</span>
|
const on = participants.has(p.id)
|
||||||
{paidEntered
|
return (
|
||||||
? <span style={{ fontWeight: 600, color: balanced ? '#16a34a' : '#dc2626' }}>{sym(currency)}{paidSum.toFixed(2)} / {sym(currency)}{totalNum.toFixed(2)}</span>
|
<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 }}>
|
||||||
: (totalNum > 0 && <span style={{ color: '#d97706', fontWeight: 600 }}>{t('costs.unfinishedHint')}</span>)}
|
<button type="button" onClick={() => toggleParticipant(p.id)} style={{ display: 'inline-flex', alignItems: 'center', gap: 8, background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', padding: 0, minWidth: 0, textAlign: 'left' }}>
|
||||||
</div>
|
{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: 14, fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{p.id === me ? t('costs.you') : p.username}</span>
|
||||||
|
</button>
|
||||||
|
{splitMode === 'equally' ? (
|
||||||
|
on ? (
|
||||||
|
<span className="text-content" style={{ fontSize: 14, fontWeight: 600, textAlign: 'right', paddingRight: 10 }}>
|
||||||
|
{sym(currency)}{(equalShares[p.id] || 0).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-content-faint" style={{ fontSize: 12, textAlign: 'right', paddingRight: 10 }}>Excluded</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
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: 13 }}>{sym(currency)}</span>
|
||||||
|
<input type="text" inputMode="decimal" placeholder={(placeholderShares[p.id] || 0).toFixed(2)} value={customAmounts[p.id] || ''}
|
||||||
|
onChange={e => handleCustomAmountChange(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' }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => toggleParticipant(p.id)} className="text-content-faint" style={{ background: 'none', border: 0, cursor: 'pointer', fontFamily: 'inherit', fontSize: 12, textAlign: 'right' }}>{t('costs.tapToInclude')}</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, fontSize: 12.5, display: 'flex', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap' }}>
|
||||||
|
{splitMode === 'equally' ? (
|
||||||
|
<span className="text-content-faint">
|
||||||
|
{participants.size > 0 && t('costs.splitSummary', { count: participants.size, amount: sym(currency) + each.toFixed(2) })}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ fontWeight: 600, color: customBalanced ? '#16a34a' : '#dc2626' }}>
|
||||||
|
{customBalanced
|
||||||
|
? 'Split matches total'
|
||||||
|
: `Sum of splits: ${sym(currency)}${splitSum.toFixed(2)} of ${sym(currency)}${totalNum.toFixed(2)} (${(totalNum - splitSum) > 0 ? 'under by' : 'over by'} ${sym(currency)}${Math.abs(totalNum - splitSum).toFixed(2)})`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ describe('budgetSlice', () => {
|
|||||||
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
|
HttpResponse.json({ error: 'Validation failed' }, { status: 422 })
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
await expect(useTripStore.getState().addBudgetItem(1, {})).rejects.toThrow();
|
await expect(useTripStore.getState().addBudgetItem(1, { name: 'x' })).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
|
it('FE-STORE-BUDGET-005: updateBudgetItem replaces item in store', async () => {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ type GetState = StoreApi<TripStoreState>['getState']
|
|||||||
|
|
||||||
export interface BudgetSlice {
|
export interface BudgetSlice {
|
||||||
loadBudgetItems: (tripId: number | string) => Promise<void>
|
loadBudgetItems: (tripId: number | string) => Promise<void>
|
||||||
addBudgetItem: (tripId: number | string, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
addBudgetItem: (tripId: number | string, data: BudgetCreateItemRequest) => Promise<BudgetItem>
|
||||||
updateBudgetItem: (tripId: number | string, id: number, data: Partial<BudgetItem>) => Promise<BudgetItem>
|
updateBudgetItem: (tripId: number | string, id: number, data: BudgetUpdateItemRequest) => Promise<BudgetItem>
|
||||||
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
|
deleteBudgetItem: (tripId: number | string, id: number) => Promise<void>
|
||||||
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }>
|
setBudgetItemMembers: (tripId: number | string, itemId: number, userIds: number[]) => Promise<{ members: BudgetItemMember[]; item: BudgetItem }>
|
||||||
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
toggleBudgetMemberPaid: (tripId: number | string, itemId: number, userId: number, paid: boolean) => Promise<void>
|
||||||
@@ -33,7 +33,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
|||||||
|
|
||||||
addBudgetItem: async (tripId, data) => {
|
addBudgetItem: async (tripId, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await budgetApi.create(tripId, data as BudgetCreateItemRequest)
|
const result = await budgetApi.create(tripId, data)
|
||||||
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
set(state => ({ budgetItems: [...state.budgetItems, result.item] }))
|
||||||
return result.item
|
return result.item
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -43,7 +43,7 @@ export const createBudgetSlice = (set: SetState, get: GetState): BudgetSlice =>
|
|||||||
|
|
||||||
updateBudgetItem: async (tripId, id, data) => {
|
updateBudgetItem: async (tripId, id, data) => {
|
||||||
try {
|
try {
|
||||||
const result = await budgetApi.update(tripId, id, data as BudgetUpdateItemRequest)
|
const result = await budgetApi.update(tripId, id, data)
|
||||||
set(state => ({
|
set(state => ({
|
||||||
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
budgetItems: state.budgetItems.map(item => item.id === id ? result.item : item)
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -3071,6 +3071,13 @@ function runMigrations(db: Database.Database): void {
|
|||||||
if (!err.message?.includes('duplicate column name')) throw err;
|
if (!err.message?.includes('duplicate column name')) throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
() => {
|
||||||
|
try {
|
||||||
|
db.exec('ALTER TABLE budget_item_members ADD COLUMN amount REAL');
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!err.message?.includes('duplicate column name')) throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (currentVersion < migrations.length) {
|
if (currentVersion < migrations.length) {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export { verifyTripAccess } from './tripAccess';
|
|||||||
|
|
||||||
function loadItemMembers(itemId: number | string) {
|
function loadItemMembers(itemId: number | string) {
|
||||||
const rows = db.prepare(`
|
const rows = db.prepare(`
|
||||||
SELECT bm.user_id, bm.paid, u.username, u.avatar
|
SELECT bm.user_id, bm.paid, bm.amount, u.username, u.avatar
|
||||||
FROM budget_item_members bm
|
FROM budget_item_members bm
|
||||||
JOIN users u ON bm.user_id = u.id
|
JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.budget_item_id = ?
|
WHERE bm.budget_item_id = ?
|
||||||
@@ -60,7 +60,7 @@ export function listBudgetItems(tripId: string | number) {
|
|||||||
|
|
||||||
if (itemIds.length > 0) {
|
if (itemIds.length > 0) {
|
||||||
const allMembers = db.prepare(`
|
const allMembers = db.prepare(`
|
||||||
SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar
|
SELECT bm.budget_item_id, bm.user_id, bm.paid, bm.amount, u.username, u.avatar
|
||||||
FROM budget_item_members bm
|
FROM budget_item_members bm
|
||||||
JOIN users u ON bm.user_id = u.id
|
JOIN users u ON bm.user_id = u.id
|
||||||
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
WHERE bm.budget_item_id IN (${itemIds.map(() => '?').join(',')})
|
||||||
@@ -69,7 +69,7 @@ export function listBudgetItems(tripId: string | number) {
|
|||||||
for (const m of allMembers) {
|
for (const m of allMembers) {
|
||||||
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
|
if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
|
||||||
membersByItem[m.budget_item_id].push({
|
membersByItem[m.budget_item_id].push({
|
||||||
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m),
|
user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m), amount: m.amount,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,7 @@ export function createBudgetItem(
|
|||||||
category?: string; name: string; total_price?: number;
|
category?: string; name: string; total_price?: number;
|
||||||
currency?: string | null; exchange_rate?: number;
|
currency?: string | null; exchange_rate?: number;
|
||||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||||
|
members?: { user_id: number; amount?: number | null }[];
|
||||||
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
persons?: number | null; days?: number | null; note?: string | null; expense_date?: string | null;
|
||||||
reservation_id?: number | null;
|
reservation_id?: number | null;
|
||||||
},
|
},
|
||||||
@@ -147,8 +148,11 @@ export function createBudgetItem(
|
|||||||
|
|
||||||
const itemId = result.lastInsertRowid as number;
|
const itemId = result.lastInsertRowid as number;
|
||||||
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
|
if (data.payers && data.payers.length > 0) writeItemPayers(itemId, data.payers);
|
||||||
if (data.member_ids && data.member_ids.length > 0) {
|
if (data.members && data.members.length > 0) {
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
|
||||||
|
for (const m of data.members) insert.run(itemId, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
|
||||||
|
} else if (data.member_ids && data.member_ids.length > 0) {
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
|
||||||
for (const uid of data.member_ids) insert.run(itemId, uid);
|
for (const uid of data.member_ids) insert.run(itemId, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +189,7 @@ export function updateBudgetItem(
|
|||||||
category?: string; name?: string; total_price?: number;
|
category?: string; name?: string; total_price?: number;
|
||||||
currency?: string | null; exchange_rate?: number;
|
currency?: string | null; exchange_rate?: number;
|
||||||
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
payers?: { user_id: number; amount: number }[]; member_ids?: number[];
|
||||||
|
members?: { user_id: number; amount?: number | null }[];
|
||||||
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
persons?: number | null; days?: number | null; note?: string | null; sort_order?: number; expense_date?: string | null;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -228,9 +233,14 @@ export function updateBudgetItem(
|
|||||||
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
|
db.prepare('UPDATE budget_items SET total_price = ? WHERE id = ?').run(data.total_price, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (data.member_ids !== undefined) {
|
if (data.members !== undefined) {
|
||||||
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||||
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)');
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, ?)');
|
||||||
|
for (const m of data.members) insert.run(id, m.user_id, m.amount !== undefined && m.amount !== null ? m.amount : null);
|
||||||
|
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.members.length || null, id);
|
||||||
|
} else if (data.member_ids !== undefined) {
|
||||||
|
db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
|
||||||
|
const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid, amount) VALUES (?, ?, 0, NULL)');
|
||||||
for (const uid of data.member_ids) insert.run(id, uid);
|
for (const uid of data.member_ids) insert.run(id, uid);
|
||||||
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
|
db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(data.member_ids.length || null, id);
|
||||||
}
|
}
|
||||||
@@ -323,8 +333,8 @@ export function toggleMemberPaid(id: string | number, tripId: string | number, u
|
|||||||
export function getPerPersonSummary(tripId: string | number) {
|
export function getPerPersonSummary(tripId: string | number) {
|
||||||
const summary = db.prepare(`
|
const summary = db.prepare(`
|
||||||
SELECT bm.user_id, u.username, u.avatar,
|
SELECT bm.user_id, u.username, u.avatar,
|
||||||
SUM(bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) as total_assigned,
|
SUM(COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id))) as total_assigned,
|
||||||
SUM(CASE WHEN bm.paid = 1 THEN bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id) ELSE 0 END) as total_paid,
|
SUM(CASE WHEN bm.paid = 1 THEN COALESCE(bm.amount, bi.total_price * 1.0 / (SELECT COUNT(*) FROM budget_item_members WHERE budget_item_id = bi.id)) ELSE 0 END) as total_paid,
|
||||||
COUNT(bi.id) as items_count
|
COUNT(bi.id) as items_count
|
||||||
FROM budget_item_members bm
|
FROM budget_item_members bm
|
||||||
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
JOIN budget_items bi ON bm.budget_item_id = bi.id
|
||||||
@@ -336,9 +346,26 @@ export function getPerPersonSummary(tripId: string | number) {
|
|||||||
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
return summary.map(s => ({ ...s, avatar_url: avatarUrl(s) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export function splitEqualShares(total: number, members: { user_id: number }[], itemId: number): Record<number, number> {
|
||||||
// Settlement calculation (greedy debt matching)
|
const n = members.length;
|
||||||
// ---------------------------------------------------------------------------
|
if (n === 0) return {};
|
||||||
|
|
||||||
|
const totalCents = Math.round(total * 100);
|
||||||
|
const baseCents = Math.floor(totalCents / n);
|
||||||
|
const remainder = totalCents % n;
|
||||||
|
|
||||||
|
const shares: Record<number, number> = {};
|
||||||
|
const sortedMembers = [...members].sort((a, b) => a.user_id - b.user_id);
|
||||||
|
const startIndex = itemId % n;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const member = sortedMembers[i];
|
||||||
|
const hasExtraCent = ((i - startIndex + n) % n) < remainder;
|
||||||
|
shares[member.user_id] = (baseCents + (hasExtraCent ? 1 : 0)) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shares;
|
||||||
|
}
|
||||||
|
|
||||||
export function calculateSettlement(
|
export function calculateSettlement(
|
||||||
tripId: string | number,
|
tripId: string | number,
|
||||||
@@ -392,13 +419,19 @@ export function calculateSettlement(
|
|||||||
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
const payers = allPayers.filter(p => p.budget_item_id === item.id);
|
||||||
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
if (members.length === 0) continue; // planning-only entry → doesn't affect balances
|
||||||
|
|
||||||
const paidBase = payers.reduce((a, p) => a + toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate), 0);
|
// Payers are credited what they actually paid (converted to base with the
|
||||||
const sharePerMember = paidBase / members.length;
|
// item's stored exchange rate)…
|
||||||
|
|
||||||
// Payers are credited what they actually paid (converted to base)…
|
|
||||||
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
|
for (const p of payers) ensure(p.user_id, p).balance += toBase(p.amount > 0 ? p.amount : 0, item.currency, item.exchange_rate);
|
||||||
// …and every split participant owes an equal share of the base total.
|
// …and each split participant owes their share — a custom per-member amount
|
||||||
for (const m of members) ensure(m.user_id, m).balance -= sharePerMember;
|
// when one is set, otherwise an equal share of the expense total.
|
||||||
|
const hasCustomSplit = members.some(m => m.amount !== null && m.amount !== undefined);
|
||||||
|
const equalShares = !hasCustomSplit ? splitEqualShares(item.total_price, members, item.id) : {};
|
||||||
|
for (const m of members) {
|
||||||
|
const memberShare = hasCustomSplit && m.amount !== null && m.amount !== undefined
|
||||||
|
? toBase(m.amount, item.currency, item.exchange_rate)
|
||||||
|
: toBase(equalShares[m.user_id] || 0, item.currency, item.exchange_rate);
|
||||||
|
ensure(m.user_id, m).balance -= memberShare;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
// Persisted settle-up transfers already moved money: the payer's debt shrinks,
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export interface BudgetItemMember {
|
|||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
budget_item_id?: number;
|
budget_item_id?: number;
|
||||||
|
amount?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BudgetItemPayer {
|
export interface BudgetItemPayer {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const budgetItemMemberSchema = z.object({
|
|||||||
avatar_url: z.string().nullable().optional(),
|
avatar_url: z.string().nullable().optional(),
|
||||||
avatar: z.string().nullable().optional(),
|
avatar: z.string().nullable().optional(),
|
||||||
budget_item_id: z.number().optional(),
|
budget_item_id: z.number().optional(),
|
||||||
|
amount: z.number().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
|
export type BudgetItemMember = z.infer<typeof budgetItemMemberSchema>;
|
||||||
|
|
||||||
@@ -126,6 +127,11 @@ const payerInputSchema = z.object({
|
|||||||
amount: z.number(),
|
amount: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const memberInputSchema = z.object({
|
||||||
|
user_id: z.number(),
|
||||||
|
amount: z.number().nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const budgetCreateItemRequestSchema = z.object({
|
export const budgetCreateItemRequestSchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
category: z.string().optional(),
|
category: z.string().optional(),
|
||||||
@@ -137,6 +143,7 @@ export const budgetCreateItemRequestSchema = z.object({
|
|||||||
payers: z.array(payerInputSchema).optional(),
|
payers: z.array(payerInputSchema).optional(),
|
||||||
// Equal-split participants. When omitted, the item has no split (planning-only).
|
// Equal-split participants. When omitted, the item has no split (planning-only).
|
||||||
member_ids: z.array(z.number()).optional(),
|
member_ids: z.array(z.number()).optional(),
|
||||||
|
members: z.array(memberInputSchema).optional(),
|
||||||
persons: z.number().nullable().optional(),
|
persons: z.number().nullable().optional(),
|
||||||
days: z.number().nullable().optional(),
|
days: z.number().nullable().optional(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
@@ -156,6 +163,7 @@ export const budgetUpdateItemRequestSchema = z.object({
|
|||||||
exchange_rate: z.number().optional(),
|
exchange_rate: z.number().optional(),
|
||||||
payers: z.array(payerInputSchema).optional(),
|
payers: z.array(payerInputSchema).optional(),
|
||||||
member_ids: z.array(z.number()).optional(),
|
member_ids: z.array(z.number()).optional(),
|
||||||
|
members: z.array(memberInputSchema).optional(),
|
||||||
persons: z.number().nullable().optional(),
|
persons: z.number().nullable().optional(),
|
||||||
days: z.number().nullable().optional(),
|
days: z.number().nullable().optional(),
|
||||||
note: z.string().nullable().optional(),
|
note: z.string().nullable().optional(),
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'في الإجمالي فقط — لم تتم التسوية بعد',
|
'costs.unfinishedHint': 'في الإجمالي فقط — لم تتم التسوية بعد',
|
||||||
'costs.tapToInclude': 'اضغط للتضمين',
|
'costs.tapToInclude': 'اضغط للتضمين',
|
||||||
'costs.amount': 'المبلغ',
|
'costs.amount': 'المبلغ',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Apenas no total — ainda não acertado',
|
'costs.unfinishedHint': 'Apenas no total — ainda não acertado',
|
||||||
'costs.tapToInclude': 'Toque para incluir',
|
'costs.tapToInclude': 'Toque para incluir',
|
||||||
'costs.amount': 'Valor',
|
'costs.amount': 'Valor',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Jen v součtu — zatím nevyrovnáno',
|
'costs.unfinishedHint': 'Jen v součtu — zatím nevyrovnáno',
|
||||||
'costs.tapToInclude': 'Klepnutím zahrnout',
|
'costs.tapToInclude': 'Klepnutím zahrnout',
|
||||||
'costs.amount': 'Částka',
|
'costs.amount': 'Částka',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Nur Gesamtsumme — noch nicht abgerechnet',
|
'costs.unfinishedHint': 'Nur Gesamtsumme — noch nicht abgerechnet',
|
||||||
'costs.tapToInclude': 'Zum Einbeziehen tippen',
|
'costs.tapToInclude': 'Zum Einbeziehen tippen',
|
||||||
'costs.amount': 'Betrag',
|
'costs.amount': 'Betrag',
|
||||||
|
'costs.split': "Aufteilen",
|
||||||
|
'costs.splitEqually': "Gleichmäßig",
|
||||||
|
'costs.splitCustom': "Individuell",
|
||||||
|
'costs.splitTicket': "Beleg",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -91,6 +91,10 @@ const budget: TranslationStrings = {
|
|||||||
'costs.category': 'Category',
|
'costs.category': 'Category',
|
||||||
'costs.whoPaid': 'Who paid?',
|
'costs.whoPaid': 'Who paid?',
|
||||||
'costs.splitBetween': 'Split equally between',
|
'costs.splitBetween': 'Split equally between',
|
||||||
|
'costs.split': 'Split',
|
||||||
|
'costs.splitEqually': 'Equally',
|
||||||
|
'costs.splitCustom': 'Custom',
|
||||||
|
'costs.splitTicket': 'Ticket',
|
||||||
'costs.pickSomeone': 'Pick at least one person to split with.',
|
'costs.pickSomeone': 'Pick at least one person to split with.',
|
||||||
'costs.splitSummary': 'Split {count} ways · {amount} each',
|
'costs.splitSummary': 'Split {count} ways · {amount} each',
|
||||||
'costs.cat.accommodation': 'Accommodation',
|
'costs.cat.accommodation': 'Accommodation',
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Solo en el total — aún sin saldar',
|
'costs.unfinishedHint': 'Solo en el total — aún sin saldar',
|
||||||
'costs.tapToInclude': 'Toca para incluir',
|
'costs.tapToInclude': 'Toca para incluir',
|
||||||
'costs.amount': 'Importe',
|
'costs.amount': 'Importe',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Total seulement — pas encore réglé',
|
'costs.unfinishedHint': 'Total seulement — pas encore réglé',
|
||||||
'costs.tapToInclude': 'Toucher pour inclure',
|
'costs.tapToInclude': 'Toucher pour inclure',
|
||||||
'costs.amount': 'Montant',
|
'costs.amount': 'Montant',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Μόνο στο σύνολο — δεν έχει διακανονιστεί',
|
'costs.unfinishedHint': 'Μόνο στο σύνολο — δεν έχει διακανονιστεί',
|
||||||
'costs.tapToInclude': 'Πατήστε για προσθήκη',
|
'costs.tapToInclude': 'Πατήστε για προσθήκη',
|
||||||
'costs.amount': 'Ποσό',
|
'costs.amount': 'Ποσό',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Csak az összegben — még nincs rendezve',
|
'costs.unfinishedHint': 'Csak az összegben — még nincs rendezve',
|
||||||
'costs.tapToInclude': 'Koppintson a hozzáadáshoz',
|
'costs.tapToInclude': 'Koppintson a hozzáadáshoz',
|
||||||
'costs.amount': 'Összeg',
|
'costs.amount': 'Összeg',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Hanya total — belum diselesaikan',
|
'costs.unfinishedHint': 'Hanya total — belum diselesaikan',
|
||||||
'costs.tapToInclude': 'Ketuk untuk menyertakan',
|
'costs.tapToInclude': 'Ketuk untuk menyertakan',
|
||||||
'costs.amount': 'Jumlah',
|
'costs.amount': 'Jumlah',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Solo nel totale — non ancora saldato',
|
'costs.unfinishedHint': 'Solo nel totale — non ancora saldato',
|
||||||
'costs.tapToInclude': 'Tocca per includere',
|
'costs.tapToInclude': 'Tocca per includere',
|
||||||
'costs.amount': 'Importo',
|
'costs.amount': 'Importo',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': '合計のみ — 未精算',
|
'costs.unfinishedHint': '合計のみ — 未精算',
|
||||||
'costs.tapToInclude': 'タップして追加',
|
'costs.tapToInclude': 'タップして追加',
|
||||||
'costs.amount': '金額',
|
'costs.amount': '金額',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': '합계에만 반영 — 아직 미정산',
|
'costs.unfinishedHint': '합계에만 반영 — 아직 미정산',
|
||||||
'costs.tapToInclude': '탭하여 포함',
|
'costs.tapToInclude': '탭하여 포함',
|
||||||
'costs.amount': '금액',
|
'costs.amount': '금액',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Alleen in totaal — nog niet verrekend',
|
'costs.unfinishedHint': 'Alleen in totaal — nog niet verrekend',
|
||||||
'costs.tapToInclude': 'Tik om toe te voegen',
|
'costs.tapToInclude': 'Tik om toe te voegen',
|
||||||
'costs.amount': 'Bedrag',
|
'costs.amount': 'Bedrag',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Tylko w sumie — jeszcze nierozliczone',
|
'costs.unfinishedHint': 'Tylko w sumie — jeszcze nierozliczone',
|
||||||
'costs.tapToInclude': 'Dotknij, aby dodać',
|
'costs.tapToInclude': 'Dotknij, aby dodać',
|
||||||
'costs.amount': 'Kwota',
|
'costs.amount': 'Kwota',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Только в сумме — ещё не урегулировано',
|
'costs.unfinishedHint': 'Только в сумме — ещё не урегулировано',
|
||||||
'costs.tapToInclude': 'Нажмите, чтобы добавить',
|
'costs.tapToInclude': 'Нажмите, чтобы добавить',
|
||||||
'costs.amount': 'Сумма',
|
'costs.amount': 'Сумма',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Endast totalt – ännu inte reglerat',
|
'costs.unfinishedHint': 'Endast totalt – ännu inte reglerat',
|
||||||
'costs.tapToInclude': 'Tryck för att inkludera',
|
'costs.tapToInclude': 'Tryck för att inkludera',
|
||||||
'costs.amount': 'Summa',
|
'costs.amount': 'Summa',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Yalnızca toplamda — henüz ödenmedi',
|
'costs.unfinishedHint': 'Yalnızca toplamda — henüz ödenmedi',
|
||||||
'costs.tapToInclude': 'Eklemek için dokun',
|
'costs.tapToInclude': 'Eklemek için dokun',
|
||||||
'costs.amount': 'Tutar',
|
'costs.amount': 'Tutar',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': 'Лише в сумі — ще не врегульовано',
|
'costs.unfinishedHint': 'Лише в сумі — ще не врегульовано',
|
||||||
'costs.tapToInclude': 'Натисніть, щоб додати',
|
'costs.tapToInclude': 'Натисніть, щоб додати',
|
||||||
'costs.amount': 'Сума',
|
'costs.amount': 'Сума',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': '僅計入總額 — 尚未結算',
|
'costs.unfinishedHint': '僅計入總額 — 尚未結算',
|
||||||
'costs.tapToInclude': '點按以加入',
|
'costs.tapToInclude': '點按以加入',
|
||||||
'costs.amount': '金額',
|
'costs.amount': '金額',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -115,6 +115,9 @@ const budget: TranslationStrings = {
|
|||||||
'costs.unfinishedHint': '仅计入总额 — 尚未结算',
|
'costs.unfinishedHint': '仅计入总额 — 尚未结算',
|
||||||
'costs.tapToInclude': '点按以加入',
|
'costs.tapToInclude': '点按以加入',
|
||||||
'costs.amount': '金额',
|
'costs.amount': '金额',
|
||||||
|
'costs.split': "Split",
|
||||||
|
'costs.splitEqually': "Equally",
|
||||||
|
'costs.splitCustom': "Custom",
|
||||||
|
'costs.splitTicket': "Ticket",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default budget;
|
export default budget;
|
||||||
|
|||||||
@@ -54,7 +54,12 @@ Add a new item using the inline **add row** at the bottom of each category table
|
|||||||
The **Persons** column behaves differently depending on the trip:
|
The **Persons** column behaves differently depending on the trip:
|
||||||
|
|
||||||
- **Single-user trip** — enter a number of persons directly.
|
- **Single-user trip** — enter a number of persons directly.
|
||||||
- **Multi-member trip** — a member chip picker appears. Click the edit button to assign or remove members from an expense. Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
|
- **Multi-member trip** — a member chip picker appears. Click the edit button to open the expense modal, where you can select:
|
||||||
|
- **Equally** — Splits the cost equally among selected members. Remainder cents (from rounding errors) are distributed deterministically and rotated using the item ID to ensure everyone is charged equally over the course of the trip.
|
||||||
|
- **Custom** — Enter specific custom amounts for each traveler. The sum of the custom splits must balance exactly to the total price.
|
||||||
|
- **Ticket** — Build an itemized list of expenses (e.g. Apples: $10, cake: $50, Milk: $40) and assign specific trip participants to split each individual item. Individual shares are calculated cent-perfectly, the total expense price is automatically summed, and the list of itemized splits is saved/restored across edits.
|
||||||
|
|
||||||
|
Click an assigned member chip again to mark them as **paid** (the chip shows a green ring).
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user