diff --git a/app/controllers/transactions/categorizes_controller.rb b/app/controllers/transactions/categorizes_controller.rb new file mode 100644 index 000000000..38a505029 --- /dev/null +++ b/app/controllers/transactions/categorizes_controller.rb @@ -0,0 +1,131 @@ +class Transactions::CategorizesController < ApplicationController + def show + @breadcrumbs = [ + [ t("breadcrumbs.home"), root_path ], + [ t("breadcrumbs.transactions"), transactions_path ], + [ t("breadcrumbs.categorize"), nil ] + ] + @position = [ params[:position].to_i, 0 ].max + groups = Transaction::Grouper.strategy.call( + Current.accessible_entries, + limit: 1, + offset: @position + ) + + if groups.empty? + redirect_to transactions_path, notice: t(".all_done") and return + end + + @group = groups.first + @categories = Current.family.categories.alphabetically + @total_uncategorized = Entry.uncategorized_count(Current.accessible_entries) + end + + def create + @position = params[:position].to_i + entry_ids = Array.wrap(params[:entry_ids]).reject(&:blank?) + all_entry_ids = Array.wrap(params[:all_entry_ids]).reject(&:blank?) + remaining_ids = all_entry_ids - entry_ids + + category = Current.family.categories.find(params[:category_id]) + entries = Current.accessible_entries.excluding_split_parents.where(id: entry_ids) + count = entries.bulk_update!({ category_id: category.id }) + + if params[:create_rule] == "1" + rule = Rule.create_from_grouping( + Current.family, + params[:grouping_key], + category, + transaction_type: params[:transaction_type] + ) + flash[:alert] = t(".rule_creation_failed") if rule.nil? + end + + respond_to do |format| + format.turbo_stream do + remaining_entries = uncategorized_entries_for(remaining_ids) + remaining_ids = remaining_entries.map { |e| e.id.to_s } + + if remaining_ids.empty? + render turbo_stream: turbo_stream.action(:redirect, transactions_categorize_path(position: @position)) + else + @categories = Current.family.categories.alphabetically + streams = entry_ids.map { |id| turbo_stream.remove("categorize_entry_#{id}") } + remaining_entries.each do |entry| + streams << turbo_stream.replace( + "categorize_entry_#{entry.id}", + partial: "transactions/categorizes/entry_row", + locals: { entry: entry, categories: @categories } + ) + end + streams << turbo_stream.replace("categorize_remaining", + partial: "transactions/categorizes/remaining_count", + locals: { total_uncategorized: Entry.uncategorized_count(Current.accessible_entries) }) + streams << turbo_stream.replace("categorize_group_summary", + partial: "transactions/categorizes/group_summary", + locals: { entries: remaining_entries }) + streams.concat(flash_notification_stream_items) + render turbo_stream: streams + end + end + format.html { redirect_to transactions_categorize_path(position: @position), notice: t(".categorized", count: count) } + end + end + + def preview_rule + filter = params[:filter].to_s.strip + transaction_type = params[:transaction_type].presence + entries = filter.present? ? Entry.uncategorized_matching(Current.accessible_entries, filter, transaction_type) : [] + @categories = Current.family.categories.alphabetically + + render turbo_stream: [ + turbo_stream.replace("categorize_group_title", + partial: "transactions/categorizes/group_title", + locals: { display_name: filter.presence || "…", color: "#737373", transaction_type: transaction_type }), + turbo_stream.replace("categorize_group_summary", + partial: "transactions/categorizes/group_summary", + locals: { entries: entries }), + turbo_stream.replace("categorize_transaction_list", + partial: "transactions/categorizes/transaction_list", + locals: { entries: entries, categories: @categories }) + ] + end + + def assign_entry + entry = Current.accessible_entries.excluding_split_parents.find(params[:entry_id]) + category = Current.family.categories.find(params[:category_id]) + position = params[:position].to_i + all_entry_ids = Array.wrap(params[:all_entry_ids]).reject(&:blank?) + remaining_ids = all_entry_ids - [ entry.id.to_s ] + + Entry.where(id: entry.id).bulk_update!({ category_id: category.id }) + + remaining_entries = uncategorized_entries_for(remaining_ids) + remaining_ids = remaining_entries.map { |e| e.id.to_s } + + streams = [ turbo_stream.remove("categorize_entry_#{entry.id}") ] + if remaining_ids.empty? + streams << turbo_stream.action(:redirect, transactions_categorize_path(position: position)) + else + streams << turbo_stream.replace("categorize_remaining", + partial: "transactions/categorizes/remaining_count", + locals: { total_uncategorized: Entry.uncategorized_count(Current.accessible_entries) }) + streams << turbo_stream.replace("categorize_group_summary", + partial: "transactions/categorizes/group_summary", + locals: { entries: remaining_entries }) + end + render turbo_stream: streams + end + + private + + def uncategorized_entries_for(ids) + return [] if ids.blank? + Current.accessible_entries + .excluding_split_parents + .where(id: ids) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(transactions: { category_id: nil }) + .to_a + end +end diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb index dc79aaeb6..876e7d848 100644 --- a/app/controllers/transactions_controller.rb +++ b/app/controllers/transactions_controller.rb @@ -52,6 +52,8 @@ class TransactionsController < ApplicationController Set.new end + @uncategorized_count = Current.family.uncategorized_transaction_count + # Load projected recurring transactions for next 10 days @projected_recurring = Current.family.recurring_transactions .accessible_by(Current.user) diff --git a/app/javascript/controllers/categorize_controller.js b/app/javascript/controllers/categorize_controller.js new file mode 100644 index 000000000..08f5a1dc7 --- /dev/null +++ b/app/javascript/controllers/categorize_controller.js @@ -0,0 +1,165 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + static targets = [ + "list", + "createRuleCheckbox", + "filterDisplay", + "filterEditTrigger", + "filterEditArea", + "filterInput", + "groupingKeyHidden", + "filter", + "ruleDetails", + ]; + static values = { assignEntryUrl: String, position: Number, previewRuleUrl: String, transactionType: String }; + + connect() { + this.boundSelectFirst = this.selectFirst.bind(this); + document.addEventListener("keydown", this.boundSelectFirst); + this.toggleRuleDetails(); + } + + disconnect() { + document.removeEventListener("keydown", this.boundSelectFirst); + clearTimeout(this._previewTimer); + } + + selectFirst(event) { + if (event.key !== "Enter") return; + + const tag = event.target.tagName; + if (tag === "BUTTON" || tag === "A") return; + + // Don't intercept Enter when the user is confirming an inline filter edit + if (this.hasFilterInputTarget && event.target === this.filterInputTarget) return; + + event.preventDefault(); + + const visible = Array.from( + this.listTarget.querySelectorAll(".filterable-item") + ).filter((el) => el.style.display !== "none"); + + if (visible.length !== 1) return; + + visible[0].click(); + } + + clearFilter(event) { + if (event.target.tagName !== "BUTTON") return; + if (!this.hasFilterTarget) return; + this.filterTarget.value = ""; + this.filterTarget.dispatchEvent(new Event("input")); + } + + uncheckRule() { + if (this.hasCreateRuleCheckboxTarget) { + this.createRuleCheckboxTarget.checked = false; + this.toggleRuleDetails(); + } + } + + toggleRuleDetails() { + if (!this.hasRuleDetailsTarget || !this.hasCreateRuleCheckboxTarget) return; + const enabled = this.createRuleCheckboxTarget.checked; + this.ruleDetailsTarget.classList.toggle("opacity-40", !enabled); + if (this.hasFilterInputTarget) { + this.filterInputTarget.disabled = !enabled; + } + } + + startFilterEdit() { + this.filterDisplayTarget.classList.add("hidden"); + this.filterEditTriggerTarget.classList.add("hidden"); + this.filterEditAreaTarget.classList.remove("hidden"); + this.filterEditAreaTarget.classList.add("flex"); + this.filterInputTarget.focus(); + this.filterInputTarget.select(); + } + + confirmFilterEdit(event) { + event.preventDefault(); + event.stopPropagation(); + const value = this.filterInputTarget.value.trim(); + if (!value) return; + + this.filterDisplayTarget.textContent = `"${value}"`; + this.groupingKeyHiddenTarget.value = value; + + this.filterEditAreaTarget.classList.add("hidden"); + this.filterEditAreaTarget.classList.remove("flex"); + this.filterDisplayTarget.classList.remove("hidden"); + this.filterEditTriggerTarget.classList.remove("hidden"); + + this._doPreviewRule(value); + } + + cancelFilterEdit(event) { + event.preventDefault(); + event.stopPropagation(); + this.filterEditAreaTarget.classList.add("hidden"); + this.filterEditAreaTarget.classList.remove("flex"); + this.filterDisplayTarget.classList.remove("hidden"); + this.filterEditTriggerTarget.classList.remove("hidden"); + } + + previewRule(event) { + this._doPreviewRule(event.target.value); + } + + _doPreviewRule(filter) { + clearTimeout(this._previewTimer); + this._previewTimer = setTimeout(() => { + const url = new URL(this.previewRuleUrlValue, window.location.origin); + url.searchParams.set("filter", filter); + url.searchParams.set("position", this.positionValue); + url.searchParams.set("transaction_type", this.transactionTypeValue); + fetch(url.toString(), { + credentials: "same-origin", + headers: { + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, + Accept: "text/vnd.turbo-stream.html", + }, + }) + .then((r) => { if (!r.ok) throw new Error(r.statusText); return r.text(); }) + .then((html) => Turbo.renderStreamMessage(html)) + .catch((err) => console.error("Rule preview failed:", err)); + }, 300); + } + + assignEntry(event) { + const select = event.target; + const categoryId = select.value; + if (!categoryId) return; + + this.uncheckRule(); + + const entryId = select.dataset.entryId; + const body = new FormData(); + body.append("entry_id", entryId); + body.append("category_id", categoryId); + body.append("position", this.positionValue); + + // all_entry_ids[] hidden inputs live inside each Turbo Frame — + // automatically stay in sync as frames are removed + this.element.querySelectorAll("input[name='all_entry_ids[]']").forEach((input) => { + body.append("all_entry_ids[]", input.value); + }); + + fetch(this.assignEntryUrlValue, { + method: "PATCH", + credentials: "same-origin", + headers: { + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]')?.content, + Accept: "text/vnd.turbo-stream.html", + }, + body, + }) + .then((r) => { if (!r.ok) throw new Error(r.statusText); return r.text(); }) + .then((html) => Turbo.renderStreamMessage(html)) + .catch((err) => { + console.error("Entry assignment failed:", err); + select.value = ""; + }); + } +} diff --git a/app/models/entry.rb b/app/models/entry.rb index 6c90114d2..2a3de89ad 100644 --- a/app/models/entry.rb +++ b/app/models/entry.rb @@ -91,6 +91,43 @@ class Entry < ApplicationRecord joins(:account).where(accounts: { family_id: family.id }) end + # Counts uncategorized, non-transfer entries in the given scope. + # Used by the Quick Categorize Wizard to show the remaining count. + # @param entries [ActiveRecord::Relation] pre-scoped entries (caller controls authorization) + def self.uncategorized_count(entries) + entries + .joins(:account) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(accounts: { status: %w[draft active] }) + .where(transactions: { category_id: nil }) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(entries: { excluded: false }) + .count + end + + # Returns uncategorized, non-transfer entries whose name matches the given filter string. + # Used by the Quick Categorize Wizard to preview which transactions a rule would affect. + # @param entries [ActiveRecord::Relation] pre-scoped entries (caller controls authorization) + def self.uncategorized_matching(entries, filter, transaction_type = nil) + sanitized = sanitize_sql_like(filter.gsub(/\s+/, " ").strip) + scope = entries + .joins(:account) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(accounts: { status: %w[draft active] }) + .where(transactions: { category_id: nil }) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(entries: { excluded: false }) + .where("BTRIM(REGEXP_REPLACE(entries.name, '[[:space:]]+', ' ', 'g')) ILIKE ?", "%#{sanitized}%") + + scope = case transaction_type + when "income" then scope.where("entries.amount < 0") + when "expense" then scope.where("entries.amount >= 0") + else scope + end + + scope.includes(entryable: :merchant).order(entries: { date: :desc }).to_a + end + # Auto-exclude stale pending transactions for an account # Called during sync to clean up pending transactions that never posted # @param account [Account] The account to clean up diff --git a/app/models/family.rb b/app/models/family.rb index f5eceb93f..2f7ad1b50 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -144,6 +144,17 @@ class Family < ApplicationRecord AutoMerchantDetector.new(self, transaction_ids: transaction_ids).auto_detect end + def uncategorized_transaction_count + Transaction + .joins("INNER JOIN entries ON entries.entryable_id = transactions.id AND entries.entryable_type = 'Transaction'") + .joins("INNER JOIN accounts ON accounts.id = entries.account_id") + .where(accounts: { family_id: id, status: %w[draft active] }) + .where(transactions: { category_id: nil }) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(entries: { excluded: false }) + .count + end + def balance_sheet(user: Current.user) BalanceSheet.new(self, user: user) end diff --git a/app/models/rule.rb b/app/models/rule.rb index 74dbaece8..b53f80f83 100644 --- a/app/models/rule.rb +++ b/app/models/rule.rb @@ -40,6 +40,19 @@ class Rule < ApplicationRecord matching_resources_scope.count end + # Creates a categorization rule for the Quick Categorize Wizard. + # Returns the saved rule, or nil if a duplicate or invalid rule already exists. + def self.create_from_grouping(family, grouping_key, category, transaction_type: nil) + rule = family.rules.build(name: grouping_key, resource_type: "transaction", active: true) + rule.conditions.build(condition_type: "transaction_name", operator: "like", value: grouping_key) + rule.conditions.build(condition_type: "transaction_type", operator: "=", value: transaction_type) if transaction_type.present? + rule.actions.build(action_type: "set_transaction_category", value: category.id.to_s) + rule.save! + rule + rescue ActiveRecord::RecordInvalid + nil + end + # Calculates total unique resources affected across multiple rules # This handles overlapping rules by deduplicating transaction IDs def self.total_affected_resource_count(rules) diff --git a/app/models/transaction/grouper.rb b/app/models/transaction/grouper.rb new file mode 100644 index 000000000..e55ac2573 --- /dev/null +++ b/app/models/transaction/grouper.rb @@ -0,0 +1,17 @@ +class Transaction::Grouper + Group = Data.define(:grouping_key, :display_name, :entries, :merchant, :transaction_type) + + # Returns the active grouping strategy class. + # Change this method to swap algorithms without touching the wizard. + def self.strategy + Transaction::Grouper::ByMerchantOrName + end + + # @param entries [ActiveRecord::Relation] pre-scoped entries to group (caller controls authorization) + # @param limit [Integer] max number of groups to return + # @param offset [Integer] number of groups to skip (for pagination) + # @return [Array] + def self.call(entries, limit: 20, offset: 0) + raise NotImplementedError, "#{name} must implement .call" + end +end diff --git a/app/models/transaction/grouper/by_merchant_or_name.rb b/app/models/transaction/grouper/by_merchant_or_name.rb new file mode 100644 index 000000000..3618f65de --- /dev/null +++ b/app/models/transaction/grouper/by_merchant_or_name.rb @@ -0,0 +1,53 @@ +class Transaction::Grouper::ByMerchantOrName < Transaction::Grouper + def self.call(entries, limit: 20, offset: 0) + new(entries).call(limit: limit, offset: offset) + end + + def initialize(entries) + @entries = entries + end + + def call(limit: 20, offset: 0) + uncategorized_entries + .group_by { |entry| grouping_key_for(entry) } + .map { |key, entries| build_group(key, entries) } + .sort_by { |g| [ -g.entries.size, g.display_name ] } + .drop([ offset, 0 ].max) + .first(limit) + end + + private + + attr_reader :entries + + def uncategorized_entries + entries + .joins(:account) + .joins("INNER JOIN transactions ON transactions.id = entries.entryable_id AND entries.entryable_type = 'Transaction'") + .where(accounts: { status: %w[draft active] }) + .where(transactions: { category_id: nil }) + .where.not(transactions: { kind: Transaction::TRANSFER_KINDS }) + .where(entries: { excluded: false }) + .includes(entryable: :merchant) + .order(entries: { date: :desc }) + end + + def grouping_key_for(entry) + name = entry.entryable.merchant&.name.presence || entry.name + type = entry.amount.negative? ? "income" : "expense" + [ name, type ] + end + + def build_group(key, entries) + name, type = key + merchant = entries.find { |e| e.entryable.merchant.present? }&.entryable&.merchant + + Transaction::Grouper::Group.new( + grouping_key: name, + display_name: name, + entries: entries, + merchant: merchant, + transaction_type: type + ) + end +end diff --git a/app/views/transactions/categorizes/_entry_row.html.erb b/app/views/transactions/categorizes/_entry_row.html.erb new file mode 100644 index 000000000..5321079bc --- /dev/null +++ b/app/views/transactions/categorizes/_entry_row.html.erb @@ -0,0 +1,20 @@ +<%= turbo_frame_tag "categorize_entry_#{entry.id}" do %> + +
+ " + data-action="change->categorize#uncheckRule"> + <%= entry.name %> + <%= l(entry.date, format: :short) %> + "> + <%= format_money(entry.amount_money.abs) %> + + <%= select_tag "category_id", + options_from_collection_for_select(categories, :id, :name), + prompt: t("transactions.categorizes.show.assign_category_prompt"), + aria: { label: t("transactions.categorizes.entry_row.assign_category_select", name: entry.name) }, + class: "w-full text-xs border border-primary rounded-lg px-1.5 py-0.5 bg-container text-secondary", + data: { entry_id: entry.id, action: "change->categorize#assignEntry" } %> +
+<% end %> diff --git a/app/views/transactions/categorizes/_group_summary.html.erb b/app/views/transactions/categorizes/_group_summary.html.erb new file mode 100644 index 000000000..eeaa0dc14 --- /dev/null +++ b/app/views/transactions/categorizes/_group_summary.html.erb @@ -0,0 +1,7 @@ +<%= turbo_frame_tag "categorize_group_summary" do %> +

+ <%= t("transactions.categorizes.show.transaction_count", count: entries.size) %> + · + <%= format_money(entries.sum { |e| e.amount_money.abs }) %> +

+<% end %> diff --git a/app/views/transactions/categorizes/_group_title.html.erb b/app/views/transactions/categorizes/_group_title.html.erb new file mode 100644 index 000000000..948b504ac --- /dev/null +++ b/app/views/transactions/categorizes/_group_title.html.erb @@ -0,0 +1,10 @@ +<%= turbo_frame_tag "categorize_group_title" do %> +
+

<%= display_name %>

+ <% if transaction_type == "income" %> + <%= t("transactions.categorizes.show.type_income") %> + <% elsif transaction_type == "expense" %> + <%= t("transactions.categorizes.show.type_expense") %> + <% end %> +
+<% end %> diff --git a/app/views/transactions/categorizes/_remaining_count.html.erb b/app/views/transactions/categorizes/_remaining_count.html.erb new file mode 100644 index 000000000..d185e3357 --- /dev/null +++ b/app/views/transactions/categorizes/_remaining_count.html.erb @@ -0,0 +1,5 @@ +<%= turbo_frame_tag "categorize_remaining" do %> + + <%= t("transactions.categorizes.show.remaining", count: total_uncategorized) %> + +<% end %> diff --git a/app/views/transactions/categorizes/_transaction_list.html.erb b/app/views/transactions/categorizes/_transaction_list.html.erb new file mode 100644 index 000000000..d624a0e3c --- /dev/null +++ b/app/views/transactions/categorizes/_transaction_list.html.erb @@ -0,0 +1,20 @@ +<%= turbo_frame_tag "categorize_transaction_list" do %> +
+ <%# Header — same grid template and padding as each row %> +
+
+

<%= t("transactions.categorizes.show.col_transaction") %>

+

<%= t("transactions.categorizes.show.col_date") %>

+

<%= t("transactions.categorizes.show.col_amount") %>

+

<%= t("transactions.categorizes.show.col_category") %>

+
+ <%# Rows %> +
+
+ <% entries.each do |entry| %> + <%= render partial: "transactions/categorizes/entry_row", locals: { entry: entry, categories: categories } %> + <% end %> +
+
+
+<% end %> diff --git a/app/views/transactions/categorizes/show.html.erb b/app/views/transactions/categorizes/show.html.erb new file mode 100644 index 000000000..59d49f61b --- /dev/null +++ b/app/views/transactions/categorizes/show.html.erb @@ -0,0 +1,145 @@ +<%# Wizard step: categorize one group of uncategorized transactions %> +
+ <%# Top bar: remaining count + skip %> +
+ <%= turbo_frame_tag "categorize_remaining" do %> + + <%= t(".remaining", count: @total_uncategorized) %> + + <% end %> + <%= link_to transactions_categorize_path(position: @position + 1), + class: "flex items-center gap-1.5 text-sm font-medium text-secondary hover:text-primary" do %> + <%= t(".skip") %> + <%= icon("arrow-right", size: "sm") %> + <% end %> +
+ + <%# Group identity — above columns %> +
+
+ <%= render partial: "transactions/categorizes/group_title", + locals: { display_name: @group.display_name, color: @group.merchant&.color || "#737373", transaction_type: @group.transaction_type } %> +
+ <%= turbo_frame_tag "categorize_group_summary" do %> +

+ <%= t(".transaction_count", count: @group.entries.size) %> + · + <%= format_money(@group.entries.sum { |e| e.amount_money.abs }) %> +

+ <% end %> +
+ + <%# Main form %> + <%= form_with url: transactions_categorize_path, method: :post, id: "categorize-form", class: "w-full" do |form| %> + <%= form.hidden_field :position, value: @position %> + <%= form.hidden_field :transaction_type, value: @group.transaction_type %> + +
+ <%# Left column (60%): rule creation + transaction list %> +
+ <%# Rule creation %> +
+ + +
1 %>"> + + +

+ <%= t(".rule_description_prefix", type: t(".type_#{@group.transaction_type}").downcase) %> + + "<%= @group.grouping_key %>" + + + + <%= t(".rule_description_suffix") %> +

+
+
+ + <%# Transaction list %> +
+

<%= t(".transactions_hint") %>

+ <%= render partial: "transactions/categorizes/transaction_list", + locals: { entries: @group.entries, categories: @categories } %> +
+
+ + <%# Right column (40%): category picker %> +
+
+

<%= t(".assign_category") %>

+ +
+ <%# Search field %> +
+ <%= icon("search", class: "absolute left-3 text-secondary") %> + " + autocomplete="off" + autofocus + class="w-full pl-9 pr-3 py-2 text-sm bg-transparent border-none rounded-lg focus:outline-none focus:ring-0 placeholder:text-secondary" + data-list-filter-target="input" + data-categorize-target="filter" + data-action="input->list-filter#filter"> +
+ + <%# Category pills — submit the form directly %> +
+ + <% @categories.each do |category| %> + + <% end %> +
+
+
+
+
+ + <% end %> +
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb index 671710c06..c56aec7a4 100644 --- a/app/views/transactions/index.html.erb +++ b/app/views/transactions/index.html.erb @@ -11,6 +11,9 @@ <% menu.with_item(variant: "link", text: "Edit merchants", href: family_merchants_path, icon: "store", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Edit imports", href: imports_path, icon: "hard-drive-upload", data: { turbo_frame: :_top }) %> <% menu.with_item(variant: "link", text: "Import", href: new_import_path, icon: "download", data: { turbo_frame: "modal", class_name: "md:!hidden" }) %> + <% if @uncategorized_count > 0 %> + <% menu.with_item(variant: "link", text: t(".categorize_button", count: @uncategorized_count), href: transactions_categorize_path, icon: "tag", data: { turbo_frame: :_top }) %> + <% end %> <% end %>