- 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>
235 lines
9.8 KiB
HTML
235 lines
9.8 KiB
HTML
{{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}}
|