Files
sure/app/views/reports/_investment_performance.html.erb
Guillem Arias Fauste e15349d57e refactor(misc): migrate misc badges to DS::Pill (#1751 PR D) (#1919)
* refactor(transactions): migrate 5 transaction badges to DS::Pill (#1751 PR B)

Migrates the hand-rolled "Pending" / "Review recommended" / "Potential
duplicate" / "Split" badges across the transaction views to the
extended DS::Pill primitive from #1902.

**Visual contract for badge mode**

In #1902 the badge mode (`marker: false`) used `rounded-md` (chip shape)
because the marker mode does. But every existing pill / status badge
in the codebase uses `rounded-full` — see
`settings/providers/_status_pill.html.erb`,
`settings/providers/_maturity_badge.html.erb`, and the inline
transaction badges this PR is migrating. To keep the visual contract
consistent, this PR shifts `DS::Pill`'s badge mode to `rounded-full`
(marker mode stays `rounded-md`, unchanged from #1829). The shape
distinction now reads: markers are tags, badges are pills.

**Callsites migrated** (5):

- `app/views/transactions/_transaction.html.erb` — Pending,
  Review-recommended, Possible-duplicate, Split badges
- `app/views/transactions/_header.html.erb` — Pending badge
- `app/views/transactions/_split_parent_row.html.erb` — Split badge

**Tone mapping**

| Badge | Tone | Notes |
|---|---|---|
| Pending | `:neutral` | unchanged copy/icon, gains subtle DS-controlled bg |
| Review recommended | `:neutral` | matches existing `bg-surface-inset` look |
| Possible duplicate | `:warning` | DS semantic alias for the existing `text-warning` |
| Split | `:neutral` | matches existing `bg-surface-inset` look |

**Deferred to follow-up PRs**

- `app/views/transactions/_transfer_match.html.erb` — uses two
  responsive-visibility variants (`hidden lg:inline-flex` for long
  copy, `inline-flex lg:hidden` for short). DS::Pill currently has no
  `class:` arg for caller-controlled wrapper classes; deferring until
  that lands.
- `app/views/transactions/searches/filters/_badge.html.erb` — has a
  close button alongside the label (`button_to clear_filter_*`) and
  uses `rounded-3xl p-1.5` instead of a true pill. Closer to a
  removable filter chip — better fit for a separate `DS::FilterChip`
  primitive than for `DS::Pill`.

Refs #1751.

* refactor(misc): migrate misc badges to DS::Pill (#1751 PR D)

Replaces five misc badge callsites with `DS::Pill` (badge mode:
`marker: false`, `show_dot: false`) so the long-tail badges share the
same shape, padding, and dark-mode tokens as the rest of the design
system. No raw palette classes remain in the migrated files.

Migrated:
- app/views/shared/_badge.html.erb — converted to a thin shim that
  renders `DS::Pill`; preserves the block-content API and the
  `pulse: true` option (wraps the pill in `animate-pulse`). Maps
  `success`/`error`/`warning`/default → `:success`/`:error`/`:warning`/`:neutral`.
- app/views/accounts/_tax_treatment_badge.html.erb — maps tax
  treatments to DS tones: `:tax_exempt → :green`,
  `:tax_deferred → :indigo` (was raw blue-500/10),
  `:tax_advantaged → :violet` (was raw purple-500/10), default → `:neutral`.
- app/views/reports/_investment_performance.html.erb (line ~121,
  inline twin of the tax-treatment badge) — uses the same mapping via
  a new `tax_treatment_pill_tone` helper.
- app/helpers/reports_helper.rb — replaces `tax_treatment_badge_classes`
  with `tax_treatment_pill_tone` (the old helper had no other callers).
- app/views/import/qif_category_selections/show.html.erb (~line 86) —
  inline split badge → `tone: :warning`.
- app/views/investment_activity/_badge.html.erb — fixed activity
  enum mapped to DS tones: Buy/Reinvestment → :indigo,
  Sell → :red, Dividend/Interest → :green, Contribution → :violet,
  Withdrawal → :amber, others → :gray.

Skipped (true mismatches, not extendable without changing DS::Pill):
- app/views/shared/_color_badge.html.erb — takes an arbitrary
  user-supplied color via `color-mix(in oklab, #{color} ...)`. DS::Pill
  only supports the fixed tone enum, so this would lose information.
- app/views/categories/_badge.html.erb — same reason; renders
  `category.color` (arbitrary hex per record).
- app/views/investment_activity/_quick_edit_badge.html.erb — interactive
  button with a Stimulus controller, click action, hover state, and
  dropdown anchor. DS::Pill renders a `<span>`; converting would
  destroy the interactive surface.

Stack: based on `feat/ds-pill-transactions-1751-b` (PR #1917), which
ships the `marker: false` → `rounded-full` badge shape this PR depends on.

Refs #1751.
2026-05-23 08:55:39 +02:00

224 lines
12 KiB
Plaintext

<%# locals: (investment_metrics:) %>
<% if investment_metrics[:has_investments] %>
<div class="space-y-6">
<%# Investment Summary Cards %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%# Portfolio Value Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<%= icon("briefcase", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.portfolio_value") %></span>
</div>
<p class="text-xl font-semibold text-primary privacy-sensitive">
<%= format_money(investment_metrics[:portfolio_value]) %>
</p>
</div>
<%# Total Return Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<%= icon("trending-up", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.total_return") %></span>
</div>
<% if investment_metrics[:unrealized_trend] %>
<p class="text-xl font-semibold privacy-sensitive" style="color: <%= investment_metrics[:unrealized_trend].color %>">
<%= format_money(Money.new(investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
(<%= investment_metrics[:unrealized_trend].percent_formatted %>)
</p>
<% else %>
<p class="text-xl font-semibold text-secondary"><%= t("reports.investment_performance.no_data") %></p>
<% end %>
</div>
<%# Period Contributions Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<%= icon("arrow-down-to-line", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.contributions") %></span>
</div>
<p class="text-xl font-semibold text-primary privacy-sensitive">
<%= format_money(investment_metrics[:period_contributions]) %>
</p>
</div>
<%# Period Withdrawals Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-3">
<%= icon("arrow-up-from-line", size: "sm") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.withdrawals") %></span>
</div>
<p class="text-xl font-semibold text-primary privacy-sensitive">
<%= format_money(investment_metrics[:period_withdrawals]) %>
</p>
</div>
</div>
<%# Top Holdings Table %>
<% if investment_metrics[:top_holdings].any? %>
<div class="space-y-3">
<h4 class="text-lg font-medium"><%= t("reports.investment_performance.top_holdings") %></h4>
<div class="bg-container-inset rounded-xl p-1 overflow-x-auto">
<div class="w-max sm:w-full">
<div class="rounded-lg shadow-border-xs bg-container overflow-hidden">
<table class="w-full">
<thead class="bg-container-inset">
<tr class="uppercase text-xs font-medium text-secondary">
<th class="px-4 py-2 text-left font-medium"><%= t("reports.investment_performance.holding") %></th>
<th class="px-4 py-2 text-right font-medium"><%= t("reports.investment_performance.weight") %></th>
<th class="px-4 py-2 text-right font-medium"><%= t("reports.investment_performance.value") %></th>
<th class="px-4 py-2 text-right font-medium"><%= t("reports.investment_performance.return") %></th>
</tr>
</thead>
<tbody class="text-secondary text-sm">
<% investment_metrics[:top_holdings].each_with_index do |holding, idx| %>
<tr class="<%= idx < investment_metrics[:top_holdings].size - 1 ? "table-divider" : "" %>">
<td class="py-3 px-4 lg:px-6">
<div class="flex items-center gap-3">
<% if (logo = holding.security.display_logo_url).present? %>
<img src="<%= Setting.transform_brand_fetch_url(logo) %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% else %>
<div class="w-8 h-8 rounded-full bg-container-inset flex items-center justify-center text-xs font-medium text-secondary">
<%= holding.ticker[0..1] %>
</div>
<% end %>
<div>
<p class="font-medium text-primary"><%= holding.ticker %></p>
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 25) %></p>
</div>
</div>
</td>
<td class="py-3 px-4 lg:px-6 text-right text-secondary"><%= number_to_percentage(holding.weight || 0, precision: 1) %></td>
<td class="py-3 px-4 lg:px-6 text-right font-medium text-primary privacy-sensitive"><%= format_money(holding.amount_money) %></td>
<td class="py-3 px-4 lg:px-6 text-right">
<% if holding.trend %>
<span style="color: <%= holding.trend.color %>">
<%= holding.trend.percent_formatted %>
</span>
<% else %>
<span class="text-secondary"><%= t("reports.investment_performance.no_data") %></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<% end %>
<%# Gains by Tax Treatment %>
<% if investment_metrics[:gains_by_tax_treatment].present? %>
<div class="space-y-3">
<h4 class="text-lg font-medium"><%= t("reports.investment_performance.gains_by_tax_treatment") %></h4>
<div class="flex flex-wrap gap-4">
<% investment_metrics[:gains_by_tax_treatment].each do |treatment, data| %>
<div class="bg-container-inset rounded-lg p-4 space-y-3 flex-1 min-w-64">
<div class="flex items-center justify-between">
<%= render DS::Pill.new(
label: t("accounts.tax_treatments.#{treatment}"),
tone: tax_treatment_pill_tone(treatment),
marker: false,
show_dot: false,
title: t("accounts.tax_treatment_descriptions.#{treatment}")
) %>
<span class="text-sm font-semibold text-primary privacy-sensitive">
<%= format_money(data[:total_gain]) %>
</span>
</div>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-secondary"><%= t("reports.investment_performance.unrealized_gains") %></span>
<span class="text-primary privacy-sensitive"><%= format_money(data[:unrealized_gain]) %></span>
</div>
<div class="flex justify-between">
<span class="text-secondary"><%= t("reports.investment_performance.realized_gains") %></span>
<span class="text-primary privacy-sensitive"><%= format_money(data[:realized_gain]) %></span>
</div>
</div>
<% if treatment == :taxable && data[:realized_gain].amount > 0 %>
<p class="text-xs text-warning flex items-center gap-1">
<%= icon("alert-triangle", size: "sm") %>
<%= t("reports.investment_performance.taxable_realized_note") %>
</p>
<% end %>
<details class="group">
<summary class="cursor-pointer text-xs text-secondary hover:text-primary flex items-center gap-1">
<%= icon "chevron-right", size: "sm", class: "group-open:rotate-90 transition-transform" %>
<%= t("reports.investment_performance.view_details") %>
(<%= t("reports.investment_performance.holdings_count", count: data[:holdings].count) %>, <%= t("reports.investment_performance.sells_count", count: data[:sell_trades].count) %>)
</summary>
<div class="mt-2 space-y-2 text-xs">
<% if data[:holdings].any? %>
<div class="space-y-1">
<p class="text-secondary font-medium"><%= t("reports.investment_performance.holdings") %></p>
<% data[:holdings].first(5).each do |holding| %>
<div class="flex justify-between pl-2">
<span class="text-primary"><%= holding.ticker %></span>
<span class="<%= holding.trend ? "" : "text-secondary" %>" style="<%= holding.trend ? "color: #{holding.trend.color}" : "" %>">
<%= holding.trend ? format_money(Money.new(holding.trend.value, Current.family.currency)) : t("reports.investment_performance.no_data") %>
</span>
</div>
<% end %>
<% if data[:holdings].count > 5 %>
<p class="text-secondary pl-2"><%= t("reports.investment_performance.and_more", count: data[:holdings].count - 5) %></p>
<% end %>
</div>
<% end %>
<% if data[:sell_trades].any? %>
<div class="space-y-1">
<p class="text-secondary font-medium"><%= t("reports.investment_performance.sell_trades") %></p>
<% data[:sell_trades].first(5).each do |trade| %>
<% gain = trade.realized_gain_loss %>
<div class="flex justify-between pl-2">
<span class="text-primary"><%= trade.security.ticker %></span>
<span class="<%= gain ? "" : "text-secondary" %>" style="<%= gain ? "color: #{gain.color}" : "" %>">
<%= gain ? format_money(Money.new(gain.value, Current.family.currency)) : t("reports.investment_performance.no_data") %>
</span>
</div>
<% end %>
<% if data[:sell_trades].count > 5 %>
<p class="text-secondary pl-2"><%= t("reports.investment_performance.and_more", count: data[:sell_trades].count - 5) %></p>
<% end %>
</div>
<% end %>
</div>
</details>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Investment Accounts Summary %>
<% if investment_metrics[:accounts].any? %>
<div class="space-y-3">
<h4 class="text-lg font-medium"><%= t("reports.investment_performance.accounts") %></h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<% investment_metrics[:accounts].each do |account| %>
<%= link_to account_path(account), class: "bg-container-inset rounded-lg p-4 flex items-center justify-between hover:bg-container-hover transition-colors" do %>
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account, size: "sm" %>
<div>
<p class="font-medium text-primary text-sm"><%= account.name %></p>
<p class="text-xs text-secondary"><%= account.short_subtype_label %></p>
</div>
</div>
<p class="font-medium text-primary privacy-sensitive"><%= format_money(account.balance_money) %></p>
<% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>