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

251 lines
10 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 := .}}
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<h1>Transactions</h1>
<button class="btn btn-primary" onclick="openAddModal()">+ Add Transaction</button>
</div>
<div class="card mb-16">
<form method="GET" class="flex flex-wrap" style="gap:10px; align-items:flex-end;">
<div class="form-group" style="margin-bottom:0; min-width:160px;">
<label>Category</label>
<select name="category">
<option value="">All Categories</option>
{{range $d.Categories}}
<option value="{{.Name}}" {{if eq $.Cat .Name}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group" style="margin-bottom:0; min-width:130px;">
<label>Period</label>
<select name="days">
<option value="">All time</option>
<option value="30" {{if eq $.Days "30"}}selected{{end}}>30 days</option>
<option value="90" {{if eq $.Days "90"}}selected{{end}}>90 days</option>
<option value="365" {{if eq $.Days "365"}}selected{{end}}>1 year</option>
</select>
</div>
<div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;">
<label>Search</label>
<input type="text" name="search" placeholder="Description…" value="{{.Search}}">
</div>
<button type="submit" class="btn btn-primary">Filter</button>
{{if or $.Cat $.Search $.Days}}
<a href="/transactions" class="btn btn-outline">Clear</a>
{{end}}
</form>
</div>
<div class="card">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:12px;">
<span class="text-muted">{{len $d.Txns}} transaction{{if ne (len $d.Txns) 1}}s{{end}}</span>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Date</th>
<th>Description</th>
<th>Account</th>
<th>Category</th>
<th class="text-right">Amount</th>
<th></th>
</tr>
</thead>
<tbody>
{{range $d.Txns}}
{{$color := index $d.CategoryColors .Category}}
<tr id="row-{{.ID}}">
<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 style="font-size:12.5px; color:var(--text3);">
{{$name := index $d.AccountNames .AccountID}}
{{if $name}}{{$name}}{{else}}<span style="opacity:.35;"></span>{{end}}
</td>
<td>
<!-- badge shown normally -->
<span id="cat-{{.ID}}" 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>
<!-- inline dropdown, hidden until pencil clicked -->
<select id="sel-{{.ID}}" style="display:none; font-size:12px; padding:4px 8px;
border:1px solid var(--border2); border-radius:6px; background:var(--bg2); color:var(--text);"
onchange="saveCategory('{{.ID}}', this.value)"
onblur="cancelEdit('{{.ID}}')">
{{range $d.Categories}}
<option value="{{.Name}}">{{.Name}}</option>
{{end}}
</select>
<button id="edit-btn-{{.ID}}" class="btn btn-outline btn-sm"
onclick="editCat('{{.ID}}')" title="Edit category"
style="margin-left:4px; padding:3px 7px;"></button>
</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>
<td>
<button class="btn btn-danger btn-sm" onclick="delTxn('{{.ID}}')" title="Delete"></button>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" class="text-center text-muted" style="padding:44px;">
No transactions found.
<a href="/import" style="color:var(--accent);">Import some</a> or
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">add manually</button>.
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
<!-- Add Transaction Modal -->
<div id="add-modal" style="display:none; position:fixed; inset:0;
background:rgba(0,0,0,0.55); backdrop-filter:blur(4px);
z-index:300; align-items:center; justify-content:center;">
<div class="card" style="width:440px; max-width:95vw; margin:0;
border:1px solid var(--border2); box-shadow:var(--shadow-lg);
animation:fadeUp 0.2s ease-out both;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:20px;">
<span style="font-size:16px; font-weight:700;">Add Transaction</span>
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;"></button>
</div>
<div class="form-group">
<label>Date</label>
<input type="date" id="add-date" required>
</div>
<div class="form-group">
<label>Description</label>
<input type="text" id="add-desc" placeholder="e.g. Coffee at Starbucks" required>
</div>
<div class="form-group">
<label>Amount (€)</label>
<div style="display:flex; gap:8px;">
<select id="add-sign" style="width:110px; padding:9px 10px;
border:1px solid var(--border2); border-radius:var(--radius-sm);
background:var(--bg2); color:var(--text);">
<option value="-1"> Expense</option>
<option value="1">+ Income</option>
</select>
<input type="number" id="add-amount" placeholder="0.00" step="0.01" min="0" style="flex:1;">
</div>
</div>
<div class="form-group">
<label>Category</label>
<select id="add-cat">
{{range $d.Categories}}
<option value="{{.Name}}">{{.Name}}</option>
{{end}}
{{if not $d.Categories}}<option value="Others">Others</option>{{end}}
</select>
</div>
<div class="form-group">
<label>Account</label>
<select id="add-account">
<option value="">— none —</option>
{{range $d.Accounts}}
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
{{end}}
</select>
</div>
<div id="add-error" class="error" style="display:none;"></div>
<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:4px;">
<button class="btn btn-outline" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAdd()">Save Transaction</button>
</div>
</div>
</div>
<script>
const catColors = { {{range $k,$v := $d.CategoryColors}}"{{$k}}":"{{$v}}",{{end}} };
function editCat(id) {
document.getElementById('cat-' + id).style.display = 'none';
document.getElementById('edit-btn-' + id).style.display = 'none';
const sel = document.getElementById('sel-' + id);
sel.style.display = 'inline-block';
sel.focus();
}
function cancelEdit(id) {
setTimeout(() => {
document.getElementById('cat-' + id).style.display = 'inline-flex';
document.getElementById('edit-btn-' + id).style.display = 'inline-flex';
document.getElementById('sel-' + id).style.display = 'none';
}, 160);
}
function saveCategory(id, cat) {
fetch('/api/transactions/' + id, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({category: cat})
}).then(r => {
if (!r.ok) return;
const color = catColors[cat] || '';
const badge = document.getElementById('cat-' + id);
badge.style.background = color ? color + '18' : 'var(--bg3)';
badge.style.color = color || 'var(--text2)';
badge.style.borderColor = color ? color + '33' : 'var(--border2)';
badge.innerHTML = (color
? `<span class="category-dot" style="background:${color};box-shadow:0 0 4px ${color}88;"></span>`
: '') + cat;
cancelEdit(id);
});
}
function delTxn(id) {
if (!confirm('Delete this transaction?')) return;
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
if (r.ok) document.getElementById('row-' + id).remove();
});
}
function openAddModal() {
document.getElementById('add-modal').style.display = 'flex';
document.getElementById('add-date').value = new Date().toISOString().slice(0, 10);
document.getElementById('add-desc').focus();
}
function closeAddModal() {
document.getElementById('add-modal').style.display = 'none';
document.getElementById('add-error').style.display = 'none';
}
function submitAdd() {
const date = document.getElementById('add-date').value;
const desc = document.getElementById('add-desc').value.trim();
const sign = parseInt(document.getElementById('add-sign').value);
const amt = parseFloat(document.getElementById('add-amount').value);
const cat = document.getElementById('add-cat').value;
const acct = document.getElementById('add-account').value;
const errEl = document.getElementById('add-error');
if (!date || !desc || isNaN(amt) || amt <= 0) {
errEl.textContent = 'Please fill in date, description, and a positive amount.';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
fetch('/api/transactions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat})
}).then(r => {
if (!r.ok) { errEl.textContent = 'Failed to save.'; errEl.style.display = 'block'; return; }
closeAddModal();
location.reload();
});
}
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
</script>
{{end}}