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:
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div ref={ref} onMouseEnter={onEnter} onMouseLeave={() => 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
|
||||
? <img src={avatarUrl} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: label?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
{hover && ReactDOM.createPortal(
|
||||
<div style={{
|
||||
position: 'fixed', top: pos.top, left: pos.left, transform: 'translate(-50%, -100%)',
|
||||
pointerEvents: 'none', zIndex: 10000, whiteSpace: 'nowrap',
|
||||
background: 'var(--bg-card, white)', color: 'var(--text-primary, #111827)',
|
||||
fontSize: 11, fontWeight: 500, padding: '5px 10px', borderRadius: 8,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)', border: '1px solid var(--border-faint, #e5e7eb)',
|
||||
}}>
|
||||
{label}
|
||||
</div>,
|
||||
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 (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 2, flexWrap: 'wrap' }}>
|
||||
{members.map(m => (
|
||||
<ChipWithTooltip key={m.user_id} label={m.username} avatarUrl={m.avatar_url} size={chipSize} />
|
||||
))}
|
||||
<button ref={btnRef} onClick={openDropdown}
|
||||
style={{
|
||||
width: btnSize, height: btnSize, borderRadius: '50%', border: '1.5px dashed var(--border-primary)',
|
||||
background: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
color: 'var(--text-faint)', padding: 0, flexShrink: 0,
|
||||
}}>
|
||||
{members.length > 0 ? <Pencil size={iconSize} /> : <Users size={iconSize} />}
|
||||
</button>
|
||||
{showDropdown && ReactDOM.createPortal(
|
||||
<div ref={dropRef} style={{
|
||||
position: 'fixed', top: dropPos.top, left: dropPos.left, transform: 'translateX(-50%)', zIndex: 10000,
|
||||
background: 'var(--bg-card)', border: '1px solid var(--border-primary)', borderRadius: 10,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', padding: 4, minWidth: 150,
|
||||
}}>
|
||||
{tripMembers.map(tm => {
|
||||
const isActive = memberIds.includes(tm.id)
|
||||
return (
|
||||
<button key={tm.id} onClick={() => toggleMember(tm.id)} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
|
||||
borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
|
||||
fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
|
||||
>
|
||||
<div style={{
|
||||
width: 18, height: 18, borderRadius: '50%', background: 'var(--bg-tertiary)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 8, fontWeight: 700,
|
||||
color: 'var(--text-muted)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{tm.avatar_url
|
||||
? <img src={tm.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: tm.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1 }}>{tm.username}</span>
|
||||
{isActive && <Check size={12} color="var(--text-primary)" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ marginTop: 16, borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: 14, display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{data.map(person => (
|
||||
<div key={person.user_id} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: '50%', background: 'rgba(255,255,255,0.1)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 9, fontWeight: 700,
|
||||
color: 'rgba(255,255,255,0.7)', overflow: 'hidden', flexShrink: 0,
|
||||
}}>
|
||||
{person.avatar_url
|
||||
? <img src={person.avatar_url} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
: person.username?.[0]?.toUpperCase()
|
||||
}
|
||||
</div>
|
||||
<span style={{ flex: 1, fontSize: 12, fontWeight: 500, color: 'rgba(255,255,255,0.7)' }}>{person.username}</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: '#fff' }}>{fmt(person.total_assigned)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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 }) {
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ ...th, textAlign: 'left', minWidth: 100 }}>{t('budget.table.name')}</th>
|
||||
<th style={{ ...th, minWidth: 80 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 50 }}>{t('budget.table.persons')}</th>
|
||||
<th style={{ ...th, minWidth: 60 }}>{t('budget.table.total')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 130 }}>{t('budget.table.persons')}</th>
|
||||
<th className="hidden sm:table-cell" style={{ ...th, minWidth: 45 }}>{t('budget.table.days')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 90 }}>{t('budget.table.perPerson')}</th>
|
||||
<th className="hidden md:table-cell" style={{ ...th, minWidth: 80 }}>{t('budget.table.perDay')}</th>
|
||||
@@ -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 (
|
||||
<tr key={item.id} style={{ transition: 'background 0.1s' }}
|
||||
onMouseEnter={e => e.currentTarget.style.background = 'var(--bg-hover)'}
|
||||
onMouseLeave={e => e.currentTarget.style.background = 'transparent'}>
|
||||
<td style={td}><InlineEditCell value={item.name} onSave={v => handleUpdateField(item.id, 'name', v)} placeholder={t('budget.table.name')} locale={locale} editTooltip={t('budget.editTooltip')} /></td>
|
||||
<td style={td}>
|
||||
<InlineEditCell value={item.name} onSave={v => 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 && (
|
||||
<div className="sm:hidden" style={{ marginTop: 4 }}>
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
compact={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.total_price} type="number" onSave={v => handleUpdateField(item.id, 'total_price', v)} style={{ textAlign: 'center' }} placeholder="0,00" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center', position: 'relative' }}>
|
||||
{hasMultipleMembers ? (
|
||||
<BudgetMemberChips
|
||||
members={item.members || []}
|
||||
tripMembers={tripMembers}
|
||||
onSetMembers={(userIds) => setBudgetItemMembers(tripId, item.id, userIds)}
|
||||
/>
|
||||
) : (
|
||||
<InlineEditCell value={item.persons} type="number" decimals={0} onSave={v => handleUpdateField(item.id, 'persons', v != null ? parseInt(v) || null : null)} style={{ textAlign: 'center' }} placeholder="-" locale={locale} editTooltip={t('budget.editTooltip')} />
|
||||
)}
|
||||
</td>
|
||||
<td className="hidden sm:table-cell" style={{ ...td, textAlign: 'center' }}>
|
||||
<InlineEditCell value={item.days} type="number" decimals={0} onSave={v => 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 })}
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'rgba(255,255,255,0.5)', fontWeight: 500 }}>{SYMBOLS[currency] || currency} {currency}</div>
|
||||
{hasMultipleMembers && (budgetItems || []).some(i => i.members?.length > 0) && (
|
||||
<PerPersonInline tripId={tripId} budgetItems={budgetItems} currency={currency} locale={locale} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{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,
|
||||
}}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 16, textAlign: 'center' }}>{t('budget.byCategory')}</div>
|
||||
|
||||
@@ -410,6 +605,7 @@ export default function BudgetPanel({ tripId }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -675,8 +675,8 @@ export default function TripPlannerPage() {
|
||||
)}
|
||||
|
||||
{activeTab === 'finanzplan' && (
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1400, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} />
|
||||
<div style={{ height: '100%', overflowY: 'auto', overscrollBehavior: 'contain', maxWidth: 1800, margin: '0 auto', width: '100%', padding: '8px 0' }}>
|
||||
<BudgetPanel tripId={tripId} tripMembers={tripMembers} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user