Files
sure/app/views/retirement/show.html.erb
Guillem Arias 01118b858f feat(retirement): PR4d what-if slider rail
Each lever (retire age / target spend / save per mo / real return) now
pairs a numeric input with a range slider; retirement_what_if_controller
mirrors the value across the pair (data-lever) and debounces the live
forecast preview. Birth year stays a plain numeric input.

(Skinned delete confirmations already render via Sure's global
Turbo.config.forms.confirm → DS::Dialog override, so the PR2 turbo_confirm
buttons are already styled — no change needed.)
2026-05-29 12:38:24 +02:00

261 lines
13 KiB
Plaintext

<% content_for :breadcrumbs do %>
<%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
<% end %>
<div class="space-y-6">
<header class="flex items-start justify-between gap-4">
<div class="space-y-2">
<h1 class="text-primary text-2xl font-semibold"><%= t("retirement.show.title") %></h1>
<p class="text-secondary text-sm"><%= t("retirement.show.subtitle") %></p>
<p class="text-subdued text-xs"><%= t("retirement.show.preview_note") %></p>
</div>
<% if @glide.present? %>
<%= render DS::Pill.new(label: t("retirement.show.active_plan"), tone: :success, marker: false, show_dot: true, size: :sm) %>
<% end %>
</header>
<%= render "retirement/kpis", plan: @plan %>
<%# Glide path chart %>
<% if @glide.present? %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-3">
<div>
<h2 class="text-primary font-medium"><%= t("retirement.glide.title") %></h2>
<p class="text-secondary text-xs"><%= t("retirement.glide.subtitle") %></p>
</div>
<div class="h-72 w-full privacy-sensitive"
data-controller="retirement-glide-chart"
data-retirement-glide-chart-data-value="<%= @glide.to_json %>"
data-retirement-glide-chart-aria-label-value="<%= t("retirement.glide.title") %>"
data-retirement-glide-chart-covered-label-value="<%= t("retirement.glide.covered") %>"
data-retirement-glide-chart-portfolio-label-value="<%= t("retirement.glide.portfolio") %>"
data-retirement-glide-chart-state-label-value="<%= t("retirement.glide.state") %>"
data-retirement-glide-chart-workplace-label-value="<%= t("retirement.glide.workplace") %>"
data-retirement-glide-chart-drawdown-label-value="<%= t("retirement.glide.drawdown") %>"
data-retirement-glide-chart-total-label-value="<%= t("retirement.glide.total") %>"
data-retirement-glide-chart-retire-label-value="<%= t("retirement.glide.retire") %>"
data-retirement-glide-chart-coast-label-value="<%= t("retirement.glide.coast") %>"></div>
</section>
<% end %>
<%# What-if — live recompute on input, persist on save %>
<section data-controller="retirement-what-if"
data-retirement-what-if-url-value="<%= forecast_retirement_path %>"
class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-primary font-medium"><%= t("retirement.what_if.title") %></h2>
<p class="text-secondary text-xs"><%= t("retirement.what_if.hint") %></p>
</div>
<%= form_with url: retirement_path, method: :patch, data: { retirement_what_if_target: "form" }, class: "space-y-3" do |form| %>
<% levers = {
"retire_age" => { min: 40, max: 75, step: 1 },
"target_spend" => { min: 0, max: 20_000, step: 50 },
"monthly_savings" => { min: 0, max: 20_000, step: 50 },
"real_return_pct" => { min: 0, max: 10, step: 0.1 }
} %>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-4">
<label class="text-xs text-secondary space-y-1">
<span class="block"><%= t("retirement.what_if.fields.birth_year") %></span>
<%= number_field_tag "retirement[birth_year]", @plan.birth_year, step: 1,
autocomplete: "off", class: "form-field__input w-full",
data: { action: "input->retirement-what-if#preview" } %>
</label>
<% levers.each do |field, cfg| %>
<% value = @plan.public_send(field) %>
<div class="space-y-1.5">
<label class="text-xs text-secondary block" for="rwi_<%= field %>"><%= t("retirement.what_if.fields.#{field}") %></label>
<%= number_field_tag "retirement[#{field}]", value, step: cfg[:step], id: "rwi_#{field}",
autocomplete: "off", class: "form-field__input w-full",
data: { lever: field, action: "input->retirement-what-if#sync" } %>
<%= range_field_tag "rwi_#{field}_slider", value, min: cfg[:min], max: cfg[:max], step: cfg[:step],
class: "w-full accent-green-600 cursor-pointer",
data: { lever: field, action: "input->retirement-what-if#sync" } %>
</div>
<% end %>
</div>
<%= form.submit t("retirement.what_if.save"), class: "text-sm font-medium text-primary underline cursor-pointer" %>
<% end %>
</section>
<%# Why this target? — spending anchor + FI number %>
<% if @glide.present? %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="text-primary font-medium"><%= t("retirement.why.title") %></h2>
<p class="text-secondary text-xs"><%= t("retirement.why.subtitle") %></p>
</div>
<%= button_to t("retirement.why.use_average"), retirement_path, method: :patch,
params: { retirement: { target_spend: @baseline.amount.to_i } },
class: "text-sm text-primary underline shrink-0" %>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<p class="text-secondary text-xs uppercase tracking-wide"><%= t("retirement.why.last_12") %></p>
<p class="text-primary text-xl font-semibold tabular-nums privacy-sensitive">
<%= @baseline.format(precision: 0) %><span class="text-secondary text-sm"><%= t("retirement.show.per_month") %></span>
</p>
</div>
<div>
<p class="text-secondary text-xs uppercase tracking-wide"><%= t("retirement.why.target") %></p>
<p class="text-primary text-xl font-semibold tabular-nums privacy-sensitive">
<%= Money.new(@plan.target_spend_monthly, @plan.currency).format(precision: 0) %><span class="text-secondary text-sm"><%= t("retirement.show.per_month") %></span>
</p>
</div>
<div>
<p class="text-secondary text-xs uppercase tracking-wide"><%= t("retirement.why.fi_number") %></p>
<p class="text-primary text-xl font-semibold tabular-nums privacy-sensitive">
<%= Money.new(@plan.fi_number, @plan.currency).format(precision: 0) %>
</p>
</div>
</div>
</section>
<% end %>
<%# Pension sources %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-primary font-medium"><%= t("retirement.show.sources_title") %></h2>
<%= link_to t("retirement.show.add_source"), new_retirement_pension_source_path,
class: "text-sm font-medium text-primary underline" %>
</div>
<% if @pension_sources.empty? %>
<p class="text-secondary text-sm"><%= t("retirement.show.no_sources") %></p>
<% else %>
<ul class="divide-y divide-tertiary">
<% @pension_sources.each do |source| %>
<li class="py-3 flex items-center justify-between gap-3">
<div>
<p class="text-primary text-sm font-medium">
<%= source.name %>
<span class="text-secondary text-xs">· <%= t("retirement.pension_sources.kinds.#{source.kind}") %></span>
</p>
<p class="text-secondary text-xs">
<%= t("retirement.show.starts_at_age", age: source.start_age) %> ·
<%= source.country %> ·
<%= t("retirement.pension_sources.payout_shapes.#{source.payout_shape}") %>
</p>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-primary text-sm tabular-nums privacy-sensitive">
<%= source.amount_money&.format %><span class="text-secondary"><%= t("retirement.show.per_month") %></span>
</span>
<%= link_to t("retirement.show.edit"), edit_retirement_pension_source_path(source),
class: "text-xs text-secondary underline" %>
<%= button_to t("retirement.show.delete"), retirement_pension_source_path(source),
method: :delete, class: "text-xs text-destructive underline",
form: { data: { turbo_confirm: t("retirement.show.delete_confirm") } } %>
</div>
</li>
<% end %>
</ul>
<% end %>
</section>
<%# Adjustments %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-primary font-medium"><%= t("retirement.show.adjustments_title") %></h2>
<%= link_to t("retirement.show.add_adjustment"), new_retirement_adjustment_path,
class: "text-sm font-medium text-primary underline" %>
</div>
<% if @adjustments.empty? %>
<p class="text-secondary text-sm"><%= t("retirement.show.no_adjustments") %></p>
<% else %>
<ul class="divide-y divide-tertiary">
<% @adjustments.each do |adjustment| %>
<li class="py-3 flex items-center justify-between gap-3">
<div>
<p class="text-primary text-sm font-medium"><%= adjustment.label %></p>
<p class="text-secondary text-xs"><%= t("retirement.show.from_age", age: adjustment.from_age) %></p>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-primary text-sm tabular-nums privacy-sensitive"><%= adjustment.amount_today_money&.format %></span>
<%= link_to t("retirement.show.edit"), edit_retirement_adjustment_path(adjustment),
class: "text-xs text-secondary underline" %>
<%= button_to t("retirement.show.delete"), retirement_adjustment_path(adjustment),
method: :delete, class: "text-xs text-destructive underline",
form: { data: { turbo_confirm: t("retirement.show.delete_confirm") } } %>
</div>
</li>
<% end %>
</ul>
<% end %>
</section>
<%# Retirement bucket %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div>
<h2 class="text-primary font-medium"><%= t("retirement.show.bucket_title") %></h2>
<p class="text-secondary text-xs"><%= t("retirement.show.bucket_hint") %></p>
</div>
<%= form_with url: retirement_bucket_path, method: :patch, class: "space-y-3" do |form| %>
<div class="space-y-2">
<% @bucket_candidates.each do |account| %>
<%= render DS::SelectableCard.new(
name: "bucket[account_ids][]",
value: account.id,
title: account.name,
subtitle: account.accountable_type&.underscore&.humanize,
amount: account.balance_money&.format,
checked: @bucket_account_ids.include?(account.id)
) %>
<% end %>
</div>
<%= form.submit t("retirement.show.save_bucket"), class: "text-sm font-medium text-primary underline cursor-pointer" %>
<% end %>
</section>
<%# Statement journal %>
<section class="bg-container shadow-border-xs rounded-xl p-5 space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-primary font-medium"><%= t("retirement.show.statements_title") %></h2>
<p class="text-secondary text-xs"><%= t("retirement.show.statements_subtitle") %></p>
</div>
<%= link_to t("retirement.show.log_statement"), new_retirement_statement_path,
class: "text-sm font-medium text-primary underline" %>
</div>
<% if @statements.empty? %>
<p class="text-secondary text-sm"><%= t("retirement.show.no_statements") %></p>
<% else %>
<table class="w-full text-sm">
<thead class="text-secondary text-xs text-left">
<tr>
<th class="py-2"><%= t("retirement.statements.table.date") %></th>
<th class="py-2"><%= t("retirement.statements.table.source") %></th>
<th class="py-2"><%= t("retirement.statements.table.points") %></th>
<th class="py-2"><%= t("retirement.statements.table.amount") %></th>
<th class="py-2"><%= t("retirement.statements.table.age") %></th>
<th class="py-2"><%= t("retirement.statements.table.notes") %></th>
<th class="py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-tertiary">
<% @statements.each do |statement| %>
<tr>
<td class="py-2 text-primary tabular-nums"><%= I18n.l(statement.received_on) %></td>
<td class="py-2 text-secondary"><%= statement.pension_source.name %></td>
<td class="py-2 text-secondary tabular-nums"><%= statement.current_points %></td>
<td class="py-2 text-primary tabular-nums privacy-sensitive"><%= statement.projected_monthly_amount_money&.format %></td>
<td class="py-2 text-secondary tabular-nums"><%= statement.projected_at_age %></td>
<td class="py-2 text-secondary"><%= statement.raw_source_doc %></td>
<td class="py-2 text-right">
<%= button_to t("retirement.show.delete"), retirement_statement_path(statement),
method: :delete, class: "text-xs text-destructive underline",
form: { data: { turbo_confirm: t("retirement.show.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
</section>
</div>