diff --git a/app/controllers/import/configurations_controller.rb b/app/controllers/import/configurations_controller.rb index 3b9ca5b5..f723c63e 100644 --- a/app/controllers/import/configurations_controller.rb +++ b/app/controllers/import/configurations_controller.rb @@ -36,7 +36,9 @@ class Import::ConfigurationsController < ApplicationController :currency_col_label, :date_format, :number_format, - :signage_convention + :signage_convention, + :amount_type_strategy, + :amount_type_inflow_value, ) end end diff --git a/app/javascript/controllers/import_controller.js b/app/javascript/controllers/import_controller.js new file mode 100644 index 00000000..7c555d3f --- /dev/null +++ b/app/javascript/controllers/import_controller.js @@ -0,0 +1,118 @@ +import { Controller } from "@hotwired/stimulus"; + +// Connects to data-controller="import" +export default class extends Controller { + static values = { + csv: { type: Array, default: [] }, + amountTypeColumnKey: { type: String, default: "" }, + }; + + static targets = [ + "signedAmountFieldset", + "customColumnFieldset", + "amountTypeValue", + "amountTypeStrategySelect", + ]; + + connect() { + if ( + this.amountTypeStrategySelectTarget.value === "custom_column" && + this.amountTypeColumnKeyValue + ) { + this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue); + } + } + + handleAmountTypeStrategyChange(event) { + const amountTypeStrategy = event.target.value; + + if (amountTypeStrategy === "custom_column") { + this.#enableCustomColumnFieldset(); + + if (this.amountTypeColumnKeyValue) { + this.#showAmountTypeValueTargets(this.amountTypeColumnKeyValue); + } + } + + if (amountTypeStrategy === "signed_amount") { + this.#enableSignedAmountFieldset(); + } + } + + handleAmountTypeChange(event) { + const amountTypeColumnKey = event.target.value; + + this.#showAmountTypeValueTargets(amountTypeColumnKey); + } + + #showAmountTypeValueTargets(amountTypeColumnKey) { + const selectableValues = this.#uniqueValuesForColumn(amountTypeColumnKey); + + this.amountTypeValueTarget.classList.remove("hidden"); + this.amountTypeValueTarget.classList.add("flex"); + + const select = this.amountTypeValueTarget.querySelector("select"); + const currentValue = select.value; + select.options.length = 0; + const fragment = document.createDocumentFragment(); + + // Only add the prompt if there's no current value + if (!currentValue) { + fragment.appendChild(new Option("Select value", "")); + } + + selectableValues.forEach((value) => { + const option = new Option(value, value); + if (value === currentValue) { + option.selected = true; + } + fragment.appendChild(option); + }); + + select.appendChild(fragment); + } + + #uniqueValuesForColumn(column) { + const colIdx = this.csvValue[0].indexOf(column); + const values = this.csvValue.slice(1).map((row) => row[colIdx]); + return [...new Set(values)]; + } + + #enableCustomColumnFieldset() { + this.customColumnFieldsetTarget.classList.remove("hidden"); + this.signedAmountFieldsetTarget.classList.add("hidden"); + + // Set required on custom column fields + this.customColumnFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.setAttribute("required", ""); + }); + + // Remove required from signed amount fields + this.signedAmountFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.removeAttribute("required"); + }); + } + + #enableSignedAmountFieldset() { + this.customColumnFieldsetTarget.classList.add("hidden"); + this.signedAmountFieldsetTarget.classList.remove("hidden"); + + // Remove required from custom column fields + this.customColumnFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.removeAttribute("required"); + }); + + // Set required on signed amount fields + this.signedAmountFieldsetTarget + .querySelectorAll("select, input") + .forEach((field) => { + field.setAttribute("required", ""); + }); + } +} diff --git a/app/models/import.rb b/app/models/import.rb index bb367cf9..3ea68015 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -10,6 +10,8 @@ class Import < ApplicationRecord "1,234" => { separator: "", delimiter: "," } # Zero-decimal currencies like JPY }.freeze + AMOUNT_TYPE_STRATEGIES = %w[signed_amount custom_column].freeze + belongs_to :family belongs_to :account, optional: true @@ -27,8 +29,9 @@ class Import < ApplicationRecord }, validate: true, default: "pending" validates :type, inclusion: { in: TYPES } + validates :amount_type_strategy, inclusion: { in: AMOUNT_TYPE_STRATEGIES } validates :col_sep, inclusion: { in: SEPARATORS.map(&:last) } - validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS } + validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }, allow_nil: true validates :number_format, presence: true, inclusion: { in: NUMBER_FORMATS.keys } has_many :rows, dependent: :destroy diff --git a/app/models/import/row.rb b/app/models/import/row.rb index 350d8084..0d8d9aba 100644 --- a/app/models/import/row.rb +++ b/app/models/import/row.rb @@ -44,7 +44,19 @@ class Import::Row < ApplicationRecord # In the Maybe system, positive amounts == "outflows", so we must reverse signage def apply_transaction_signage_convention(value) - value * (import.signage_convention == "inflows_positive" ? -1 : 1) + if import.amount_type_strategy == "signed_amount" + value * (import.signage_convention == "inflows_positive" ? -1 : 1) + elsif import.amount_type_strategy == "custom_column" + inflow_value = import.amount_type_inflow_value + + if entity_type == inflow_value + value * -1 + else + value + end + else + raise "Unknown amount type strategy for import: #{import.amount_type_strategy}" + end end def required_columns diff --git a/app/models/trade_import.rb b/app/models/trade_import.rb index d126fec0..04a6a4cf 100644 --- a/app/models/trade_import.rb +++ b/app/models/trade_import.rb @@ -31,6 +31,7 @@ class TradeImport < Import ), ) end + Trade.import!(trades, recursive: true) end end diff --git a/app/models/transaction_import.rb b/app/models/transaction_import.rb index dd44cca4..d233c2e4 100644 --- a/app/models/transaction_import.rb +++ b/app/models/transaction_import.rb @@ -48,6 +48,12 @@ class TransactionImport < Import base end + def selectable_amount_type_values + return [] if entity_type_col_label.nil? + + csv_rows.map { |row| row[entity_type_col_label] }.uniq + end + def csv_template template = <<-CSV date*,amount*,name,currency,category,tags,account,notes diff --git a/app/views/import/configurations/_account_import.html.erb b/app/views/import/configurations/_account_import.html.erb index afcd3e8b..7f6ff6f2 100644 --- a/app/views/import/configurations/_account_import.html.erb +++ b/app/views/import/configurations/_account_import.html.erb @@ -1,10 +1,10 @@ <%# locals: (import:) %> -<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> +<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-4" do |form| %> <%= form.select :entity_type_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Entity Type" } %> - <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %> - <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" } %> - <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Currency" } %> + <%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name" }, required: true %> + <%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Balance" }, required: true %> + <%= form.select :currency_col_label, import.csv_headers, { include_blank: "Default", label: "Currency" } %> <%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %> <% end %> diff --git a/app/views/import/configurations/_mint_import.html.erb b/app/views/import/configurations/_mint_import.html.erb index 987f2fed..e1271c4a 100644 --- a/app/views/import/configurations/_mint_import.html.erb +++ b/app/views/import/configurations/_mint_import.html.erb @@ -5,18 +5,18 @@
We have pre-configured your Mint import for you. Please proceed to the next step.
-<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %> -<%= t(".description") %>