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 {
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user