mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-21 22:29:38 +02:00
Nested Categories (#1561)
* Prepare entry search for nested categories * Subcategory implementation * Remove caching for test stability
This commit is contained in:
parent
a4d10097d5
commit
77def1db40
31 changed files with 297 additions and 234 deletions
|
@ -10,6 +10,7 @@ class CategoriesController < ApplicationController
|
||||||
|
|
||||||
def new
|
def new
|
||||||
@category = Current.family.categories.new color: Category::COLORS.sample
|
@category = Current.family.categories.new color: Category::COLORS.sample
|
||||||
|
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@ -17,19 +18,21 @@ class CategoriesController < ApplicationController
|
||||||
|
|
||||||
if @category.save
|
if @category.save
|
||||||
@transaction.update(category_id: @category.id) if @transaction
|
@transaction.update(category_id: @category.id) if @transaction
|
||||||
redirect_back_or_to transactions_path, notice: t(".success")
|
|
||||||
|
redirect_back_or_to categories_path, notice: t(".success")
|
||||||
else
|
else
|
||||||
redirect_back_or_to transactions_path, alert: t(".failure", error: @category.errors.full_messages.to_sentence)
|
render :new, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
@categories = Current.family.categories.alphabetically.where(parent_id: nil).where.not(id: @category.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@category.update! category_params
|
@category.update! category_params
|
||||||
|
|
||||||
redirect_back_or_to transactions_path, notice: t(".success")
|
redirect_back_or_to categories_path, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
@ -38,6 +41,12 @@ class CategoriesController < ApplicationController
|
||||||
redirect_back_or_to categories_path, notice: t(".success")
|
redirect_back_or_to categories_path, notice: t(".success")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def bootstrap
|
||||||
|
Current.family.categories.bootstrap_defaults
|
||||||
|
|
||||||
|
redirect_back_or_to categories_path, notice: t(".success")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
def set_category
|
def set_category
|
||||||
@category = Current.family.categories.find(params[:id])
|
@category = Current.family.categories.find(params[:id])
|
||||||
|
@ -50,6 +59,6 @@ class CategoriesController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def category_params
|
def category_params
|
||||||
params.require(:category).permit(:name, :color)
|
params.require(:category).permit(:name, :color, :parent_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -24,7 +24,6 @@ class RegistrationsController < ApplicationController
|
||||||
|
|
||||||
if @user.save
|
if @user.save
|
||||||
@invitation&.update!(accepted_at: Time.current)
|
@invitation&.update!(accepted_at: Time.current)
|
||||||
Category.create_default_categories(@user.family) unless @invitation
|
|
||||||
@session = create_session_for(@user)
|
@session = create_session_for(@user)
|
||||||
redirect_to root_path, notice: t(".success")
|
redirect_to root_path, notice: t(".success")
|
||||||
else
|
else
|
||||||
|
|
|
@ -3,13 +3,13 @@ class TransactionsController < ApplicationController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@q = search_params
|
@q = search_params
|
||||||
result = Current.family.entries.account_transactions.search(@q).reverse_chronological
|
search_query = Current.family.transactions.search(@q).includes(:entryable).reverse_chronological
|
||||||
@pagy, @transaction_entries = pagy(result, limit: params[:per_page] || "50")
|
@pagy, @transaction_entries = pagy(search_query, limit: params[:per_page] || "50")
|
||||||
|
|
||||||
@totals = {
|
@totals = {
|
||||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
count: search_query.select { |t| t.currency == Current.family.currency }.count,
|
||||||
income: result.income_total(Current.family.currency).abs,
|
income: search_query.income_total(Current.family.currency).abs,
|
||||||
expense: result.expense_total(Current.family.currency)
|
expense: search_query.expense_total(Current.family.currency)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -60,6 +60,10 @@ class Account::Entry < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
|
def search(params)
|
||||||
|
Account::EntrySearch.new(params).build_query(all)
|
||||||
|
end
|
||||||
|
|
||||||
# arbitrary cutoff date to avoid expensive sync operations
|
# arbitrary cutoff date to avoid expensive sync operations
|
||||||
def min_supported_date
|
def min_supported_date
|
||||||
30.years.ago.to_date
|
30.years.ago.to_date
|
||||||
|
@ -141,49 +145,7 @@ class Account::Entry < ApplicationRecord
|
||||||
Money.new(total, currency)
|
Money.new(total, currency)
|
||||||
end
|
end
|
||||||
|
|
||||||
def search(params)
|
|
||||||
query = all
|
|
||||||
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
|
|
||||||
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
|
|
||||||
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
|
|
||||||
|
|
||||||
if params[:types].present?
|
|
||||||
query = query.where(marked_as_transfer: false) unless params[:types].include?("transfer")
|
|
||||||
|
|
||||||
if params[:types].include?("income") && !params[:types].include?("expense")
|
|
||||||
query = query.where("account_entries.amount < 0")
|
|
||||||
elsif params[:types].include?("expense") && !params[:types].include?("income")
|
|
||||||
query = query.where("account_entries.amount >= 0")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if params[:amount].present? && params[:amount_operator].present?
|
|
||||||
case params[:amount_operator]
|
|
||||||
when "equal"
|
|
||||||
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", params[:amount].to_f.abs)
|
|
||||||
when "less"
|
|
||||||
query = query.where("ABS(account_entries.amount) < ?", params[:amount].to_f.abs)
|
|
||||||
when "greater"
|
|
||||||
query = query.where("ABS(account_entries.amount) > ?", params[:amount].to_f.abs)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if params[:accounts].present? || params[:account_ids].present?
|
|
||||||
query = query.joins(:account)
|
|
||||||
end
|
|
||||||
|
|
||||||
query = query.where(accounts: { name: params[:accounts] }) if params[:accounts].present?
|
|
||||||
query = query.where(accounts: { id: params[:account_ids] }) if params[:account_ids].present?
|
|
||||||
|
|
||||||
# Search attributes on each entryable to further refine results
|
|
||||||
entryable_ids = entryable_search(params)
|
|
||||||
query = query.where(entryable_id: entryable_ids) unless entryable_ids.nil?
|
|
||||||
|
|
||||||
query
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def entryable_search(params)
|
def entryable_search(params)
|
||||||
entryable_ids = []
|
entryable_ids = []
|
||||||
entryable_search_performed = false
|
entryable_search_performed = false
|
||||||
|
|
59
app/models/account/entry_search.rb
Normal file
59
app/models/account/entry_search.rb
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
class Account::EntrySearch
|
||||||
|
include ActiveModel::Model
|
||||||
|
include ActiveModel::Attributes
|
||||||
|
|
||||||
|
attribute :search, :string
|
||||||
|
attribute :amount, :string
|
||||||
|
attribute :amount_operator, :string
|
||||||
|
attribute :types, :string
|
||||||
|
attribute :accounts, :string
|
||||||
|
attribute :account_ids, :string
|
||||||
|
attribute :start_date, :string
|
||||||
|
attribute :end_date, :string
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def from_entryable_search(entryable_search)
|
||||||
|
new(entryable_search.attributes.slice(*attribute_names))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_query(scope)
|
||||||
|
query = scope
|
||||||
|
|
||||||
|
query = query.where("account_entries.name ILIKE :search OR account_entries.enriched_name ILIKE :search",
|
||||||
|
search: "%#{ActiveRecord::Base.sanitize_sql_like(search)}%"
|
||||||
|
) if search.present?
|
||||||
|
query = query.where("account_entries.date >= ?", start_date) if start_date.present?
|
||||||
|
query = query.where("account_entries.date <= ?", end_date) if end_date.present?
|
||||||
|
|
||||||
|
if types.present?
|
||||||
|
query = query.where(marked_as_transfer: false) unless types.include?("transfer")
|
||||||
|
|
||||||
|
if types.include?("income") && !types.include?("expense")
|
||||||
|
query = query.where("account_entries.amount < 0")
|
||||||
|
elsif types.include?("expense") && !types.include?("income")
|
||||||
|
query = query.where("account_entries.amount >= 0")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if amount.present? && amount_operator.present?
|
||||||
|
case amount_operator
|
||||||
|
when "equal"
|
||||||
|
query = query.where("ABS(ABS(account_entries.amount) - ?) <= 0.01", amount.to_f.abs)
|
||||||
|
when "less"
|
||||||
|
query = query.where("ABS(account_entries.amount) < ?", amount.to_f.abs)
|
||||||
|
when "greater"
|
||||||
|
query = query.where("ABS(account_entries.amount) > ?", amount.to_f.abs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if accounts.present? || account_ids.present?
|
||||||
|
query = query.joins(:account)
|
||||||
|
end
|
||||||
|
|
||||||
|
query = query.where(accounts: { name: accounts }) if accounts.present?
|
||||||
|
query = query.where(accounts: { id: account_ids }) if account_ids.present?
|
||||||
|
|
||||||
|
query
|
||||||
|
end
|
||||||
|
end
|
|
@ -8,26 +8,8 @@ class Account::Trade < ApplicationRecord
|
||||||
validates :qty, presence: true
|
validates :qty, presence: true
|
||||||
validates :price, :currency, presence: true
|
validates :price, :currency, presence: true
|
||||||
|
|
||||||
class << self
|
|
||||||
def search(_params)
|
|
||||||
all
|
|
||||||
end
|
|
||||||
|
|
||||||
def requires_search?(_params)
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sell?
|
|
||||||
qty < 0
|
|
||||||
end
|
|
||||||
|
|
||||||
def buy?
|
|
||||||
qty > 0
|
|
||||||
end
|
|
||||||
|
|
||||||
def unrealized_gain_loss
|
def unrealized_gain_loss
|
||||||
return nil if sell?
|
return nil if qty.negative?
|
||||||
current_price = security.current_price
|
current_price = security.current_price
|
||||||
return nil if current_price.nil?
|
return nil if current_price.nil?
|
||||||
|
|
||||||
|
|
|
@ -12,52 +12,7 @@ class Account::Transaction < ApplicationRecord
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def search(params)
|
def search(params)
|
||||||
query = all
|
Account::TransactionSearch.new(params).build_query(all)
|
||||||
if params[:categories].present?
|
|
||||||
if params[:categories].exclude?("Uncategorized")
|
|
||||||
query = query
|
|
||||||
.joins(:category)
|
|
||||||
.where(categories: { name: params[:categories] })
|
|
||||||
else
|
|
||||||
query = query
|
|
||||||
.left_joins(:category)
|
|
||||||
.where(categories: { name: params[:categories] })
|
|
||||||
.or(query.where(category_id: nil))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
query = query.joins(:merchant).where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
|
||||||
|
|
||||||
if params[:tags].present?
|
|
||||||
query = query.joins(:tags)
|
|
||||||
.where(tags: { name: params[:tags] })
|
|
||||||
.distinct
|
|
||||||
end
|
|
||||||
|
|
||||||
query
|
|
||||||
end
|
|
||||||
|
|
||||||
def requires_search?(params)
|
|
||||||
searchable_keys.any? { |key| params.key?(key) }
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def searchable_keys
|
|
||||||
%i[categories merchants tags]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def eod_balance
|
|
||||||
entry.amount_money
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def account
|
|
||||||
entry.account
|
|
||||||
end
|
|
||||||
|
|
||||||
def daily_transactions
|
|
||||||
account.entries.account_transactions
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
42
app/models/account/transaction_search.rb
Normal file
42
app/models/account/transaction_search.rb
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
class Account::TransactionSearch
|
||||||
|
include ActiveModel::Model
|
||||||
|
include ActiveModel::Attributes
|
||||||
|
|
||||||
|
attribute :search, :string
|
||||||
|
attribute :amount, :string
|
||||||
|
attribute :amount_operator, :string
|
||||||
|
attribute :types, array: true
|
||||||
|
attribute :accounts, array: true
|
||||||
|
attribute :account_ids, array: true
|
||||||
|
attribute :start_date, :string
|
||||||
|
attribute :end_date, :string
|
||||||
|
attribute :categories, array: true
|
||||||
|
attribute :merchants, array: true
|
||||||
|
attribute :tags, array: true
|
||||||
|
|
||||||
|
# Returns array of Account::Entry objects to stay consistent with partials, which only deal with Account::Entry
|
||||||
|
def build_query(scope)
|
||||||
|
query = scope
|
||||||
|
|
||||||
|
if categories.present?
|
||||||
|
if categories.exclude?("Uncategorized")
|
||||||
|
query = query
|
||||||
|
.joins(:category)
|
||||||
|
.where(categories: { name: categories })
|
||||||
|
else
|
||||||
|
query = query
|
||||||
|
.left_joins(:category)
|
||||||
|
.where(categories: { name: categories })
|
||||||
|
.or(query.where(category_id: nil))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
query = query.joins(:merchant).where(merchants: { name: merchants }) if merchants.present?
|
||||||
|
|
||||||
|
query = query.joins(:tags).where(tags: { name: tags }) if tags.present?
|
||||||
|
|
||||||
|
entries_scope = Account::Entry.account_transactions.where(entryable_id: query.select(:id))
|
||||||
|
|
||||||
|
Account::EntrySearch.from_entryable_search(self).build_query(entries_scope)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,13 +1,3 @@
|
||||||
class Account::Valuation < ApplicationRecord
|
class Account::Valuation < ApplicationRecord
|
||||||
include Account::Entryable
|
include Account::Entryable
|
||||||
|
|
||||||
class << self
|
|
||||||
def search(_params)
|
|
||||||
all
|
|
||||||
end
|
|
||||||
|
|
||||||
def requires_search?(_params)
|
|
||||||
false
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,12 +1,16 @@
|
||||||
class Category < ApplicationRecord
|
class Category < ApplicationRecord
|
||||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||||
|
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
|
|
||||||
|
has_many :subcategories, class_name: "Category", foreign_key: :parent_id
|
||||||
|
belongs_to :parent, class_name: "Category", optional: true
|
||||||
|
|
||||||
validates :name, :color, :family, presence: true
|
validates :name, :color, :family, presence: true
|
||||||
validates :name, uniqueness: { scope: :family_id }
|
validates :name, uniqueness: { scope: :family_id }
|
||||||
|
|
||||||
before_update :clear_internal_category, if: :name_changed?
|
validate :category_level_limit
|
||||||
|
|
||||||
scope :alphabetically, -> { order(:name) }
|
scope :alphabetically, -> { order(:name) }
|
||||||
|
|
||||||
|
@ -14,30 +18,55 @@ class Category < ApplicationRecord
|
||||||
|
|
||||||
UNCATEGORIZED_COLOR = "#737373"
|
UNCATEGORIZED_COLOR = "#737373"
|
||||||
|
|
||||||
DEFAULT_CATEGORIES = [
|
class Group
|
||||||
{ internal_category: "income", color: COLORS[0] },
|
attr_reader :category, :subcategories
|
||||||
{ internal_category: "food_and_drink", color: COLORS[1] },
|
|
||||||
{ internal_category: "entertainment", color: COLORS[2] },
|
|
||||||
{ internal_category: "personal_care", color: COLORS[3] },
|
|
||||||
{ internal_category: "general_services", color: COLORS[4] },
|
|
||||||
{ internal_category: "auto_and_transport", color: COLORS[5] },
|
|
||||||
{ internal_category: "rent_and_utilities", color: COLORS[6] },
|
|
||||||
{ internal_category: "home_improvement", color: COLORS[7] }
|
|
||||||
]
|
|
||||||
|
|
||||||
def self.create_default_categories(family)
|
delegate :name, :color, to: :category
|
||||||
if family.categories.size > 0
|
|
||||||
raise ArgumentError, "Family already has some categories"
|
def self.for(categories)
|
||||||
|
categories.select { |category| category.parent_id.nil? }.map do |category|
|
||||||
|
new(category, category.subcategories)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
family_id = family.id
|
def initialize(category, subcategories = nil)
|
||||||
categories = self::DEFAULT_CATEGORIES.map { |c| {
|
@category = category
|
||||||
name: I18n.t("transaction.default_category.#{c[:internal_category]}"),
|
@subcategories = subcategories || []
|
||||||
internal_category: c[:internal_category],
|
end
|
||||||
color: c[:color],
|
end
|
||||||
family_id:
|
|
||||||
} }
|
class << self
|
||||||
self.insert_all(categories)
|
def bootstrap_defaults
|
||||||
|
default_categories.each do |name, color|
|
||||||
|
find_or_create_by!(name: name) do |category|
|
||||||
|
category.color = color
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def default_categories
|
||||||
|
[
|
||||||
|
[ "Income", "#e99537" ],
|
||||||
|
[ "Loan Payments", "#6471eb" ],
|
||||||
|
[ "Bank Fees", "#db5a54" ],
|
||||||
|
[ "Entertainment", "#df4e92" ],
|
||||||
|
[ "Food & Drink", "#c44fe9" ],
|
||||||
|
[ "Groceries", "#eb5429" ],
|
||||||
|
[ "Dining Out", "#61c9ea" ],
|
||||||
|
[ "General Merchandise", "#805dee" ],
|
||||||
|
[ "Clothing & Accessories", "#6ad28a" ],
|
||||||
|
[ "Electronics", "#e99537" ],
|
||||||
|
[ "Healthcare", "#4da568" ],
|
||||||
|
[ "Insurance", "#6471eb" ],
|
||||||
|
[ "Utilities", "#db5a54" ],
|
||||||
|
[ "Transportation", "#df4e92" ],
|
||||||
|
[ "Gas & Fuel", "#c44fe9" ],
|
||||||
|
[ "Education", "#eb5429" ],
|
||||||
|
[ "Charitable Donations", "#61c9ea" ],
|
||||||
|
[ "Subscriptions", "#805dee" ]
|
||||||
|
]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def replace_and_destroy!(replacement)
|
def replace_and_destroy!(replacement)
|
||||||
|
@ -47,9 +76,14 @@ class Category < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def subcategory?
|
||||||
|
parent.present?
|
||||||
|
end
|
||||||
|
|
||||||
def clear_internal_category
|
private
|
||||||
self.internal_category = nil
|
def category_level_limit
|
||||||
|
if subcategory? && parent.subcategory?
|
||||||
|
errors.add(:parent, "can't have more than 2 levels of subcategories")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -90,6 +90,11 @@ class Demo::Generator
|
||||||
categories.each do |category|
|
categories.each do |category|
|
||||||
family.categories.create!(name: category, color: COLORS.sample)
|
family.categories.create!(name: category, color: COLORS.sample)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
food = family.categories.find_by(name: "Food & Drink")
|
||||||
|
family.categories.create!(name: "Restaurants", parent_category: food)
|
||||||
|
family.categories.create!(name: "Groceries", parent_category: food)
|
||||||
|
family.categories.create!(name: "Alcohol & Bars", parent_category: food)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_merchants!
|
def create_merchants!
|
||||||
|
|
|
@ -34,7 +34,7 @@
|
||||||
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
|
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if trade.buy? %>
|
<% if trade.qty.positive? %>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
|
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
|
||||||
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
|
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
|
||||||
|
@ -53,7 +53,7 @@
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
|
<% if trade.qty.positive? && trade.unrealized_gain_loss.present? %>
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
|
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
|
||||||
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
|
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<% category ||= null_category %>
|
<% category ||= null_category %>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border"
|
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
|
||||||
style="
|
style="
|
||||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
||||||
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
|
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
|
||||||
|
|
|
@ -1,7 +1,11 @@
|
||||||
<%# locals: (category:) %>
|
<%# locals: (category:) %>
|
||||||
|
|
||||||
<div id="<%= dom_id(category) %>" class="flex justify-between items-center p-4 bg-white">
|
<div id="<%= dom_id(category) %>" class="flex justify-between items-center px-4 pb-4 <%= "pt-4" unless category.subcategory? %> <%= "pb-4" unless category.subcategories.any? %> bg-white">
|
||||||
<div class="flex w-full items-center gap-2.5">
|
<div class="flex w-full items-center gap-2.5">
|
||||||
|
<% if category.subcategory? %>
|
||||||
|
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400 ml-2" %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<%= render partial: "categories/badge", locals: { category: category } %>
|
<%= render partial: "categories/badge", locals: { category: category } %>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-self-end">
|
<div class="justify-self-end">
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
|
<%# locals: (category:, categories:) %>
|
||||||
|
|
||||||
<div data-controller="color-avatar">
|
<div data-controller="color-avatar">
|
||||||
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
|
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<div class="w-fit m-auto">
|
<div class="w-fit m-auto">
|
||||||
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2 items-center justify-center">
|
<div class="flex gap-2 items-center justify-center">
|
||||||
<% Category::COLORS.each do |color| %>
|
<% Category::COLORS.each do |color| %>
|
||||||
<label class="relative">
|
<label class="relative">
|
||||||
|
@ -12,8 +15,14 @@
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
|
||||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, data: { color_avatar_target: "name" } %>
|
<% if category.errors.any? %>
|
||||||
|
<%= render "shared/form_errors", model: category %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="space-y-2">
|
||||||
|
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||||
|
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
|
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
|
||||||
</button>
|
</button>
|
||||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
<div class="w-80 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||||
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
|
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
|
||||||
<div class="p-6 flex items-center justify-center">
|
<div class="p-6 flex items-center justify-center">
|
||||||
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
<%= modal_form_wrapper title: t(".edit") do %>
|
<%= modal_form_wrapper title: t(".edit") do %>
|
||||||
<%= render "form", category: @category %>
|
<%= render "form", category: @category, categories: @categories %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -23,20 +23,34 @@
|
||||||
|
|
||||||
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||||
<div class="overflow-hidden rounded-md">
|
<div class="overflow-hidden rounded-md">
|
||||||
<%= render partial: @categories, spacer_template: "categories/ruler" %>
|
<% Category::Group.for(@categories).each_with_index do |group, idx| %>
|
||||||
|
<%= render group.category %>
|
||||||
|
|
||||||
|
<% group.subcategories.each do |subcategory| %>
|
||||||
|
<%= render subcategory %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% unless idx == Category::Group.for(@categories).count - 1 %>
|
||||||
|
<%= render "categories/ruler" %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="flex justify-center items-center py-20">
|
<div class="flex justify-center items-center py-20">
|
||||||
<div class="text-center flex flex-col items-center max-w-[300px]">
|
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".empty") %></p>
|
<p class="text-sm text-gray-500 mb-4"><%= t(".empty") %></p>
|
||||||
<%= link_to new_category_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 %>
|
<div class="flex items-center gap-2">
|
||||||
|
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--primary" %>
|
||||||
|
|
||||||
|
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
|
||||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||||
<span><%= t(".new") %></span>
|
<span><%= t(".new") %></span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
<%= modal_form_wrapper title: t(".new_category") do %>
|
<%= modal_form_wrapper title: t(".new_category") do %>
|
||||||
<%= render "form", category: @category %>
|
<%= render "form", category: @category, categories: @categories %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
@ -15,6 +15,9 @@
|
||||||
<span class="w-5 h-5">
|
<span class="w-5 h-5">
|
||||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||||
</span>
|
</span>
|
||||||
|
<% if category.subcategory? %>
|
||||||
|
<%= lucide_icon "corner-down-right", class: "shrink-0 w-5 h-5 text-gray-400" %>
|
||||||
|
<% end %>
|
||||||
<%= render partial: "categories/badge", locals: { category: category } %>
|
<%= render partial: "categories/badge", locals: { category: category } %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
|
|
@ -10,8 +10,21 @@
|
||||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||||
<%= t(".no_categories") %>
|
<%= t(".no_categories") %>
|
||||||
</div>
|
</div>
|
||||||
<% @categories.each do |category| %>
|
<% if @categories.any? %>
|
||||||
<%= render partial: "category/dropdowns/row", locals: { category: } %>
|
<% Category::Group.for(@categories).each do |group| %>
|
||||||
|
<%= render "category/dropdowns/row", category: group.category %>
|
||||||
|
|
||||||
|
<% group.subcategories.each do |category| %>
|
||||||
|
<%= render "category/dropdowns/row", category: category %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<div class="flex justify-center items-center py-12">
|
||||||
|
<div class="text-center flex flex-col items-center max-w-[500px]">
|
||||||
|
<p class="text-sm text-gray-500 font-normal mb-4"><%= t(".empty") %></p>
|
||||||
|
<%= button_to t(".bootstrap"), bootstrap_categories_path, class: "btn btn--outline", data: { turbo_frame: :_top } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
---
|
|
||||||
en:
|
|
||||||
transaction:
|
|
||||||
default_category:
|
|
||||||
auto_and_transport: Auto & Transport
|
|
||||||
entertainment: Entertainment
|
|
||||||
food_and_drink: Food & Drink
|
|
||||||
general_services: General Services
|
|
||||||
home_improvement: Home Improvement
|
|
||||||
income: Income
|
|
||||||
personal_care: Personal Care
|
|
||||||
rent_and_utilities: Rent & Utilities
|
|
|
@ -1,12 +1,16 @@
|
||||||
---
|
---
|
||||||
en:
|
en:
|
||||||
|
category:
|
||||||
|
dropdowns:
|
||||||
|
show:
|
||||||
|
empty: No categories found
|
||||||
|
bootstrap: Generate default categories
|
||||||
categories:
|
categories:
|
||||||
category:
|
category:
|
||||||
delete: Delete category
|
delete: Delete category
|
||||||
edit: Edit category
|
edit: Edit category
|
||||||
create:
|
create:
|
||||||
failure: 'Failed to create category: %{error}'
|
success: Category created successfully
|
||||||
success: New transaction category created successfully
|
|
||||||
destroy:
|
destroy:
|
||||||
success: Category deleted successfully
|
success: Category deleted successfully
|
||||||
edit:
|
edit:
|
||||||
|
@ -17,9 +21,12 @@ en:
|
||||||
categories: Categories
|
categories: Categories
|
||||||
empty: No categories found
|
empty: No categories found
|
||||||
new: New category
|
new: New category
|
||||||
|
bootstrap: Use default categories
|
||||||
|
bootstrap:
|
||||||
|
success: Default categories created successfully
|
||||||
menu:
|
menu:
|
||||||
loading: Loading...
|
loading: Loading...
|
||||||
new:
|
new:
|
||||||
new_category: New category
|
new_category: New category
|
||||||
update:
|
update:
|
||||||
success: Transaction category updated successfully
|
success: Category updated successfully
|
||||||
|
|
|
@ -40,6 +40,8 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
resources :categories do
|
resources :categories do
|
||||||
resources :deletions, only: %i[new create], module: :category
|
resources :deletions, only: %i[new create], module: :category
|
||||||
|
|
||||||
|
post :bootstrap, on: :collection
|
||||||
end
|
end
|
||||||
|
|
||||||
resources :merchants, only: %i[index new create edit update destroy]
|
resources :merchants, only: %i[index new create edit update destroy]
|
||||||
|
|
6
db/migrate/20241219174803_add_parent_category.rb
Normal file
6
db/migrate/20241219174803_add_parent_category.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class AddParentCategory < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :categories, :parent_id, :uuid
|
||||||
|
remove_column :categories, :internal_category, :string
|
||||||
|
end
|
||||||
|
end
|
6
db/schema.rb
generated
6
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_12_18_132503) do
|
ActiveRecord::Schema[7.2].define(version: 2024_12_19_174803) do
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -110,7 +110,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_18_132503) do
|
||||||
t.decimal "balance", precision: 19, scale: 4
|
t.decimal "balance", precision: 19, scale: 4
|
||||||
t.string "currency"
|
t.string "currency"
|
||||||
t.boolean "is_active", default: true, null: false
|
t.boolean "is_active", default: true, null: false
|
||||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||||
t.uuid "import_id"
|
t.uuid "import_id"
|
||||||
t.uuid "plaid_account_id"
|
t.uuid "plaid_account_id"
|
||||||
t.boolean "scheduled_for_deletion", default: false
|
t.boolean "scheduled_for_deletion", default: false
|
||||||
|
@ -171,10 +171,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_12_18_132503) do
|
||||||
create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "categories", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "color", default: "#6172F3", null: false
|
t.string "color", default: "#6172F3", null: false
|
||||||
t.string "internal_category"
|
|
||||||
t.uuid "family_id", null: false
|
t.uuid "family_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.uuid "parent_id"
|
||||||
t.index ["family_id"], name: "index_categories_on_family_id"
|
t.index ["family_id"], name: "index_categories_on_family_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -28,7 +28,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
new_category = Category.order(:created_at).last
|
new_category = Category.order(:created_at).last
|
||||||
|
|
||||||
assert_redirected_to transactions_url
|
assert_redirected_to categories_url
|
||||||
assert_equal "New Category", new_category.name
|
assert_equal "New Category", new_category.name
|
||||||
assert_equal color, new_category.color
|
assert_equal color, new_category.color
|
||||||
end
|
end
|
||||||
|
@ -46,7 +46,7 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
new_category = Category.order(:created_at).last
|
new_category = Category.order(:created_at).last
|
||||||
|
|
||||||
assert_redirected_to transactions_url
|
assert_redirected_to categories_url
|
||||||
assert_equal "New Category", new_category.name
|
assert_equal "New Category", new_category.name
|
||||||
assert_equal color, new_category.color
|
assert_equal color, new_category.color
|
||||||
assert_equal @transaction.reload.category, new_category
|
assert_equal @transaction.reload.category, new_category
|
||||||
|
@ -69,6 +69,14 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
assert_redirected_to transactions_url
|
assert_redirected_to categories_url
|
||||||
|
end
|
||||||
|
|
||||||
|
test "bootstrap" do
|
||||||
|
assert_difference "Category.count", 16 do
|
||||||
|
post bootstrap_categories_url
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_redirected_to categories_url
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,15 +15,6 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_redirected_to root_url
|
assert_redirected_to root_url
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create seeds default transaction categories" do
|
|
||||||
assert_difference "Category.count", Category::DEFAULT_CATEGORIES.size do
|
|
||||||
post registration_url, params: { user: {
|
|
||||||
email: "john@example.com",
|
|
||||||
password: "password",
|
|
||||||
password_confirmation: "password" } }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "create when hosted requires an invite code" do
|
test "create when hosted requires an invite code" do
|
||||||
with_env_overrides REQUIRE_INVITE_CODE: "true" do
|
with_env_overrides REQUIRE_INVITE_CODE: "true" do
|
||||||
assert_no_difference "User.count" do
|
assert_no_difference "User.count" do
|
||||||
|
|
7
test/fixtures/categories.yml
vendored
7
test/fixtures/categories.yml
vendored
|
@ -4,11 +4,14 @@ one:
|
||||||
|
|
||||||
income:
|
income:
|
||||||
name: Income
|
name: Income
|
||||||
internal_category: income
|
|
||||||
color: "#fd7f6f"
|
color: "#fd7f6f"
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
|
|
||||||
food_and_drink:
|
food_and_drink:
|
||||||
name: Food & Drink
|
name: Food & Drink
|
||||||
internal_category: food_and_drink
|
family: dylan_family
|
||||||
|
|
||||||
|
subcategory:
|
||||||
|
name: Restaurants
|
||||||
|
parent: food_and_drink
|
||||||
family: dylan_family
|
family: dylan_family
|
||||||
|
|
|
@ -63,10 +63,6 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_equal 2, family.entries.search(params).size
|
assert_equal 2, family.entries.search(params).size
|
||||||
|
|
||||||
params = params.merge(categories: [ category.name ], merchants: [ merchant.name ]) # transaction specific search param
|
|
||||||
|
|
||||||
assert_equal 1, family.entries.search(params).size
|
|
||||||
|
|
||||||
params = { search: "%" }
|
params = { search: "%" }
|
||||||
assert_equal 0, family.entries.search(params).size
|
assert_equal 0, family.entries.search(params).size
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,34 +5,6 @@ class CategoryTest < ActiveSupport::TestCase
|
||||||
@family = families(:dylan_family)
|
@family = families(:dylan_family)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create_default_categories should generate categories if none exist" do
|
|
||||||
@family.accounts.destroy_all
|
|
||||||
@family.categories.destroy_all
|
|
||||||
assert_difference "Category.count", Category::DEFAULT_CATEGORIES.size do
|
|
||||||
Category.create_default_categories(@family)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "create_default_categories should raise when there are existing categories" do
|
|
||||||
assert_raises(ArgumentError) do
|
|
||||||
Category.create_default_categories(@family)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "updating name should clear the internal_category field" do
|
|
||||||
category = categories(:income)
|
|
||||||
assert_changes "category.reload.internal_category", to: nil do
|
|
||||||
category.update_attribute(:name, "new name")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "updating other field than name should not clear the internal_category field" do
|
|
||||||
category = Category.take
|
|
||||||
assert_no_changes "category.reload.internal_category" do
|
|
||||||
category.update_attribute(:color, "#000")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "replacing and destroying" do
|
test "replacing and destroying" do
|
||||||
transactions = categories(:food_and_drink).transactions.to_a
|
transactions = categories(:food_and_drink).transactions.to_a
|
||||||
|
|
||||||
|
@ -48,4 +20,14 @@ class CategoryTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_nil transactions.map { |t| t.reload.category }.uniq.first
|
assert_nil transactions.map { |t| t.reload.category }.uniq.first
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "subcategory can only be one level deep" do
|
||||||
|
category = categories(:subcategory)
|
||||||
|
|
||||||
|
error = assert_raises(ActiveRecord::RecordInvalid) do
|
||||||
|
category.subcategories.create!(name: "Invalid category", color: "#000", family: @family)
|
||||||
|
end
|
||||||
|
|
||||||
|
assert_equal "Validation failed: Parent can't have more than 2 levels of subcategories", error.message
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue