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>
597 lines
39 KiB
HTML
597 lines
39 KiB
HTML
{{define "content"}}
|
|
{{$d := .}}
|
|
|
|
<div style="display:flex; align-items:baseline; justify-content:space-between; margin-bottom:20px; flex-wrap:wrap; gap:8px;">
|
|
<h1>{{$d.T.Get "goals.title"}}</h1>
|
|
</div>
|
|
|
|
<!-- Tab bar -->
|
|
<div style="display:flex; gap:4px; margin-bottom:24px; border-bottom:1px solid var(--border); padding-bottom:0;">
|
|
<a href="/goals?tab=goals"
|
|
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
|
|
{{if eq $d.Tab "goals"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
|
|
{{$d.T.Get "goals.tab_committed"}}
|
|
</a>
|
|
<a href="/goals?tab=planner"
|
|
style="padding:8px 16px; font-size:14px; font-weight:500; border-radius:6px 6px 0 0; text-decoration:none;
|
|
{{if eq $d.Tab "planner"}}background:var(--bg2); color:var(--text1); border:1px solid var(--border); border-bottom:1px solid var(--bg2); margin-bottom:-1px;{{else}}color:var(--text3);{{end}}">
|
|
{{$d.T.Get "goals.tab_planner"}}
|
|
</a>
|
|
</div>
|
|
|
|
{{if eq $d.Tab "goals"}}
|
|
{{/* ─── GOALS TAB ─────────────────────────────────────────────────────── */}}
|
|
|
|
<div style="display:flex; gap:10px; margin-bottom:20px; flex-wrap:wrap;">
|
|
{{if $d.AvgMonthlySavings}}
|
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
|
<h2>{{$d.T.Get "goals.summary_cards.avg_monthly_savings"}}</h2>
|
|
<div class="value positive animate-counter" data-target="{{$d.AvgMonthlySavings}}" data-prefix="€">€0</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.last_3_months"}}</p>
|
|
</div>
|
|
{{end}}
|
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
|
<h2>{{$d.T.Get "goals.summary_cards.income"}}</h2>
|
|
<div class="value positive animate-counter" data-target="{{$d.WaterfallIncome}}" data-prefix="€">€0</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.this_month"}}</p>
|
|
</div>
|
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
|
<h2>{{$d.T.Get "goals.summary_cards.goal_funded"}}</h2>
|
|
<div class="value animate-counter" style="color:var(--accent);" data-target="{{$d.WaterfallGoals}}" data-prefix="€">€0</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.tagged_transactions"}}</p>
|
|
</div>
|
|
<div class="card value-card animate-on-scroll" style="flex:1; min-width:150px;">
|
|
<h2>{{$d.T.Get "goals.summary_cards.free_cash"}}</h2>
|
|
<div class="value animate-counter {{if lt $d.WaterfallFreeCash 0}}negative{{else}}positive{{end}}"
|
|
data-target="{{$d.WaterfallFreeCash}}" data-prefix="€">€0</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:4px;">{{$d.T.Get "goals.summary_cards.after_goals"}}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{if $d.Goals}}
|
|
<div style="display:flex; flex-direction:column; gap:14px;">
|
|
{{range $d.Goals}}
|
|
<div class="card animate-on-scroll">
|
|
<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:12px; flex-wrap:wrap;">
|
|
|
|
<div style="flex:1; min-width:200px;">
|
|
<div style="display:flex; align-items:center; gap:10px; margin-bottom:4px;">
|
|
<span style="font-size:18px;">{{if eq .Type "once"}}🎯{{else if eq .Type "deposit"}}🏠{{else if eq .Type "emergency"}}🛡️{{else}}📈{{end}}</span>
|
|
<div>
|
|
<div style="font-size:15px; font-weight:600; color:var(--text);">{{.Name}}</div>
|
|
<div style="font-size:11px; color:var(--text3); text-transform:uppercase; letter-spacing:.4px;">
|
|
{{if eq .Type "once"}}{{$d.T.Get "goals.goal_card.type_once"}}{{else if eq .Type "deposit"}}{{$d.T.Get "goals.goal_card.type_deposit"}}{{else if eq .Type "emergency"}}{{$d.T.Get "goals.goal_card.type_emergency"}}{{else}}{{$d.T.Get "goals.goal_card.type_recurring"}}{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:12px;">
|
|
<div style="display:flex; justify-content:space-between; margin-bottom:5px;">
|
|
<span style="font-size:12px; color:var(--text2);">€{{cents .SavedCents}} {{$d.T.Get "goals.goal_card.saved_of"}} €{{cents .TargetCents}}</span>
|
|
<span style="font-size:12px; color:var(--text2);">{{.ProgressPct}}%</span>
|
|
</div>
|
|
<div style="background:var(--bg3); border-radius:99px; height:6px; overflow:hidden;">
|
|
<div style="height:100%; border-radius:99px; width:{{.ProgressPct}}%;
|
|
background:{{if .Feasible}}var(--green){{else}}var(--accent){{end}};
|
|
transition:width 1s ease;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
|
|
<div style="text-align:center;">
|
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.need_per_month"}}</div>
|
|
<div class="animate-counter" style="font-size:18px; font-weight:600; color:var(--text);"
|
|
data-target="{{.MonthlyCents}}" data-prefix="€">€0</div>
|
|
</div>
|
|
<div style="text-align:center;">
|
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.months_left"}}</div>
|
|
<div style="font-size:18px; font-weight:600; color:var(--text);">{{.MonthsLeft}}</div>
|
|
</div>
|
|
<div style="text-align:center;">
|
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.at_current_rate"}}</div>
|
|
{{if gt .MonthsAtCurrentRate 0}}
|
|
<div style="font-size:18px; font-weight:600;
|
|
{{if .Feasible}}color:var(--green){{else}}color:var(--red){{end}};">
|
|
{{.MonthsAtCurrentRate}}mo
|
|
</div>
|
|
{{else}}
|
|
<div style="font-size:14px; color:var(--text3);">—</div>
|
|
{{end}}
|
|
</div>
|
|
<div style="text-align:center;">
|
|
<div style="font-size:11px; color:var(--text3); margin-bottom:3px;">{{$d.T.Get "goals.goal_card.disposable_after"}}</div>
|
|
<div class="animate-counter" style="font-size:18px; font-weight:600;
|
|
{{if ge .ImpactOnDisposable 0}}color:var(--green){{else}}color:var(--red){{end}};"
|
|
data-target="{{.ImpactOnDisposable}}" data-prefix="€">€0</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- feasibility banner -->
|
|
<div style="margin-top:14px; padding:10px 14px; border-radius:8px; font-size:13px;
|
|
background:{{if .Feasible}}rgba(74,222,128,0.08){{else}}rgba(248,113,113,0.08){{end}};
|
|
border:1px solid {{if .Feasible}}rgba(74,222,128,0.2){{else}}rgba(248,113,113,0.2){{end}};
|
|
color:{{if .Feasible}}var(--green){{else}}var(--red){{end}}; display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;">
|
|
<span>
|
|
{{if .Feasible}}
|
|
✓ On track — your current savings rate covers €{{cents .MonthlyCents}}/month with {{.MonthsLeft}} months to go.
|
|
{{else}}
|
|
⚠ At your current rate (€{{cents $d.AvgMonthlySavings}}/mo) you'd reach this in {{.MonthsAtCurrentRate}} months — {{sub .MonthsAtCurrentRate .MonthsLeft}} months late.
|
|
{{end}}
|
|
</span>
|
|
{{if and (not .Feasible) (gt .MonthsAtCurrentRate 0)}}
|
|
<form method="POST" action="/goals" style="margin:0;">
|
|
<input type="hidden" name="action" value="adjust_deadline">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<input type="hidden" name="months" value="{{.MonthsAtCurrentRate}}">
|
|
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)44; white-space:nowrap;">
|
|
{{$d.T.Get "goals.goal_card.btn_adjust_deadline"}}
|
|
</button>
|
|
</form>
|
|
{{end}}
|
|
</div>
|
|
|
|
<!-- commit / uncommit / delete / fund -->
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-top:14px; flex-wrap:wrap; gap:8px;">
|
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
|
<form method="POST" action="/goals">
|
|
<input type="hidden" name="action" value="{{if .Committed}}uncommit{{else}}commit{{end}}">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
{{if .Committed}}
|
|
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--green); border-color:var(--green)55;">
|
|
{{$d.T.Get "goals.goal_card.btn_committed"}}
|
|
</button>
|
|
{{else}}
|
|
<button type="submit" class="btn btn-primary btn-sm">{{$d.T.Get "goals.goal_card.btn_commit"}}</button>
|
|
{{end}}
|
|
</form>
|
|
<a href="/transactions?fund_goal={{.ID}}" class="btn btn-outline btn-sm" style="color:var(--accent); border-color:var(--accent)55;">
|
|
{{$d.T.Get "goals.goal_card.btn_fund"}}
|
|
</a>
|
|
</div>
|
|
<form method="POST" action="/goals">
|
|
<input type="hidden" name="action" value="delete">
|
|
<input type="hidden" name="id" value="{{.ID}}">
|
|
<button type="submit" class="btn btn-outline btn-sm" style="color:var(--red); border-color:var(--red)33;"
|
|
onclick="return confirm('{{$d.T.Get "goals.goal_card.confirm_remove"}}')">{{$d.T.Get "goals.goal_card.btn_remove"}}</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- funding history -->
|
|
<div style="margin-top:14px; padding-top:14px; border-top:1px solid var(--border);">
|
|
<div style="font-size:12px; font-weight:600; color:var(--text3); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px;">
|
|
{{$d.T.Get "goals.goal_card.funding_history"}}
|
|
<span style="font-weight:400; color:var(--accent); font-size:11px; margin-left:4px;">€{{cents .SavedCents}} {{$d.T.Get "goals.goal_card.saved_of"}} €{{cents .TargetCents}}</span>
|
|
</div>
|
|
{{if .FundingTxns}}
|
|
<div style="display:flex; flex-direction:column; gap:4px;">
|
|
{{range .FundingTxns}}
|
|
<div style="display:flex; justify-content:space-between; align-items:center; font-size:12px; padding:5px 0; border-bottom:1px solid var(--border);">
|
|
<span style="color:var(--text2);">{{.Date.Format "02 Jan"}}</span>
|
|
<span style="flex:1; padding:0 10px; color:var(--text);">{{.Description}}</span>
|
|
<span style="color:var(--accent); font-weight:600;">€{{cents (abs .AmountCents)}}</span>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
{{else}}
|
|
<p style="font-size:12px; color:var(--text3); margin:0;">{{$d.T.Get "goals.goal_card.no_funding_yet"}}</p>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
{{else}}
|
|
<div class="card empty-state animate-on-scroll">
|
|
<div style="font-size:48px; margin-bottom:16px;">🎯</div>
|
|
<h3>{{$d.T.Get "goals.empty.title"}}</h3>
|
|
<p style="margin-bottom:20px;">{{$d.T.Get "goals.empty.desc"}}</p>
|
|
<a href="/goals?tab=planner" class="btn btn-primary">{{$d.T.Get "goals.empty.btn_open_planner"}}</a>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{else}}
|
|
{{/* ─── PLANNER TAB ─────────────────────────────────────────────────────── */}}
|
|
{{$r := $d.PlanResult}}
|
|
{{$pr := $d.PurchaseResult}}
|
|
|
|
<!-- Type selector -->
|
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
|
<h2 style="margin-bottom:14px;">{{$d.T.Get "goals.planner.what_kind"}}</h2>
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
|
<a href="/goals?tab=planner&planner_type=purchase"
|
|
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "purchase"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
|
|
<div style="font-size:22px; margin-bottom:8px;">🛒</div>
|
|
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">{{$d.T.Get "goals.planner.purchase_title"}}</div>
|
|
<div style="font-size:12px; color:var(--text3);">{{$d.T.Get "goals.planner.purchase_desc"}}</div>
|
|
</a>
|
|
<a href="/goals?tab=planner&planner_type=transition"
|
|
style="display:block; padding:16px 18px; border-radius:var(--radius); border:2px solid {{if eq $d.PlannerType "transition"}}var(--accent){{else}}var(--border){{end}}; text-decoration:none; transition:border-color 0.15s;">
|
|
<div style="font-size:22px; margin-bottom:8px;">🔄</div>
|
|
<div style="font-weight:600; font-size:14px; color:var(--text1); margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_title"}}</div>
|
|
<div style="font-size:12px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_desc"}}</div>
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
{{if eq $d.PlannerType "purchase"}}
|
|
<!-- ── PURCHASE FORM ──────────────────────────────────────────────────────── -->
|
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "goals.planner.purchase.form_title"}}</h2>
|
|
<form method="GET" action="/goals">
|
|
<input type="hidden" name="tab" value="planner">
|
|
<input type="hidden" name="planner_type" value="purchase">
|
|
<input type="hidden" name="run" value="1">
|
|
<div style="display:flex; flex-direction:column; gap:14px; margin-bottom:16px;">
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_name"}}</label>
|
|
<input name="name" placeholder="{{$d.T.Get "goals.planner.purchase.placeholder_name"}}"
|
|
value="{{if $d.HasPurchaseResult}}{{$pr.Name}}{{end}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:12px;">
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_target"}}</label>
|
|
<input type="number" name="target" min="0" step="100"
|
|
value="{{if $d.HasPurchaseResult}}{{div $pr.TargetCents 100}}{{end}}"
|
|
placeholder="{{$d.T.Get "goals.planner.purchase.placeholder_target"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_monthly_savings"}}</label>
|
|
<input type="number" name="monthly_savings" min="0" step="50"
|
|
value="{{if $d.HasPurchaseResult}}{{div $pr.MonthlySavingsCents 100}}{{end}}"
|
|
placeholder="{{$d.T.Get "goals.planner.purchase.placeholder_monthly"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase.label_deadline"}}</label>
|
|
<input type="month" name="deadline"
|
|
value="{{if and $d.HasPurchaseResult $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{end}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "goals.planner.purchase.btn_calculate"}}</button>
|
|
</form>
|
|
</div>
|
|
|
|
{{if $d.HasPurchaseResult}}
|
|
<!-- Purchase result -->
|
|
<div class="grid" style="margin-bottom:20px;">
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.purchase_result.card_at_rate"}}</h2>
|
|
{{if gt $pr.MonthsNeeded 0}}
|
|
<div class="value positive">{{$pr.YearsNeeded}}y {{$pr.RemMonths}}m</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.reach_label"}} {{dateShort $pr.ReachDate}}</p>
|
|
{{else}}
|
|
<div class="value" style="color:var(--text3); font-size:18px;">—</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.enter_monthly"}}</p>
|
|
{{end}}
|
|
</div>
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.purchase_result.card_monthly_needed"}}</h2>
|
|
{{if $pr.HasDeadline}}
|
|
<div class="value {{if $pr.Feasible}}positive{{else}}negative{{end}}">€{{cents $pr.MonthlyNeededForDeadline}}</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.to_hit_deadline"}} {{dateShort $pr.DeadlineDate}}</p>
|
|
{{else}}
|
|
<div class="value" style="color:var(--text3); font-size:18px;">—</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.set_target_date"}}</p>
|
|
{{end}}
|
|
</div>
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.purchase_result.card_target"}}</h2>
|
|
<div class="value positive">€{{cents $pr.TargetCents}}</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.purchase_result.your_goal_amount"}}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{{if $pr.HasDeadline}}
|
|
<div style="padding:12px 16px; border-radius:var(--radius); margin-bottom:20px; font-size:13px;
|
|
background:{{if $pr.Feasible}}rgba(74,222,128,0.08){{else}}rgba(248,113,113,0.08){{end}};
|
|
border:1px solid {{if $pr.Feasible}}rgba(74,222,128,0.25){{else}}rgba(248,113,113,0.25){{end}};
|
|
color:{{if $pr.Feasible}}var(--green){{else}}var(--red){{end}};">
|
|
{{if $pr.Feasible}}
|
|
✓ On track — €{{cents $pr.MonthlySavingsCents}}/mo covers the required €{{cents $pr.MonthlyNeededForDeadline}}/mo.
|
|
{{else}}
|
|
⚠ You need €{{cents $pr.MonthlyNeededForDeadline}}/mo to hit the deadline but you're saving €{{cents $pr.MonthlySavingsCents}}/mo. At your current rate you'll get there in {{$pr.MonthsNeeded}} months ({{dateShort $pr.ReachDate}}).
|
|
{{end}}
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Save as goal -->
|
|
<div class="card animate-on-scroll">
|
|
<h2 style="margin-bottom:4px;">{{$d.T.Get "goals.planner.purchase_result.save_as_goal_title"}}</h2>
|
|
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">{{$d.T.Get "goals.planner.purchase_result.save_as_goal_desc"}}</p>
|
|
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
|
<input type="hidden" name="type" value="once">
|
|
<input type="hidden" name="target_euros" value="{{div $pr.TargetCents 100}}">
|
|
<input type="hidden" name="deadline" value="{{if $pr.HasDeadline}}{{$pr.DeadlineDate.Format "2006-01"}}{{else}}{{$pr.ReachDate.Format "2006-01"}}{{end}}">
|
|
<div style="flex:1; min-width:200px;">
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.purchase_result.label_goal_name"}}</label>
|
|
<input name="name" required value="{{$pr.Name}}" placeholder="{{$d.T.Get "goals.planner.purchase_result.placeholder_goal_name"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">{{$d.T.Get "goals.planner.purchase_result.btn_save_goal"}}</button>
|
|
</form>
|
|
</div>
|
|
{{end}}
|
|
|
|
{{else if eq $d.PlannerType "transition"}}
|
|
<!-- ── TRANSITION FORM ───────────────────────────────────────────────────── -->
|
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
|
<h2 style="margin-bottom:16px;">{{$d.T.Get "goals.planner.transition.form_title"}}</h2>
|
|
<form method="GET" action="/goals">
|
|
<input type="hidden" name="tab" value="planner">
|
|
<input type="hidden" name="planner_type" value="transition">
|
|
<input type="hidden" name="run" value="1">
|
|
|
|
<div class="grid" style="gap:14px; margin-bottom:16px;">
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_current_asset"}}</label>
|
|
<select name="property_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
|
<option value="">{{$d.T.Get "goals.planner.transition.option_none_asset"}}</option>
|
|
{{range $d.PlanProperties}}
|
|
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.PropertyID .ID)}}selected{{end}}>{{.Name}} (€{{cents .CurrentValueCents}})</option>
|
|
{{end}}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_current_loan"}}</label>
|
|
<select name="loan_id" style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px;">
|
|
<option value="">{{$d.T.Get "goals.planner.transition.option_none_loan"}}</option>
|
|
{{range $d.PlanLoans}}
|
|
<option value="{{.ID}}" {{if and $d.HasPlanResult (eq $d.PlanForm.LoanID .ID)}}selected{{end}}>{{.Name}} (€{{cents .BalanceCents}} left)</option>
|
|
{{end}}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_dream_cost"}}</label>
|
|
<input type="number" name="dream_cost" min="0" step="1000"
|
|
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.DreamCostCents 100}}{{end}}"
|
|
placeholder="{{$d.T.Get "goals.planner.transition.placeholder_dream_cost"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_down_pct"}}</label>
|
|
<input type="number" name="down_pct" min="0" max="100" step="1"
|
|
value="{{if $d.HasPlanResult}}{{round $d.PlanForm.DownPaymentPct}}{{else}}20{{end}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_loan_rate"}}</label>
|
|
<input type="number" name="const_rate" min="0" max="30" step="0.1"
|
|
value="{{if $d.HasPlanResult}}{{$d.PlanForm.ConstructionRatePct}}{{else}}4.0{{end}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_loan_term"}}</label>
|
|
<input type="number" name="const_term" min="1" max="40" step="1"
|
|
value="{{if $d.HasPlanResult}}{{$d.PlanForm.ConstructionTermYears}}{{else}}30{{end}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_build_months"}}</label>
|
|
<input type="number" name="build_months" min="1" max="60" step="1"
|
|
value="{{if $d.HasPlanResult}}{{$d.PlanForm.BuildMonths}}{{else}}18{{end}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_monthly_savings"}}</label>
|
|
<input type="number" name="monthly_savings" min="0" step="100"
|
|
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.MonthlySavingsCents 100}}{{end}}"
|
|
placeholder="{{$d.T.Get "goals.planner.transition.placeholder_savings"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
<div>
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition.label_sale_price"}}</label>
|
|
<input type="number" name="sale_price" min="0" step="1000"
|
|
value="{{if $d.HasPlanResult}}{{div $d.PlanForm.ExpectedSalePriceCents 100}}{{end}}"
|
|
placeholder="{{$d.T.Get "goals.planner.transition.placeholder_sale_price"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<button type="submit" class="btn btn-primary" style="width:100%;">{{$d.T.Get "goals.planner.transition.btn_run"}}</button>
|
|
</form>
|
|
</div>
|
|
|
|
{{if $d.HasPlanResult}}
|
|
|
|
{{if $r.Warning}}
|
|
<div style="background:rgba(251,191,36,0.12); border:1px solid rgba(251,191,36,0.4); border-radius:var(--radius); padding:12px 16px; margin-bottom:16px; font-size:13px; color:var(--text2);">
|
|
⚠️ {{$r.Warning}}
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Summary cards -->
|
|
<div class="grid" style="margin-bottom:20px;">
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_total_timeline"}}</h2>
|
|
<div class="value positive">{{$r.TotalYears}}y {{$r.TotalRemMonths}}m</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.until_paid_off"}}</p>
|
|
</div>
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_final_monthly"}}</h2>
|
|
<div class="value positive">€{{cents $r.Phase4MonthlyCents}}</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.after_selling"}}</p>
|
|
</div>
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_total_interest"}}</h2>
|
|
<div class="value" style="color:var(--red);">€{{cents $r.TotalInterestCents}}</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.across_both_loans"}}</p>
|
|
</div>
|
|
<div class="card value-card animate-on-scroll">
|
|
<h2>{{$d.T.Get "goals.planner.transition_result.card_free_by"}}</h2>
|
|
<div class="value positive" style="font-size:24px;">{{dateShort $r.FinalDate}}</div>
|
|
<p style="font-size:12px; color:var(--text3); margin-top:6px;">{{$d.T.Get "goals.planner.transition_result.fully_paid_off"}}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase timeline -->
|
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
|
<h2 style="margin-bottom:20px;">{{$d.T.Get "goals.planner.transition_result.roadmap_title"}}</h2>
|
|
<div style="display:grid; grid-template-columns:repeat(4,1fr); gap:0; position:relative;">
|
|
<div style="position:absolute; top:20px; left:12.5%; right:12.5%; height:3px; background:var(--border); z-index:0; border-radius:2px;"></div>
|
|
|
|
<!-- Phase 1 -->
|
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--accent); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">1</div>
|
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase1_title"}}</div>
|
|
{{if gt $r.Phase1Months 0}}
|
|
<div style="font-size:22px; font-weight:500; color:var(--accent); margin-bottom:4px;">{{$r.Phase1Months}}mo</div>
|
|
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase1EndDate}}</div>
|
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_target"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_already_have"}} <strong>€{{cents $r.AlreadyHaveCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_still_need"}} <strong>€{{cents $r.StillNeededCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_saving"}} <strong>€{{cents $r.Form.MonthlySavingsCents}}/mo</strong></div>
|
|
</div>
|
|
{{else}}
|
|
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.phase1_ready"}}</div>
|
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.phase1_equity_covers"}}</div>
|
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_down_payment"}} <strong>€{{cents $r.DownPaymentCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase1_your_equity"}} <strong style="color:var(--green);">€{{cents $r.AlreadyHaveCents}}</strong></div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
|
|
<!-- Phase 2 -->
|
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
|
<div style="width:40px; height:40px; border-radius:50%; background:#f97316; color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">2</div>
|
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase2_title"}}</div>
|
|
<div style="font-size:22px; font-weight:500; color:#f97316; margin-bottom:4px;">{{$r.Phase2Months}}mo</div>
|
|
<div style="font-size:11px; color:var(--text3);">until {{dateShort $r.Phase2EndDate}}</div>
|
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase2_new_loan"}} <strong>€{{cents $r.ConstructionLoanCents}}</strong></div>
|
|
{{if $r.CurrentLoan}}<div>{{$d.T.Get "goals.planner.transition_result.phase2_existing_loan"}} <strong>€{{cents $r.CurrentMonthlyCents}}/mo</strong></div>{{end}}
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase2_new_emi"}} <strong>€{{cents $r.ConstructionMonthly}}/mo</strong></div>
|
|
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "goals.planner.transition_result.phase2_total_burden"}} <strong style="color:#f97316;">€{{cents $r.Phase2MonthlyCents}}/mo</strong></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 3 -->
|
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
|
<div style="width:40px; height:40px; border-radius:50%; background:#14b8a6; color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">3</div>
|
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase3_title"}}</div>
|
|
<div style="font-size:16px; font-weight:600; color:#14b8a6; margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.phase3_one_time"}}</div>
|
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.phase3_after_acquisition"}}</div>
|
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase3_sale_price"}} <strong>€{{cents $r.SalePriceCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase3_pay_off"}} <strong style="color:var(--red);">-€{{cents $r.MortgagePayoffCents}}</strong></div>
|
|
<div style="border-top:1px solid var(--border); margin-top:6px; padding-top:6px;">{{$d.T.Get "goals.planner.transition_result.phase3_net_proceeds"}} <strong style="color:var(--green);">€{{cents $r.NetProceedsCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase3_applied"}}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Phase 4 -->
|
|
<div style="text-align:center; position:relative; padding:0 8px;">
|
|
<div style="width:40px; height:40px; border-radius:50%; background:var(--green); color:#fff; display:flex; align-items:center; justify-content:center; font-size:18px; margin:0 auto 12px; position:relative; z-index:1;">4</div>
|
|
<div style="font-weight:600; font-size:13px; color:var(--text1); margin-bottom:6px;">{{$d.T.Get "goals.planner.transition_result.phase4_title"}}</div>
|
|
{{if gt $r.Phase4Months 0}}
|
|
<div style="font-size:22px; font-weight:500; color:var(--green); margin-bottom:4px;">{{$r.Phase4Months}}mo</div>
|
|
<div style="font-size:11px; color:var(--text3);">paid off {{dateShort $r.Phase4EndDate}}</div>
|
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase4_remaining_loan"}} <strong>€{{cents $r.RemainingBalanceCents}}</strong></div>
|
|
<div>{{$d.T.Get "goals.planner.transition_result.phase4_monthly"}} <strong style="color:var(--green);">€{{cents $r.Phase4MonthlyCents}}/mo</strong></div>
|
|
</div>
|
|
{{else}}
|
|
<div style="font-size:16px; font-weight:600; color:var(--green); margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.phase4_fully_paid"}}</div>
|
|
<div style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.phase4_sale_cleared"}}</div>
|
|
<div style="margin-top:10px; font-size:12px; color:var(--text2); background:var(--bg3); border-radius:var(--radius-sm); padding:8px 10px; text-align:left;">
|
|
<div style="color:var(--green); font-weight:600;">{{$d.T.Get "goals.planner.transition_result.phase4_no_remaining"}}</div>
|
|
</div>
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Monthly cost chart -->
|
|
<div class="card animate-on-scroll" style="margin-bottom:20px;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;">
|
|
<h2>{{$d.T.Get "goals.planner.transition_result.chart_title"}}</h2>
|
|
<span style="font-size:11px; color:var(--text3);">{{$d.T.Get "goals.planner.transition_result.chart_subtitle"}}</span>
|
|
</div>
|
|
<canvas id="cost-chart" height="160"></canvas>
|
|
</div>
|
|
|
|
<script>
|
|
(function() {
|
|
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
|
const style = getComputedStyle(document.documentElement);
|
|
const accent = style.getPropertyValue('--accent').trim() || '#6979f8';
|
|
const green = style.getPropertyValue('--green').trim() || '#4ade80';
|
|
|
|
const phase1Months = {{$r.Phase1Months}};
|
|
const phase2Months = {{$r.Phase2Months}};
|
|
const phase4Months = {{$r.Phase4Months}};
|
|
const existingMonthly = {{$r.CurrentMonthlyCents}} / 100;
|
|
const phase2Monthly = {{$r.Phase2MonthlyCents}} / 100;
|
|
const phase4Monthly = {{$r.Phase4MonthlyCents}} / 100;
|
|
|
|
const labels = [], costs = [], colors = [];
|
|
for (let i = 0; i < phase1Months; i++) { labels.push('Save '+(i+1)); costs.push(existingMonthly); colors.push(accent); }
|
|
for (let i = 0; i < phase2Months; i++) { labels.push('Acquire '+(i+1)); costs.push(phase2Monthly); colors.push('#f97316'); }
|
|
for (let i = 0; i < phase4Months; i++) { labels.push('Goal '+(i+1)); costs.push(phase4Monthly); colors.push(green); }
|
|
|
|
const step = Math.ceil(labels.length / 24);
|
|
const displayLabels = labels.map((l, i) => i % step === 0 ? l : '');
|
|
|
|
new Chart(document.getElementById('cost-chart').getContext('2d'), {
|
|
type: 'bar',
|
|
data: { labels: displayLabels, datasets: [{ data: costs, backgroundColor: colors, borderRadius: 3, borderSkipped: false }] },
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { display: false },
|
|
tooltip: { callbacks: { title: i => labels[i[0].dataIndex], label: c => '€'+c.parsed.y.toLocaleString('pt-PT',{minimumFractionDigits:2})+'/mo' } }
|
|
},
|
|
scales: {
|
|
x: { grid: { display: false }, ticks: { color: isDark?'#5c6585':'#9fa8c7', font:{size:10} } },
|
|
y: { grid: { color: isDark?'rgba(255,255,255,0.04)':'rgba(0,0,0,0.05)' }, ticks: { color: isDark?'#5c6585':'#9fa8c7', callback: v=>'€'+(v/1000).toFixed(0)+'k' } }
|
|
}
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
<!-- Save as goal (transition) -->
|
|
<div class="card animate-on-scroll">
|
|
<h2 style="margin-bottom:4px;">{{$d.T.Get "goals.planner.transition_result.save_as_goal_title"}}</h2>
|
|
<p style="font-size:13px; color:var(--text3); margin-bottom:14px;">{{$d.T.Get "goals.planner.transition_result.save_as_goal_desc"}}</p>
|
|
<form method="POST" action="/goals" style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end;">
|
|
<input type="hidden" name="type" value="deposit">
|
|
<input type="hidden" name="target_euros" value="{{div $r.Form.DreamCostCents 100}}">
|
|
<input type="hidden" name="deadline" value="{{$r.FinalDate.Format "2006-01"}}">
|
|
<div style="flex:1; min-width:200px;">
|
|
<label style="font-size:12px; color:var(--text3); display:block; margin-bottom:5px;">{{$d.T.Get "goals.planner.transition_result.label_goal_name"}}</label>
|
|
<input name="name" required placeholder="{{$d.T.Get "goals.planner.transition_result.placeholder_goal_name"}}"
|
|
style="width:100%; padding:8px 10px; border:1px solid var(--border); border-radius:var(--radius-sm); background:var(--bg2); color:var(--text1); font-size:14px; box-sizing:border-box;">
|
|
</div>
|
|
<button type="submit" class="btn btn-primary" style="white-space:nowrap;">{{$d.T.Get "goals.planner.transition_result.btn_save_goal"}}</button>
|
|
</form>
|
|
</div>
|
|
|
|
{{end}}{{/* end HasPlanResult */}}
|
|
|
|
{{end}}{{/* end planner_type transition */}}
|
|
|
|
{{end}}{{/* end tab */}}
|
|
{{end}}
|