Goals are now funded entirely through tagged transactions — no more manually-maintained saved_cents. Free cash waterfall (income → living → goals → free cash) is the single source of truth for where money goes. Core changes: - Transaction.GoalID field links outflows to goals; SavedCents is derived via MongoDB aggregation (getGoalFundedCentsAll) instead of stored - Waterfall on dashboard and goals page splits outflows into living vs goal-funded using GoalID presence - ImpactOnDisposable fixed: uses income−living−monthlyCents instead of waterfallFreeCash−monthlyCents (was double-subtracting goal spend) - avgMonthlySavings fixed: divides by positive-saving months only, and uses year+month key to avoid Dec cross-year collision Interactive waterfall drill-down: - Click Income / Living / Goals rows to expand category breakdown - Click a category to reveal individual transactions inline - All rendered server-side (instant, no extra API call) - New WaterfallRow type + IncomeCats/LivingCats/IncomeCatTxns/LivingCatTxns on DashboardData Goals page: - Summary cards switched from heuristic disposable/committed to waterfall - Each goal card shows funding history (last 5 tagged transactions) - "Fund this goal" button links to /transactions?fund_goal=<id> Transactions page: - Add Transaction modal has goal picker dropdown - submitAdd() includes goal_id in POST body - Auto-opens modal pre-selected when arriving from goals page Seed: - seedGoalTransactions() back-fills tagged contributions for all 4 demo goals (Emergency fund, House down payment, Japan trip, MacBook Pro) - Idempotent — skips if goal-tagged transactions already exist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
286 lines
13 KiB
HTML
286 lines
13 KiB
HTML
{{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" class="text-center text-muted" style="padding:44px;">
|
||
{{$d.T.Get "transactions.table.empty_msg"}}
|
||
<a href="/import" style="color:var(--accent);">{{$d.T.Get "transactions.table.empty_import_link"}}</a> or
|
||
<button class="btn btn-outline btn-sm" onclick="openAddModal()" style="margin-left:4px;">{{$d.T.Get "transactions.table.empty_add_btn"}}</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;">{{$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}}
|