Investments Page Refinements — Design

Date: 2026-05-28 Scope: /investments page form, Liquidez tab, Distribuição tab, Timeline tab

Goals

  1. Slim down the new-investment modal by removing status and holder inputs.
  2. Derive status from purchase_date, liquidity_date, and maturity_date instead of asking the user.
  3. Fix the Resumo Mensal de Liquidez showing maturity values; show both liquidity and maturity values per month.
  4. In Calendário de Vencimentos, show both liquidity and maturity values per month with clear visual differentiation.
  5. Sort Próximos Vencimentos by maturity_date ascending.
  6. Merge the Distribuição and Timeline tabs into a single Distribuição tab.

1. Form — remove Status & Holder

File: app/views/investments/_form.html.erb

  • Drop the Status input (line 97–103) and the Titular input (line 104–109).
  • The fourth row becomes grid-cols-1 sm:grid-cols-2: Indexador + Taxa only.

File: app/controllers/investments_controller.rb

  • Remove :status and :holder from investment_params (line 135).

DB: No migration. Columns status and holder remain (still used by admin filters, MCP tools, model indexes). They become internal — never written via the user form.

2. Derived status

File: app/models/investment.rb

Add liquidity_reached to STATUSES and the enum :status mapping.

STATUSES = %w[active liquidity_reached maturing_soon matured].freeze

enum :status, {
  active: "active",
  liquidity_reached: "liquidity_reached",
  maturing_soon: "maturing_soon",
  matured: "matured"
}, default: :active, prefix: true

Add a before_validation :recompute_status callback:

def recompute_status
  today = Date.current
  self.status =
    if maturity_date.present? && maturity_date < today
      "matured"
    elsif maturity_date.present? && maturity_date <= today + 30.days
      "maturing_soon"
    elsif liquidity_date.present? && liquidity_date <= today &&
          (maturity_date.blank? || maturity_date > today)
      "liquidity_reached"
    else
      "active"
    end
end

Update status_label:

def status_label
  case status
  when "active" then "Ativo"
  when "liquidity_reached" then "Disponível"
  when "maturing_soon" then "Vencendo em breve"
  when "matured" then "Vencido"
  else status&.titleize
  end
end

Cascading changes

  • app/views/investments/_investment_row.html.erb: add a fourth elsif for status_liquidity_reached? — teal badge (#06b6d4, bg rgba(6,182,212,0.12)), label "Disponível".
  • app/views/investments/_timeline_tab.html.erb: extend status_config with "liquidity_reached" => { label: "Disponível", color: "#06b6d4", bg: "rgba(6,182,212,0.12)" } (used by the cards section that moves to the merged Distribuição tab).
  • i18n entries investments.statuses.liquidity_reached in config/locales/{pt-BR,en}.yml for both languages (mirroring existing keys).

Data backfill

No backfill task — the callback runs on next save. Existing records keep their stored value until edited. Acceptable because:

  • Existing status values are still valid (active, maturing_soon, matured).
  • The new liquidity_reached state will only appear after the next save event.

3. Liquidez tab — Calendário de Vencimentos (grouped bars)

File: app/views/investments/_liquidez_tab.html.erb

Build two parallel monthly series:

<% monthly_totals = calendar_months.map { |m|
  liq_invs = maturity_investments.select { |i| i.liquidity_date.present? && i.liquidity_date.beginning_of_month == m }
  mat_invs = maturity_investments.select { |i| i.maturity_date.present? && i.maturity_date.beginning_of_month == m }
  {
    month: m,
    liquidity_total: liq_invs.sum(&:current_value),
    maturity_total:  mat_invs.sum(&:current_value),
    liquidity_invs:  liq_invs,
    maturity_invs:   mat_invs
  }
} %>
<% max_bar = [monthly_totals.flat_map { |m| [m[:liquidity_total], m[:maturity_total]] }.max || 1, 1].max %>

Render two narrow bars side-by-side per month:

  • Blue bar (#3b82f6 linear gradient) for liquidity_total
  • Orange bar (#f97316 linear gradient) for maturity_total

Add a legend above the chart:

<div class="flex items-center gap-4 mb-3 text-[11px]">
  <span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded-sm" style="background:#3b82f6"></span>Liquidez</span>
  <span class="flex items-center gap-1.5"><span class="w-3 h-3 rounded-sm" style="background:#f97316"></span>Vencimento</span>
</div>

Update subtitle: "Liquidez (azul) e Vencimentos (laranja) por mês".

4. Liquidez tab — Resumo Mensal cards

File: app/views/investments/_liquidez_tab.html.erb

Each card uses the new monthly_totals structure. Two rows per card:

<div class="rounded-2xl p-4 bg-white dark:bg-[#161b22] border border-gray-200/60 dark:border-white/[0.06]" style="min-height: 110px;">
  <p class="text-[12px] font-bold text-gray-700 dark:text-gray-200 mb-2"><%= format_month_pt(m[:month]) %></p>

  <div class="flex items-center justify-between mb-0.5">
    <span class="text-[11px] font-medium" style="color:#3b82f6;">Liquidez</span>
    <span class="text-[13px] font-bold text-gray-900 dark:text-white">
      <%= m[:liquidity_total] > 0 ? format_brl(m[:liquidity_total]) : "—" %>
    </span>
  </div>

  <div class="flex items-center justify-between">
    <span class="text-[11px] font-medium" style="color:#f97316;">Vencimento</span>
    <span class="text-[13px] font-bold text-gray-900 dark:text-white">
      <%= m[:maturity_total] > 0 ? format_brl(m[:maturity_total]) : "—" %>
    </span>
  </div>

  <% combined_names = (m[:liquidity_invs] + m[:maturity_invs]).map(&:name).uniq %>
  <% if combined_names.any? %>
    <p class="text-[10px] text-gray-500 mt-1.5 truncate"><%= combined_names.join(" • ") %></p>
  <% end %>
</div>

Drop the obsolete accent_colors lookup.

5. Próximos Vencimentos sort

File: app/views/investments/_liquidez_tab.html.erb

Replace the current bucket sort (lines 29–37) with:

<% sorted_investments = maturity_investments.sort_by { |i| i.maturity_date || Date.new(9999, 12, 31) } %>

(Investments without maturity_date go last — but note maturity_investments is already scoped via with_maturity, so nil isn't expected.)

6. Merge Distribuição + Timeline tabs

Tabs list

File: app/views/investments/index.html.erb (line 48)

<% tabs = [
  ["lista", t("investments.tabs.list")],
  ["distribuicao", t("investments.tabs.distribution")],
  ["liquidez", t("investments.tabs.liquidez")]
] %>

Remove the timeline tab entry. Remove the <div data-investments-page-target="tabContent" data-tab="timeline"> block (lines 135–138).

If params[:tab] == "timeline", the controller will redirect to distribuicao.

Controller

File: app/controllers/investments_controller.rb

  • Remove @timeline_months computation (no longer used).
  • Keep @timeline_cards rename it to @upcoming_maturity_cards for clarity; keep the same logic: investments with maturity_date, sorted by maturity_date asc, first 6.
  • In index: @active_tab = (params[:tab] == "timeline") ? "distribuicao" : (params[:tab] || "lista").

Distribuição partial

File: app/views/investments/_distribuicao_tab.html.erb

After the existing 2-column grid (donut + bars), append a "Próximos Vencimentos" section:

<% if @upcoming_maturity_cards.any? %>
  <div class="mt-6 bg-white dark:bg-[#161b22] rounded-2xl p-5 border border-gray-200/60 dark:border-white/[0.06]">
    <h3 class="text-[15px] font-bold text-gray-900 dark:text-white mb-4">Próximos Vencimentos</h3>
    <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
      <% @upcoming_maturity_cards.each do |inv| %>
        <% st = status_config[inv.status.to_s] || status_config["active"] %>
        <div class="rounded-xl p-4 flex items-center justify-between bg-[#f8fafc] dark:bg-[#1e2736]">
          <div class="min-w-0 pr-3">
            <p class="text-[13px] font-semibold text-gray-900 dark:text-white truncate"><%= inv.name %></p>
            <p class="text-[11px] text-gray-500 dark:text-gray-400 mt-0.5">Vence: <%= l(inv.maturity_date) %></p>
          </div>
          <div class="text-right shrink-0">
            <p class="text-[13px] font-bold text-gray-900 dark:text-white mb-1"><%= format_brl(inv.current_value) %></p>
            <span class="inline-flex items-center px-2 py-0.5 rounded-lg text-[10px] font-bold"
                  style="background: <%= st[:bg] %>; color: <%= st[:color] %>;">
              <%= st[:label] %>
            </span>
          </div>
        </div>
      <% end %>
    </div>
  </div>
<% end %>

Move the status_config definition to the top of _distribuicao_tab.html.erb (from _timeline_tab.html.erb). Delete _timeline_tab.html.erb.

i18n changes

config/locales/pt-BR.yml, config/locales/en.yml:

  • Add investments.statuses.liquidity_reached ("Disponível" / "Available")
  • Remove no longer used keys: investments.tabs.timeline, investments.fields.holder, investments.fields.status (only if no other view references them).

Out of scope

  • Backfilling existing investment status values
  • Removing the holder / status DB columns
  • Updating Investment admin views (admin still shows status)
  • Updating MCP tool schemas (status/holder fields kept for API users)

Files changed

File Change
app/models/investment.rb Add liquidity_reached, before_validation :recompute_status, label, comment update
app/controllers/investments_controller.rb Drop status/holder from params, rename @timeline_cards, drop @timeline_months, redirect timelinedistribuicao
app/views/investments/_form.html.erb Remove status + holder inputs; row 4 becomes 2 cols
app/views/investments/_investment_row.html.erb Add liquidity_reached badge case
app/views/investments/_liquidez_tab.html.erb Grouped bars (chart), 2-row monthly cards, sort by maturity_date
app/views/investments/_distribuicao_tab.html.erb Append "Próximos Vencimentos" cards section + status_config
app/views/investments/_timeline_tab.html.erb Delete
app/views/investments/index.html.erb Remove timeline tab + tab content block
config/locales/pt-BR.yml, en.yml Add statuses.liquidity_reached; remove timeline tab key, holder/status field keys