diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index f723c63ef..989e44409 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -20,7 +20,7 @@ class Import::ConfigurationsController < ApplicationController end def import_params - params.require(:import).permit( + params.fetch(:import, {}).permit( :date_col_label, :amount_col_label, :name_col_label, diff --git a/app/controllers/import/rows_controller.rb b/app/controllers/import/rows_controller.rb index a3905b148..ecab770bc 100644 --- a/app/controllers/import/rows_controller.rb +++ b/app/controllers/import/rows_controller.rb @@ -12,7 +12,7 @@ class Import::RowsController < ApplicationController private def row_params - params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes) + params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes, :category_color, :category_classification, :category_parent, :category_icon) end def set_import_row diff --git a/app/helpers/imports_helper.rb b/app/helpers/imports_helper.rb index cabc31acb..1ad7587b9 100644 --- a/app/helpers/imports_helper.rb +++ b/app/helpers/imports_helper.rb @@ -22,7 +22,11 @@ module ImportsHelper ticker: "Ticker", exchange: "Exchange", price: "Price", - entity_type: "Type" + entity_type: "Type", + category_parent: "Parent category", + category_color: "Color", + category_classification: "Classification", + category_icon: "Lucide icon" }[key] end @@ -62,7 +66,7 @@ module ImportsHelper private def permitted_import_types - %w[transaction_import trade_import account_import mint_import] + %w[transaction_import trade_import account_import mint_import category_import] end DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true) diff --git a/app/models/category_import.rb b/app/models/category_import.rb new file mode 100644 index 000000000..fadab4520 --- /dev/null +++ b/app/models/category_import.rb @@ -0,0 +1,86 @@ +class CategoryImport < Import + def import! + transaction do + rows.each do |row| + category_name = row.name.to_s.strip + category = family.categories.find_or_initialize_by(name: category_name) + category.color = row.category_color.presence || category.color || Category::UNCATEGORIZED_COLOR + category.classification = row.category_classification.presence || category.classification || "expense" + category.lucide_icon = row.category_icon.presence || category.lucide_icon || "shapes" + category.parent = nil + category.save! + + ensure_placeholder_category(row.category_parent) + end + + rows.each do |row| + category = family.categories.find_by!(name: row.name.to_s.strip) + parent = ensure_placeholder_category(row.category_parent) + + if parent && parent == category + errors.add(:base, "Category '#{category.name}' cannot be its own parent") + raise ActiveRecord::RecordInvalid.new(self) + end + + next if category.parent == parent + + category.update!(parent: parent) + end + end + end + + def column_keys + %i[name category_color category_parent category_classification category_icon] + end + + def required_column_keys + %i[name] + end + + def mapping_steps + [] + end + + def dry_run + { categories: rows.count } + end + + def csv_template + template = <<-CSV + name*,color,parent_category,classification,lucide-icon + Food & Drink,#f97316,,expense,carrot + Groceries,#407706,Food & Drink,expense,shopping-basket + Salary,#22c55e,,income,briefcase + CSV + + CSV.parse(template, headers: true) + end + + def generate_rows_from_csv + rows.destroy_all + + csv_rows.each do |row| + rows.create!( + name: row["name"].to_s.strip, + category_color: row["color"].to_s.strip, + category_parent: row["parent_category"].to_s.strip, + category_classification: row["classification"].to_s.strip, + category_icon: (row["lucide-icon"].presence || row["icon"]).to_s.strip, + currency: default_currency + ) + end + end + + private + + def ensure_placeholder_category(name) + trimmed_name = name.to_s.strip + return if trimmed_name.blank? + + family.categories.find_or_create_by!(name: trimmed_name) do |placeholder| + placeholder.color = Category::UNCATEGORIZED_COLOR + placeholder.classification = "expense" + placeholder.lucide_icon = "shapes" + end + end +end diff --git a/app/models/family/data_exporter.rb b/app/models/family/data_exporter.rb index 93546cd6d..1247097aa 100644 --- a/app/models/family/data_exporter.rb +++ b/app/models/family/data_exporter.rb @@ -101,7 +101,7 @@ class Family::DataExporter def generate_categories_csv CSV.generate do |csv| - csv << [ "name", "color", "parent_category", "classification" ] + csv << [ "name", "color", "parent_category", "classification", "lucide_icon" ] # Only export categories belonging to this family @family.categories.includes(:parent).find_each do |category| @@ -109,7 +109,8 @@ class Family::DataExporter category.name, category.color, category.parent&.name, - category.classification + category.classification, + category.lucide_icon ] end end diff --git a/app/models/import.rb b/app/models/import.rb index b6e25e416..f18c03091 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -2,7 +2,7 @@ class Import < ApplicationRecord MaxRowCountExceededError = Class.new(StandardError) MappingError = Class.new(StandardError) - TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze + TYPES = %w[TransactionImport TradeImport AccountImport MintImport CategoryImport].freeze SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative] SEPARATORS = [ [ "Comma (,)", "," ], [ "Semicolon (;)", ";" ] ].freeze diff --git a/app/views/import/configurations/_category_import.html.erb b/app/views/import/configurations/_category_import.html.erb new file mode 100644 index 000000000..4f0cf2279 --- /dev/null +++ b/app/views/import/configurations/_category_import.html.erb @@ -0,0 +1,14 @@ +<%# locals: (import:) %> + +
<%= t("import.configurations.category_import.description") %>
+ + <%= styled_form_with model: import, + url: import_configuration_path(import), + scope: :import, + method: :patch, + class: "space-y-3" do |form| %> +<%= t("import.configurations.category_import.instructions") %>
+ <%= form.submit t("import.configurations.category_import.button_label"), disabled: import.complete? %> + <% end %> +