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
+53 -27
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 budgetData: { monthly_target_eur: number | null; entries: any[] } | null = null;
let isAdmin = false; let isAdmin = false;
let addEntryType: 'donation' | 'expense' = 'donation'; let addEntryType: 'donation' | 'expense' = 'donation';
const expandedMonths = new Set<string>();
let budgetFirstRender = true;
async function loadBudget() { async function loadBudget() {
try { try {
@@ -400,7 +402,10 @@ function renderBudget() {
for (const e of entries) (byMonth[e.month] ??= []).push(e); for (const e of entries) (byMonth[e.month] ??= []).push(e);
const months = Object.keys(byMonth).sort().reverse(); const months = Object.keys(byMonth).sort().reverse();
let html = ''; if (budgetFirstRender) { expandedMonths.add(months[0]); budgetFirstRender = false; }
listEl.innerHTML = '';
for (const ym of months) { for (const ym of months) {
const items = byMonth[ym]; const items = byMonth[ym];
const donated = items.filter(e => e.type === 'donation').reduce((s, e) => s + e.amount_eur, 0); const donated = items.filter(e => e.type === 'donation').reduce((s, e) => s + e.amount_eur, 0);
@@ -408,23 +413,49 @@ function renderBudget() {
const balance = donated - spent; const balance = donated - spent;
const target = budgetData?.monthly_target_eur; const target = budgetData?.monthly_target_eur;
const pct = target ? Math.min(100, Math.round(donated / target * 100)) : null; const pct = target ? Math.min(100, Math.round(donated / target * 100)) : null;
const open = expandedMonths.has(ym);
html += `<div class="mb-6">`; const wrapper = document.createElement('div');
html += `<h3 class="text-sm font-semibold text-white mb-2">${monthLabel(ym)}</h3>`; wrapper.className = 'mb-2 rounded-xl border border-zinc-800 overflow-hidden';
// ── 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);
// ── Expanded detail ───────────────────────────────────────────────────
if (open) {
const detail = document.createElement('div');
detail.className = 'border-t border-zinc-800';
if (pct !== null) { if (pct !== null) {
html += `<div class="mb-3"> 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"> <div class="flex justify-between text-xs text-zinc-500 mb-1">
<span>Donations toward monthly goal</span> <span>Toward monthly goal</span><span>${pct}% of ${fmtEur(target!)}</span>
<span>${pct}% of ${fmtEur(target!)}</span>
</div> </div>
<div class="h-1.5 rounded-full bg-zinc-800 overflow-hidden"> <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 class="h-full rounded-full" style="width:${pct}%;background:var(--accent)"></div>
</div>
</div>`; </div>`;
detail.appendChild(prog);
} }
html += `<div class="divide-y divide-zinc-800/60 rounded-xl border border-zinc-800 overflow-hidden">`; const entriesDiv = document.createElement('div');
entriesDiv.className = 'divide-y divide-zinc-800/60';
let entriesHtml = '';
for (const e of items) { for (const e of items) {
const icon = e.type === 'donation' ? '💚' : '🔴'; const icon = e.type === 'donation' ? '💚' : '🔴';
const sign = e.type === 'donation' ? '+' : ''; const sign = e.type === 'donation' ? '+' : '';
@@ -438,27 +469,19 @@ function renderBudget() {
? `<button data-edit="${e.id}" class="edit-btn text-zinc-600 hover:text-zinc-300 text-xs px-1 transition-colors">✎</button> ? `<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>` <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}"> 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="text-base shrink-0">${icon}</span>
<span class="flex-1 text-zinc-300 min-w-0"> <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>
${e.label}${e.note ? `<span class="text-zinc-600 text-xs ml-2">${e.note}</span>` : ''}
</span>
${recurBadge} ${recurBadge}
<span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span> <span class="font-medium shrink-0 ${color}">${sign}${fmtEur(e.amount_eur)}</span>
${adminBtns} ${adminBtns}
</div>`; </div>`;
} }
html += `</div>`; entriesDiv.innerHTML = entriesHtml;
html += `<div class="flex gap-4 mt-2 text-xs text-zinc-500 px-1"> detail.appendChild(entriesDiv);
<span>Donated: <span class="text-green-400">${fmtEur(donated)}</span></span> wrapper.appendChild(detail);
<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;
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => { entriesDiv.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
btn.addEventListener('click', async () => { btn.addEventListener('click', async () => {
if (!confirm('Delete this entry?')) return; if (!confirm('Delete this entry?')) return;
const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' }); const r = await fetch(`/api/budget/entries/${btn.dataset.del}`, { method: 'DELETE', credentials: 'include' });
@@ -466,12 +489,12 @@ function renderBudget() {
}); });
}); });
listEl.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => { entriesDiv.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const id = btn.dataset.edit!; const id = btn.dataset.edit!;
const entry = budgetData!.entries.find(e => e.id === id); const entry = budgetData!.entries.find(e => e.id === id);
if (!entry) return; if (!entry) return;
const row = listEl.querySelector<HTMLElement>(`[data-entry-id="${id}"]`)!; const row = entriesDiv.querySelector<HTMLElement>(`[data-entry-id="${id}"]`)!;
row.innerHTML = ` row.innerHTML = `
<form class="inline-edit-form flex flex-wrap gap-2 items-center w-full py-1" data-id="${id}"> <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"> <select name="type" class="px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none">
@@ -484,7 +507,7 @@ function renderBudget() {
class="w-20 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-white text-xs focus:outline-none" /> 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}" <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]" /> 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" <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" /> 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"> <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" />↻ <input type="checkbox" name="recurring" ${entry.recurring?'checked':''} class="accent-[--accent] w-3 h-3" />↻
@@ -492,7 +515,6 @@ function renderBudget() {
<button type="submit" class="px-3 py-1 rounded bg-[--accent] text-white text-xs hover:opacity-90">Save</button> <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> <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>`; </form>`;
row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => { row.querySelector<HTMLFormElement>('.inline-edit-form')!.addEventListener('submit', async ev => {
ev.preventDefault(); ev.preventDefault();
const fd = new FormData(ev.target as HTMLFormElement); const fd = new FormData(ev.target as HTMLFormElement);
@@ -505,6 +527,10 @@ function renderBudget() {
}); });
} }
listEl.appendChild(wrapper);
}
}
// Load budget on page load (donate is the default tab) // Load budget on page load (donate is the default tab)
loadBudget(); loadBudget();