diff --git a/client/src/api/client.js b/client/src/api/client.js index bbf4cdb..6920b87 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -152,6 +152,9 @@ export const budgetApi = { create: (tripId, data) => apiClient.post(`/trips/${tripId}/budget`, data).then(r => r.data), update: (tripId, id, data) => apiClient.put(`/trips/${tripId}/budget/${id}`, data).then(r => r.data), delete: (tripId, id) => apiClient.delete(`/trips/${tripId}/budget/${id}`).then(r => r.data), + setMembers: (tripId, id, userIds) => apiClient.put(`/trips/${tripId}/budget/${id}/members`, { user_ids: userIds }).then(r => r.data), + togglePaid: (tripId, id, userId, paid) => apiClient.put(`/trips/${tripId}/budget/${id}/members/${userId}/paid`, { paid }).then(r => r.data), + perPersonSummary: (tripId) => apiClient.get(`/trips/${tripId}/budget/summary/per-person`).then(r => r.data), } export const filesApi = { diff --git a/client/src/components/Budget/BudgetPanel.jsx b/client/src/components/Budget/BudgetPanel.jsx index 388b2b4..e5dbb0c 100644 --- a/client/src/components/Budget/BudgetPanel.jsx +++ b/client/src/components/Budget/BudgetPanel.jsx @@ -1,8 +1,10 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react' +import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react' +import ReactDOM from 'react-dom' import { useTripStore } from '../../store/tripStore' import { useTranslation } from '../../i18n' -import { Plus, Trash2, Calculator, Wallet, Pencil } from 'lucide-react' +import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check } from 'lucide-react' import CustomSelect from '../shared/CustomSelect' +import { budgetApi } from '../../api/client' // ── Helpers ────────────────────────────────────────────────────────────────── const CURRENCIES = ['EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK', 'TRY', 'THB', 'AUD', 'CAD'] @@ -110,6 +112,172 @@ function AddItemRow({ onAdd, t }) { ) } +// ── Chip with custom tooltip ───────────────────────────────────────────────── +function ChipWithTooltip({ label, avatarUrl, size = 20 }) { + const [hover, setHover] = useState(false) + const [pos, setPos] = useState({ top: 0, left: 0 }) + const ref = useRef(null) + + const onEnter = () => { + if (ref.current) { + const rect = ref.current.getBoundingClientRect() + setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 }) + } + setHover(true) + } + + return ( + <> +
setHover(false)} + 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, + }}> + {avatarUrl + ? + : label?.[0]?.toUpperCase() + } +
+ {hover && ReactDOM.createPortal( +
+ {label} +
, + document.body + )} + + ) +} + +// ── Budget Member Chips (for Persons column) ──────────────────────────────── +function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, compact = true }) { + const chipSize = compact ? 20 : 30 + const btnSize = compact ? 18 : 28 + const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14) + const [showDropdown, setShowDropdown] = useState(false) + const [dropPos, setDropPos] = useState({ top: 0, left: 0 }) + const btnRef = useRef(null) + const dropRef = useRef(null) + + const openDropdown = useCallback(() => { + if (btnRef.current) { + const rect = btnRef.current.getBoundingClientRect() + setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 }) + } + setShowDropdown(v => !v) + }, []) + + useEffect(() => { + if (!showDropdown) return + const close = (e) => { + if (dropRef.current && dropRef.current.contains(e.target)) return + if (btnRef.current && btnRef.current.contains(e.target)) return + setShowDropdown(false) + } + document.addEventListener('mousedown', close) + return () => document.removeEventListener('mousedown', close) + }, [showDropdown]) + + const memberIds = members.map(m => m.user_id) + + const toggleMember = (userId) => { + const newIds = memberIds.includes(userId) + ? memberIds.filter(id => id !== userId) + : [...memberIds, userId] + onSetMembers(newIds) + } + + return ( +
+ {members.map(m => ( + + ))} + + {showDropdown && ReactDOM.createPortal( +
+ {tripMembers.map(tm => { + const isActive = memberIds.includes(tm.id) + return ( + + ) + })} +
, + document.body + )} +
+ ) +} + +// ── Per-Person Inline (inside total card) ──────────────────────────────────── +function PerPersonInline({ tripId, budgetItems, currency, locale }) { + const [data, setData] = useState(null) + const fmt = (v) => fmtNum(v, locale, currency) + + useEffect(() => { + budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {}) + }, [tripId, budgetItems]) + + if (!data || data.length === 0) return null + + return ( +
+ {data.map(person => ( +
+
+ {person.avatar_url + ? + : person.username?.[0]?.toUpperCase() + } +
+ {person.username} + {fmt(person.total_assigned)} +
+ ))} +
+ ) +} + // ── Pie Chart (pure CSS conic-gradient) ────────────────────────────────────── function PieChart({ segments, size = 200, totalLabel }) { if (!segments.length) return null @@ -148,14 +316,15 @@ function PieChart({ segments, size = 200, totalLabel }) { } // ── Main Component ─────────────────────────────────────────────────────────── -export default function BudgetPanel({ tripId }) { - const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip } = useTripStore() +export default function BudgetPanel({ tripId, tripMembers = [] }) { + const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers } = useTripStore() const { t, locale } = useTranslation() const [newCategoryName, setNewCategoryName] = useState('') const [editingCat, setEditingCat] = useState(null) // { name, value } const currency = trip?.currency || 'EUR' const fmt = (v, cur) => fmtNum(v, locale, cur) + const hasMultipleMembers = tripMembers.length > 1 const setCurrency = (cur) => { if (tripId) updateTrip(tripId, { currency: cur }) @@ -282,8 +451,8 @@ export default function BudgetPanel({ tripId }) { {t('budget.table.name')} - {t('budget.table.total')} - {t('budget.table.persons')} + {t('budget.table.total')} + {t('budget.table.persons')} {t('budget.table.days')} {t('budget.table.perPerson')} {t('budget.table.perDay')} @@ -297,16 +466,38 @@ export default function BudgetPanel({ tripId }) { const pp = calcPP(item.total_price, item.persons) const pd = calcPD(item.total_price, item.days) const ppd = calcPPD(item.total_price, item.persons, item.days) + const hasMembers = item.members?.length > 0 return ( 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;