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 %> +
+
+

transaction

+
+
+

category

+
+
+

account

+

amount

+
+
+
+ <% 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 %> + + <% 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 } %> +
+
+ + +
+
+ <% 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") %> -
-
-
- -
-
- <%= 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 %> -
-
-

transaction

-
-
-

category

-
-
-

account

-

amount

-
-
-
- <% @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 %> - - <% 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:) %> +
+

Coming soon...

+
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:) %> +
+

Coming soon...

+
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:) %> +
+

Coming soon...

+
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 @@
<% 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