feat: collapsible month rows in running costs, most recent auto-expanded

This commit is contained in:
Davide Scaini
2026-06-03 23:47:09 +02:00
parent da351cc53b
commit 7a20af72fa
+119 -93
View File
@@ -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<string>();
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 += `<div class="mb-6">`;
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`;
const wrapper = document.createElement('div');
wrapper.className = 'mb-2 rounded-xl border border-zinc-800 overflow-hidden';
if (pct !== null) {
html += `<div class="mb-3">
<div class="flex justify-between text-xs text-zinc-500 mb-1">
<span>Donations toward monthly goal</span>
<span>${pct}% of ${fmtEur(target!)}</span>
</div>
<div class="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
<div class="h-full rounded-full transition-all" style="width:${pct}%;background:var(--accent)"></div>
</div>
</div>`;
}
// ── 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 = `
<span class="font-medium text-white flex-1 text-left">${monthLabel(ym)}</span>
<span class="text-xs text-green-400">+${fmtEur(donated)}</span>
<span class="text-xs text-zinc-700">/</span>
<span class="text-xs text-red-400">${fmtEur(spent)}</span>
<span class="text-xs text-zinc-700">/</span>
<span class="text-xs ${balance >= 0 ? 'text-zinc-300' : 'text-red-400'}">${balance >= 0 ? '+' : ''}${fmtEur(Math.abs(balance))}</span>
<span class="text-zinc-600 text-xs ml-1">${open ? '▲' : '▼'}</span>`;
header.addEventListener('click', () => {
if (expandedMonths.has(ym)) expandedMonths.delete(ym); else expandedMonths.add(ym);
renderBudget();
});
wrapper.appendChild(header);
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`;
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
? `<span class="text-zinc-500 text-xs shrink-0" title="Repeats every month">↻</span>`
: e.recurring_from
? `<span class="text-zinc-600 text-xs shrink-0" title="Auto-added (recurring)">↻</span>`
// ── 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 = `
<div class="flex justify-between text-xs text-zinc-500 mb-1">
<span>Toward monthly goal</span><span>${pct}% of ${fmtEur(target!)}</span>
</div>
<div class="h-1.5 rounded-full bg-zinc-800 overflow-hidden">
<div class="h-full rounded-full" style="width:${pct}%;background:var(--accent)"></div>
</div>`;
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
? `<span class="text-zinc-500 text-xs shrink-0" title="Repeats every month">↻</span>`
: e.recurring_from
? `<span class="text-zinc-600 text-xs shrink-0" title="Auto-added (recurring)">↻</span>`
: '';
const adminBtns = isAdmin
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button>
<button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>`
: '';
const adminBtns = isAdmin
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button>
<button data-del="${e.id}" class="del-btn text-zinc-700 hover:text-red-400 text-xs px-1 transition-colors">✕</button>`
: '';
html += `<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 text-sm" data-entry-id="${e.id}">
<span class="text-base shrink-0">${icon}</span>
<span class="flex-1 text-zinc-300 min-w-0">
${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}
</span>
${recurBadge}
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
${adminBtns}
</div>`;
}
html += `</div>`;
html += `<div class="flex gap-4 mt-2 text-xs text-zinc-500 px-1">
<span>Donated: <span class="text-green-400">${fmtEur(donated)}</span></span>
<span>Spent: <span class="text-red-400">${fmtEur(spent)}</span></span>
<span>Balance: <span class="${balance >= 0 ? 'text-zinc-300' : 'text-red-400'}">${balance >= 0 ? '+' : ''}${fmtEur(Math.abs(balance))}</span></span>
</div>`;
html += `</div>`;
}
listEl.innerHTML = html;
entriesHtml += `<div class="flex items-center gap-3 px-3 py-2 bg-zinc-900 text-sm" data-entry-id="${e.id}">
<span class="text-base shrink-0">${icon}</span>
<span class="flex-1 text-zinc-300 min-w-0">${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}</span>
${recurBadge}
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
${adminBtns}
</div>`;
}
entriesDiv.innerHTML = entriesHtml;
detail.appendChild(entriesDiv);
wrapper.appendChild(detail);
listEl.querySelectorAll<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>(`[data-entry-id="${id}"]`)!;
row.innerHTML = `
<form class="inline-edit-form flex flex-wrap gap-2 items-center w-full py-1" data-id="${id}">
<select name="type" class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none">
<option value="donation" ${entry.type==='donation'?'selected':''}>💚 Donation</option>
<option value="expense" ${entry.type==='expense' ?'selected':''}>🔴 Expense</option>
</select>
<input name="label" value="${entry.label}" placeholder="Label"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="amount_eur" type="number" value="${entry.amount_eur}" step="0.01" min="0"
class="w-20 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="month" type="month" value="${entry.month}"
class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" />
<input name="note" value="${entry.note}" placeholder="Note"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<label class="flex items-center gap-1 text-xs text-zinc-400 cursor-pointer">
<input type="checkbox" name="recurring" ${entry.recurring?'checked':''} class="accent-[--accent] w-3 h-3" />↻
</label>
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button>
<button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button>
</form>`;
row.querySelector<HTMLFormElement>('.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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>(`[data-entry-id="${id}"]`)!;
row.innerHTML = `
<form class="inline-edit-form flex flex-wrap gap-2 items-center w-full py-1" data-id="${id}">
<select name="type" class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none">
<option value="donation" ${entry.type==='donation'?'selected':''}>💚 Donation</option>
<option value="expense" ${entry.type==='expense' ?'selected':''}>🔴 Expense</option>
</select>
<input name="label" value="${entry.label}" placeholder="Label"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="amount_eur" type="number" value="${entry.amount_eur}" step="0.01" min="0"
class="w-20 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<input name="month" type="month" value="${entry.month}"
class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none [color-scheme:dark]" />
<input name="note" value="${entry.note ?? ''}" placeholder="Note"
class="flex-1 min-w-24 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" />
<label class="flex items-center gap-1 text-xs text-zinc-400 cursor-pointer">
<input type="checkbox" name="recurring" ${entry.recurring?'checked':''} class="accent-[--accent] w-3 h-3" />↻
</label>
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button>
<button type="button" class="cancel-edit px-2 py-1 rounded bg-zinc-800 text-zinc-400 text-xs hover:bg-zinc-700">✕</button>
</form>`;
row.querySelector<HTMLFormElement>('.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)