Gonçalo Rodrigues 452f97e6d9 feat(finance): improve UX across dashboard, transactions, reports and categories
- Fix sortStrings no-op bug that caused balance trend chart to display in random date order
- Fix reports table referencing wrong scope for row totals ($.Totals → .Totals per row)
- Add income vs expense split cards on dashboard alongside net figure
- Add budget progress bars on dashboard using existing category budget_cents data
- Color-coded category badges throughout using each category's configured color
- Replace window.prompt() category editor in transactions with inline dropdown
- Replace window.prompt() budget editor in categories with inline input field
- Add manual transaction entry modal (POST /api/transactions) so users don't need CSV for every entry
- Show account names instead of raw MongoDB IDs in the transaction list
- Add clampPct and isOver template helpers for budget bar rendering

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 11:33:39 +01:00

194 lines
7.4 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{{define "content"}}
{{$d := .}}
{{$change := sub $d.ThisMonth.TotalCents $d.LastMonth.TotalCents}}
<h1 style="margin-bottom:24px;">Dashboard</h1>
<!-- Summary cards -->
<div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(200px,1fr));">
<div class="card value-card animate-on-scroll">
<h2>This Month (Net)</h2>
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>Income</h2>
<div class="value positive animate-counter"
data-target="{{$d.ThisMonthIncome}}" data-prefix="€">€0</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>Expenses</h2>
<div class="value negative animate-counter"
data-target="{{$d.ThisMonthExpense}}" data-prefix="€">€0</div>
</div>
<div class="card value-card animate-on-scroll">
<h2>vs Last Month</h2>
<div class="value {{if lt $change 0}}negative{{else}}positive{{end}} animate-counter"
data-target="{{$change}}" data-prefix="€">€0</div>
</div>
</div>
<!-- Charts row -->
<div class="grid" style="grid-template-columns:1fr 1fr;">
<div class="card animate-on-scroll">
<h2>Spending by Category (This Month)</h2>
{{if $d.ThisMonth.ByCategory}}
<canvas id="thisMonthChart" height="220"></canvas>
{{else}}
<div class="empty-state" style="padding:32px;">No spending data this month.</div>
{{end}}
</div>
<div class="card animate-on-scroll">
<h2>Balance Trend (90 days)</h2>
{{if $d.BalanceTrend}}
<canvas id="balanceChart" height="220"></canvas>
{{else}}
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></div>
{{end}}
</div>
</div>
<!-- Budget progress -->
{{if $d.CategoryBudgets}}
<div class="card animate-on-scroll">
<h2 style="margin-bottom:16px;">Budget vs Actual (This Month)</h2>
<div style="display:flex; flex-direction:column; gap:14px;">
{{range $cat, $budget := $d.CategoryBudgets}}
{{$spent := index $d.ThisMonth.ByCategory $cat}}
{{$color := index $d.CategoryColors $cat}}
{{$spentAbs := centsAbs $spent}}
<div>
<div style="display:flex; justify-content:space-between; align-items:baseline; margin-bottom:5px;">
<span style="font-size:14px; font-weight:500; display:flex; align-items:center; gap:6px;">
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;"></span>{{end}}
{{$cat}}
</span>
<span style="font-size:13px; color:#666;">
€{{cents $spentAbs}} <span style="color:#aaa;">/ €{{cents $budget}}</span>
</span>
</div>
<div style="background:#f0f0f0; border-radius:6px; height:8px; overflow:hidden;">
<div style="height:100%; border-radius:6px; width:{{clampPct $spentAbs $budget}}%; transition:width 0.8s ease;
background:{{if isOver $spentAbs $budget}}#f44336{{else if $color}}{{$color}}{{else}}#3949ab{{end}};"></div>
</div>
</div>
{{end}}
</div>
<p class="text-muted" style="margin-top:12px;">Set budgets in <a href="/categories">Categories</a>.</p>
</div>
{{end}}
<!-- Recent transactions -->
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<h2>Recent Transactions</h2>
<a href="/transactions" class="btn btn-outline btn-sm">View all</a>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Category</th>
<th class="text-right">Amount</th>
</tr>
</thead>
<tbody>
{{range $d.RecentTxns}}
{{$color := index $d.CategoryColors .Category}}
<tr>
<td style="white-space:nowrap;">{{dateShort .Date}}</td>
<td style="max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
<td>
<span style="display:inline-flex; align-items:center; gap:5px; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500;
background:{{if $color}}{{$color}}22{{else}}#e0e0e0{{end}};
color:{{if $color}}{{$color}}{{else}}#555{{end}};
border:1px solid {{if $color}}{{$color}}44{{else}}#ccc{{end}};">
{{if $color}}<span style="width:7px;height:7px;border-radius:50%;background:{{$color}};"></span>{{end}}
{{.Category}}
</span>
</td>
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="white-space:nowrap;">
{{if lt .AmountCents 0}}{{else}}+{{end}}€{{cents (centsAbs .AmountCents)}}
</td>
</tr>
{{else}}
<tr><td colspan="4" class="text-center text-muted" style="padding:32px;">No transactions yet. <a href="/import">Import some!</a></td></tr>
{{end}}
</tbody>
</table>
</div>
</div>
<script>
const barColors = ['#FF6384','#36A2EB','#FFCE56','#4BC0C0','#9966FF','#FF9F40','#C9CBCF','#00E676','#651FFF','#FF6F00','#E91E63','#607D8B','#3F51B5','#9E9E9E'];
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
{{if $d.ThisMonth.ByCategory}}
const thisMonthLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
const thisMonthData = {{jsonVals $d.ThisMonth.ByCategory}};
const resolvedColors = thisMonthLabels.map((k,i) => catColors[k] || barColors[i % barColors.length]);
new Chart(document.getElementById('thisMonthChart'), {
type: 'bar',
data: {
labels: thisMonthLabels,
datasets: [{
label: 'Amount (€)',
data: thisMonthData.map(v => Math.abs(v) / 100),
backgroundColor: resolvedColors.map(c => c + 'cc'),
borderColor: resolvedColors,
borderWidth: 1,
borderRadius: 5,
borderSkipped: false,
}]
},
options: {
responsive: true,
animation: { duration: 1000, easing: 'easeOutQuart' },
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
x: { grid: { display: false } }
}
}
});
{{end}}
{{if $d.BalanceTrend}}
const grad = document.createElement('canvas').getContext('2d').createLinearGradient(0,0,0,300);
grad.addColorStop(0, 'rgba(57,73,171,0.35)');
grad.addColorStop(0.6, 'rgba(57,73,171,0.1)');
grad.addColorStop(1, 'rgba(57,73,171,0.01)');
new Chart(document.getElementById('balanceChart'), {
type: 'line',
data: {
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
datasets: [{
label: 'Balance (€)',
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
borderColor: '#3949ab',
backgroundColor: grad,
fill: true,
tension: 0.4,
pointRadius: 0,
pointHoverRadius: 5,
pointBackgroundColor: '#fff',
pointBorderColor: '#3949ab',
pointBorderWidth: 2,
}]
},
options: {
responsive: true,
animation: { duration: 1200, easing: 'easeOutQuart' },
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: false, grid: { color: 'rgba(0,0,0,0.05)' }, ticks: { callback: v => '€' + v } },
x: { grid: { display: false }, ticks: { maxTicksLimit: 8 } }
},
interaction: { intersect: false, mode: 'index' }
}
});
{{end}}
</script>
{{end}}