Budget: per-person expense tracking with member chips

- New budget_item_members junction table (migration 27)
- Assign trip members to budget items via avatar chips in Persons column
- Per-person split auto-calculated from assigned member count
- Per-person summary integrated into total budget card
- Member chips rendered via portal dropdown (no overflow clipping)
- Mobile: larger touch-friendly chips (30px) under item name
- Desktop: compact chips (20px) in Persons column
- Custom NOMAD-style tooltips on chips
- WebSocket live sync for all member operations
- Fix invite button text color in dark mode
- Widen budget layout to 1800px max-width
- Shorten "Per Person/Day" column header
This commit is contained in:
Maurice
2026-03-25 17:31:37 +01:00
parent 3bf49d4180
commit 17288f9a0e
9 changed files with 376 additions and 14 deletions

View File

@@ -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)
];

View File

@@ -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;