diff --git a/site/src/pages/support/index.astro b/site/src/pages/support/index.astro index 7cd3978..f3093a0 100644 --- a/site/src/pages/support/index.astro +++ b/site/src/pages/support/index.astro @@ -355,6 +355,8 @@ if (location.hash.slice(1) === 'community') { communityLoaded = true; loadCommun let budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null; let isAdmin = false; let addEntryType: 'donation' | 'expense' = 'donation'; +const expandedMonths = new Set(); +let budgetFirstRender = true; async function loadBudget() { try { @@ -381,9 +383,9 @@ function monthLabel(ym: string) { } function renderBudget() { - const listEl = document.getElementById('budget-list')!; - const adminCtrls = document.getElementById('budget-admin-controls')!; - const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement; + const listEl = document.getElementById('budget-list')!; + const adminCtrls = document.getElementById('budget-admin-controls')!; + const goalInput = document.getElementById('budget-goal-input') as HTMLInputElement; if (isAdmin) { adminCtrls.classList.remove('hidden'); @@ -400,7 +402,10 @@ function renderBudget() { for (const e of entries) (byMonth[e.month] ??= []).push(e); const months = Object.keys(byMonth).sort().reverse(); - let html = ''; + if (budgetFirstRender) { expandedMonths.add(months[0]); budgetFirstRender = false; } + + listEl.innerHTML = ''; + for (const ym of months) { const items = byMonth[ym]; const donated = items.filter(e => e.type === 'donation').reduce((s, e) => s + e.amount_eur, 0); @@ -408,101 +413,122 @@ function renderBudget() { const balance = donated - spent; const target = budgetData?.monthly_target_eur; const pct = target ? Math.min(100, Math.round(donated / target * 100)) : null; + const open = expandedMonths.has(ym); - html += `
`; - html += `

${monthLabel(ym)}

`; + const wrapper = document.createElement('div'); + wrapper.className = 'mb-2 rounded-xl border border-zinc-800 overflow-hidden'; - if (pct !== null) { - html += `
-
- Donations toward monthly goal - ${pct}% of ${fmtEur(target!)} -
-
-
-
-
`; - } + // ── Month header row ────────────────────────────────────────────────── + const header = document.createElement('button'); + header.className = 'w-full flex items-center gap-2 px-3 py-2.5 bg-zinc-900 hover:bg-zinc-800/60 transition-colors text-sm'; + header.innerHTML = ` + ${monthLabel(ym)} + +${fmtEur(donated)} + / + −${fmtEur(spent)} + / + ${balance >= 0 ? '+' : '−'}${fmtEur(Math.abs(balance))} + ${open ? '▲' : '▼'}`; + header.addEventListener('click', () => { + if (expandedMonths.has(ym)) expandedMonths.delete(ym); else expandedMonths.add(ym); + renderBudget(); + }); + wrapper.appendChild(header); - html += `
`; - for (const e of items) { - const icon = e.type === 'donation' ? '💚' : '🔴'; - const sign = e.type === 'donation' ? '+' : '−'; - const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400'; - const recurBadge = e.recurring - ? `` - : e.recurring_from - ? `` + // ── Expanded detail ─────────────────────────────────────────────────── + if (open) { + const detail = document.createElement('div'); + detail.className = 'border-t border-zinc-800'; + + if (pct !== null) { + const prog = document.createElement('div'); + prog.className = 'px-3 py-2 border-b border-zinc-800'; + prog.innerHTML = ` +
+ Toward monthly goal${pct}% of ${fmtEur(target!)} +
+
+
+
`; + detail.appendChild(prog); + } + + const entriesDiv = document.createElement('div'); + entriesDiv.className = 'divide-y divide-zinc-800/60'; + let entriesHtml = ''; + for (const e of items) { + const icon = e.type === 'donation' ? '💚' : '🔴'; + const sign = e.type === 'donation' ? '+' : '−'; + const color = e.type === 'donation' ? 'text-green-400' : 'text-red-400'; + const recurBadge = e.recurring + ? `` + : e.recurring_from + ? `` + : ''; + const adminBtns = isAdmin + ? ` + ` : ''; - const adminBtns = isAdmin - ? ` - ` - : ''; - html += `
- ${icon} - - ${e.label}${e.note ? `${e.note}` : ''} - - ${recurBadge} - ${sign}${fmtEur(e.amount_eur)} - ${adminBtns} -
`; - } - html += `
`; - html += `
- Donated: ${fmtEur(donated)} - Spent: ${fmtEur(spent)} - Balance: ${balance >= 0 ? '+' : '−'}${fmtEur(Math.abs(balance))} -
`; - html += `
`; - } - listEl.innerHTML = html; + entriesHtml += `
+ ${icon} + ${e.label}${e.note ? `${e.note}` : ''} + ${recurBadge} + ${sign}${fmtEur(e.amount_eur)} + ${adminBtns} +
`; + } + entriesDiv.innerHTML = entriesHtml; + detail.appendChild(entriesDiv); + wrapper.appendChild(detail); - listEl.querySelectorAll('.del-btn').forEach(btn => { - btn.addEventListener('click', async () => { - if (!confirm('Delete this entry?')) return; - const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' }); - if (r.ok) { budgetData!.entries = budgetData!.entries.filter(e => e.id !== btn.dataset.del); renderBudget(); } - }); - }); - - listEl.querySelectorAll('.edit-btn').forEach(btn => { - btn.addEventListener('click', () => { - const id = btn.dataset.edit!; - const entry = budgetData!.entries.find(e => e.id === id); - if (!entry) return; - const row = listEl.querySelector(`[data-entry-id="${id}"]`)!; - row.innerHTML = ` -
- - - - - - - - -
`; - - row.querySelector('.inline-edit-form')!.addEventListener('submit', async ev => { - ev.preventDefault(); - const fd = new FormData(ev.target as HTMLFormElement); - const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note'), recurring: fd.get('recurring') === 'on' }; - const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); - if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); } + entriesDiv.querySelectorAll('.del-btn').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Delete this entry?')) return; + const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' }); + if (r.ok) { budgetData!.entries = budgetData!.entries.filter(e => e.id !== btn.dataset.del); renderBudget(); } + }); }); - row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget); - }); - }); + + entriesDiv.querySelectorAll('.edit-btn').forEach(btn => { + btn.addEventListener('click', () => { + const id = btn.dataset.edit!; + const entry = budgetData!.entries.find(e => e.id === id); + if (!entry) return; + const row = entriesDiv.querySelector(`[data-entry-id="${id}"]`)!; + row.innerHTML = ` +
+ + + + + + + + +
`; + row.querySelector('.inline-edit-form')!.addEventListener('submit', async ev => { + ev.preventDefault(); + const fd = new FormData(ev.target as HTMLFormElement); + const body = { type: fd.get('type'), label: fd.get('label'), amount_eur: parseFloat(fd.get('amount_eur') as string), month: fd.get('month'), note: fd.get('note'), recurring: fd.get('recurring') === 'on' }; + const r = await fetch(`/api/budget/entries/${id}`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); + if (r.ok) { const updated = await r.json(); const idx = budgetData!.entries.findIndex(e => e.id === id); if (idx !== -1) budgetData!.entries[idx] = updated; renderBudget(); } + }); + row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget); + }); + }); + } + + listEl.appendChild(wrapper); + } } // Load budget on page load (donate is the default tab)