e.currentTarget.style.background = 'var(--bg-hover)'}
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
- | handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /> |
+
+ handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} />
+ {/* Mobile: larger chips under name since Persons column is hidden */}
+ {hasMultipleMembers && (
+
+ setBudgetItemMembers(tripId, item.id, userIds)}
+ compact={false}
+ />
+
+ )}
+ |
handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
|
-
- handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
+ |
+ {hasMultipleMembers ? (
+ setBudgetItemMembers(tripId, item.id, userIds)}
+ />
+ ) : (
+ handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
+ )}
|
handleUpdateField(item.id, 'days', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
@@ -375,6 +566,9 @@ export default function BudgetPanel({ tripId }) {
{Number(grandTotal).toLocaleString(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
{SYMBOLS[currency] || currency} {currency}
+ {hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
+
+ )}
{pieSegments.length > 0 && (
@@ -382,6 +576,7 @@ export default function BudgetPanel({ tripId }) {
background: 'var(--bg-card)', borderRadius: 16, padding: '20px 16px',
border: '1px solid var(--border-primary)',
boxShadow: '0 2px 12px rgba(0,0,0,0.04)',
+ marginBottom: 16,
}}>
{t('budget.byCategory')}
@@ -410,6 +605,7 @@ export default function BudgetPanel({ tripId }) {
)}
+
diff --git a/client/src/components/Trips/TripMembersModal.jsx b/client/src/components/Trips/TripMembersModal.jsx
index 00bc24c..37f1f8f 100644
--- a/client/src/components/Trips/TripMembersModal.jsx
+++ b/client/src/components/Trips/TripMembersModal.jsx
@@ -144,7 +144,7 @@ export default function TripMembersModal({ isOpen, onClose, tripId, tripTitle })
disabled={adding || !selectedUserId}
style={{
display: 'flex', alignItems: 'center', gap: 5, padding: '8px 14px',
- background: 'var(--accent)', color: 'white', border: 'none', borderRadius: 10,
+ background: 'var(--accent)', color: 'var(--accent-text)', border: 'none', borderRadius: 10,
fontSize: 13, fontWeight: 600, cursor: adding || !selectedUserId ? 'default' : 'pointer',
fontFamily: 'inherit', opacity: adding || !selectedUserId ? 0.4 : 1, flexShrink: 0,
}}
diff --git a/client/src/i18n/translations/de.js b/client/src/i18n/translations/de.js
index 3318aa3..65a3651 100644
--- a/client/src/i18n/translations/de.js
+++ b/client/src/i18n/translations/de.js
@@ -630,7 +630,7 @@ const de = {
'budget.table.days': 'Tage',
'budget.table.perPerson': 'Pro Person',
'budget.table.perDay': 'Pro Tag',
- 'budget.table.perPersonDay': 'Pro Person/Tag',
+ 'budget.table.perPersonDay': 'P. p / Tag',
'budget.table.note': 'Notiz',
'budget.newEntry': 'Neuer Eintrag',
'budget.defaultEntry': 'Neuer Eintrag',
@@ -641,6 +641,10 @@ const de = {
'budget.editTooltip': 'Klicken zum Bearbeiten',
'budget.confirm.deleteCategory': 'Möchtest du die Kategorie "{name}" mit {count} Einträgen wirklich löschen?',
'budget.deleteCategory': 'Kategorie löschen',
+ 'budget.perPerson': 'Pro Person',
+ 'budget.paid': 'Bezahlt',
+ 'budget.open': 'Offen',
+ 'budget.noMembers': 'Keine Teilnehmer zugewiesen',
// Files
'files.title': 'Dateien',
diff --git a/client/src/i18n/translations/en.js b/client/src/i18n/translations/en.js
index 73948db..a5df0f0 100644
--- a/client/src/i18n/translations/en.js
+++ b/client/src/i18n/translations/en.js
@@ -630,7 +630,7 @@ const en = {
'budget.table.days': 'Days',
'budget.table.perPerson': 'Per Person',
'budget.table.perDay': 'Per Day',
- 'budget.table.perPersonDay': 'Per Person/Day',
+ 'budget.table.perPersonDay': 'P. p / Day',
'budget.table.note': 'Note',
'budget.newEntry': 'New Entry',
'budget.defaultEntry': 'New Entry',
@@ -641,6 +641,10 @@ const en = {
'budget.editTooltip': 'Click to edit',
'budget.confirm.deleteCategory': 'Are you sure you want to delete the category "{name}" with {count} entries?',
'budget.deleteCategory': 'Delete Category',
+ 'budget.perPerson': 'Per Person',
+ 'budget.paid': 'Paid',
+ 'budget.open': 'Open',
+ 'budget.noMembers': 'No members assigned',
// Files
'files.title': 'Files',
diff --git a/client/src/pages/TripPlannerPage.jsx b/client/src/pages/TripPlannerPage.jsx
index cb1e9ba..1fc67fd 100644
--- a/client/src/pages/TripPlannerPage.jsx
+++ b/client/src/pages/TripPlannerPage.jsx
@@ -675,8 +675,8 @@ export default function TripPlannerPage() {
)}
{activeTab === 'finanzplan' && (
-
-
+
+
)}
diff --git a/client/src/store/tripStore.js b/client/src/store/tripStore.js
index 6a25228..a287f66 100644
--- a/client/src/store/tripStore.js
+++ b/client/src/store/tripStore.js
@@ -201,6 +201,20 @@ export const useTripStore = create((set, get) => ({
return {
budgetItems: state.budgetItems.filter(i => i.id !== payload.itemId),
}
+ case 'budget:members-updated':
+ return {
+ budgetItems: state.budgetItems.map(i =>
+ i.id === payload.itemId ? { ...i, members: payload.members, persons: payload.persons } : i
+ ),
+ }
+ case 'budget:member-paid-updated':
+ return {
+ budgetItems: state.budgetItems.map(i =>
+ i.id === payload.itemId
+ ? { ...i, members: (i.members || []).map(m => m.user_id === payload.userId ? { ...m, paid: payload.paid } : m) }
+ : i
+ ),
+ }
// Reservations
case 'reservation:created':
@@ -683,6 +697,27 @@ export const useTripStore = create((set, get) => ({
}
},
+ setBudgetItemMembers: async (tripId, itemId, userIds) => {
+ const result = await budgetApi.setMembers(tripId, itemId, userIds);
+ set(state => ({
+ budgetItems: state.budgetItems.map(item =>
+ item.id === itemId ? { ...item, members: result.members, persons: result.item.persons } : item
+ )
+ }));
+ return result;
+ },
+
+ toggleBudgetMemberPaid: async (tripId, itemId, userId, paid) => {
+ await budgetApi.togglePaid(tripId, itemId, userId, paid);
+ set(state => ({
+ budgetItems: state.budgetItems.map(item =>
+ item.id === itemId
+ ? { ...item, members: (item.members || []).map(m => m.user_id === userId ? { ...m, paid } : m) }
+ : item
+ )
+ }));
+ },
+
loadFiles: async (tripId) => {
try {
const data = await filesApi.list(tripId)
diff --git a/server/src/db/database.js b/server/src/db/database.js
index f497a6f..be85859 100644
--- a/server/src/db/database.js
+++ b/server/src/db/database.js
@@ -569,6 +569,20 @@ function initDb() {
`);
} catch {}
},
+ // 27: Budget item members (per-person expense tracking)
+ () => {
+ _db.exec(`
+ CREATE TABLE IF NOT EXISTS budget_item_members (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ budget_item_id INTEGER NOT NULL REFERENCES budget_items(id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ paid INTEGER NOT NULL DEFAULT 0,
+ UNIQUE(budget_item_id, user_id)
+ );
+ CREATE INDEX IF NOT EXISTS idx_budget_item_members_item ON budget_item_members(budget_item_id);
+ CREATE INDEX IF NOT EXISTS idx_budget_item_members_user ON budget_item_members(user_id);
+ `);
+ },
// Future migrations go here (append only, never reorder)
];
diff --git a/server/src/routes/budget.js b/server/src/routes/budget.js
index d1487bc..ec2bbb6 100644
--- a/server/src/routes/budget.js
+++ b/server/src/routes/budget.js
@@ -9,6 +9,19 @@ function verifyTripOwnership(tripId, userId) {
return canAccessTrip(tripId, userId);
}
+function loadItemMembers(itemId) {
+ return db.prepare(`
+ SELECT 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 = ?
+ `).all(itemId);
+}
+
+function avatarUrl(user) {
+ return user.avatar ? `/uploads/avatars/${user.avatar}` : null;
+}
+
// GET /api/trips/:tripId/budget
router.get('/', authenticate, (req, res) => {
const { tripId } = req.params;
@@ -20,9 +33,48 @@ router.get('/', authenticate, (req, res) => {
'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC'
).all(tripId);
+ // Batch-load all members
+ const itemIds = items.map(i => i.id);
+ const membersByItem = {};
+ if (itemIds.length > 0) {
+ 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 (${itemIds.map(() => '?').join(',')})
+ `).all(...itemIds);
+ for (const m of allMembers) {
+ if (!membersByItem[m.budget_item_id]) membersByItem[m.budget_item_id] = [];
+ membersByItem[m.budget_item_id].push({
+ user_id: m.user_id, paid: m.paid, username: m.username, avatar_url: avatarUrl(m)
+ });
+ }
+ }
+ items.forEach(item => { item.members = membersByItem[item.id] || []; });
+
res.json({ items });
});
+// GET /api/trips/:tripId/budget/summary/per-person (must be before /:id routes)
+router.get('/summary/per-person', authenticate, (req, res) => {
+ const { tripId } = req.params;
+ if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const summary = db.prepare(`
+ 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(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,
+ COUNT(bi.id) as items_count
+ FROM budget_item_members bm
+ JOIN budget_items bi ON bm.budget_item_id = bi.id
+ JOIN users u ON bm.user_id = u.id
+ WHERE bi.trip_id = ?
+ GROUP BY bm.user_id
+ `).all(tripId);
+
+ res.json({ summary: summary.map(s => ({ ...s, avatar_url: avatarUrl(s) })) });
+});
+
// POST /api/trips/:tripId/budget
router.post('/', authenticate, (req, res) => {
const { tripId } = req.params;
@@ -50,6 +102,7 @@ router.post('/', authenticate, (req, res) => {
);
const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid);
+ item.members = [];
res.status(201).json({ item });
broadcast(tripId, 'budget:created', { item }, req.headers['x-socket-id']);
});
@@ -87,10 +140,63 @@ router.put('/:id', authenticate, (req, res) => {
);
const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
+ updated.members = loadItemMembers(id);
res.json({ item: updated });
broadcast(tripId, 'budget:updated', { item: updated }, req.headers['x-socket-id']);
});
+// PUT /api/trips/:tripId/budget/:id/members
+router.put('/:id/members', authenticate, (req, res) => {
+ const { tripId, id } = req.params;
+ if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const item = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
+ if (!item) return res.status(404).json({ error: 'Budget item not found' });
+
+ const { user_ids } = req.body;
+ if (!Array.isArray(user_ids)) return res.status(400).json({ error: 'user_ids must be an array' });
+
+ // Preserve paid status for existing members
+ const existingPaid = {};
+ const existing = db.prepare('SELECT user_id, paid FROM budget_item_members WHERE budget_item_id = ?').all(id);
+ for (const e of existing) existingPaid[e.user_id] = e.paid;
+
+ db.prepare('DELETE FROM budget_item_members WHERE budget_item_id = ?').run(id);
+ if (user_ids.length > 0) {
+ const insert = db.prepare('INSERT OR IGNORE INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, ?)');
+ for (const userId of user_ids) insert.run(id, userId, existingPaid[userId] || 0);
+ // Auto-update persons count
+ db.prepare('UPDATE budget_items SET persons = ? WHERE id = ?').run(user_ids.length, id);
+ } else {
+ db.prepare('UPDATE budget_items SET persons = NULL WHERE id = ?').run(id);
+ }
+
+ const members = loadItemMembers(id).map(m => ({ ...m, avatar_url: avatarUrl(m) }));
+ const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(id);
+ res.json({ members, item: updated });
+ broadcast(Number(tripId), 'budget:members-updated', { itemId: Number(id), members, persons: updated.persons }, req.headers['x-socket-id']);
+});
+
+// PUT /api/trips/:tripId/budget/:id/members/:userId/paid
+router.put('/:id/members/:userId/paid', authenticate, (req, res) => {
+ const { tripId, id, userId } = req.params;
+ if (!canAccessTrip(Number(tripId), req.user.id)) return res.status(404).json({ error: 'Trip not found' });
+
+ const { paid } = req.body;
+ db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
+ .run(paid ? 1 : 0, id, userId);
+
+ const member = db.prepare(`
+ SELECT 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 = ? AND bm.user_id = ?
+ `).get(id, userId);
+
+ const result = member ? { ...member, avatar_url: avatarUrl(member) } : null;
+ res.json({ member: result });
+ broadcast(Number(tripId), 'budget:member-paid-updated', { itemId: Number(id), userId: Number(userId), paid: paid ? 1 : 0 }, req.headers['x-socket-id']);
+});
+
// DELETE /api/trips/:tripId/budget/:id
router.delete('/:id', authenticate, (req, res) => {
const { tripId, id } = req.params;
| |