From f6d08582ec7e281d99a4183f2432c3825cb706d8 Mon Sep 17 00:00:00 2001 From: Maurice Date: Mon, 30 Mar 2026 11:12:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20expense=20settlement=20=E2=80=94=20trac?= =?UTF-8?q?k=20who=20paid,=20show=20who=20owes=20whom=20=E2=80=94=20closes?= =?UTF-8?q?=20#41?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Click member avatars on budget items to mark who paid (green = paid) - Multiple green chips = those people split the payment equally - Settlement dropdown in the total budget card shows optimized payment flows (who owes whom how much) and net balances per person - Info tooltip explains how the feature works - New server endpoint GET /budget/settlement calculates net balances and minimized payment flows using a greedy algorithm - Merged category legend: amount + percentage in one row - i18n keys added for DE and EN --- client/src/api/client.ts | 1 + client/src/components/Budget/BudgetPanel.tsx | 145 ++++++++++++++++--- client/src/i18n/translations/de.ts | 3 + client/src/i18n/translations/en.ts | 3 + server/src/db/migrations.ts | 4 + server/src/routes/budget.ts | 71 +++++++++ 6 files changed, 208 insertions(+), 19 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 75c6a8e..346b7d8 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -184,6 +184,7 @@ export const budgetApi = { setMembers: (tripId: number | string, id: number, userIds: number[]) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), togglePaid: (tripId: number | string, id: number, userId: number, paid: boolean) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), perPersonSummary: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), + settlement: (tripId: number | string) => apiClient.get(`/trips/${tripId}/budget/settlement`).then(r => r.data), } export const filesApi = { diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx index 171375f..ba59f0d 100644 --- a/client/src/components/Budget/BudgetPanel.tsx +++ b/client/src/components/Budget/BudgetPanel.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react' import DOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' import { budgetApi } from '../../api/client' import type { BudgetItem, BudgetMember } from '../../types' @@ -145,9 +145,11 @@ interface ChipWithTooltipProps { label: string avatarUrl: string | null size?: number + paid?: boolean + onClick?: () => void } -function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) { +function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) { const [hover, setHover] = useState(false) const [pos, setPos] = useState({ top: 0, left: 0 }) const ref = useRef(null) @@ -160,13 +162,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps) setHover(true) } + const borderColor = paid ? '#22c55e' : 'var(--border-primary)' + const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)' + return ( <>
setHover(false)} + onClick={onClick} style={{ - width: size, height: size, borderRadius: '50%', border: '1.5px solid var(--border-primary)', - background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', - fontSize: size * 0.4, fontWeight: 700, color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0, + width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`, + background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', + fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)', + overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default', + transition: 'border-color 0.15s, background 0.15s', }}> {avatarUrl ? @@ -177,11 +185,19 @@ function ChipWithTooltip({ label, avatarUrl, size = 20 }: ChipWithTooltipProps)
{label} + {paid && ( + Paid + )}
, document.body )} @@ -194,10 +210,11 @@ interface BudgetMemberChipsProps { members?: BudgetMember[] tripMembers?: TripMember[] onSetMembers: (memberIds: number[]) => void + onTogglePaid?: (userId: number, paid: boolean) => void compact?: boolean } -function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }: BudgetMemberChipsProps) { +function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true }: BudgetMemberChipsProps) { const chipSize = compact ? 20 : 30 const btnSize = compact ? 18 : 28 const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) @@ -237,7 +254,10 @@ function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compa return (
{members.map(m => ( - + onTogglePaid(m.user_id, !m.paid) : undefined} + /> ))}
@@ -553,6 +582,7 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro members={item.members || []} tripMembers={tripMembers} onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)} + onTogglePaid={(userId, paid) => toggleBudgetMemberPaid(tripId, item.id, userId, paid)} /> ) : ( handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} /> @@ -628,6 +658,91 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && ( )} + + {/* Settlement dropdown inside the total card */} + {hasMultipleMembers && settlement && settlement.flows.length > 0 && ( +
+ + + {settlementOpen && ( +
+ {settlement.flows.map((flow, i) => ( +
+ +
+ + + {fmt(flow.amount, currency)} + + +
+ +
+ ))} + + {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).length > 0 && ( +
+
+ {t('budget.netBalances')} +
+ {settlement.balances.filter(b => Math.abs(b.balance) > 0.01).map(b => ( +
+
+ {b.avatar_url + ? + : b.username?.[0]?.toUpperCase() + } +
+ + {b.username} + + 0 ? '#4ade80' : '#f87171', + }}> + {b.balance > 0 ? '+' : ''}{fmt(b.balance, currency)} + +
+ ))} +
+ )} +
+ )} +
+ )}
{pieSegments.length > 0 && ( @@ -641,27 +756,19 @@ export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelPro -
+
{pieSegments.map(seg => { const pct = grandTotal > 0 ? ((seg.value / grandTotal) * 100).toFixed(1) : '0.0' return (
{seg.name} - {pct}% + {fmt(seg.value, currency)} + {pct}%
) })}
- -
- {pieSegments.map(seg => ( -
- {seg.name} - {fmt(seg.value, currency)} -
- ))} -
)} diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts index 09c82d1..b09273e 100644 --- a/client/src/i18n/translations/de.ts +++ b/client/src/i18n/translations/de.ts @@ -800,6 +800,9 @@ const de: Record = { 'budget.paid': 'Bezahlt', 'budget.open': 'Offen', 'budget.noMembers': 'Keine Teilnehmer zugewiesen', + 'budget.settlement': 'Ausgleich', + 'budget.settlementInfo': 'Klicke auf ein Mitglied-Bild bei einem Eintrag, um es grün zu markieren — das bedeutet, diese Person hat bezahlt. Der Ausgleich zeigt dann, wer wem wie viel schuldet.', + 'budget.netBalances': 'Netto-Salden', // Files 'files.title': 'Dateien', diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 21ca7a6..b85b6dc 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -800,6 +800,9 @@ const en: Record = { 'budget.paid': 'Paid', 'budget.open': 'Open', 'budget.noMembers': 'No members assigned', + 'budget.settlement': 'Settlement', + 'budget.settlementInfo': 'Click a member avatar on a budget item to mark them green — this means they paid. The settlement then shows who owes whom and how much.', + 'budget.netBalances': 'Net Balances', // Files 'files.title': 'Files', diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index f62499d..2254b21 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -325,6 +325,10 @@ function runMigrations(db: Database.Database): void { // Add day_plan_position to reservations for persistent transport ordering in day timeline try { db.exec('ALTER TABLE reservations ADD COLUMN day_plan_position REAL DEFAULT NULL'); } catch {} }, + () => { + // Add paid_by_user_id to budget_items for expense tracking / settlement + try { db.exec('ALTER TABLE budget_items ADD COLUMN paid_by_user_id INTEGER REFERENCES users(id)'); } catch {} + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/routes/budget.ts b/server/src/routes/budget.ts index c1b9258..410f62b 100644 --- a/server/src/routes/budget.ts +++ b/server/src/routes/budget.ts @@ -195,6 +195,77 @@ router.put('/:id/members/:userId/paid', authenticate, (req: Request, res: Respon broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id'] as string); }); +// Settlement calculation: who owes whom +router.get('/settlement', authenticate, (req: Request, res: Response) => { + const authReq = req as AuthRequest; + const { tripId } = req.params; + if (!canAccessTrip(Number(tripId), authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); + + const items = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as BudgetItem[]; + const allMembers = db.prepare(` + SELECT bm.budget_item_id, bm.user_id, bm.paid, u.username, u.avatar + FROM budget_item_members bm + JOIN users u ON bm.user_id = u.id + WHERE bm.budget_item_id IN (SELECT id FROM budget_items WHERE trip_id = ?) + `).all(tripId) as (BudgetItemMember & { budget_item_id: number })[]; + + // Calculate net balance per user: positive = is owed money, negative = owes money + const balances: Record = {}; + + for (const item of items) { + const members = allMembers.filter(m => m.budget_item_id === item.id); + if (members.length === 0) continue; + + const payers = members.filter(m => m.paid); + if (payers.length === 0) continue; // no one marked as paid + + const sharePerMember = item.total_price / members.length; + const paidPerPayer = item.total_price / payers.length; + + for (const m of members) { + if (!balances[m.user_id]) { + balances[m.user_id] = { user_id: m.user_id, username: m.username, avatar_url: avatarUrl(m), balance: 0 }; + } + // Everyone owes their share + balances[m.user_id].balance -= sharePerMember; + // Payers get credited what they paid + if (m.paid) balances[m.user_id].balance += paidPerPayer; + } + } + + // Calculate optimized payment flows (greedy algorithm) + const people = Object.values(balances).filter(b => Math.abs(b.balance) > 0.01); + const debtors = people.filter(p => p.balance < -0.01).map(p => ({ ...p, amount: -p.balance })); + const creditors = people.filter(p => p.balance > 0.01).map(p => ({ ...p, amount: p.balance })); + + // Sort by amount descending for efficient matching + debtors.sort((a, b) => b.amount - a.amount); + creditors.sort((a, b) => b.amount - a.amount); + + const flows: { from: { user_id: number; username: string; avatar_url: string | null }; to: { user_id: number; username: string; avatar_url: string | null }; amount: number }[] = []; + + let di = 0, ci = 0; + while (di < debtors.length && ci < creditors.length) { + const transfer = Math.min(debtors[di].amount, creditors[ci].amount); + if (transfer > 0.01) { + flows.push({ + from: { user_id: debtors[di].user_id, username: debtors[di].username, avatar_url: debtors[di].avatar_url }, + to: { user_id: creditors[ci].user_id, username: creditors[ci].username, avatar_url: creditors[ci].avatar_url }, + amount: Math.round(transfer * 100) / 100, + }); + } + debtors[di].amount -= transfer; + creditors[ci].amount -= transfer; + if (debtors[di].amount < 0.01) di++; + if (creditors[ci].amount < 0.01) ci++; + } + + res.json({ + balances: Object.values(balances).map(b => ({ ...b, balance: Math.round(b.balance * 100) / 100 })), + flows, + }); +}); + router.delete('/:id', authenticate, (req: Request, res: Response) => { const authReq = req as AuthRequest; const { tripId, id } = req.params;