Gonçalo Rodrigues 6acea3da31 feat(finance): inline help tips + guided empty states
- Global .help-tip / .help-popup CSS + click-toggle JS in base.html
- Global .setup-steps / .setup-step CSS for step-by-step guidance
- Dashboard: ? tooltips on Free Cash (formula), Savings Rate, Net Worth
- Goals: ? tooltips on Monthly Amount, At Current Rate, Free Cash After
- Goals empty state: 3-step guide (planner → commit → fund)
- Transactions empty state: 3-step guide (account → import → tag)
  with prominent Import / Add buttons replacing the inline text links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-19 22:43:59 +01:00

315 lines
14 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 := .}}
{{if eq $d.Notice "all_duplicates"}}
<div style="background:rgba(245,158,11,0.1); border:1px solid rgba(245,158,11,0.4); border-radius:10px; padding:12px 16px; margin-bottom:20px; display:flex; align-items:center; gap:10px;">
<span style="font-size:1.1rem;"></span>
<span style="font-size:0.9rem;">{{$d.T.Get "transactions.warning_all_dupes"}}</span>
</div>
{{end}}
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:24px; flex-wrap:wrap; gap:12px;">
<h1>{{$d.T.Get "transactions.title"}}</h1>
<button class="btn btn-primary" onclick="openAddModal()">{{$d.T.Get "transactions.btn_add"}}</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>{{$d.T.Get "transactions.filter.label_category"}}</label>
<select name="category">
<option value="">{{$d.T.Get "transactions.filter.option_all_cats"}}</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>{{$d.T.Get "transactions.filter.label_period"}}</label>
<select name="days">
<option value="">{{$d.T.Get "transactions.filter.option_all_time"}}</option>
<option value="30" {{if eq $.Days "30"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_30_days"}}</option>
<option value="90" {{if eq $.Days "90"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_90_days"}}</option>
<option value="365" {{if eq $.Days "365"}}selected{{end}}>{{$d.T.Get "transactions.filter.option_1_year"}}</option>
</select>
</div>
<div class="form-group" style="margin-bottom:0; flex:1; min-width:200px;">
<label>{{$d.T.Get "transactions.filter.label_search"}}</label>
<input type="text" name="search" placeholder="{{$d.T.Get "transactions.filter.placeholder_search"}}" value="{{.Search}}">
</div>
<button type="submit" class="btn btn-primary">{{$d.T.Get "transactions.filter.btn_filter"}}</button>
{{if or $.Cat $.Search $.Days}}
<a href="/transactions" class="btn btn-outline">{{$d.T.Get "transactions.filter.btn_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>{{$d.T.Get "transactions.table.col_date"}}</th>
<th>{{$d.T.Get "transactions.table.col_description"}}</th>
<th>{{$d.T.Get "transactions.table.col_account"}}</th>
<th>{{$d.T.Get "transactions.table.col_category"}}</th>
<th class="text-right">{{$d.T.Get "transactions.table.col_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="{{$d.T.Get "transactions.table.btn_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="{{$d.T.Get "transactions.table.btn_delete"}}"></button>
</td>
</tr>
{{else}}
<tr>
<td colspan="6" style="padding:0;">
<div style="text-align:center; padding:40px 24px 48px;">
<div style="font-size:40px; margin-bottom:14px;">🧾</div>
<div style="font-size:16px; font-weight:600; color:var(--text2); margin-bottom:8px;">No transactions yet</div>
<p style="font-size:13px; color:var(--text3); margin-bottom:28px;">Transactions are the foundation of every chart, goal, and insight in this app.</p>
<div class="setup-steps" style="margin-bottom:28px;">
<div class="setup-step">
<span class="setup-step-num">1</span>
<div>
<strong>Add an account</strong>
<p>Go to Settings → Accounts and create your checking or savings account first.</p>
</div>
</div>
<div class="setup-step">
<span class="setup-step-num">2</span>
<div>
<strong>Import a CSV or add manually</strong>
<p>Export a statement from your bank and import it, or tap "+ Add Transaction" to enter one by hand.</p>
</div>
</div>
<div class="setup-step">
<span class="setup-step-num">3</span>
<div>
<strong>Tag categories &amp; goals</strong>
<p>Once imported, categorise your spending. Link categories to goals so contributions are tracked automatically.</p>
</div>
</div>
</div>
<div style="display:flex; gap:10px; justify-content:center; flex-wrap:wrap;">
<a href="/import" class="btn btn-primary">{{$d.T.Get "transactions.table.empty_import_link"}}</a>
<button class="btn btn-outline" onclick="openAddModal()">{{$d.T.Get "transactions.table.empty_add_btn"}}</button>
</div>
</div>
</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;">{{$d.T.Get "transactions.modal_add.title"}}</span>
<button class="btn btn-outline btn-sm" onclick="closeAddModal()" style="padding:3px 8px;"></button>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_date"}}</label>
<input type="date" id="add-date" required>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_description"}}</label>
<input type="text" id="add-desc" placeholder="{{$d.T.Get "transactions.modal_add.placeholder_desc"}}" required>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.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">{{$d.T.Get "transactions.modal_add.option_expense"}}</option>
<option value="1">{{$d.T.Get "transactions.modal_add.option_income"}}</option>
</select>
<input type="number" id="add-amount" placeholder="{{$d.T.Get "transactions.modal_add.placeholder_amount"}}" step="0.01" min="0" style="flex:1;">
</div>
</div>
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.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>{{$d.T.Get "transactions.modal_add.label_account"}}</label>
<select id="add-account">
<option value="">{{$d.T.Get "transactions.modal_add.option_no_account"}}</option>
{{range $d.Accounts}}
<option value="{{.ID}}">{{.Name}} ({{.Type}})</option>
{{end}}
</select>
</div>
{{if $d.Goals}}
<div class="form-group">
<label>{{$d.T.Get "transactions.modal_add.label_goal"}}</label>
<select id="add-goal">
<option value="">{{$d.T.Get "transactions.modal_add.option_no_goal"}}</option>
{{range $d.Goals}}
<option value="{{.ID}}">{{.Name}}</option>
{{end}}
</select>
</div>
{{end}}
<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()">{{$d.T.Get "transactions.modal_add.btn_cancel"}}</button>
<button class="btn btn-primary" onclick="submitAdd()">{{$d.T.Get "transactions.modal_add.btn_save"}}</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('{{$d.T.Get "transactions.confirm.delete_msg"}}')) return;
fetch('/api/transactions/' + id, {method: 'DELETE'}).then(r => {
if (r.ok) document.getElementById('row-' + id).remove();
});
}
function openAddModal(preselectedGoalID) {
document.getElementById('add-modal').style.display = 'flex';
document.getElementById('add-date').value = new Date().toISOString().slice(0, 10);
if (preselectedGoalID) {
const goalSel = document.getElementById('add-goal');
if (goalSel) goalSel.value = preselectedGoalID;
const signSel = document.getElementById('add-sign');
if (signSel) signSel.value = '-1'; // goal contributions are expenses
}
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 goalEl = document.getElementById('add-goal');
const goalID = goalEl ? goalEl.value : '';
const errEl = document.getElementById('add-error');
if (!date || !desc || isNaN(amt) || amt <= 0) {
errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_required"}}';
errEl.style.display = 'block';
return;
}
errEl.style.display = 'none';
const body = {account_id: acct, date, description: desc, amount_cents: Math.round(amt * 100) * sign, category: cat};
if (goalID) body.goal_id = goalID;
fetch('/api/transactions', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
}).then(r => {
if (!r.ok) { errEl.textContent = '{{$d.T.Get "transactions.modal_add.error_save_failed"}}'; errEl.style.display = 'block'; return; }
closeAddModal();
location.reload();
});
}
// auto-open modal when redirected from goals page with ?fund_goal=<id>
(function() {
const params = new URLSearchParams(window.location.search);
const fundGoal = params.get('fund_goal');
if (fundGoal) openAddModal(fundGoal);
})();
document.getElementById('add-modal').addEventListener('click', e => { if (e.target === e.currentTarget) closeAddModal(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAddModal(); });
</script>
{{end}}