feat: Move upcoming recurring transactions in a dedicated tab (#771)

* feat: Move upcoming transactions in a dedicated tab

* Adjust formatting

* feat: adjust visibility on mobile

* feat: change translation label

* feat: show only upcoming transactions expected in the next 10 days

* feat: show upcoming transactions tab only when option enabled

* feat: render empty partial when there are no recurring transactions

* feat: align icon sizing and spacing between transactions and upcoming sections

* feat: add missing localitazion labels

* fix: move filter on upcoming transactions in controller

* fix: add missing localitazion labels
This commit is contained in:
Alessio Cappa
2026-01-27 12:32:35 +01:00
committed by GitHub
parent 33df3b781e
commit aef582f553
8 changed files with 129 additions and 94 deletions

View File

@@ -24,11 +24,11 @@ class TransactionsController < ApplicationController
@pagy, @transactions = pagy(base_scope, limit: safe_per_page) @pagy, @transactions = pagy(base_scope, limit: safe_per_page)
# Load projected recurring transactions for next month # Load projected recurring transactions for next 10 days
@projected_recurring = Current.family.recurring_transactions @projected_recurring = Current.family.recurring_transactions
.active .active
.where("next_expected_date <= ? AND next_expected_date >= ?", .where("next_expected_date <= ? AND next_expected_date >= ?",
1.month.from_now.to_date, 10.days.from_now.to_date,
Date.current) Date.current)
.includes(:merchant) .includes(:merchant)
end end

View File

@@ -0,0 +1,4 @@
<div class="flex flex-col items-center justify-center py-40">
<p class="text-secondary mb-2"><%= t(".title") %></p>
<p class="text-subdued max-w-xs text-center"><%= t(".description") %></p>
</div>

View File

@@ -1,19 +1,19 @@
<%# locals: (recurring_transaction:) %> <%# locals: (recurring_transaction:) %>
<div class="grid grid-cols-12 items-center text-subdued text-sm font-medium p-4 lg:p-4 bg-container-inset rounded"> <div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4">
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8"> <div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
<div class="max-w-full"> <div class="max-w-full">
<%= content_tag :div, class: ["flex items-center gap-2"] do %> <%= content_tag :div, class: ["flex items-center gap-3 lg:gap-4"] do %>
<% if recurring_transaction.merchant.present? %> <% if recurring_transaction.merchant.present? %>
<% if recurring_transaction.merchant.logo_url.present? %> <% if recurring_transaction.merchant.logo_url.present? %>
<%= image_tag Setting.transform_brand_fetch_url(recurring_transaction.merchant.logo_url), <%= image_tag Setting.transform_brand_fetch_url(recurring_transaction.merchant.logo_url),
class: "w-6 h-6 rounded-full", class: "w-9 h-9 rounded-full",
loading: "lazy" %> loading: "lazy" %>
<% else %> <% else %>
<%= render DS::FilledIcon.new( <%= render DS::FilledIcon.new(
variant: :text, variant: :text,
text: recurring_transaction.merchant.name, text: recurring_transaction.merchant.name,
size: "sm", size: "lg",
rounded: true rounded: true
) %> ) %>
<% end %> <% end %>
@@ -21,27 +21,28 @@
<%= render DS::FilledIcon.new( <%= render DS::FilledIcon.new(
variant: :text, variant: :text,
text: recurring_transaction.name, text: recurring_transaction.name,
size: "sm", size: "lg",
rounded: true rounded: true
) %> ) %>
<% end %> <% end %>
<div class="truncate"> <div class="truncate">
<div class="space-y-0.5"> <div class="space-y-0.5">
<div class="flex items-center gap-2 min-w-0"> <div class="flex items-center gap-1 min-w-0">
<div class="truncate flex-shrink"> <div class="truncate flex-shrink">
<%= recurring_transaction.merchant.present? ? recurring_transaction.merchant.name : recurring_transaction.name %> <%= recurring_transaction.merchant.present? ? recurring_transaction.merchant.name : recurring_transaction.name %>
</div> </div>
<div class="flex items-center gap-1 flex-shrink-0"> <div class="flex items-center gap-1 flex-shrink-0">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-tint-10 text-link"> <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-tint-10 text-link">
<%= t("recurring_transactions.projected") %> <%= t("recurring_transactions.projected") %>
</span> </span>
</div> </div>
</div> </div>
<div class="text-secondary text-xs font-normal"> <div class="text-secondary text-xs font-normal">
<%= t("recurring_transactions.expected_on", date: l(recurring_transaction.next_expected_date, format: :short)) %> <%= days_left = (recurring_transaction.next_expected_date.to_date - Time.zone.today).to_i
days_left.zero? ? t("recurring_transactions.expected_today") : t("recurring_transactions.expected_in", count: days_left) %>
</div> </div>
</div> </div>
</div> </div>
@@ -53,7 +54,7 @@
<span class="text-xs text-secondary"><%= t("recurring_transactions.recurring") %></span> <span class="text-xs text-secondary"><%= t("recurring_transactions.recurring") %></span>
</div> </div>
<div class="col-span-2 ml-auto text-right"> <div class="col-span-2 ml-auto text-right shrink-0">
<% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %> <% display_amount = recurring_transaction.manual? && recurring_transaction.expected_amount_avg.present? ? recurring_transaction.expected_amount_avg : recurring_transaction.amount %>
<%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", display_amount.negative? ? "text-success" : "text-subdued"] %> <%= content_tag :p, format_money(-Money.new(display_amount, recurring_transaction.currency)), class: ["font-medium", display_amount.negative? ? "text-success" : "text-subdued"] %>
</div> </div>

View File

@@ -0,0 +1,53 @@
<%# locals: (transactions:, projected_recurring:, q:, pagy:) %>
<div id="transactions"
data-controller="bulk-select checkbox-toggle drag-and-drop-import"
data-bulk-select-singular-label-value="<%= t(".transaction") %>"
data-bulk-select-plural-label-value="<%= t(".transactions") %>"
class="flex flex-col bg-container rounded-xl shadow-border-xs px-3 py-4 lg:p-4 relative group">
<%= form_with url: imports_path, method: :post, class: "hidden", data: { drag_and_drop_import_target: "form" } do |f| %>
<%= f.hidden_field "import[type]", value: "TransactionImport" %>
<%= f.file_field "import[csv_file]", class: "hidden", data: { drag_and_drop_import_target: "input" }, accept: ".csv" %>
<% end %>
<%= render "imports/drag_drop_overlay", title: t(".drag_drop_title"), subtitle: t(".drag_drop_subtitle") %>
<%= render "transactions/searches/search" %>
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
<%= render "transactions/selection_bar" %>
</div>
<% if @pagy.count > 0 || (@projected_recurring.any? && @q.blank?) %>
<div class="grow overflow-y-auto">
<% if @transactions.any? %>
<div class="grid-cols-12 bg-container-inset rounded-xl px-5 py-3 text-xs uppercase font-medium text-secondary items-center mb-4 grid">
<div class="pl-0.5 col-span-8 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "checkbox checkbox--light hidden lg:block",
data: {
action: "bulk-select#togglePageSelection",
checkbox_toggle_target: "selectionEntry"
} %>
<p>transaction</p>
</div>
<p class="col-span-2 md:block hidden"><%= t("transactions.form.category_label") %></p>
<p class="col-span-2 col-start-11 md:col-start-auto justify-self-end md:block"><%= t("transactions.show.amount") %></p>
</div>
<% end %>
<div class="space-y-6">
<%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %>
<%= render entries %>
<% end %>
</div>
</div>
<% else %>
<%= render "entries/empty" %>
<% end %>
<div class="pt-4">
<%= render "shared/pagination", pagy: @pagy %>
</div>
</div>

View File

@@ -0,0 +1,29 @@
<% if @projected_recurring.any? %>
<div id="upcoming" class="flex flex-col bg-container rounded-xl shadow-border-xs px-3 py-4 lg:p-4 relative group">
<div class="grid-cols-12 bg-container-inset rounded-xl px-5 py-3 text-xs uppercase font-medium text-secondary items-center mb-4 grid">
<div class="pl-0.5 col-span-8 flex items-center gap-4"><p><%= t("transactions.list.transaction") %></p></div>
<p class="col-span-2 md:block hidden"><%= t("transactions.search.filters.type") %></p>
<p class="col-span-2 col-start-11 md:col-start-auto justify-self-end md:block"><%= t("transactions.show.amount") %></p>
</div>
<div class="space-y-6">
<% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %>
<div id="upcoming-group-<%= date %>" class="bg-container-inset rounded-xl p-1 w-full">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
<p class="uppercase space-x-1.5">
<%= tag.span I18n.l(date, format: :long) %>
<span>&middot;</span>
<%= tag.span transactions.size %>
</p>
</div>
<div class="bg-container shadow-border-xs rounded-lg">
<% transactions.each do |recurring_transaction| %>
<%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% else %>
<%= render "recurring_transactions/empty" %>
<% end %>

View File

@@ -44,88 +44,26 @@
</header> </header>
<%= render "summary", totals: @search.totals %> <%= render "summary", totals: @search.totals %>
<% if Current.family.recurring_transactions_disabled? %>
<%= render "transactions/list", transactions: @transactions, projected_recurring: @projected_recurring, q: params[:q], pagy: @pagy %>
<% else %>
<%= render DS::Tabs.new(active_tab: params[:tab].presence || "transactions") do |tabs| %>
<% tabs.with_nav(classes: "max-w-fit") do |nav| %>
<% nav.with_btn(id: "transactions", label: t("transactions.show.tab_transactions"), classes: "px-6") %>
<% nav.with_btn(id: "upcoming", label: t("transactions.show.tab_upcoming"), classes: "px-6") %>
<% end %>
<div id="transactions" <% tabs.with_panel(tab_id: "transactions") do %>
data-controller="bulk-select checkbox-toggle drag-and-drop-import" <div class="bg-container rounded-xl shadow-border-xs">
data-bulk-select-singular-label-value="<%= t(".transaction") %>" <%= render "transactions/list", transactions: @transactions, projected_recurring: @projected_recurring, q: params[:q], pagy: @pagy %>
data-bulk-select-plural-label-value="<%= t(".transactions") %>"
class="flex flex-col bg-container rounded-xl shadow-border-xs px-3 py-4 lg:p-4 relative group">
<%= form_with url: imports_path, method: :post, class: "hidden", data: { drag_and_drop_import_target: "form" } do |f| %>
<%= f.hidden_field "import[type]", value: "TransactionImport" %>
<%= f.file_field "import[csv_file]", class: "hidden", data: { drag_and_drop_import_target: "input" }, accept: ".csv" %>
<% end %>
<%= render "imports/drag_drop_overlay", title: "Drop CSV to import", subtitle: "Upload transactions directly" %>
<%= render "transactions/searches/search" %>
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
<%= render "transactions/selection_bar" %>
</div>
<% if @pagy.count > 0 || (@projected_recurring.any? && @q.blank?) %>
<div class="grow overflow-y-auto">
<% if @transactions.any? %>
<div class="grid-cols-12 bg-container-inset rounded-xl px-5 py-3 text-xs uppercase font-medium text-secondary items-center mb-4 grid">
<div class="pl-0.5 col-span-8 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "checkbox checkbox--light hidden lg:block",
data: {
action: "bulk-select#togglePageSelection",
checkbox_toggle_target: "selectionEntry"
} %>
<p>transaction</p>
</div>
<p class="col-span-2 md:block hidden">category</p>
<p class="col-span-2 col-start-11 md:col-start-auto justify-self-end md:block">amount</p>
</div>
<% end %>
<div class="space-y-6">
<% if @projected_recurring.any? && @q.blank? %>
<div
class="space-y-2"
data-controller="transactions-section"
data-transactions-section-section-key-value="upcoming_recurring"
data-transactions-section-collapsed-value="<%= Current.user.transactions_section_collapsed?("upcoming_recurring") %>">
<div class="flex items-center gap-2 px-2">
<button
type="button"
class="text-secondary hover:text-primary transition-colors p-0.5"
data-action="click->transactions-section#toggle keydown->transactions-section#handleToggleKeydown"
data-transactions-section-target="button"
aria-label="<%= t("transactions.toggle_recurring_section") %>"
aria-expanded="<%= !Current.user.transactions_section_collapsed?("upcoming_recurring") %>">
<%= icon("chevron-down", size: "sm", class: "transition-transform", data: { transactions_section_target: "chevron" }) %>
</button>
<h3 class="text-xs uppercase font-medium text-secondary"><%= t("recurring_transactions.upcoming") %></h3>
</div>
<div data-transactions-section-target="content">
<% @projected_recurring.group_by(&:next_expected_date).sort.each do |date, transactions| %>
<div class="space-y-2">
<div class="text-xs text-secondary px-2 pt-2"><%= l(date, format: :long) %></div>
<% transactions.each do |recurring_transaction| %>
<%= render "recurring_transactions/projected_transaction", recurring_transaction: recurring_transaction %>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
<%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %>
<%= render entries %>
<% end %>
</div> </div>
</div> <% end %>
<% else %>
<%= render "entries/empty" %>
<% end %>
<div class="pt-4"> <% tabs.with_panel(tab_id: "upcoming") do %>
<%= render "shared/pagination", pagy: @pagy %> <div class="bg-container rounded-xl shadow-border-xs">
</div> <%= render "transactions/upcoming" %>
</div> </div>
<% end %>
<% end %>
<% end %>
</div> </div>

View File

@@ -5,7 +5,10 @@ en:
upcoming: Upcoming Recurring Transactions upcoming: Upcoming Recurring Transactions
projected: Projected projected: Projected
recurring: Recurring recurring: Recurring
expected_on: Expected on %{date} expected_today: "Expected today"
expected_in:
one: "Expected in %{count} day"
other: "Expected in %{count} days"
day_of_month: Day %{day} of month day_of_month: Day %{day} of month
identify_patterns: Identify Patterns identify_patterns: Identify Patterns
cleanup_stale: Clean Up Stale cleanup_stale: Clean Up Stale

View File

@@ -49,6 +49,8 @@ en:
overview: Overview overview: Overview
settings: Settings settings: Settings
tags_label: Tags tags_label: Tags
tab_transactions: Transactions
tab_upcoming: Upcoming
uncategorized: "(uncategorized)" uncategorized: "(uncategorized)"
activity_labels: activity_labels:
buy: Buy buy: Buy
@@ -95,6 +97,11 @@ en:
transaction: transaction transaction: transaction
transactions: transactions transactions: transactions
import: Import import: Import
list:
drag_drop_title: Drop CSV to import
drag_drop_subtitle: Upload transactions directly
transaction: transaction
transactions: transactions
toggle_recurring_section: Toggle upcoming recurring transactions toggle_recurring_section: Toggle upcoming recurring transactions
search: search:
filters: filters: