diff --git a/app/controllers/transactions_controller.rb b/app/controllers/transactions_controller.rb
index 88ebb2a9..dabcc32b 100644
--- a/app/controllers/transactions_controller.rb
+++ b/app/controllers/transactions_controller.rb
@@ -3,18 +3,46 @@ class TransactionsController < ApplicationController
before_action :set_transaction, only: %i[ show edit update destroy ]
def index
- search_params = params[:q] || {}
- period = Period.find_by_name(search_params[:date])
- if period&.date_range
- search_params.merge!({ date_gteq: period.date_range.begin, date_lteq: period.date_range.end })
- end
-
+ search_params = session[ransack_session_key] || params[:q]
@q = Current.family.transactions.ransack(search_params)
- @pagy, @transactions = pagy(@q.result.order(date: :desc), items: 50)
+ result = @q.result.order(date: :desc)
+ @pagy, @transactions = pagy(result, items: 10)
+ @totals = {
+ count: result.count,
+ income: result.inflows.sum(&:amount_money).abs,
+ expense: result.outflows.sum(&:amount_money).abs
+ }
+ @filter_list = Transaction.build_filter_list(search_params, Current.family)
respond_to do |format|
- format.html # For full page reloads
- format.turbo_stream # For Turbo Frame requests
+ format.html
+ format.turbo_stream
+ end
+ end
+
+ def search
+ if params[:clear]
+ session.delete(ransack_session_key)
+ elsif params[:remove_param]
+ current_params = session[ransack_session_key] || {}
+ updated_params = delete_search_param(current_params, params[:remove_param], value: params[:remove_param_value])
+ session[ransack_session_key] = updated_params
+ elsif params[:q]
+ session[ransack_session_key] = params[:q]
+ end
+
+ index
+
+ respond_to do |format|
+ format.html { render :index }
+ format.turbo_stream do
+ render turbo_stream: [
+ turbo_stream.replace("transactions_summary", partial: "transactions/summary", locals: { totals: @totals }),
+ turbo_stream.replace("transactions_search_form", partial: "transactions/search_form", locals: { q: @q }),
+ turbo_stream.replace("transactions_filters", partial: "transactions/filters", locals: { filters: @filter_list }),
+ turbo_stream.replace("transactions_list", partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy })
+ ]
+ end
end
end
@@ -67,6 +95,21 @@ class TransactionsController < ApplicationController
end
private
+ def delete_search_param(params, key, value: nil)
+ if value
+ params[key]&.delete(value)
+ params.delete(key) if params[key].empty? # Remove key if it's empty after deleting value
+ else
+ params.delete(key)
+ end
+
+ params
+ end
+
+ def ransack_session_key
+ :ransack_transactions_q
+ end
+
# Use callbacks to share common setup or constraints between actions.
def set_transaction
@transaction = Transaction.find(params[:id])
diff --git a/app/javascript/controllers/auto_submit_form_controller.js b/app/javascript/controllers/auto_submit_form_controller.js
index f59b9127..c1dc5f0d 100644
--- a/app/javascript/controllers/auto_submit_form_controller.js
+++ b/app/javascript/controllers/auto_submit_form_controller.js
@@ -1,31 +1,26 @@
-import { Controller } from '@hotwired/stimulus';
+import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
-
- get cssInputSelector() {
- return 'input:not(.no-auto-submit), textarea:not(.no-auto-submit)';
- }
-
- get inputElements() {
- return this.element.querySelectorAll(this.cssInputSelector);
- }
-
- get selectElements() {
- return this.element.querySelectorAll('select:not(.no-auto-submit)');
- }
+ // By default, auto-submit is "opt-in" to avoid unexpected behavior. Each `auto` target
+ // will trigger a form submission when the input event is triggered.
+ static targets = ["auto"];
connect() {
- [...this.inputElements, ...this.selectElements].forEach(el => el.addEventListener('change', this.handler));
+ this.autoTargets.forEach((element) => {
+ element.addEventListener("input", this.handleInput);
+ });
}
disconnect() {
- [...this.inputElements, ...this.selectElements].forEach(el => el.removeEventListener('change', this.handler));
- }
-
- handler = (e) => {
- console.log(e);
- this.element.requestSubmit();
+ this.autoTargets.forEach((element) => {
+ element.removeEventListener("input", this.handleInput);
+ });
}
+ handleInput = () => {
+ clearTimeout(this.timeout);
+ this.timeout = setTimeout(() => {
+ this.element.requestSubmit();
+ }, 500);
+ };
}
-
diff --git a/app/javascript/controllers/list_filter_controller.js b/app/javascript/controllers/list_filter_controller.js
new file mode 100644
index 00000000..d0504572
--- /dev/null
+++ b/app/javascript/controllers/list_filter_controller.js
@@ -0,0 +1,16 @@
+import { Controller } from "@hotwired/stimulus";
+
+// Basic functionality to filter a list based on a provided text attribute.
+export default class extends Controller {
+ static targets = ["input", "list"];
+
+ filter() {
+ const filterValue = this.inputTarget.value.toLowerCase();
+ const items = this.listTarget.querySelectorAll(".filterable-item");
+
+ items.forEach((item) => {
+ const text = item.getAttribute("data-filter-name").toLowerCase();
+ item.style.display = text.includes(filterValue) ? "" : "none";
+ });
+ }
+}
diff --git a/app/javascript/controllers/tabs_controller.js b/app/javascript/controllers/tabs_controller.js
index c0e4f844..15332871 100644
--- a/app/javascript/controllers/tabs_controller.js
+++ b/app/javascript/controllers/tabs_controller.js
@@ -21,15 +21,17 @@ export default class extends Controller {
onTurboLoad = () => {
this.updateClasses(this.defaultTabValue);
- }
+ };
updateClasses = (selectedId) => {
- this.btnTargets.forEach((btn) => btn.classList.remove(this.activeClass));
+ this.btnTargets.forEach((btn) =>
+ btn.classList.remove(...this.activeClasses)
+ );
this.tabTargets.forEach((tab) => tab.classList.add("hidden"));
this.btnTargets.forEach((btn) => {
if (btn.dataset.id === selectedId) {
- btn.classList.add(this.activeClass);
+ btn.classList.add(...this.activeClasses);
}
});
@@ -38,5 +40,5 @@ export default class extends Controller {
tab.classList.remove("hidden");
}
});
- }
+ };
}
diff --git a/app/javascript/controllers/transactions_search_controller.js b/app/javascript/controllers/transactions_search_controller.js
deleted file mode 100644
index 8f47ae53..00000000
--- a/app/javascript/controllers/transactions_search_controller.js
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Controller } from "@hotwired/stimulus"
-
-export default class extends Controller {
- static targets = ["search", "date"]
-
- connect() {
- this.timeout = null
- }
-
- search() {
- clearTimeout(this.timeout);
- this.timeout = setTimeout(() => {
- this.submitForm();
- }, 300); // Debounce time in milliseconds
- }
-
- submitForm() {
- const formData = new FormData(this.searchTarget.form);
- const searchParams = new URLSearchParams(formData).toString();
- const newUrl = `${window.location.pathname}?${searchParams}`;
-
- history.pushState({}, '', newUrl);
- this.searchTarget.form.requestSubmit();
- }
-
- afterSubmit() {
- this.searchTarget.focus();
- }
-}
diff --git a/app/models/account.rb b/app/models/account.rb
index 5682214e..14be3bcd 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -21,7 +21,7 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
def self.ransackable_attributes(auth_object = nil)
- %w[name]
+ %w[name id]
end
def balance_on(date)
diff --git a/app/models/transaction.rb b/app/models/transaction.rb
index 6e8cdbca..2caecbe7 100644
--- a/app/models/transaction.rb
+++ b/app/models/transaction.rb
@@ -22,6 +22,27 @@ class Transaction < ApplicationRecord
%w[category account]
end
+ def self.build_filter_list(params, family)
+ filters = []
+
+ if params
+ params.each do |key, value|
+ next if value.blank?
+
+ case key
+ when "account_id_in"
+ value.each do |account_id|
+ filters << { type: "account", value: family.accounts.find(account_id), original: { key: key, value: account_id } }
+ end
+ when "category_name_or_account_name_or_name_cont"
+ filters << { type: "search", value: value, original: { key: key, value: nil } }
+ end
+ end
+ end
+
+ filters
+ end
+
private
def sync_account
diff --git a/app/views/transactions/_filter.html.erb b/app/views/transactions/_filter.html.erb
new file mode 100644
index 00000000..538c4de9
--- /dev/null
+++ b/app/views/transactions/_filter.html.erb
@@ -0,0 +1,23 @@
+<%# locals: (filter:) %>
+
+ <% if filter[:type] == "account" %>
+
+
<%= filter[:value].name[0].upcase %>
+
<%= filter[:value].name %>
+
+ <% elsif filter[:type] == "search" %>
+
+ <%= lucide_icon "text", class: "w-5 h-5 text-gray-500" %>
+
<%= "\"#{filter[:value]}\"".truncate(20) %>
+
+ <% end %>
+ <%= form_with url: search_transactions_path, html: { class: "flex items-center" } do |form| %>
+ <%= form.hidden_field :remove_param, value: filter[:original][:key] %>
+ <% if filter[:original][:value] %>
+ <%= form.hidden_field :remove_param_value, value: filter[:original][:value] %>
+ <% end %>
+ <%= form.button type: "submit", class: "hover:text-gray-900" do %>
+ <%= lucide_icon "x", class: "w-4 h-4 text-gray-500" %>
+ <% end %>
+ <% end %>
+
diff --git a/app/views/transactions/_filters.html.erb b/app/views/transactions/_filters.html.erb
new file mode 100644
index 00000000..5a7d2b7e
--- /dev/null
+++ b/app/views/transactions/_filters.html.erb
@@ -0,0 +1,10 @@
+<%# locals: (filters:) %>
+
+ <%= turbo_frame_tag "transactions_filters" do %>
+
+ <% filters.each do |filter| %>
+ <%= render partial: "transactions/filter", locals: { filter: filter } %>
+ <% end %>
+
+ <% end %>
+
diff --git a/app/views/transactions/_list.html.erb b/app/views/transactions/_list.html.erb
new file mode 100644
index 00000000..b3ae0d58
--- /dev/null
+++ b/app/views/transactions/_list.html.erb
@@ -0,0 +1,34 @@
+<%# locals: (transactions:, pagy:) %>
+
+ <%= turbo_frame_tag "transactions_list" do %>
+ <% if transactions.empty? %>
+
+
No transactions found
+
Try adding a transaction, editing filters or refining your search
+
+ <% else %>
+
+
+ <% transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
+ <%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
+ <% end %>
+
+ <% end %>
+ <% if pagy.pages > 1 %>
+
+ <%= render partial: "transactions/pagination", locals: { pagy: pagy } %>
+
+ <% end %>
+ <% end %>
+
\ No newline at end of file
diff --git a/app/views/transactions/_search_form.html.erb b/app/views/transactions/_search_form.html.erb
new file mode 100644
index 00000000..1622cbf7
--- /dev/null
+++ b/app/views/transactions/_search_form.html.erb
@@ -0,0 +1,46 @@
+<%# locals: (q:) %>
+
+ <%= turbo_frame_tag "transactions_search_form" do %>
+ <%= search_form_for @q, url: search_transactions_path, html: { method: :post, data: { turbo_frame: "transactions_list", "controller": "auto-submit-form" } } do |form| %>
+
+
+ <%= render partial: "transactions/search_form/search_filter", locals: { form: form } %>
+
+
+
+ <%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
+ Filter
+
+
+
+
+ Account
+ Amount
+ Category
+ Merchant
+
+
+
+ <%= render partial: "transactions/search_form/account_filter", locals: { form: form } %>
+
+
+ <%= render partial: "transactions/search_form/amount_filter", locals: { form: form } %>
+
+
+ <%= render partial: "transactions/search_form/category_filter", locals: { form: form } %>
+
+
+ <%= render partial: "transactions/search_form/merchant_filter", locals: { form: form } %>
+
+
+
+
+
<%= q.conditions.reject { |condition| condition.values.any?(&:blank?) }.size %> filters applied
+ <%= button_to "Clear all", search_transactions_path(clear: true), method: :post, class: "text-gray-900 font-medium", form: { "data-turbo-frame": "transactions_list" } %>
+
+
+
+
+ <% end %>
+ <% end %>
+
\ No newline at end of file
diff --git a/app/views/transactions/_summary.html.erb b/app/views/transactions/_summary.html.erb
new file mode 100644
index 00000000..7ff30b38
--- /dev/null
+++ b/app/views/transactions/_summary.html.erb
@@ -0,0 +1,21 @@
+ <%# locals: (totals:) %>
+<%= turbo_frame_tag "transactions_summary" do %>
+
+
+
Total transactions
+
<%= totals[:count] %>
+
+
+
Income
+
+ <%= format_money totals[:income] %>
+
+
+
+
Expenses
+
+ <%= format_money totals[:expense] %>
+
+
+
+<% end %>
diff --git a/app/views/transactions/index.html.erb b/app/views/transactions/index.html.erb
index a03872e7..ee244218 100644
--- a/app/views/transactions/index.html.erb
+++ b/app/views/transactions/index.html.erb
@@ -2,90 +2,20 @@
Transactions
-
- USD $
- <%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
-
-
- <%= lucide_icon("settings-2", class: "cursor-not-allowed w-5 h-5 text-gray-500") %>
- <%= link_to new_transaction_path, class: "rounded-full w-9 h-9 bg-gray-900 text-white flex items-center justify-center hover:bg-gray-700" do %>
+ <%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2" do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
+
New transaction
<% end %>
-
-
-
Total transactions
-
<%= @transactions.size %>
-
-
-
Income
-
- <%= format_money @transactions.inflows.sum(&:amount_money).abs %>
-
-
-
-
Expenses
-
- <%= format_money @transactions.outflows.sum(&:amount_money) %>
-
-
+
+ <%= render partial: "transactions/summary", locals: { totals: @totals } %>
- <%= search_form_for @q, url: transactions_path, method: :get, remote: true, html: { class: "search-form", "data-controller": "transactions-search", "data-action": "keyup->transactions-search#search change->transactions-search#search", "data-turbo-frame": "transactions_list" } do |form| %>
-
-
-
- <%= form.search_field :category_name_or_account_name_or_name_cont, placeholder: "Search transaction by name, category or amount", class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg", "data-transactions-search-target": "search", 'data-action': "input->transactions-search#search" %>
- <%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
-
-
-
-
- <%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
- Filter
-
-
-
- <%= form.select :date, options_for_select([['All', 'all'], ['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"]], selected: params.dig(:q, :date)), {}, { class: "block h-full w-full border border-gray-200 rounded-lg text-sm py-2 pr-8 pl-2", "data-transactions-search-target": "date" } %>
-
- <%= form.hidden_field :date_gteq, value: '', "data-transactions-search-target": "dateGteq" %>
- <%= form.hidden_field :date_lteq, value: '', "data-transactions-search-target": "dateLteq" %>
-
-
- <% end %>
- <%= turbo_frame_tag "transactions_list" do %>
- <% if @transactions.empty? %>
-
-
No transactions found
-
Try adding a transaction, editing filters or refining your search
-
- <% else %>
-
-
- <% @transactions.group_by { |transaction| transaction.date }.each do |date, grouped_transactions| %>
- <%= render partial: "transactions/transaction_group", locals: { date: date, transactions: grouped_transactions } %>
- <% end %>
-
- <% end %>
- <% if @pagy.pages > 1 %>
-
- <%= render partial: "transactions/pagination", locals: { pagy: @pagy } %>
-
- <% end %>
- <% end %>
+ <%= render partial: "transactions/search_form", locals: { q: @q } %>
+ <%= render partial: "transactions/filters", locals: { filters: @filter_list } %>
+ <%= render partial: "transactions/list", locals: { transactions: @transactions, pagy: @pagy } %>
diff --git a/app/views/transactions/search_form/_account_filter.html.erb b/app/views/transactions/search_form/_account_filter.html.erb
new file mode 100644
index 00000000..6a6302e7
--- /dev/null
+++ b/app/views/transactions/search_form/_account_filter.html.erb
@@ -0,0 +1,15 @@
+<%# locals: (form:) %>
+
+
+
+ <%= lucide_icon("search", class: "w-5 h-5 text-gray-500 absolute inset-y-0 left-2 top-1/2 transform -translate-y-1/2") %>
+
+
+ <% Current.family.accounts.each do |account| %>
+
+ <%= form.check_box :account_id_in, { "data-auto-submit-form-target": "auto", multiple: true, id: dom_id(account), class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, account.id, nil %>
+ <%= form.label :account_id_in, account.name, value: account.id, class: "text-sm text-gray-900" %>
+
+ <% end %>
+
+
\ No newline at end of file
diff --git a/app/views/transactions/search_form/_amount_filter.html.erb b/app/views/transactions/search_form/_amount_filter.html.erb
new file mode 100644
index 00000000..54bf8744
--- /dev/null
+++ b/app/views/transactions/search_form/_amount_filter.html.erb
@@ -0,0 +1,4 @@
+<%# locals: (form:) %>
+
diff --git a/app/views/transactions/search_form/_category_filter.html.erb b/app/views/transactions/search_form/_category_filter.html.erb
new file mode 100644
index 00000000..54bf8744
--- /dev/null
+++ b/app/views/transactions/search_form/_category_filter.html.erb
@@ -0,0 +1,4 @@
+<%# locals: (form:) %>
+
diff --git a/app/views/transactions/search_form/_merchant_filter.html.erb b/app/views/transactions/search_form/_merchant_filter.html.erb
new file mode 100644
index 00000000..54bf8744
--- /dev/null
+++ b/app/views/transactions/search_form/_merchant_filter.html.erb
@@ -0,0 +1,4 @@
+<%# locals: (form:) %>
+
diff --git a/app/views/transactions/search_form/_search_filter.html.erb b/app/views/transactions/search_form/_search_filter.html.erb
new file mode 100644
index 00000000..7a6d2ff1
--- /dev/null
+++ b/app/views/transactions/search_form/_search_filter.html.erb
@@ -0,0 +1,8 @@
+<%# locals: (form:) %>
+
+ <%= form.search_field :category_name_or_account_name_or_name_cont,
+ placeholder: "Search transaction by name, category or amount",
+ class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg",
+ "data-auto-submit-form-target": "auto" %>
+ <%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
+
\ No newline at end of file
diff --git a/app/views/transactions/show.html.erb b/app/views/transactions/show.html.erb
index 86524981..06b6892e 100644
--- a/app/views/transactions/show.html.erb
+++ b/app/views/transactions/show.html.erb
@@ -13,7 +13,7 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
- <%= f.date_field :date, label: "Date" %>
+ <%= f.date_field :date, label: "Date", "data-auto-submit-form-target": "auto" %>
<%= f.collection_select :account_id, Current.family.accounts, :id, :name, { prompt: "Select an Account", label: "Account", class: "text-gray-400" }, {class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled"} %>
@@ -25,7 +25,7 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
- <%= f.text_field :name, label: "Name" %>
+ <%= f.text_field :name, label: "Name", "data-auto-submit-form-target": "auto" %>
@@ -36,7 +36,7 @@
- <%= f.check_box :excluded, class: "sr-only peer" %>
+ <%= f.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
Exclude from analytics
This excludes the transaction from any in-app features or analytics.
@@ -52,7 +52,7 @@
<%= lucide_icon("chevron-right", class: "group-open:hidden text-gray-500 w-5 h-5") %>
- <%= f.text_area :notes, label: "Notes", placeholder: "Enter a note" %>
+ <%= f.text_area :notes, label: "Notes", placeholder: "Enter a note", "data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index f2c2b68f..89e7082a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -7,7 +7,10 @@ Rails.application.routes.draw do
resource :password
resource :settings, only: %i[edit update]
- resources :transactions
+ resources :transactions do
+ match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
+ end
+
resources :accounts, shallow: true do
post :sync, on: :member
resources :valuations