Gonçalo Rodrigues 7a2cb10c79 feat(finance): dark mode UI overhaul + admin seed data
UI — full dark/light theme system:
- CSS custom-property token system (--bg, --surface, --accent, --green, --red, etc.)
  with complete light-mode overrides via [data-theme="light"]
- Sticky frosted-glass nav with animated brand icon, theme toggle persisted to localStorage,
  respects prefers-color-scheme on first visit
- Cards with layered shadows, glass backdrop-filter, shimmer accent stripe on value cards
- Glowing category color dots, colored P&L badges, budget bars with glow effect
- All Chart.js instances use CSS-variable-aware grid/text colours
- Scroll-reveal animations and animated money counters on every KPI card
- 3-D donut portfolio chart recoloured to match palette; hover lifts the hovered slice
- Accounts page shows type emoji icons; delete removes row in-place
- Sharing page search dropdown themed with var() colours
- Import preview: colour-coded left border on category select driven by category colour
- Projections: second KPI card (monthly avg) + pace bars per category

Seed data (seed.go):
- SeedAdmin() runs in a goroutine at startup; idempotent (skips if transactions exist)
- Resolves admin user ID via internal users service GET /admin/users?search=<email>
  (SEED_USER_EMAIL env var, defaults to admin@homelab.local)
- Seeds 4 accounts (CGD Checking, CGD Savings, Visa Credit, Trade Republic)
- Seeds 14 categories with colours and monthly budgets
- Seeds ~65 realistic Portuguese household transactions spread across 6 months
- Seeds 7 ETF buy trades across VWCE, SXR8 (S&P 500), EUNL (MSCI World)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:16:23 +01:00

218 lines
8.1 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}}
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:8px;">
<h1>Dashboard</h1>
<span class="text-muted">{{if $d.Email}}{{$d.Email}}{{end}}</span>
</div>
<!-- KPI cards -->
<div class="grid">
<div class="card value-card animate-on-scroll">
<h2>Net This Month</h2>
<div class="value {{if lt $d.ThisMonth.TotalCents 0}}negative{{else}}positive{{end}} animate-counter"
data-target="{{$d.ThisMonth.TotalCents}}" data-prefix="€">€0.00</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.00</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.00</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.00</div>
</div>
</div>
<!-- Charts -->
<div class="grid-2">
<div class="card animate-on-scroll">
<h2>Spending by Category — This Month</h2>
{{if $d.ThisMonth.ByCategory}}
<div style="position:relative; padding-top:8px;">
<canvas id="thisMonthChart" height="220"></canvas>
</div>
{{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}}
<div style="position:relative; padding-top:8px;">
<canvas id="balanceChart" height="220"></canvas>
</div>
{{else}}
<div class="empty-state" style="padding:32px;">No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a></div>
{{end}}
</div>
</div>
<!-- Budget progress -->
{{if $d.CategoryBudgets}}
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:18px;">
<h2>Budget vs Actual — This Month</h2>
<a href="/categories" class="btn btn-outline btn-sm">Manage budgets</a>
</div>
<div style="display:flex; flex-direction:column; gap:16px;">
{{range $cat, $budget := $d.CategoryBudgets}}
{{$spent := index $d.ThisMonth.ByCategory $cat}}
{{$spentAbs := centsAbs $spent}}
{{$color := index $d.CategoryColors $cat}}
{{$over := isOver $spentAbs $budget}}
{{$pct := clampPct $spentAbs $budget}}
<div>
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:7px;">
<span style="font-size:13.5px; font-weight:500; display:flex; align-items:center; gap:7px;">
{{if $color}}<span style="width:9px;height:9px;border-radius:50%;background:{{$color}};display:inline-block;box-shadow:0 0 6px {{$color}}66;"></span>{{end}}
{{$cat}}
</span>
<span style="font-size:12.5px; {{if $over}}color:var(--red);font-weight:600;{{else}}color:var(--text2);{{end}}">
€{{cents $spentAbs}} / €{{cents $budget}}
{{if $over}}&nbsp;⚠ over budget{{end}}
</span>
</div>
<div style="background:var(--bg3); border-radius:8px; height:7px; overflow:hidden;">
<div style="height:100%; border-radius:8px; width:{{$pct}}%; transition:width 1s ease;
background:{{if $over}}var(--red){{else if $color}}{{$color}}{{else}}var(--accent){{end}};
box-shadow:{{if $over}}0 0 8px var(--red){{else if $color}}0 0 6px {{$color}}88{{else}}0 0 6px var(--accent-glow){{end}};"></div>
</div>
</div>
{{end}}
</div>
</div>
{{end}}
<!-- Recent transactions -->
<div class="card animate-on-scroll">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:14px;">
<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; color:var(--text2);">{{dateShort .Date}}</td>
<td style="max-width:260px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{.Description}}</td>
<td>
<span class="badge" style="
background:{{if $color}}{{$color}}18{{else}}var(--bg3){{end}};
color:{{if $color}}{{$color}}{{else}}var(--text2){{end}};
border:1px solid {{if $color}}{{$color}}33{{else}}var(--border2){{end}};">
{{if $color}}<span class="category-dot" style="background:{{$color}};box-shadow:0 0 4px {{$color}}88;"></span>{{end}}
{{.Category}}
</span>
</td>
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-weight:600; 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:36px;">
No transactions yet. <a href="/import" style="color:var(--accent);">Import some!</a>
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<script>
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
const isDark = () => document.documentElement.getAttribute('data-theme') === 'dark';
function gridColor() { return isDark() ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.06)'; }
function textColor() { return isDark() ? '#5c6585' : '#8a92b0'; }
{{if $d.ThisMonth.ByCategory}}
const catLabels = {{jsonKeys $d.ThisMonth.ByCategory}};
const catData = {{jsonVals $d.ThisMonth.ByCategory}};
const resolvedColors = catLabels.map(k => catColors[k] || '#6979f8');
new Chart(document.getElementById('thisMonthChart'), {
type: 'bar',
data: {
labels: catLabels,
datasets: [{
data: catData.map(v => Math.abs(v) / 100),
backgroundColor: resolvedColors.map(c => c + '99'),
borderColor: resolvedColors,
borderWidth: 1.5,
borderRadius: 6,
borderSkipped: false,
}]
},
options: {
responsive: true,
animation: { duration: 900, easing: 'easeOutQuart' },
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
x: { grid: { display: false }, ticks: { color: textColor() } }
}
}
});
{{end}}
{{if $d.BalanceTrend}}
const ctx = document.getElementById('balanceChart').getContext('2d');
const grad = ctx.createLinearGradient(0, 0, 0, 260);
grad.addColorStop(0, 'rgba(105,121,248,0.30)');
grad.addColorStop(0.6, 'rgba(105,121,248,0.08)');
grad.addColorStop(1, 'rgba(105,121,248,0.00)');
new Chart(ctx, {
type: 'line',
data: {
labels: [{{range $d.BalanceTrend}}"{{dateShort .Date}}",{{end}}],
datasets: [{
label: 'Balance (€)',
data: [{{range $d.BalanceTrend}}{{div .Cents 100}},{{end}}],
borderColor: '#6979f8',
backgroundColor: grad,
fill: true,
tension: 0.42,
pointRadius: 0,
pointHoverRadius: 5,
pointBackgroundColor: '#fff',
pointBorderColor: '#6979f8',
pointBorderWidth: 2,
borderWidth: 2.5,
}]
},
options: {
responsive: true,
animation: { duration: 1100, easing: 'easeOutQuart' },
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: false, grid: { color: gridColor() }, ticks: { color: textColor(), callback: v => '€' + v } },
x: { grid: { display: false }, ticks: { color: textColor(), maxTicksLimit: 7 } }
},
interaction: { intersect: false, mode: 'index' }
}
});
{{end}}
</script>
{{end}}