From 9956a9540e793251e35aa44eb37150ca42ed1d5c Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Thu, 13 Jun 2024 14:37:27 -0400 Subject: [PATCH] Add institution management and account editing controls (#868) * Add institution management * Allow user to select institution on create or edit * Improve redirect behavior * Final cleanup * i18n normalization --- app/controllers/accounts_controller.rb | 16 +++- app/controllers/institutions_controller.rb | 35 ++++++++ app/helpers/institutions_helper.rb | 5 ++ .../profile_image_preview_controller.js | 2 +- app/models/account.rb | 5 +- app/models/family.rb | 1 + app/models/institution.rb | 7 ++ app/views/accounts/_account.html.erb | 6 +- app/views/accounts/_account_type.html.erb | 2 +- .../accounts/_accountable_group.html.erb | 17 ++++ app/views/accounts/_empty.html.erb | 11 +++ app/views/accounts/_entry_method.html.erb | 2 +- .../accounts/_institution_accounts.html.erb | 69 ++++++++++++++++ .../_institutionless_accounts.html.erb | 17 ++++ app/views/accounts/edit.html.erb | 21 +++++ app/views/accounts/index.html.erb | 77 +++++++----------- app/views/accounts/new.html.erb | 3 +- app/views/accounts/show.html.erb | 14 ++-- app/views/institutions/_form.html.erb | 27 ++++++ app/views/institutions/edit.html.erb | 10 +++ app/views/institutions/new.html.erb | 10 +++ app/views/settings/profiles/show.html.erb | 2 +- app/views/shared/_modal.html.erb | 2 +- config/locales/views/accounts/en.yml | 25 ++++++ config/locales/views/institutions/en.yml | 15 ++++ config/routes.rb | 2 + .../20240612164751_create_institutions.rb | 11 +++ ...40612164944_add_institution_to_accounts.rb | 5 ++ db/schema.rb | 15 +++- test/controllers/accounts_controller_test.rb | 17 +++- .../institutions_controller_test.rb | 55 +++++++++++++ test/fixtures/accounts.yml | 5 ++ test/fixtures/active_storage/attachments.yml | 4 + test/fixtures/active_storage/blobs.yml | 1 + test/fixtures/files/square-placeholder.png | Bin 0 -> 7592 bytes test/fixtures/institutions.yml | 8 ++ 36 files changed, 456 insertions(+), 68 deletions(-) create mode 100644 app/controllers/institutions_controller.rb create mode 100644 app/helpers/institutions_helper.rb create mode 100644 app/models/institution.rb create mode 100644 app/views/accounts/_accountable_group.html.erb create mode 100644 app/views/accounts/_empty.html.erb create mode 100644 app/views/accounts/_institution_accounts.html.erb create mode 100644 app/views/accounts/_institutionless_accounts.html.erb create mode 100644 app/views/accounts/edit.html.erb create mode 100644 app/views/institutions/_form.html.erb create mode 100644 app/views/institutions/edit.html.erb create mode 100644 app/views/institutions/new.html.erb create mode 100644 config/locales/views/institutions/en.yml create mode 100644 db/migrate/20240612164751_create_institutions.rb create mode 100644 db/migrate/20240612164944_add_institution_to_accounts.rb create mode 100644 test/controllers/institutions_controller_test.rb create mode 100644 test/fixtures/active_storage/attachments.yml create mode 100644 test/fixtures/active_storage/blobs.yml create mode 100644 test/fixtures/files/square-placeholder.png create mode 100644 test/fixtures/institutions.yml diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 9fabaebc..94e9bbe3 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -2,11 +2,12 @@ class AccountsController < ApplicationController layout "with_sidebar" include Filterable - before_action :set_account, only: %i[ show destroy sync update ] + before_action :set_account, only: %i[ edit show destroy sync update ] after_action :sync_account, only: :create def index - @accounts = Current.family.accounts + @institutions = Current.family.institutions + @accounts = Current.family.accounts.ungrouped.alphabetically end def summary @@ -26,6 +27,10 @@ class AccountsController < ApplicationController balance: nil, accountable: Accountable.from_type(params[:type])&.new ) + + if params[:institution_id] + @account.institution = Current.family.institutions.find_by(id: params[:institution_id]) + end end def show @@ -33,6 +38,9 @@ class AccountsController < ApplicationController @valuation_series = @account.valuations.to_series end + def edit + end + def update @account.update! account_params.except(:accountable_type) redirect_back_or_to account_path(@account), notice: t(".success") @@ -46,7 +54,7 @@ class AccountsController < ApplicationController start_date: account_params[:start_date], start_balance: account_params[:start_balance] - redirect_to account_path(@account), notice: t(".success") + redirect_back_or_to account_path(@account), notice: t(".success") end def destroy @@ -80,7 +88,7 @@ class AccountsController < ApplicationController end def account_params - params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active) + params.require(:account).permit(:name, :accountable_type, :balance, :start_date, :start_balance, :currency, :subtype, :is_active, :institution_id) end def sync_account diff --git a/app/controllers/institutions_controller.rb b/app/controllers/institutions_controller.rb new file mode 100644 index 00000000..92e9e63d --- /dev/null +++ b/app/controllers/institutions_controller.rb @@ -0,0 +1,35 @@ +class InstitutionsController < ApplicationController + before_action :set_institution, except: %i[ new create ] + + def new + @institution = Institution.new + end + + def create + Current.family.institutions.create!(institution_params) + redirect_to accounts_path, notice: t(".success") + end + + def edit + end + + def update + @institution.update!(institution_params) + redirect_to accounts_path, notice: t(".success") + end + + def destroy + @institution.destroy! + redirect_to accounts_path, notice: t(".success") + end + + private + + def institution_params + params.require(:institution).permit(:name, :logo) + end + + def set_institution + @institution = Current.family.institutions.find(params[:id]) + end +end diff --git a/app/helpers/institutions_helper.rb b/app/helpers/institutions_helper.rb new file mode 100644 index 00000000..3fa70e37 --- /dev/null +++ b/app/helpers/institutions_helper.rb @@ -0,0 +1,5 @@ +module InstitutionsHelper + def institution_logo(institution) + institution.logo.attached? ? institution.logo : institution.logo_url + end +end diff --git a/app/javascript/controllers/profile_image_preview_controller.js b/app/javascript/controllers/profile_image_preview_controller.js index 6118634b..50896625 100644 --- a/app/javascript/controllers/profile_image_preview_controller.js +++ b/app/javascript/controllers/profile_image_preview_controller.js @@ -8,7 +8,7 @@ export default class extends Controller { if (file) { const reader = new FileReader(); reader.onload = (e) => { - this.imagePreviewTarget.innerHTML = `Preview`; + this.imagePreviewTarget.innerHTML = `Preview`; this.templateTarget.classList.add("hidden"); this.clearBtnTarget.classList.remove("hidden"); }; diff --git a/app/models/account.rb b/app/models/account.rb index a41e9b3f..dee4cae7 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -2,10 +2,12 @@ class Account < ApplicationRecord include Syncable include Monetizable + broadcasts_refreshes + validates :family, presence: true - broadcasts_refreshes belongs_to :family + belongs_to :institution, optional: true has_many :balances, dependent: :destroy has_many :valuations, dependent: :destroy has_many :transactions, dependent: :destroy @@ -19,6 +21,7 @@ class Account < ApplicationRecord scope :assets, -> { where(classification: "asset") } scope :liabilities, -> { where(classification: "liability") } scope :alphabetically, -> { order(:name) } + scope :ungrouped, -> { where(institution_id: nil) } delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy diff --git a/app/models/family.rb b/app/models/family.rb index 88c9b022..3b644bac 100644 --- a/app/models/family.rb +++ b/app/models/family.rb @@ -2,6 +2,7 @@ class Family < ApplicationRecord has_many :users, dependent: :destroy has_many :tags, dependent: :destroy has_many :accounts, dependent: :destroy + has_many :institutions, dependent: :destroy has_many :transactions, through: :accounts has_many :imports, through: :accounts has_many :transaction_categories, dependent: :destroy, class_name: "Transaction::Category" diff --git a/app/models/institution.rb b/app/models/institution.rb new file mode 100644 index 00000000..3d9f13db --- /dev/null +++ b/app/models/institution.rb @@ -0,0 +1,7 @@ +class Institution < ApplicationRecord + belongs_to :family + has_many :accounts, dependent: :nullify + has_one_attached :logo + + scope :alphabetically, -> { order(name: :asc) } +end diff --git a/app/views/accounts/_account.html.erb b/app/views/accounts/_account.html.erb index ed3fc6fd..715570c7 100644 --- a/app/views/accounts/_account.html.erb +++ b/app/views/accounts/_account.html.erb @@ -1,10 +1,14 @@ <%= turbo_frame_tag dom_id(account) do %> -
+
"> <%= account.name[0].upcase %>
<%= link_to account.name, account, class: [(account.is_active ? "text-gray-900" : "text-gray-400"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %> + + <%= link_to edit_account_path(account), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %> + <%= lucide_icon "pencil-line", class: "w-4 h-4 text-gray-500" %> + <% end %>

"> diff --git a/app/views/accounts/_account_type.html.erb b/app/views/accounts/_account_type.html.erb index b37ebe29..be85f847 100644 --- a/app/views/accounts/_account_type.html.erb +++ b/app/views/accounts/_account_type.html.erb @@ -1,4 +1,4 @@ -<%= link_to new_account_path(step: "method", type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-25 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-25 rounded-lg p-2" do %> +<%= link_to new_account_path(step: "method", type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-25 border border-transparent focus:border focus:border-gray-200 block px-2 hover:bg-gray-25 rounded-lg p-2" do %> <%= lucide_icon(icon, class: "#{text_color} w-5 h-5") %> diff --git a/app/views/accounts/_accountable_group.html.erb b/app/views/accounts/_accountable_group.html.erb new file mode 100644 index 00000000..199c8fe1 --- /dev/null +++ b/app/views/accounts/_accountable_group.html.erb @@ -0,0 +1,17 @@ +<%# locals: (accounts:) %> + +<% accounts.group_by(&:accountable_type).each do |group, accounts| %> +

+
+

<%= to_accountable_title(Accountable.from_type(group)) %>

+ · +

<%= accounts.count %>

+

<%= format_money accounts.sum(&:balance_money) %>

+
+
+ <% accounts.each do |account| %> + <%= render account %> + <% end %> +
+
+<% end %> diff --git a/app/views/accounts/_empty.html.erb b/app/views/accounts/_empty.html.erb new file mode 100644 index 00000000..e938c863 --- /dev/null +++ b/app/views/accounts/_empty.html.erb @@ -0,0 +1,11 @@ +
+
+ <%= tag.p t(".no_accounts"), class: "text-gray-900 mb-1 font-medium" %> + <%= tag.p t(".empty_message"), class: "text-gray-500 mb-4" %> + + <%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-5 h-5") %> + <%= t(".new_account") %> + <% end %> +
+
diff --git a/app/views/accounts/_entry_method.html.erb b/app/views/accounts/_entry_method.html.erb index ad13d55c..ea73af73 100644 --- a/app/views/accounts/_entry_method.html.erb +++ b/app/views/accounts/_entry_method.html.erb @@ -6,7 +6,7 @@ <%= text %> <% else %> - <%= link_to new_account_path(type: type.class.name.demodulize), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> + <%= link_to new_account_path(type: type.class.name.demodulize, institution_id: params[:institution_id]), class: "flex items-center gap-4 w-full text-center focus:outline-none focus:bg-gray-50 border border-transparent focus:border focus:border-gray-200 px-2 hover:bg-gray-50 rounded-lg p-2" do %> <%= lucide_icon(icon, class: "text-gray-500 w-5 h-5") %> diff --git a/app/views/accounts/_institution_accounts.html.erb b/app/views/accounts/_institution_accounts.html.erb new file mode 100644 index 00000000..ee50e3bb --- /dev/null +++ b/app/views/accounts/_institution_accounts.html.erb @@ -0,0 +1,69 @@ +<%# locals: (institution:) %> + +
+ + <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %> + +
+ <% if institution_logo(institution) %> + <%= image_tag institution_logo(institution), class: "rounded-full h-full w-full" %> + <% else %> +
+ <%= tag.p institution.name.first.upcase, class: "text-blue-600 text-xs font-medium" %> +
+ <% end %> +
+ + <%= link_to institution.name, edit_institution_path(institution), data: { turbo_frame: :modal }, class: "text-sm font-medium text-gray-900 ml-1 mr-auto hover:underline" %> + + <%= contextual_menu do %> +
+ <%= link_to new_account_path(institution_id: institution.id), + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg", + data: { turbo_frame: :modal } do %> + <%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %> + + <%= t(".add_account_to_institution") %> + <% end %> + + <%= link_to edit_institution_path(institution), + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg", + data: { turbo_frame: :modal } do %> + <%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %> + + <%= t(".edit") %> + <% end %> + + <%= button_to institution_path(institution), + method: :delete, + class: "block w-full py-2 px-3 space-x-2 text-red-600 hover:bg-red-50 flex items-center rounded-lg", + data: { + turbo_confirm: { + title: t(".confirm_title"), + body: t(".confirm_body"), + accept: t(".confirm_accept") + } + } do %> + <%= lucide_icon "trash-2", class: "w-5 h-5" %> + + <%= t(".delete") %> + <% end %> +
+ + <% end %> +
+ +
+ <% if institution.accounts.any? %> + <%= render "accountable_group", accounts: institution.accounts %> + <% else %> +
+

There are no accounts in this financial institution

+ <%= link_to new_account_path(institution_id: institution.id), class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-1.5 pr-2", data: { turbo_frame: "modal" } do %> + <%= lucide_icon("plus", class: "w-4 h-4") %> + <%= t(".new_account") %> + <% end %> +
+ <% end %> +
+
diff --git a/app/views/accounts/_institutionless_accounts.html.erb b/app/views/accounts/_institutionless_accounts.html.erb new file mode 100644 index 00000000..335e842e --- /dev/null +++ b/app/views/accounts/_institutionless_accounts.html.erb @@ -0,0 +1,17 @@ +<%# locals: (accounts:) %> + +
+ + <%= lucide_icon "chevron-right", class: "group-open:transform group-open:rotate-90 text-gray-500 w-5" %> + +
+ <%= lucide_icon("folder-pen", class: "w-5 h-5 text-gray-500") %> +
+ + <%= t(".other_accounts") %> +
+ +
+ <%= render "accountable_group", accounts: accounts %> +
+
diff --git a/app/views/accounts/edit.html.erb b/app/views/accounts/edit.html.erb new file mode 100644 index 00000000..9f593f74 --- /dev/null +++ b/app/views/accounts/edit.html.erb @@ -0,0 +1,21 @@ +<%= modal do %> +
+
+

<%= t(".edit", account: @account.name) %>

+ <%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %> +
+ + <%= form_with model: @account, data: { turbo_frame: "_top" } do |f| %> + <%= f.text_field :name, label: "Name" %> + +
+ <%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %> + <%= link_to new_institution_path do %> + <%= lucide_icon "plus", class: "text-gray-700 hover:text-gray-500 w-4 h-4 absolute right-3 top-2" %> + <% end %> +
+ + <%= f.submit %> + <% end %> +
+<% end %> diff --git a/app/views/accounts/index.html.erb b/app/views/accounts/index.html.erb index 914dcfe7..9c5a88b0 100644 --- a/app/views/accounts/index.html.erb +++ b/app/views/accounts/index.html.erb @@ -1,62 +1,45 @@ <% content_for :sidebar do %> <%= render "settings/nav" %> <% end %> +
-
-

Accounts

- <%= link_to new_account_path, class: "flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> - <%= lucide_icon("plus", class: "w-5 h-5") %> - <%= t(".new_account") %> - <% end %> -
- <% if @accounts.empty? %> -
-
-

No accounts yet

-

Add an account either via connection, importing or entering manually.

- <%= link_to new_account_path, class: "w-fit flex text-white text-sm font-medium items-center gap-1 bg-gray-900 rounded-lg p-2 pr-3", data: { turbo_frame: "modal" } do %> +
+

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

+
+
+ <%= contextual_menu do %> +
+ <%= link_to new_institution_path, + class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg font-normal", + data: { turbo_frame: "modal" } do %> + <%= lucide_icon "building-2", class: "w-5 h-5 text-gray-500" %> + <%= t(".add_institution") %> + <% end %> +
+ <% end %> + + <%= link_to new_account_path, + data: { turbo_frame: "modal" }, + 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") %> - <%= t(".new_account") %> +

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

<% end %>
+
+ + <% if @accounts.empty? && @institutions.empty? %> + <%= render "empty" %> <% else %> -
- <% @accounts.by_provider.each do |item| %> -
- - <%= lucide_icon("chevron-down", class: "hidden group-open:block w-5 h-5 text-gray-500") %> - <%= lucide_icon("chevron-right", class: "group-open:hidden w-5 h-5 text-gray-500") %> - <% if item[:name] == "Manual accounts" %> -
- <%= lucide_icon("folder-pen", class: "w-5 h-5 text-gray-500") %> -
- <% end %> - - <%= item[:name] %> - -
-
- <% item[:accounts].each do |group, accounts| %> -
-
-

<%= to_accountable_title(Accountable.from_type(group)) %>

- · -

<%= accounts.count %>

-

<%= format_money accounts.sum(&:balance_money) %>

-
-
- <% accounts.each do |account| %> - <%= render account %> - <% end %> -
-
- <% end %> -
-
+
+ <% @institutions.each do |institution| %> + <%= render "institution_accounts", institution: %> <% end %> + + <%= render "institutionless_accounts", accounts: @accounts %>
<% end %> +
<% if self_hosted? %> <%= previous_setting("Self-Hosting", settings_hosting_path) %> diff --git a/app/views/accounts/new.html.erb b/app/views/accounts/new.html.erb index c0518c00..4bfd6c21 100644 --- a/app/views/accounts/new.html.erb +++ b/app/views/accounts/new.html.erb @@ -76,6 +76,7 @@
<%= f.hidden_field :accountable_type %> <%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %> + <%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %> <%= render "accounts/#{permitted_accountable_partial(@account.accountable_type)}", f: f %> <%= f.money_field :balance_money, label: t(".balance"), required: "required" %> @@ -83,7 +84,7 @@ <%= check_box_tag :add_start_values, class: "maybe-checkbox maybe-checkbox--light peer mb-1" %> <%= label_tag :add_start_values, t(".optional_start_balance_message"), class: "pl-1 text-sm text-gray-500" %> -