feat: collapsible month rows in running costs, most recent auto-expanded
This commit is contained in:
@@ -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 {
|
||||
@@ -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,23 +413,49 @@ 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';
|
||||
|
||||
// ── 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) {
|
||||
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">
|
||||
<span>Donations toward monthly goal</span>
|
||||
<span>${pct}% of ${fmtEur(target!)}</span>
|
||||
<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 transition-all" style="width:${pct}%;background:var(--accent)"></div>
|
||||
</div>
|
||||
<div class="h-full rounded-full" style="width:${pct}%;background:var(--accent)"></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) {
|
||||
const icon = 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-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="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>
|
||||
<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;
|
||||
entriesDiv.innerHTML = entriesHtml;
|
||||
detail.appendChild(entriesDiv);
|
||||
wrapper.appendChild(detail);
|
||||
|
||||
listEl.querySelectorAll<HTMLElement>('.del-btn').forEach(btn => {
|
||||
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' });
|
||||
@@ -466,12 +489,12 @@ function renderBudget() {
|
||||
});
|
||||
});
|
||||
|
||||
listEl.querySelectorAll<HTMLElement>('.edit-btn').forEach(btn => {
|
||||
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 = listEl.querySelector<HTMLElement>(`[data-entry-id="${id}"]`)!;
|
||||
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">
|
||||
@@ -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" />
|
||||
<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"
|
||||
<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" />↻
|
||||
@@ -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="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);
|
||||
@@ -503,6 +525,10 @@ function renderBudget() {
|
||||
row.querySelector('.cancel-edit')!.addEventListener('click', renderBudget);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listEl.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
// Load budget on page load (donate is the default tab)
|
||||
|
||||
Reference in New Issue
Block a user