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

235 lines
9.8 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:8px; align-items:flex-end;">
<div class="form-group" style="margin-bottom:0; min-width:160px;">
<label style="font-size:12px; color:#888;">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:120px;">
<label style="font-size:12px; color:#888;">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 style="font-size:12px; color:#888;">Search</label>
<input type="text" name="search" placeholder="Search 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;">{{dateShort .Date}}</td>
<td class="desc-cell">{{.Description}}</td>
<td class="text-muted" style="font-size:13px;">
{{$name := index $d.AccountNames .AccountID}}
{{if $name}}{{$name}}{{else}}<span style="opacity:0.4;"></span>{{end}}
</td>
<td>
<span class="cat-badge" id="cat-{{.ID}}"
style="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}}; padding:2px 10px; border-radius:12px; font-size:12px; font-weight:500;">
{{if $color}}<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:{{$color}};margin-right:5px;vertical-align:middle;"></span>{{end}}{{.Category}}
</span>
<select class="cat-select" id="sel-{{.ID}}" style="display:none; font-size:12px; padding:2px 6px; border-radius:8px; border:1px solid #c5cae9;"
onchange="saveCategory('{{.ID}}', this.value)" onblur="cancelEdit('{{.ID}}')">
{{range $d.Categories}}
<option value="{{.Name}}" {{if eq .Name $.Category}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
<button class="btn btn-outline btn-sm" onclick="editCat('{{.ID}}')" id="edit-btn-{{.ID}}" title="Edit category"></button>
</td>
<td class="cents {{if lt .AmountCents 0}}negative{{else}}positive{{end}}" style="font-variant-numeric:tabular-nums; 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:40px;">No transactions found. <a href="/import">Import some</a> or <button class="btn btn-outline btn-sm" onclick="openAddModal()">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.4); z-index:200; align-items:center; justify-content:center;">
<div class="card" style="width:420px; max-width:95vw; margin:0; animation:fadeIn 0.2s ease-out;">
<h2 style="font-size:18px; font-weight:700; color:#333; margin-bottom:20px;">Add Transaction</h2>
<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 class="flex" style="gap:8px;">
<select id="add-sign" style="width:90px; padding:8px; border:1px solid #ddd; border-radius:8px;">
<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 class="flex" style="gap:8px; justify-content:flex-end; margin-top:8px;">
<button class="btn btn-outline" onclick="closeAddModal()">Cancel</button>
<button class="btn btn-primary" onclick="submitAdd()">Save</button>
</div>
<div id="add-error" class="error" style="display:none; margin-top:8px;"></div>
</div>
</div>
<style>
.desc-cell { max-width:280px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cat-badge { cursor:default; display:inline-flex; align-items:center; }
</style>
<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-block';
document.getElementById('sel-' + id).style.display = 'none';
}, 150);
}
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 badge = document.getElementById('cat-' + id);
const color = catColors[cat] || '';
badge.style.background = color ? color + '22' : '#e0e0e0';
badge.style.color = color || '#555';
badge.style.border = '1px solid ' + (color ? color + '44' : '#ccc');
badge.innerHTML = (color ? `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${color};margin-right:5px;vertical-align:middle;"></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() {
const modal = document.getElementById('add-modal');
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 account = document.getElementById('add-account').value;
if (!date || !desc || isNaN(amt) || amt <= 0) {
const err = document.getElementById('add-error');
err.textContent = 'Please fill in date, description, and a positive amount.';
err.style.display = 'block';
return;
}
const amountCents = Math.round(amt * 100) * sign;
fetch('/api/transactions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({account_id: account, date, description: desc, amount_cents: amountCents, category: cat})
}).then(r => {
if (!r.ok) { document.getElementById('add-error').textContent = 'Failed to save.'; document.getElementById('add-error').style.display = 'block'; return; }
closeAddModal();
location.reload();
});
}
document.getElementById('add-modal').addEventListener('click', function(e) {
if (e.target === this) closeAddModal();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
</script>
{{end}}