Compare commits
37 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ebcb6fc41 | ||
|
|
24d3c0243f | ||
|
|
e8d7ee3270 | ||
|
|
73d61fc990 | ||
|
|
1ffa13f3b3 | ||
|
|
82c298307d | ||
|
|
ab40289eb4 | ||
|
|
7fabca4679 | ||
|
|
cb75c537fe | ||
|
|
b1d2dc5e97 | ||
|
|
c3c0ab3530 | ||
|
|
fa3b1e016c | ||
|
|
398b246965 | ||
|
|
23786b444a | ||
|
|
edbf4eb3d6 | ||
|
|
367073f046 | ||
|
|
2cb3d806d8 | ||
|
|
3dd0aa2f37 | ||
|
|
2b9a7fdef3 | ||
|
|
cb14ef7655 | ||
|
|
73ceebccc2 | ||
|
|
17f29de773 | ||
|
|
60fadc1d68 | ||
|
|
5eaf335c49 | ||
|
|
be8f74b093 | ||
|
|
b4b4e5df31 | ||
|
|
5942ce7e3c | ||
|
|
730e58d763 | ||
|
|
e06f0c76f9 | ||
|
|
aa6d755402 | ||
|
|
fd40111264 | ||
|
|
fc0bc1ac96 | ||
|
|
b7e3c61d09 | ||
|
|
8181781570 | ||
|
|
5a5e27685a | ||
|
|
cc1954b33b | ||
|
|
9bb9a062ac |
78
Gemfile.lock
78
Gemfile.lock
@@ -82,20 +82,20 @@ GEM
|
||||
public_suffix (>= 2.0.2, < 6.0)
|
||||
ast (2.4.2)
|
||||
aws-eventstream (1.3.0)
|
||||
aws-partitions (1.971.0)
|
||||
aws-sdk-core (3.203.0)
|
||||
aws-partitions (1.981.0)
|
||||
aws-sdk-core (3.209.1)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.651.0)
|
||||
aws-sigv4 (~> 1.9)
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
aws-sdk-kms (1.89.0)
|
||||
aws-sdk-core (~> 3, >= 3.203.0)
|
||||
aws-sdk-kms (1.94.0)
|
||||
aws-sdk-core (~> 3, >= 3.207.0)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.160.0)
|
||||
aws-sdk-core (~> 3, >= 3.203.0)
|
||||
aws-sdk-s3 (1.166.0)
|
||||
aws-sdk-core (~> 3, >= 3.207.0)
|
||||
aws-sdk-kms (~> 1)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sigv4 (1.9.1)
|
||||
aws-sigv4 (1.10.0)
|
||||
aws-eventstream (~> 1, >= 1.0.2)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
@@ -136,9 +136,9 @@ GEM
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
docile (1.4.0)
|
||||
dotenv (3.1.2)
|
||||
dotenv-rails (3.1.2)
|
||||
dotenv (= 3.1.2)
|
||||
dotenv (3.1.4)
|
||||
dotenv-rails (3.1.4)
|
||||
dotenv (= 3.1.4)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.1)
|
||||
erb_lint (0.6.0)
|
||||
@@ -153,8 +153,9 @@ GEM
|
||||
tzinfo
|
||||
faker (3.4.2)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.11.0)
|
||||
faraday (2.12.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
json
|
||||
logger
|
||||
faraday-net_http (3.3.0)
|
||||
net-http
|
||||
@@ -171,7 +172,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.2.1)
|
||||
good_job (4.3.0)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -184,7 +185,7 @@ GEM
|
||||
actioncable (>= 6.0.0)
|
||||
listen (>= 3.0.0)
|
||||
railties (>= 6.0.0)
|
||||
i18n (1.14.5)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
i18n-tasks (1.0.14)
|
||||
activesupport (>= 4.0.2)
|
||||
@@ -207,7 +208,7 @@ GEM
|
||||
activesupport (>= 3.0)
|
||||
nokogiri (>= 1.6)
|
||||
io-console (0.7.2)
|
||||
irb (1.14.0)
|
||||
irb (1.14.1)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
@@ -265,14 +266,14 @@ GEM
|
||||
octokit (9.1.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
pagy (9.0.8)
|
||||
pagy (9.0.9)
|
||||
parallel (1.25.1)
|
||||
parser (3.3.4.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pg (1.5.8)
|
||||
prism (0.30.0)
|
||||
propshaft (0.9.1)
|
||||
prism (1.0.0)
|
||||
propshaft (1.0.1)
|
||||
actionpack (>= 7.0.0)
|
||||
activesupport (>= 7.0.0)
|
||||
rack
|
||||
@@ -280,7 +281,7 @@ GEM
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (5.1.0)
|
||||
puma (6.4.2)
|
||||
puma (6.4.3)
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.1)
|
||||
@@ -332,7 +333,7 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.11.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.5.2)
|
||||
rbs (3.5.3)
|
||||
logger
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
@@ -340,8 +341,7 @@ GEM
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.10)
|
||||
io-console (~> 0.5)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rexml (3.3.7)
|
||||
rubocop (1.65.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
@@ -371,13 +371,13 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.17.13)
|
||||
ruby-lsp (0.18.1)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
prism (~> 1.0)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.13)
|
||||
ruby-lsp (>= 0.17.12, < 0.18.0)
|
||||
ruby-lsp-rails (0.3.16)
|
||||
ruby-lsp (>= 0.18.0, < 0.19.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.2)
|
||||
ffi (~> 1.12)
|
||||
@@ -388,16 +388,16 @@ GEM
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
securerandom (0.3.1)
|
||||
selenium-webdriver (4.24.0)
|
||||
selenium-webdriver (4.25.0)
|
||||
base64 (~> 0.2)
|
||||
logger (~> 1.4)
|
||||
rexml (~> 3.2, >= 3.2.5)
|
||||
rubyzip (>= 1.2.2, < 3.0)
|
||||
websocket (~> 1.0)
|
||||
sentry-rails (5.19.0)
|
||||
sentry-rails (5.20.1)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sentry-ruby (5.19.0)
|
||||
sentry-ruby (~> 5.20.1)
|
||||
sentry-ruby (5.20.1)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
simplecov (0.22.0)
|
||||
@@ -407,31 +407,29 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11528)
|
||||
sorbet-runtime (0.5.11577)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
tailwindcss-rails (2.7.3)
|
||||
tailwindcss-rails (2.7.6)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.3-aarch64-linux)
|
||||
tailwindcss-rails (2.7.6-aarch64-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.3-arm-linux)
|
||||
tailwindcss-rails (2.7.6-arm-linux)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.3-arm64-darwin)
|
||||
tailwindcss-rails (2.7.6-arm64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.3-x86_64-darwin)
|
||||
tailwindcss-rails (2.7.6-x86_64-darwin)
|
||||
railties (>= 7.0.0)
|
||||
tailwindcss-rails (2.7.3-x86_64-linux)
|
||||
tailwindcss-rails (2.7.6-x86_64-linux)
|
||||
railties (>= 7.0.0)
|
||||
terminal-table (3.0.2)
|
||||
unicode-display_width (>= 1.1.1, < 3)
|
||||
thor (1.3.2)
|
||||
timeout (0.4.1)
|
||||
turbo-rails (2.0.6)
|
||||
turbo-rails (2.0.10)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
@@ -449,7 +447,7 @@ GEM
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.1)
|
||||
webrick (1.8.2)
|
||||
websocket (1.2.11)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
|
||||
@@ -20,14 +20,14 @@
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block text-xs text-gray-500;
|
||||
@apply block text-xs text-gray-500 peer-disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply border-none bg-transparent text-sm opacity-100 w-full p-0;
|
||||
@apply focus:opacity-100 focus:outline-none focus:ring-0;
|
||||
@apply placeholder-shown:opacity-50;
|
||||
@apply disabled:opacity-50;
|
||||
@apply disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.form-field__radio {
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.form-field__submit {
|
||||
@apply w-full cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
@apply cursor-pointer rounded-lg bg-black p-3 text-center text-white hover:bg-gray-700;
|
||||
}
|
||||
|
||||
input:checked+label+.toggle-switch-dot {
|
||||
@@ -63,12 +63,16 @@
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply p-2 rounded-md;
|
||||
@apply py-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply bg-gray-50;
|
||||
@apply after:content-['\2713'] after:float-right after:text-gray-500;
|
||||
@apply after:content-['\2713'] bg-white after:text-gray-500 after:ml-2;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:active,
|
||||
select[multiple="multiple"] option:focus {
|
||||
@apply bg-white;
|
||||
}
|
||||
|
||||
.maybe-switch {
|
||||
@@ -96,15 +100,23 @@
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium;
|
||||
@apply px-3 py-2 rounded-lg text-sm font-medium cursor-pointer;
|
||||
}
|
||||
|
||||
.btn--primary {
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700;
|
||||
@apply bg-gray-900 text-white hover:bg-gray-700 disabled:bg-gray-50 disabled:hover:bg-gray-50 disabled:text-gray-400;
|
||||
}
|
||||
|
||||
.btn--light {
|
||||
@apply bg-gray-25 border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
|
||||
.btn--secondary {
|
||||
@apply bg-gray-50 hover:bg-gray-100 text-gray-900;
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
@apply border border-alpha-black-200 text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
|
||||
.btn--ghost {
|
||||
@apply border border-transparent text-gray-900 hover:bg-gray-50;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,10 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
|
||||
@account = Account.new(
|
||||
accountable: Accountable.from_type(params[:type])&.new,
|
||||
currency: Current.family.currency
|
||||
)
|
||||
|
||||
@account.accountable.address = Address.new if @account.accountable.is_a?(Property)
|
||||
|
||||
@@ -58,8 +61,6 @@ class AccountsController < ApplicationController
|
||||
start_balance: account_params[:start_balance]
|
||||
@account.sync_later
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_back_or_to accounts_path, alert: e.record.errors.full_messages.to_sentence
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
include AutoSync, Authentication, Invitable, SelfHostable
|
||||
include Localize, AutoSync, Authentication, Invitable, SelfHostable, StoreLocation
|
||||
include Pagy::Backend
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
|
||||
@@ -2,6 +2,7 @@ module Authentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :set_request_details
|
||||
before_action :authenticate_user!
|
||||
end
|
||||
|
||||
@@ -12,10 +13,9 @@ module Authentication
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def authenticate_user!
|
||||
if user = User.find_by(id: session[:user_id])
|
||||
Current.user = user
|
||||
if session_record = Session.find_by_id(cookies.signed[:session_token])
|
||||
Current.session = session_record
|
||||
else
|
||||
if self_hosted_first_login?
|
||||
redirect_to new_registration_url
|
||||
@@ -25,23 +25,18 @@ module Authentication
|
||||
end
|
||||
end
|
||||
|
||||
def login(user)
|
||||
Current.user = user
|
||||
reset_session
|
||||
session[:user_id] = user.id
|
||||
set_last_login_at
|
||||
end
|
||||
|
||||
def logout
|
||||
Current.user = nil
|
||||
reset_session
|
||||
end
|
||||
|
||||
def set_last_login_at
|
||||
Current.user.update(last_login_at: DateTime.now)
|
||||
def create_session_for(user)
|
||||
session = user.sessions.create!
|
||||
cookies.signed.permanent[:session_token] = { value: session.id, httponly: true }
|
||||
session
|
||||
end
|
||||
|
||||
def self_hosted_first_login?
|
||||
Rails.application.config.app_mode.self_hosted? && User.count.zero?
|
||||
end
|
||||
|
||||
def set_request_details
|
||||
Current.user_agent = request.user_agent
|
||||
Current.ip_address = request.ip
|
||||
end
|
||||
end
|
||||
|
||||
13
app/controllers/concerns/localize.rb
Normal file
13
app/controllers/concerns/localize.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
module Localize
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
around_action :switch_locale
|
||||
end
|
||||
|
||||
private
|
||||
def switch_locale(&action)
|
||||
locale = Current.family.try(:locale) || I18n.default_locale
|
||||
I18n.with_locale(locale, &action)
|
||||
end
|
||||
end
|
||||
31
app/controllers/concerns/store_location.rb
Normal file
31
app/controllers/concerns/store_location.rb
Normal file
@@ -0,0 +1,31 @@
|
||||
module StoreLocation
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
helper_method :previous_path
|
||||
before_action :store_return_to
|
||||
after_action :clear_previous_path
|
||||
end
|
||||
|
||||
def previous_path
|
||||
session[:return_to] || fallback_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def store_return_to
|
||||
if params[:return_to].present?
|
||||
session[:return_to] = params[:return_to]
|
||||
end
|
||||
end
|
||||
|
||||
def clear_previous_path
|
||||
if request.fullpath == session[:return_to]
|
||||
session.delete(:return_to)
|
||||
end
|
||||
end
|
||||
|
||||
def fallback_path
|
||||
root_path
|
||||
end
|
||||
end
|
||||
22
app/controllers/import/cleans_controller.rb
Normal file
22
app/controllers/import/cleans_controller.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
class Import::CleansController < ApplicationController
|
||||
layout "imports"
|
||||
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
redirect_to import_configuration_path(@import), alert: "Please configure your import before proceeding." unless @import.configured?
|
||||
|
||||
rows = @import.rows.ordered
|
||||
|
||||
if params[:view] == "errors"
|
||||
rows = rows.reject { |row| row.valid? }
|
||||
end
|
||||
|
||||
@pagy, @rows = pagy_array(rows, limit: params[:per_page] || "10")
|
||||
end
|
||||
|
||||
private
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:import_id])
|
||||
end
|
||||
end
|
||||
25
app/controllers/import/configurations_controller.rb
Normal file
25
app/controllers/import/configurations_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class Import::ConfigurationsController < ApplicationController
|
||||
layout "imports"
|
||||
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
@import.update!(import_params)
|
||||
@import.generate_rows_from_csv
|
||||
@import.reload.sync_mappings
|
||||
|
||||
redirect_to import_clean_path(@import), notice: "Import configured successfully."
|
||||
end
|
||||
|
||||
private
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:import_id])
|
||||
end
|
||||
|
||||
def import_params
|
||||
params.require(:import).permit(:date_col_label, :date_format, :name_col_label, :category_col_label, :tags_col_label, :amount_col_label, :signage_convention, :account_col_label, :notes_col_label, :entity_type_col_label)
|
||||
end
|
||||
end
|
||||
14
app/controllers/import/confirms_controller.rb
Normal file
14
app/controllers/import/confirms_controller.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class Import::ConfirmsController < ApplicationController
|
||||
layout "imports"
|
||||
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
redirect_to import_clean_path(@import), alert: "You have invalid data, please edit until all errors are resolved" unless @import.cleaned?
|
||||
end
|
||||
|
||||
private
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:import_id])
|
||||
end
|
||||
end
|
||||
43
app/controllers/import/mappings_controller.rb
Normal file
43
app/controllers/import/mappings_controller.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
class Import::MappingsController < ApplicationController
|
||||
before_action :set_import
|
||||
|
||||
def update
|
||||
mapping = @import.mappings.find(params[:id])
|
||||
|
||||
mapping.update! \
|
||||
create_when_empty: create_when_empty,
|
||||
mappable: mappable,
|
||||
value: mapping_params[:value]
|
||||
|
||||
redirect_back_or_to import_confirm_path(@import)
|
||||
end
|
||||
|
||||
private
|
||||
def mapping_params
|
||||
params.require(:import_mapping).permit(:type, :key, :mappable_id, :mappable_type, :value)
|
||||
end
|
||||
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:import_id])
|
||||
end
|
||||
|
||||
def mappable
|
||||
return nil unless mappable_class.present?
|
||||
|
||||
@mappable ||= mappable_class.find_by(id: mapping_params[:mappable_id], family: Current.family)
|
||||
end
|
||||
|
||||
def create_when_empty
|
||||
return false unless mapping_class.present?
|
||||
|
||||
mapping_params[:mappable_id] == mapping_class::CREATE_NEW_KEY
|
||||
end
|
||||
|
||||
def mappable_class
|
||||
mapping_params[:mappable_type]&.constantize
|
||||
end
|
||||
|
||||
def mapping_class
|
||||
mapping_params[:type]&.constantize
|
||||
end
|
||||
end
|
||||
24
app/controllers/import/rows_controller.rb
Normal file
24
app/controllers/import/rows_controller.rb
Normal file
@@ -0,0 +1,24 @@
|
||||
class Import::RowsController < ApplicationController
|
||||
before_action :set_import_row
|
||||
|
||||
def update
|
||||
@row.assign_attributes(row_params)
|
||||
@row.save!(validate: false)
|
||||
@row.sync_mappings
|
||||
|
||||
redirect_to import_row_path(@row.import, @row)
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
private
|
||||
def row_params
|
||||
params.require(:import_row).permit(:type, :account, :date, :qty, :ticker, :price, :amount, :currency, :name, :category, :tags, :entity_type, :notes)
|
||||
end
|
||||
|
||||
def set_import_row
|
||||
@import = Current.family.imports.find(params[:import_id])
|
||||
@row = @import.rows.find(params[:id])
|
||||
end
|
||||
end
|
||||
47
app/controllers/import/uploads_controller.rb
Normal file
47
app/controllers/import/uploads_controller.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
class Import::UploadsController < ApplicationController
|
||||
layout "imports"
|
||||
|
||||
before_action :set_import
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
if csv_valid?(csv_str)
|
||||
@import.assign_attributes(raw_file_str: csv_str, col_sep: upload_params[:col_sep])
|
||||
@import.save!(validate: false)
|
||||
|
||||
redirect_to import_configuration_path(@import), notice: "CSV uploaded successfully."
|
||||
else
|
||||
flash.now[:alert] = "Must be valid CSV with headers and at least one row of data"
|
||||
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:import_id])
|
||||
end
|
||||
|
||||
def csv_str
|
||||
@csv_str ||= upload_params[:csv_file]&.read || upload_params[:raw_file_str]
|
||||
end
|
||||
|
||||
def csv_valid?(str)
|
||||
require "csv"
|
||||
|
||||
begin
|
||||
csv = CSV.parse(str || "", headers: true)
|
||||
return false if csv.headers.empty?
|
||||
return false if csv.count == 0
|
||||
true
|
||||
rescue CSV::MalformedCSVError
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def upload_params
|
||||
params.require(:import).permit(:raw_file_str, :csv_file, :col_sep)
|
||||
end
|
||||
end
|
||||
@@ -1,118 +1,44 @@
|
||||
require "ostruct"
|
||||
|
||||
class ImportsController < ApplicationController
|
||||
before_action :set_import, except: %i[index new create]
|
||||
before_action :set_import, only: %i[show publish destroy]
|
||||
|
||||
def publish
|
||||
@import.publish_later
|
||||
|
||||
redirect_to import_path(@import), notice: "Your import has started in the background."
|
||||
end
|
||||
|
||||
def index
|
||||
@imports = Current.family.imports
|
||||
render layout: "with_sidebar"
|
||||
|
||||
render layout: with_sidebar
|
||||
end
|
||||
|
||||
def new
|
||||
account = Current.family.accounts.find_by(id: params[:account_id])
|
||||
@import = Import.new account: account
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
account = Current.family.accounts.find(params[:import][:account_id])
|
||||
@import.update! account: account, col_sep: params[:import][:col_sep]
|
||||
|
||||
redirect_to load_import_path(@import), notice: t(".import_updated")
|
||||
@pending_import = Current.family.imports.ordered.pending.first
|
||||
end
|
||||
|
||||
def create
|
||||
account = Current.family.accounts.find(params[:import][:account_id])
|
||||
@import = Import.create! account: account, col_sep: params[:import][:col_sep]
|
||||
import = Current.family.imports.create! import_params
|
||||
|
||||
redirect_to load_import_path(@import), notice: t(".import_created")
|
||||
redirect_to import_upload_path(import)
|
||||
end
|
||||
|
||||
def show
|
||||
redirect_to import_confirm_path(@import), alert: "Please finalize your mappings before proceeding." unless @import.publishable?
|
||||
end
|
||||
|
||||
def destroy
|
||||
@import.destroy!
|
||||
redirect_to imports_url, notice: t(".import_destroyed"), status: :see_other
|
||||
end
|
||||
@import.destroy
|
||||
|
||||
def load
|
||||
end
|
||||
|
||||
def upload_csv
|
||||
begin
|
||||
@import.raw_file_str = import_params[:raw_file_str].read
|
||||
rescue NoMethodError
|
||||
flash.now[:alert] = "Please select a file to upload"
|
||||
render :load, status: :unprocessable_entity and return
|
||||
end
|
||||
if @import.save
|
||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||
else
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def load_csv
|
||||
if @import.update(import_params)
|
||||
redirect_to configure_import_path(@import), notice: t(".import_loaded")
|
||||
else
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def configure
|
||||
unless @import.loaded?
|
||||
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
||||
end
|
||||
end
|
||||
|
||||
def update_mappings
|
||||
@import.update! import_params(@import.expected_fields.map(&:key))
|
||||
redirect_to clean_import_path(@import), notice: t(".column_mappings_saved")
|
||||
end
|
||||
|
||||
def clean
|
||||
unless @import.loaded?
|
||||
redirect_to load_import_path(@import), alert: t(".invalid_csv")
|
||||
end
|
||||
end
|
||||
|
||||
def update_csv
|
||||
update_params = import_params[:csv_update]
|
||||
|
||||
@import.update_csv! \
|
||||
row_idx: update_params[:row_idx],
|
||||
col_idx: update_params[:col_idx],
|
||||
value: update_params[:value]
|
||||
|
||||
render :clean
|
||||
end
|
||||
|
||||
def confirm
|
||||
unless @import.cleaned?
|
||||
redirect_to clean_import_path(@import), alert: t(".invalid_data")
|
||||
end
|
||||
end
|
||||
|
||||
def publish
|
||||
if @import.valid?
|
||||
@import.publish_later
|
||||
redirect_to imports_path, notice: t(".import_published")
|
||||
else
|
||||
flash.now[:error] = t(".invalid_data")
|
||||
render :confirm, status: :unprocessable_entity
|
||||
end
|
||||
redirect_to imports_path, notice: "Your import has been deleted."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_import
|
||||
@import = Current.family.imports.find(params[:id])
|
||||
end
|
||||
|
||||
def import_params(permitted_mappings = nil)
|
||||
params.require(:import).permit(:raw_file_str, column_mappings: permitted_mappings, csv_update: [ :row_idx, :col_idx, :value ])
|
||||
def import_params
|
||||
params.require(:import).permit(:type)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,7 +17,7 @@ class RegistrationsController < ApplicationController
|
||||
|
||||
if @user.save
|
||||
Category.create_default_categories(@user.family)
|
||||
login @user
|
||||
@session = create_session_for(@user)
|
||||
flash[:notice] = t(".success")
|
||||
redirect_to root_path
|
||||
else
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
class SessionsController < ApplicationController
|
||||
before_action :set_session, only: :destroy
|
||||
skip_authentication only: %i[new create]
|
||||
|
||||
layout "auth"
|
||||
@@ -8,7 +9,7 @@ class SessionsController < ApplicationController
|
||||
|
||||
def create
|
||||
if user = User.authenticate_by(email: params[:email], password: params[:password])
|
||||
login user
|
||||
@session = create_session_for(user)
|
||||
redirect_to root_path
|
||||
else
|
||||
flash.now[:alert] = t(".invalid_credentials")
|
||||
@@ -17,7 +18,12 @@ class SessionsController < ApplicationController
|
||||
end
|
||||
|
||||
def destroy
|
||||
logout
|
||||
@session.destroy
|
||||
redirect_to root_path, notice: t(".logout_successful")
|
||||
end
|
||||
|
||||
private
|
||||
def set_session
|
||||
@session = Current.user.sessions.find(params[:id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,71 +1,43 @@
|
||||
class Settings::HostingsController < SettingsController
|
||||
before_action :verify_hosting_mode
|
||||
before_action :raise_if_not_self_hosted
|
||||
|
||||
def show
|
||||
@synth_usage = Current.family.synth_usage
|
||||
end
|
||||
|
||||
def update
|
||||
if all_updates_valid?
|
||||
hosting_params.keys.each do |key|
|
||||
Setting.send("#{key}=", hosting_params[key].strip)
|
||||
end
|
||||
if hosting_params[:upgrades_setting].present?
|
||||
mode = hosting_params[:upgrades_setting] == "manual" ? "manual" : "auto"
|
||||
target = hosting_params[:upgrades_setting] == "commit" ? "commit" : "release"
|
||||
|
||||
redirect_to settings_hosting_path, notice: t(".success")
|
||||
else
|
||||
flash.now[:error] = @errors.first.message
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def send_test_email
|
||||
unless Setting.smtp_settings_populated?
|
||||
flash[:alert] = t(".missing_smtp_setting_error")
|
||||
render(:show, status: :unprocessable_entity)
|
||||
return
|
||||
Setting.upgrades_mode = mode
|
||||
Setting.upgrades_target = target
|
||||
end
|
||||
|
||||
begin
|
||||
NotificationMailer.with(user: Current.user).test_email.deliver_now
|
||||
rescue => _e
|
||||
flash[:alert] = t(".error")
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
if hosting_params.key?(:render_deploy_hook)
|
||||
Setting.render_deploy_hook = hosting_params[:render_deploy_hook]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:require_invite_for_signup)
|
||||
Setting.require_invite_for_signup = hosting_params[:require_invite_for_signup]
|
||||
end
|
||||
|
||||
if hosting_params.key?(:synth_api_key)
|
||||
Setting.synth_api_key = hosting_params[:synth_api_key]
|
||||
end
|
||||
|
||||
redirect_to settings_hosting_path, notice: t(".success")
|
||||
rescue ActiveRecord::RecordInvalid => error
|
||||
flash.now[:alert] = t(".failure")
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
def all_updates_valid?
|
||||
@errors = ActiveModel::Errors.new(Setting)
|
||||
hosting_params.keys.each do |key|
|
||||
setting = Setting.new(var: key)
|
||||
setting.value = hosting_params[key].strip
|
||||
|
||||
unless setting.valid?
|
||||
@errors.merge!(setting.errors)
|
||||
end
|
||||
end
|
||||
|
||||
if hosting_params[:upgrades_mode] == "auto" && hosting_params[:render_deploy_hook].blank?
|
||||
@errors.add(:render_deploy_hook, t("settings.hostings.update.render_deploy_hook_error"))
|
||||
end
|
||||
|
||||
@errors.empty?
|
||||
end
|
||||
|
||||
def hosting_params
|
||||
permitted_params = params.require(:setting).permit(:render_deploy_hook, :upgrades_mode, :email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password, :require_invite_for_signup)
|
||||
|
||||
result = {}
|
||||
result[:upgrades_mode] = permitted_params[:upgrades_mode] == "manual" ? "manual" : "auto" if permitted_params.key?(:upgrades_mode)
|
||||
result[:render_deploy_hook] = permitted_params[:render_deploy_hook] if permitted_params.key?(:render_deploy_hook)
|
||||
result[:upgrades_target] = permitted_params[:upgrades_mode] unless permitted_params[:upgrades_mode] == "manual" if permitted_params.key?(:upgrades_mode)
|
||||
result.merge!(permitted_params.slice(:email_sender, :app_domain, :smtp_host, :smtp_port, :smtp_username, :smtp_password, :require_invite_for_signup))
|
||||
result
|
||||
params.require(:setting).permit(:render_deploy_hook, :upgrades_setting, :require_invite_for_signup, :synth_api_key)
|
||||
end
|
||||
|
||||
def verify_hosting_mode
|
||||
head :not_found unless self_hosted?
|
||||
def raise_if_not_self_hosted
|
||||
raise "Settings not available on non-self-hosted instance" unless self_hosted?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,6 +21,6 @@ class Settings::PreferencesController < SettingsController
|
||||
private
|
||||
|
||||
def preference_params
|
||||
params.require(:user).permit(family_attributes: [ :id, :currency ])
|
||||
params.require(:user).permit(family_attributes: [ :id, :currency, :locale ])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Settings::ProfilesController < SettingsController
|
||||
|
||||
def destroy
|
||||
if Current.user.deactivate
|
||||
logout
|
||||
Current.session.destroy
|
||||
redirect_to root_path, notice: t(".success")
|
||||
else
|
||||
redirect_to settings_profile_path, alert: Current.user.errors.full_messages.to_sentence
|
||||
@@ -31,7 +31,6 @@ class Settings::ProfilesController < SettingsController
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :profile_image,
|
||||
family_attributes: [ :name, :id ])
|
||||
|
||||
@@ -17,6 +17,9 @@ class TransactionsController < ApplicationController
|
||||
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
|
||||
if params[:account_id]
|
||||
e.account = Current.family.accounts.find(params[:account_id])
|
||||
e.currency = e.account.currency
|
||||
else
|
||||
e.currency = Current.family.currency
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -67,9 +70,6 @@ class TransactionsController < ApplicationController
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def rules
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def amount
|
||||
@@ -93,7 +93,8 @@ class TransactionsController < ApplicationController
|
||||
end
|
||||
|
||||
def search_params
|
||||
params.fetch(:q, {}).permit(:start_date, :end_date, :search, accounts: [], account_ids: [], categories: [], merchants: [])
|
||||
params.fetch(:q, {})
|
||||
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
|
||||
end
|
||||
|
||||
def transaction_entry_params
|
||||
|
||||
@@ -57,6 +57,11 @@ module ApplicationHelper
|
||||
render partial: "shared/drawer", locals: { content: content }
|
||||
end
|
||||
|
||||
def disclosure(title, &block)
|
||||
content = capture &block
|
||||
render partial: "shared/disclosure", locals: { title: title, content: content }
|
||||
end
|
||||
|
||||
def sidebar_link_to(name, path, options = {})
|
||||
is_current = current_page?(path) || (request.path.start_with?(path) && path != "/")
|
||||
|
||||
@@ -133,13 +138,13 @@ module ApplicationHelper
|
||||
|
||||
def format_money(number_or_money, options = {})
|
||||
money = Money.new(number_or_money)
|
||||
options.reverse_merge!(money.default_format_options)
|
||||
options.reverse_merge!(money.format_options(I18n.locale))
|
||||
number_to_currency(money.amount, options)
|
||||
end
|
||||
|
||||
def format_money_without_symbol(number_or_money, options = {})
|
||||
money = Money.new(number_or_money)
|
||||
options.reverse_merge!(money.default_format_options)
|
||||
options.reverse_merge!(money.format_options(I18n.locale))
|
||||
ActiveSupport::NumberHelper.number_to_delimited(money.amount.round(options[:precision] || 0), { delimiter: options[:delimiter], separator: options[:separator] })
|
||||
end
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
module AuthMessagesHelper
|
||||
def auth_messages(form = nil)
|
||||
render "shared/auth_messages", flash: flash,
|
||||
errors: form&.object&.errors&.full_messages || []
|
||||
end
|
||||
end
|
||||
@@ -10,11 +10,6 @@ module FormsHelper
|
||||
render partial: "shared/modal_form", locals: { title:, subtitle:, content: }
|
||||
end
|
||||
|
||||
def form_field_tag(options = {}, &block)
|
||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||
tag.div(**options, &block)
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
form.label name, for: form.field_id(name, value), class: "group has-[:disabled]:cursor-not-allowed" do
|
||||
concat radio_tab_contents(label:, icon:)
|
||||
@@ -27,55 +22,11 @@ module FormsHelper
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
|
||||
def money_with_currency_field(form, money_method, options = {})
|
||||
render partial: "shared/money_field", locals: {
|
||||
form: form,
|
||||
money_method: money_method,
|
||||
default_currency: options[:default_currency] || "USD",
|
||||
disable_currency: options[:disable_currency] || false,
|
||||
hide_currency: options[:hide_currency] || false,
|
||||
label: options[:label] || "Amount"
|
||||
}
|
||||
end
|
||||
|
||||
def money_field(form, method, options = {})
|
||||
value = form.object ? form.object.send(method) : nil
|
||||
|
||||
currency = value&.currency || Money::Currency.new(options[:default_currency] || "USD")
|
||||
|
||||
# See "Monetizable" concern
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
|
||||
money_options = {
|
||||
value: value&.amount,
|
||||
placeholder: "100",
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = options.merge(money_options)
|
||||
|
||||
form.number_field money_amount_method, merged_options
|
||||
end
|
||||
|
||||
def currency_select_full(form, method, options = {}, html_options = {}, &block)
|
||||
choices = currencies_for_select.map { |currency| [ "#{currency.name} (#{currency.iso_code})", currency.iso_code ] }
|
||||
form.select method, choices, options, html_options, &block
|
||||
end
|
||||
|
||||
def currency_select(form, method, options = {}, html_options = {}, &block)
|
||||
choices = currencies_for_select.map(&:iso_code)
|
||||
form.select method, choices, options, html_options, &block
|
||||
def currencies_for_select
|
||||
Money::Currency.all_instances.sort_by { |currency| [ currency.priority, currency.name ] }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def currencies_for_select
|
||||
Money::Currency.all_instances
|
||||
.sort_by(&:priority)
|
||||
end
|
||||
|
||||
def radio_tab_contents(label:, icon:)
|
||||
tag.div(class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 group-has-[:checked]:bg-white group-has-[:checked]:text-gray-800 group-has-[:checked]:shadow-sm") do
|
||||
concat lucide_icon(icon, class: "w-5 h-5")
|
||||
|
||||
@@ -1,19 +1,63 @@
|
||||
module ImportsHelper
|
||||
def table_corner_class(row_idx, col_idx, rows, cols)
|
||||
return "rounded-tl-xl" if row_idx == 0 && col_idx == 0
|
||||
return "rounded-tr-xl" if row_idx == 0 && col_idx == cols.size - 1
|
||||
return "rounded-bl-xl" if row_idx == rows.size - 1 && col_idx == 0
|
||||
return "rounded-br-xl" if row_idx == rows.size - 1 && col_idx == cols.size - 1
|
||||
""
|
||||
def mapping_label(mapping_class)
|
||||
{
|
||||
"Import::AccountTypeMapping" => "Account Type",
|
||||
"Import::AccountMapping" => "Account",
|
||||
"Import::CategoryMapping" => "Category",
|
||||
"Import::TagMapping" => "Tag"
|
||||
}.fetch(mapping_class.name)
|
||||
end
|
||||
|
||||
def nav_steps(import = Import.new)
|
||||
[
|
||||
{ name: "Select", complete: import.persisted?, path: import.persisted? ? edit_import_path(import) : new_import_path },
|
||||
{ name: "Import", complete: import.loaded?, path: import.persisted? ? load_import_path(import) : nil },
|
||||
{ name: "Setup", complete: import.configured?, path: import.persisted? ? configure_import_path(import) : nil },
|
||||
{ name: "Clean", complete: import.cleaned?, path: import.persisted? ? clean_import_path(import) : nil },
|
||||
{ name: "Confirm", complete: import.complete?, path: import.persisted? ? confirm_import_path(import) : nil }
|
||||
]
|
||||
def import_col_label(key)
|
||||
{
|
||||
date: "Date",
|
||||
amount: "Amount",
|
||||
name: "Name",
|
||||
currency: "Currency",
|
||||
category: "Category",
|
||||
tags: "Tags",
|
||||
account: "Account",
|
||||
notes: "Notes",
|
||||
qty: "Quantity",
|
||||
ticker: "Ticker",
|
||||
price: "Price",
|
||||
entity_type: "Type"
|
||||
}[key]
|
||||
end
|
||||
|
||||
def dry_run_resource(key)
|
||||
map = {
|
||||
transactions: DryRunResource.new(label: "Transactions", icon: "credit-card", text_class: "text-cyan-500", bg_class: "bg-cyan-500/5"),
|
||||
accounts: DryRunResource.new(label: "Accounts", icon: "layers", text_class: "text-orange-500", bg_class: "bg-orange-500/5"),
|
||||
categories: DryRunResource.new(label: "Categories", icon: "shapes", text_class: "text-blue-500", bg_class: "bg-blue-500/5"),
|
||||
tags: DryRunResource.new(label: "Tags", icon: "tags", text_class: "text-violet-500", bg_class: "bg-violet-500/5")
|
||||
}
|
||||
|
||||
map[key]
|
||||
end
|
||||
|
||||
def permitted_import_configuration_path(import)
|
||||
if permitted_import_types.include?(import.type.underscore)
|
||||
"import/configurations/#{import.type.underscore}"
|
||||
else
|
||||
raise "Unknown import type: #{import.type}"
|
||||
end
|
||||
end
|
||||
|
||||
def cell_class(row, field)
|
||||
base = "text-sm focus:ring-gray-900 focus:border-gray-900 w-full max-w-full disabled:text-gray-400"
|
||||
|
||||
row.valid? # populate errors
|
||||
|
||||
border = row.errors.key?(field) ? "border-red-500" : "border-transparent"
|
||||
|
||||
[ base, border ].join(" ")
|
||||
end
|
||||
|
||||
private
|
||||
def permitted_import_types
|
||||
%w[transaction_import trade_import account_import mint_import]
|
||||
end
|
||||
|
||||
DryRunResource = Struct.new(:label, :icon, :text_class, :bg_class, keyword_init: true)
|
||||
end
|
||||
|
||||
@@ -6,53 +6,69 @@ class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
text_field_helpers.each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
input_html = label_html(method, options) + super(method, merged_options(options))
|
||||
input_html = apply_form_field_wrapper(input_html) unless options[:inline]
|
||||
input_html
|
||||
merged_options = { class: "form-field__input" }.merge(options)
|
||||
label = build_label(method, options)
|
||||
field = super(method, merged_options)
|
||||
|
||||
build_styled_field(label, field, merged_options)
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
super(method, tag_value, merged_options(options, "form-field__radio"))
|
||||
merged_options = { class: "form-field__radio" }.merge(options)
|
||||
|
||||
super(method, tag_value, merged_options)
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
input_html = label_html(method, options) + super(method, choices, options, merged_options(html_options))
|
||||
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
|
||||
input_html
|
||||
merged_html_options = { class: "form-field__input" }.merge(html_options)
|
||||
|
||||
label = build_label(method, options)
|
||||
field = super(method, choices, options, merged_html_options)
|
||||
|
||||
build_styled_field(label, field, options, remove_padding_right: true)
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
input_html = label_html(method, options) + super(method, collection, value_method, text_method, options, merged_options(html_options))
|
||||
input_html = apply_form_field_wrapper(input_html, class: "pr-0") unless options[:inline]
|
||||
input_html
|
||||
merged_html_options = { class: "form-field__input" }.merge(html_options)
|
||||
|
||||
label = build_label(method, options)
|
||||
field = super(method, collection, value_method, text_method, options, merged_html_options)
|
||||
|
||||
build_styled_field(label, field, options, remove_padding_right: true)
|
||||
end
|
||||
|
||||
def money_field(amount_method, currency_method, options = {})
|
||||
@template.render partial: "shared/money_field", locals: {
|
||||
form: self,
|
||||
amount_method:,
|
||||
currency_method:,
|
||||
**options
|
||||
}
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
merged_options = { class: "btn btn--primary w-full" }.merge(options)
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
super(value, merged_options(options, "form-field__submit"))
|
||||
super(value, merged_options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_form_field_wrapper(input_html, **options)
|
||||
@template.form_field_tag(**options) do
|
||||
input_html
|
||||
def build_styled_field(label, field, options, remove_padding_right: false)
|
||||
if options[:inline]
|
||||
label + field
|
||||
else
|
||||
@template.tag.div class: [ "form-field", options[:container_class], ("pr-0" if remove_padding_right) ] do
|
||||
label + field
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def merged_options(options, default_class = "form-field__input")
|
||||
combined_classes = options.fetch(:class, "") + " #{default_class}"
|
||||
style_options = { class: combined_classes }
|
||||
non_custom_options = options.except(:class, :label, :inline)
|
||||
style_options.merge(non_custom_options)
|
||||
end
|
||||
|
||||
def label_html(method, options)
|
||||
return label(method, class: "form-field__label") if options[:label] == true
|
||||
def build_label(method, options)
|
||||
return "".html_safe unless options[:label]
|
||||
|
||||
return label(method, class: "form-field__label") if options[:label] == true
|
||||
label(method, options[:label], class: "form-field__label")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
module TransactionsHelper
|
||||
def transaction_search_filters
|
||||
[
|
||||
{ key: "account_filter", name: "Account", icon: "layers" },
|
||||
{ key: "date_filter", name: "Date", icon: "calendar" },
|
||||
{ key: "type_filter", name: "Type", icon: "shapes" },
|
||||
{ key: "amount_filter", name: "Amount", icon: "hash" },
|
||||
{ key: "category_filter", name: "Category", icon: "tag" },
|
||||
{ key: "merchant_filter", name: "Merchant", icon: "store" }
|
||||
{ key: "account_filter", icon: "layers" },
|
||||
{ key: "date_filter", icon: "calendar" },
|
||||
{ key: "type_filter", icon: "tag" },
|
||||
{ key: "amount_filter", icon: "hash" },
|
||||
{ key: "category_filter", icon: "shapes" },
|
||||
{ key: "tag_filter", icon: "tags" },
|
||||
{ key: "merchant_filter", icon: "store" }
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
||||
static values = {
|
||||
acceptedTypes: Array, // ["text/csv", "application/csv", ".csv"]
|
||||
acceptedExtension: String, // "csv"
|
||||
unacceptableTypeLabel: String, // "Only CSV files are allowed."
|
||||
};
|
||||
|
||||
connect() {
|
||||
this.submitTarget.disabled = true
|
||||
}
|
||||
|
||||
addFile(event) {
|
||||
const file = event.target.files[0]
|
||||
this._fileAdded(file)
|
||||
}
|
||||
|
||||
dragover(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.add("bg-gray-100")
|
||||
}
|
||||
|
||||
dragleave(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
}
|
||||
|
||||
drop(event) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.currentTarget.classList.remove("bg-gray-100")
|
||||
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && this._formatAcceptable(file)) {
|
||||
this._setFileInput(file);
|
||||
this._fileAdded(file)
|
||||
} else {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
||||
}
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
_fetchFileSize(size) {
|
||||
let fileSize = '';
|
||||
if (size < 1024 * 1024) {
|
||||
fileSize = (size / 1024).toFixed(2) + ' KB'; // Convert bytes to KB
|
||||
} else {
|
||||
fileSize = (size / (1024 * 1024)).toFixed(2) + ' MB'; // Convert bytes to MB
|
||||
}
|
||||
return fileSize;
|
||||
}
|
||||
|
||||
_fileAdded(file) {
|
||||
const fileSizeLimit = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
if (file) {
|
||||
if (file.size > fileSizeLimit) {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = this.unacceptableTypeLabelValue
|
||||
return
|
||||
}
|
||||
|
||||
this.submitTarget.classList.remove([
|
||||
"bg-alpha-black-25",
|
||||
"text-gray",
|
||||
"cursor-not-allowed",
|
||||
]);
|
||||
this.submitTarget.classList.add(
|
||||
"bg-gray-900",
|
||||
"text-white",
|
||||
"cursor-pointer",
|
||||
);
|
||||
this.submitTarget.disabled = false;
|
||||
this.previewTarget.innerHTML = document.querySelector("#template-preview").innerHTML;
|
||||
this.previewTarget.classList.remove("text-red-500")
|
||||
this.previewTarget.classList.add("text-gray-900")
|
||||
this.filenameTarget.textContent = file.name;
|
||||
this.filesizeTarget.textContent = this._fetchFileSize(file.size);
|
||||
}
|
||||
}
|
||||
|
||||
_formatAcceptable(file) {
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
return this.acceptedTypesValue.includes(file.type) || extension === this.acceptedExtensionValue
|
||||
}
|
||||
|
||||
_setFileInput(file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
this.inputTarget.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ export default class extends Controller {
|
||||
|
||||
updateAmount(currency) {
|
||||
(new CurrenciesService).get(currency).then((currency) => {
|
||||
console.log(currency)
|
||||
this.amountTarget.step = currency.step;
|
||||
|
||||
if (isFinite(this.amountTarget.value)) {
|
||||
|
||||
@@ -1,16 +1,3 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
layout "mailer"
|
||||
|
||||
after_action :set_self_host_settings, if: -> { Rails.configuration.app_mode.self_hosted? }
|
||||
|
||||
private
|
||||
|
||||
def set_self_host_settings
|
||||
mail.from = Setting.email_sender
|
||||
mail.delivery_method.settings.merge!({ address: Setting.smtp_host,
|
||||
port: Setting.smtp_port,
|
||||
user_name: Setting.smtp_username,
|
||||
password: Setting.smtp_password,
|
||||
tls: ENV.fetch("SMTP_TLS_ENABLED", "true") == "true" })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
class NotificationMailer < ApplicationMailer
|
||||
def test_email
|
||||
mail(to: params[:user].email, subject: t(".test_email_subject"), body: t(".test_email_body"))
|
||||
end
|
||||
end
|
||||
@@ -5,14 +5,15 @@ class Account < ApplicationRecord
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
belongs_to :import, optional: true
|
||||
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
||||
has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade"
|
||||
has_many :holdings, dependent: :destroy
|
||||
has_many :balances, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :syncs, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ class Account::Balance::Syncer
|
||||
|
||||
if daily_balances.any?
|
||||
account.reload
|
||||
account.update! balance: daily_balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
last_balance = daily_balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
account.update! balance: last_balance
|
||||
end
|
||||
end
|
||||
rescue Money::ConversionError => e
|
||||
@@ -102,7 +103,7 @@ class Account::Balance::Syncer
|
||||
end
|
||||
|
||||
def find_prior_balance
|
||||
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
||||
account.balances.where(currency: account.currency).where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
def net_entry_flows(entries, target_currency = account.currency)
|
||||
|
||||
@@ -5,6 +5,7 @@ class Account::Entry < ApplicationRecord
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true
|
||||
belongs_to :import, optional: true
|
||||
|
||||
delegated_type :entryable, types: Account::Entryable::TYPES, dependent: :destroy
|
||||
accepts_nested_attributes_for :entryable
|
||||
@@ -12,7 +13,6 @@ class Account::Entry < ApplicationRecord
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
validate :trade_valid?, if: -> { account_trade? }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
@@ -148,6 +148,27 @@ class Account::Entry < ApplicationRecord
|
||||
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
|
||||
@@ -198,20 +219,4 @@ class Account::Entry < ApplicationRecord
|
||||
previous: previous_entry&.amount_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
end
|
||||
|
||||
def trade_valid?
|
||||
if account_trade.sell?
|
||||
current_qty = account.holding_qty(account_trade.security)
|
||||
|
||||
if current_qty < account_trade.qty.abs
|
||||
errors.add(
|
||||
:base,
|
||||
:invalid_sell_quantity,
|
||||
sell_qty: account_trade.qty.abs,
|
||||
ticker: account_trade.security.ticker,
|
||||
current_qty: current_qty
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,7 +48,9 @@ class Account::Holding::Syncer
|
||||
end
|
||||
|
||||
ticker_start_dates.each do |ticker, date|
|
||||
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
|
||||
fetched_prices = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
|
||||
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: date, end_date: Date.current, cache: false).run
|
||||
prices[ticker] = gapfilled_prices
|
||||
end
|
||||
|
||||
prices
|
||||
|
||||
@@ -13,8 +13,15 @@ class Account::Transaction < ApplicationRecord
|
||||
class << self
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.joins("LEFT JOIN categories ON categories.id = account_transactions.category_id").where(categories: { name: params[:categories] }) if params[:categories].present?
|
||||
query = query.joins("LEFT JOIN merchants ON merchants.id = account_transactions.merchant_id").where(merchants: { name: params[:merchants] }) if params[:merchants].present?
|
||||
query = query.joins(:category).where(categories: { name: params[:categories] }) if params[:categories].present?
|
||||
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
|
||||
|
||||
@@ -25,7 +32,7 @@ class Account::Transaction < ApplicationRecord
|
||||
private
|
||||
|
||||
def searchable_keys
|
||||
%i[categories merchants]
|
||||
%i[categories merchants tags]
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
49
app/models/account_import.rb
Normal file
49
app/models/account_import.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
class AccountImport < Import
|
||||
def import!
|
||||
transaction do
|
||||
rows.each do |row|
|
||||
mapping = mappings.account_types.find_by(key: row.entity_type)
|
||||
accountable_class = mapping.value.constantize
|
||||
|
||||
account = family.accounts.build(
|
||||
name: row.name,
|
||||
balance: row.amount.to_d,
|
||||
currency: row.currency,
|
||||
accountable: accountable_class.new,
|
||||
import: self
|
||||
)
|
||||
|
||||
account.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def mapping_steps
|
||||
[ Import::AccountTypeMapping ]
|
||||
end
|
||||
|
||||
def required_column_keys
|
||||
%i[name amount]
|
||||
end
|
||||
|
||||
def column_keys
|
||||
%i[entity_type name amount currency]
|
||||
end
|
||||
|
||||
def dry_run
|
||||
{
|
||||
accounts: rows.count
|
||||
}
|
||||
end
|
||||
|
||||
def csv_template
|
||||
template = <<-CSV
|
||||
Account type*,Name*,Balance*,Currency
|
||||
Checking,Main Checking Account,1000.00,USD
|
||||
Savings,Emergency Fund,5000.00,USD
|
||||
Credit Card,Rewards Card,-500.00,USD
|
||||
CSV
|
||||
|
||||
CSV.parse(template, headers: true)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Category < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify, class_name: "Account::Transaction"
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
||||
@@ -18,13 +18,12 @@ module Providable
|
||||
Provider::Github.new
|
||||
end
|
||||
|
||||
def synth_provider
|
||||
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
|
||||
api_key.present? ? Provider::Synth.new(api_key) : nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def synth_provider
|
||||
api_key = self_hosted? ? Setting.synth_api_key : ENV["SYNTH_API_KEY"]
|
||||
api_key.present? ? Provider::Synth.new(api_key) : nil
|
||||
end
|
||||
|
||||
def self_hosted?
|
||||
Rails.application.config.app_mode.self_hosted?
|
||||
end
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
attribute :session
|
||||
attribute :user_agent, :ip_address
|
||||
|
||||
delegate :user, to: :session, allow_nil: true
|
||||
delegate :family, to: :user, allow_nil: true
|
||||
end
|
||||
|
||||
@@ -70,6 +70,7 @@ class Demo::Generator
|
||||
email: "user@maybe.local",
|
||||
first_name: "Demo",
|
||||
last_name: "User",
|
||||
role: "admin",
|
||||
password: "password"
|
||||
end
|
||||
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
class Family < ApplicationRecord
|
||||
include Providable
|
||||
|
||||
has_many :users, dependent: :destroy
|
||||
has_many :tags, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :institutions, dependent: :destroy
|
||||
has_many :imports, dependent: :destroy
|
||||
has_many :transactions, through: :accounts
|
||||
has_many :entries, through: :accounts
|
||||
has_many :imports, through: :accounts
|
||||
has_many :categories, dependent: :destroy
|
||||
has_many :merchants, dependent: :destroy
|
||||
has_many :issues, through: :accounts
|
||||
|
||||
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }
|
||||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
@@ -119,4 +123,8 @@ class Family < ApplicationRecord
|
||||
def needs_sync?
|
||||
last_synced_at.nil? || last_synced_at.to_date < Date.current
|
||||
end
|
||||
|
||||
def synth_usage
|
||||
self.class.synth_provider&.usage
|
||||
end
|
||||
end
|
||||
|
||||
44
app/models/gapfiller.rb
Normal file
44
app/models/gapfiller.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class Gapfiller
|
||||
attr_reader :series
|
||||
|
||||
def initialize(series, start_date:, end_date:, cache:)
|
||||
@series = series
|
||||
@date_range = start_date..end_date
|
||||
@cache = cache
|
||||
end
|
||||
|
||||
def run
|
||||
gapfilled_records = []
|
||||
|
||||
date_range.each do |date|
|
||||
record = series.find { |r| r.date == date }
|
||||
|
||||
if should_gapfill?(date, record)
|
||||
prev_record = gapfilled_records.find { |r| r.date == date - 1.day }
|
||||
|
||||
if prev_record
|
||||
new_record = create_gapfilled_record(prev_record, date)
|
||||
gapfilled_records << new_record
|
||||
end
|
||||
else
|
||||
gapfilled_records << record if record
|
||||
end
|
||||
end
|
||||
|
||||
gapfilled_records
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :date_range, :cache
|
||||
|
||||
def should_gapfill?(date, record)
|
||||
date.on_weekend? && record.nil?
|
||||
end
|
||||
|
||||
def create_gapfilled_record(prev_record, date)
|
||||
new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at"))
|
||||
new_record.date = date
|
||||
new_record.save! if cache
|
||||
new_record
|
||||
end
|
||||
end
|
||||
@@ -1,185 +1,137 @@
|
||||
class Import < ApplicationRecord
|
||||
belongs_to :account
|
||||
TYPES = %w[TransactionImport TradeImport AccountImport MintImport].freeze
|
||||
SIGNAGE_CONVENTIONS = %w[inflows_positive inflows_negative]
|
||||
|
||||
validate :raw_file_must_be_parsable
|
||||
validates :col_sep, inclusion: { in: Csv::COL_SEP_LIST }
|
||||
|
||||
before_save :initialize_csv, if: :should_initialize_csv?
|
||||
|
||||
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
||||
|
||||
store_accessor :column_mappings, :define_column_mapping_keys
|
||||
belongs_to :family
|
||||
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
FALLBACK_TRANSACTION_NAME = "Imported transaction"
|
||||
enum :status, { pending: "pending", complete: "complete", importing: "importing", failed: "failed" }, validate: true
|
||||
|
||||
validates :type, inclusion: { in: TYPES }
|
||||
validates :col_sep, inclusion: { in: [ ",", ";" ] }
|
||||
validates :signage_convention, inclusion: { in: SIGNAGE_CONVENTIONS }
|
||||
|
||||
has_many :rows, dependent: :destroy
|
||||
has_many :mappings, dependent: :destroy
|
||||
has_many :accounts, dependent: :destroy
|
||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||
|
||||
def publish_later
|
||||
raise "Import is not publishable" unless publishable?
|
||||
|
||||
update! status: :importing
|
||||
|
||||
ImportJob.perform_later(self)
|
||||
end
|
||||
|
||||
def loaded?
|
||||
def publish
|
||||
import!
|
||||
|
||||
family.sync
|
||||
|
||||
update! status: :complete
|
||||
rescue => error
|
||||
update! status: :failed, error: error.message
|
||||
end
|
||||
|
||||
def csv_rows
|
||||
@csv_rows ||= parsed_csv
|
||||
end
|
||||
|
||||
def csv_headers
|
||||
parsed_csv.headers
|
||||
end
|
||||
|
||||
def csv_sample
|
||||
@csv_sample ||= parsed_csv.first(2)
|
||||
end
|
||||
|
||||
def dry_run
|
||||
{
|
||||
transactions: rows.count,
|
||||
accounts: Import::AccountMapping.for_import(self).creational.count,
|
||||
categories: Import::CategoryMapping.for_import(self).creational.count,
|
||||
tags: Import::TagMapping.for_import(self).creational.count
|
||||
}
|
||||
end
|
||||
|
||||
def required_column_keys
|
||||
[]
|
||||
end
|
||||
|
||||
def column_keys
|
||||
raise NotImplementedError, "Subclass must implement column_keys"
|
||||
end
|
||||
|
||||
def generate_rows_from_csv
|
||||
rows.destroy_all
|
||||
|
||||
mapped_rows = csv_rows.map do |row|
|
||||
{
|
||||
account: row[account_col_label].to_s,
|
||||
date: row[date_col_label].to_s,
|
||||
qty: row[qty_col_label].to_s,
|
||||
ticker: row[ticker_col_label].to_s,
|
||||
price: row[price_col_label].to_s,
|
||||
amount: row[amount_col_label].to_s,
|
||||
currency: (row[currency_col_label] || default_currency).to_s,
|
||||
name: (row[name_col_label] || default_row_name).to_s,
|
||||
category: row[category_col_label].to_s,
|
||||
tags: row[tags_col_label].to_s,
|
||||
entity_type: row[entity_type_col_label].to_s,
|
||||
notes: row[notes_col_label].to_s
|
||||
}
|
||||
end
|
||||
|
||||
rows.insert_all!(mapped_rows)
|
||||
end
|
||||
|
||||
def sync_mappings
|
||||
mapping_steps.each do |mapping|
|
||||
mapping.sync(self)
|
||||
end
|
||||
end
|
||||
|
||||
def mapping_steps
|
||||
[]
|
||||
end
|
||||
|
||||
def uploaded?
|
||||
raw_file_str.present?
|
||||
end
|
||||
|
||||
def configured?
|
||||
csv.present?
|
||||
uploaded? && rows.any?
|
||||
end
|
||||
|
||||
def cleaned?
|
||||
loaded? && configured? && csv.valid?
|
||||
configured? && rows.all?(&:valid?)
|
||||
end
|
||||
|
||||
def csv
|
||||
get_normalized_csv_with_validation
|
||||
end
|
||||
|
||||
def available_headers
|
||||
get_raw_csv.table.headers
|
||||
end
|
||||
|
||||
def get_selected_header_for_field(field)
|
||||
column_mappings&.dig(field.key) || field.key
|
||||
end
|
||||
|
||||
def update_csv!(row_idx:, col_idx:, value:)
|
||||
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
||||
update! normalized_csv_str: updated_csv.to_s
|
||||
end
|
||||
|
||||
# Type-specific methods (potential STI inheritance in future when more import types added)
|
||||
def publish
|
||||
update!(status: "importing")
|
||||
|
||||
transaction do
|
||||
generate_transactions.each do |txn|
|
||||
txn.save!
|
||||
end
|
||||
end
|
||||
|
||||
self.account.sync
|
||||
|
||||
update!(status: "complete")
|
||||
rescue => e
|
||||
update!(status: "failed")
|
||||
Rails.logger.error("Import with id #{id} failed: #{e}")
|
||||
end
|
||||
|
||||
def dry_run
|
||||
generate_transactions
|
||||
end
|
||||
|
||||
def expected_fields
|
||||
@expected_fields ||= create_expected_fields
|
||||
def publishable?
|
||||
cleaned? && mappings.all?(&:valid?)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def get_normalized_csv_with_validation
|
||||
return nil if normalized_csv_str.nil?
|
||||
|
||||
csv = Import::Csv.new(normalized_csv_str)
|
||||
|
||||
expected_fields.each do |field|
|
||||
csv.define_validator(field.key, field.validator) if field.validator
|
||||
end
|
||||
|
||||
csv
|
||||
def import!
|
||||
# no-op, subclasses can implement for customization of algorithm
|
||||
end
|
||||
|
||||
def get_raw_csv
|
||||
return nil if raw_file_str.nil?
|
||||
Import::Csv.new(raw_file_str, col_sep:)
|
||||
def default_row_name
|
||||
"Imported item"
|
||||
end
|
||||
|
||||
def should_initialize_csv?
|
||||
raw_file_str_changed? || column_mappings_changed?
|
||||
def default_currency
|
||||
family.currency
|
||||
end
|
||||
|
||||
def initialize_csv
|
||||
generated_csv = generate_normalized_csv(raw_file_str)
|
||||
self.normalized_csv_str = generated_csv.table.to_s
|
||||
end
|
||||
|
||||
# Uses the user-provided raw CSV + mappings to generate a normalized CSV for the import
|
||||
def generate_normalized_csv(csv_str)
|
||||
Import::Csv.create_with_field_mappings(csv_str, expected_fields, column_mappings, col_sep)
|
||||
end
|
||||
|
||||
def update_csv(row_idx, col_idx, value)
|
||||
updated_csv = csv.update_cell(row_idx.to_i, col_idx.to_i, value)
|
||||
update! normalized_csv_str: updated_csv.to_s
|
||||
end
|
||||
|
||||
def generate_transactions
|
||||
transaction_entries = []
|
||||
category_cache = {}
|
||||
tag_cache = {}
|
||||
|
||||
csv.table.each do |row|
|
||||
category_name = row["category"].presence
|
||||
tag_strings = row["tags"].presence&.split("|") || []
|
||||
tags = []
|
||||
|
||||
tag_strings.each do |tag_string|
|
||||
tags << tag_cache[tag_string] ||= account.family.tags.find_or_initialize_by(name: tag_string)
|
||||
end
|
||||
|
||||
category = category_cache[category_name] ||= account.family.categories.find_or_initialize_by(name: category_name) if category_name.present?
|
||||
|
||||
entry = account.entries.build \
|
||||
name: row["name"].presence || FALLBACK_TRANSACTION_NAME,
|
||||
date: Date.iso8601(row["date"]),
|
||||
currency: account.currency,
|
||||
amount: BigDecimal(row["amount"]) * -1,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags)
|
||||
|
||||
transaction_entries << entry
|
||||
end
|
||||
|
||||
transaction_entries
|
||||
end
|
||||
|
||||
def create_expected_fields
|
||||
date_field = Import::Field.new \
|
||||
key: "date",
|
||||
label: "Date",
|
||||
validator: ->(value) { Import::Field.iso_date_validator(value) }
|
||||
|
||||
name_field = Import::Field.new \
|
||||
key: "name",
|
||||
label: "Name",
|
||||
is_optional: true
|
||||
|
||||
category_field = Import::Field.new \
|
||||
key: "category",
|
||||
label: "Category",
|
||||
is_optional: true
|
||||
|
||||
tags_field = Import::Field.new \
|
||||
key: "tags",
|
||||
label: "Tags",
|
||||
is_optional: true
|
||||
|
||||
amount_field = Import::Field.new \
|
||||
key: "amount",
|
||||
label: "Amount",
|
||||
validator: ->(value) { Import::Field.bigdecimal_validator(value) }
|
||||
|
||||
[ date_field, name_field, category_field, tags_field, amount_field ]
|
||||
end
|
||||
|
||||
def define_column_mapping_keys
|
||||
expected_fields.each do |field|
|
||||
field.key.to_sym
|
||||
end
|
||||
end
|
||||
|
||||
def raw_file_must_be_parsable
|
||||
begin
|
||||
CSV.parse(raw_file_str || "", col_sep:)
|
||||
rescue CSV::MalformedCSVError
|
||||
errors.add(:raw_file_str, :invalid_csv_format)
|
||||
end
|
||||
def parsed_csv
|
||||
@parsed_csv ||= CSV.parse(
|
||||
(raw_file_str || "").strip,
|
||||
headers: true,
|
||||
col_sep: col_sep,
|
||||
converters: [ ->(str) { str&.strip } ]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
45
app/models/import/account_mapping.rb
Normal file
45
app/models/import/account_mapping.rb
Normal file
@@ -0,0 +1,45 @@
|
||||
class Import::AccountMapping < Import::Mapping
|
||||
validates :mappable, presence: true, if: -> { key.blank? || !create_when_empty }
|
||||
|
||||
class << self
|
||||
def mapping_values(import)
|
||||
import.rows.map(&:account).uniq
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_values
|
||||
family_accounts = import.family.accounts.alphabetically.map { |account| [ account.name, account.id ] }
|
||||
|
||||
unless key.blank?
|
||||
family_accounts.unshift [ "Add as new account", CREATE_NEW_KEY ]
|
||||
end
|
||||
|
||||
family_accounts
|
||||
end
|
||||
|
||||
def requires_selection?
|
||||
true
|
||||
end
|
||||
|
||||
def values_count
|
||||
import.rows.where(account: key).count
|
||||
end
|
||||
|
||||
def mappable_class
|
||||
Account
|
||||
end
|
||||
|
||||
def create_mappable!
|
||||
return unless creatable?
|
||||
|
||||
account = import.family.accounts.create_or_find_by!(name: key) do |new_account|
|
||||
new_account.balance = 0
|
||||
new_account.import = import
|
||||
new_account.currency = import.family.currency
|
||||
new_account.accountable = Depository.new
|
||||
end
|
||||
|
||||
self.mappable = account
|
||||
save!
|
||||
end
|
||||
end
|
||||
25
app/models/import/account_type_mapping.rb
Normal file
25
app/models/import/account_type_mapping.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
class Import::AccountTypeMapping < Import::Mapping
|
||||
validates :value, presence: true
|
||||
|
||||
class << self
|
||||
def mapping_values(import)
|
||||
import.rows.map(&:entity_type).uniq
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_values
|
||||
Accountable::TYPES.map { |type| [ type.titleize, type ] }
|
||||
end
|
||||
|
||||
def requires_selection?
|
||||
true
|
||||
end
|
||||
|
||||
def values_count
|
||||
import.rows.where(entity_type: key).count
|
||||
end
|
||||
|
||||
def create_mappable!
|
||||
# no-op
|
||||
end
|
||||
end
|
||||
36
app/models/import/category_mapping.rb
Normal file
36
app/models/import/category_mapping.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class Import::CategoryMapping < Import::Mapping
|
||||
class << self
|
||||
def mapping_values(import)
|
||||
import.rows.map(&:category).uniq
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_values
|
||||
family_categories = import.family.categories.alphabetically.map { |category| [ category.name, category.id ] }
|
||||
|
||||
unless key.blank?
|
||||
family_categories.unshift [ "Add as new category", CREATE_NEW_KEY ]
|
||||
end
|
||||
|
||||
family_categories
|
||||
end
|
||||
|
||||
def requires_selection?
|
||||
false
|
||||
end
|
||||
|
||||
def values_count
|
||||
import.rows.where(category: key).count
|
||||
end
|
||||
|
||||
def mappable_class
|
||||
Category
|
||||
end
|
||||
|
||||
def create_mappable!
|
||||
return unless creatable?
|
||||
|
||||
self.mappable = import.family.categories.find_or_create_by!(name: key)
|
||||
save!
|
||||
end
|
||||
end
|
||||
@@ -1,83 +0,0 @@
|
||||
class Import::Csv
|
||||
DEFAULT_COL_SEP = ",".freeze
|
||||
COL_SEP_LIST = [ DEFAULT_COL_SEP, ";" ].freeze
|
||||
|
||||
def self.parse_csv(csv_str, col_sep: DEFAULT_COL_SEP)
|
||||
CSV.parse(
|
||||
csv_str&.strip || "",
|
||||
headers: true,
|
||||
col_sep:,
|
||||
converters: [ ->(str) { str&.strip } ]
|
||||
)
|
||||
end
|
||||
|
||||
def self.create_with_field_mappings(raw_file_str, fields, field_mappings, col_sep = DEFAULT_COL_SEP)
|
||||
raw_csv = self.parse_csv(raw_file_str, col_sep:)
|
||||
|
||||
generated_csv_str = CSV.generate headers: fields.map { |f| f.key }, write_headers: true, col_sep: do |csv|
|
||||
raw_csv.each do |row|
|
||||
row_values = []
|
||||
|
||||
fields.each do |field|
|
||||
# Finds the column header name the user has designated for the expected field
|
||||
mapped_field_key = field_mappings[field.key] if field_mappings
|
||||
mapped_header = mapped_field_key || field.key
|
||||
|
||||
row_values << row.fetch(mapped_header, "")
|
||||
end
|
||||
|
||||
csv << row_values
|
||||
end
|
||||
end
|
||||
|
||||
new(generated_csv_str, col_sep:)
|
||||
end
|
||||
|
||||
attr_reader :csv_str, :col_sep
|
||||
|
||||
def initialize(csv_str, column_validators: nil, col_sep: DEFAULT_COL_SEP)
|
||||
@csv_str = csv_str
|
||||
@col_sep = col_sep
|
||||
@column_validators = column_validators || {}
|
||||
end
|
||||
|
||||
def table
|
||||
@table ||= self.class.parse_csv(csv_str, col_sep:)
|
||||
end
|
||||
|
||||
def update_cell(row_idx, col_idx, value)
|
||||
copy = table.by_col_or_row
|
||||
copy[row_idx][col_idx] = value
|
||||
copy
|
||||
end
|
||||
|
||||
def valid?
|
||||
table.each_with_index.all? do |row, row_idx|
|
||||
row.each_with_index.all? do |cell, col_idx|
|
||||
cell_valid?(row_idx, col_idx)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def cell_valid?(row_idx, col_idx)
|
||||
value = table.dig(row_idx, col_idx)
|
||||
header = table.headers[col_idx]
|
||||
validator = get_validator_by_header(header)
|
||||
validator.call(value)
|
||||
end
|
||||
|
||||
def define_validator(header_key, validator = nil, &block)
|
||||
header = table.headers.find { |h| h.strip == header_key }
|
||||
raise "Cannot define validator for header #{header_key}: header does not exist in CSV" if header.nil?
|
||||
|
||||
column_validators[header] = validator || block
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_accessor :column_validators
|
||||
|
||||
def get_validator_by_header(header)
|
||||
column_validators&.dig(header) || ->(_v) { true }
|
||||
end
|
||||
end
|
||||
@@ -1,37 +0,0 @@
|
||||
class Import::Field
|
||||
def self.iso_date_validator(value)
|
||||
Date.iso8601(value)
|
||||
true
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
def self.bigdecimal_validator(value)
|
||||
BigDecimal(value)
|
||||
true
|
||||
rescue
|
||||
false
|
||||
end
|
||||
|
||||
attr_reader :key, :label, :validator
|
||||
|
||||
def initialize(key:, label:, is_optional: false, validator: nil)
|
||||
@key = key.to_s
|
||||
@label = label
|
||||
@is_optional = is_optional
|
||||
@validator = validator
|
||||
end
|
||||
|
||||
def optional?
|
||||
@is_optional
|
||||
end
|
||||
|
||||
def define_validator(validator = nil, &block)
|
||||
@validator = validator || block
|
||||
end
|
||||
|
||||
def validate(value)
|
||||
return true if validator.nil?
|
||||
validator.call(value)
|
||||
end
|
||||
end
|
||||
56
app/models/import/mapping.rb
Normal file
56
app/models/import/mapping.rb
Normal file
@@ -0,0 +1,56 @@
|
||||
class Import::Mapping < ApplicationRecord
|
||||
CREATE_NEW_KEY = "internal_new_resource"
|
||||
|
||||
belongs_to :import
|
||||
belongs_to :mappable, polymorphic: true, optional: true
|
||||
|
||||
validates :key, presence: true, uniqueness: { scope: [ :import_id, :type ] }, allow_blank: true
|
||||
|
||||
scope :for_import, ->(import) { where(import: import) }
|
||||
scope :creational, -> { where(create_when_empty: true, mappable: nil) }
|
||||
scope :categories, -> { where(type: "Import::CategoryMapping") }
|
||||
scope :tags, -> { where(type: "Import::TagMapping") }
|
||||
scope :accounts, -> { where(type: "Import::AccountMapping") }
|
||||
scope :account_types, -> { where(type: "Import::AccountTypeMapping") }
|
||||
|
||||
class << self
|
||||
def mappable_for(key)
|
||||
find_by(key: key)&.mappable
|
||||
end
|
||||
|
||||
def sync(import)
|
||||
unique_values = mapping_values(import).uniq
|
||||
|
||||
unique_values.each do |value|
|
||||
mapping = find_or_initialize_by(key: value, import: import, create_when_empty: value.present?)
|
||||
mapping.save(validate: false) if mapping.new_record?
|
||||
end
|
||||
|
||||
where(import: import).where.not(key: unique_values).destroy_all
|
||||
end
|
||||
|
||||
def mapping_values(import)
|
||||
raise NotImplementedError, "Subclass must implement mapping_values"
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_values
|
||||
raise NotImplementedError, "Subclass must implement selectable_values"
|
||||
end
|
||||
|
||||
def values_count
|
||||
raise NotImplementedError, "Subclass must implement values_count"
|
||||
end
|
||||
|
||||
def mappable_class
|
||||
nil
|
||||
end
|
||||
|
||||
def creatable?
|
||||
mappable.nil? && key.present? && create_when_empty
|
||||
end
|
||||
|
||||
def create_mappable!
|
||||
raise NotImplementedError, "Subclass must implement create_mappable!"
|
||||
end
|
||||
end
|
||||
76
app/models/import/row.rb
Normal file
76
app/models/import/row.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
class Import::Row < ApplicationRecord
|
||||
belongs_to :import
|
||||
|
||||
validates :amount, numericality: true, allow_blank: true
|
||||
validates :currency, presence: true
|
||||
|
||||
validate :date_matches_user_format
|
||||
validate :required_columns
|
||||
validate :currency_is_valid
|
||||
|
||||
scope :ordered, -> { order(:id) }
|
||||
|
||||
def tags_list
|
||||
if tags.blank?
|
||||
[ "" ]
|
||||
else
|
||||
tags.split("|").map(&:strip)
|
||||
end
|
||||
end
|
||||
|
||||
def date_iso
|
||||
Date.strptime(date, import.date_format).iso8601
|
||||
end
|
||||
|
||||
def signed_amount
|
||||
if import.type == "TradeImport"
|
||||
price.to_d * apply_trade_signage_convention(qty.to_d)
|
||||
else
|
||||
apply_transaction_signage_convention(amount.to_d)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_mappings
|
||||
Import::CategoryMapping.sync(import) if import.column_keys.include?(:category)
|
||||
Import::TagMapping.sync(import) if import.column_keys.include?(:tags)
|
||||
Import::AccountMapping.sync(import) if import.column_keys.include?(:account)
|
||||
Import::AccountTypeMapping.sync(import) if import.column_keys.include?(:entity_type)
|
||||
end
|
||||
|
||||
private
|
||||
# In the Maybe system, positive quantities == "inflows"
|
||||
def apply_trade_signage_convention(value)
|
||||
value * (import.signage_convention == "inflows_positive" ? 1 : -1)
|
||||
end
|
||||
|
||||
# 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)
|
||||
end
|
||||
|
||||
def required_columns
|
||||
import.required_column_keys.each do |required_key|
|
||||
errors.add(required_key, "is required") if self[required_key].blank?
|
||||
end
|
||||
end
|
||||
|
||||
def date_matches_user_format
|
||||
return if date.blank?
|
||||
|
||||
parsed_date = Date.strptime(date, import.date_format) rescue nil
|
||||
|
||||
if parsed_date.nil?
|
||||
errors.add(:date, "must exactly match the format: #{import.date_format}")
|
||||
end
|
||||
end
|
||||
|
||||
def currency_is_valid
|
||||
return true if currency.blank?
|
||||
|
||||
begin
|
||||
Money::Currency.new(currency)
|
||||
rescue Money::Currency::UnknownCurrencyError
|
||||
errors.add(:currency, "is not a valid currency code")
|
||||
end
|
||||
end
|
||||
end
|
||||
36
app/models/import/tag_mapping.rb
Normal file
36
app/models/import/tag_mapping.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
class Import::TagMapping < Import::Mapping
|
||||
class << self
|
||||
def mapping_values(import)
|
||||
import.rows.map(&:tags_list).flatten.uniq
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_values
|
||||
family_tags = import.family.tags.alphabetically.map { |tag| [ tag.name, tag.id ] }
|
||||
|
||||
unless key.blank?
|
||||
family_tags.unshift [ "Add as new tag", CREATE_NEW_KEY ]
|
||||
end
|
||||
|
||||
family_tags
|
||||
end
|
||||
|
||||
def requires_selection?
|
||||
false
|
||||
end
|
||||
|
||||
def values_count
|
||||
import.rows.map(&:tags_list).flatten.count { |tag| tag == key }
|
||||
end
|
||||
|
||||
def mappable_class
|
||||
Tag
|
||||
end
|
||||
|
||||
def create_mappable!
|
||||
return unless creatable?
|
||||
|
||||
self.mappable = import.family.tags.find_or_create_by!(name: key)
|
||||
save!
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,11 @@ class Issue::PricesMissing < Issue
|
||||
|
||||
after_initialize :initialize_missing_prices
|
||||
|
||||
validates :missing_prices, presence: true
|
||||
validates :missing_prices, presence: true, allow_blank: true
|
||||
|
||||
def append_missing_price(ticker, date)
|
||||
missing_prices[ticker] ||= []
|
||||
missing_prices[ticker] << date
|
||||
missing_prices[ticker] << date unless missing_prices[ticker].include?(date.to_s)
|
||||
end
|
||||
|
||||
def stale?
|
||||
|
||||
94
app/models/mint_import.rb
Normal file
94
app/models/mint_import.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
class MintImport < Import
|
||||
after_create :set_mappings
|
||||
|
||||
def generate_rows_from_csv
|
||||
rows.destroy_all
|
||||
|
||||
mapped_rows = csv_rows.map do |row|
|
||||
{
|
||||
account: row[account_col_label].to_s,
|
||||
date: row[date_col_label].to_s,
|
||||
amount: signed_csv_amount(row).to_s,
|
||||
currency: (row[currency_col_label] || default_currency).to_s,
|
||||
name: (row[name_col_label] || default_row_name).to_s,
|
||||
category: row[category_col_label].to_s,
|
||||
tags: row[tags_col_label].to_s,
|
||||
notes: row[notes_col_label].to_s
|
||||
}
|
||||
end
|
||||
|
||||
rows.insert_all!(mapped_rows)
|
||||
end
|
||||
|
||||
def import!
|
||||
transaction do
|
||||
mappings.each(&:create_mappable!)
|
||||
|
||||
rows.each do |row|
|
||||
account = mappings.accounts.mappable_for(row.account)
|
||||
category = mappings.categories.mappable_for(row.category)
|
||||
tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact
|
||||
|
||||
entry = account.entries.build \
|
||||
date: row.date_iso,
|
||||
amount: row.signed_amount,
|
||||
name: row.name,
|
||||
currency: row.currency,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags, notes: row.notes),
|
||||
import: self
|
||||
|
||||
entry.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def mapping_steps
|
||||
[ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ]
|
||||
end
|
||||
|
||||
def required_column_keys
|
||||
%i[date amount]
|
||||
end
|
||||
|
||||
def column_keys
|
||||
%i[date amount name currency category tags account notes]
|
||||
end
|
||||
|
||||
def csv_template
|
||||
template = <<-CSV
|
||||
Date,Amount,Account Name,Description,Category,Labels,Currency,Notes,Transaction Type
|
||||
01/01/2024,-8.55,Checking,Starbucks,Food & Drink,Coffee|Breakfast,USD,Morning coffee,debit
|
||||
04/15/2024,2000,Savings,Paycheck,Income,,USD,Bi-weekly salary,credit
|
||||
CSV
|
||||
|
||||
CSV.parse(template, headers: true)
|
||||
end
|
||||
|
||||
def signed_csv_amount(csv_row)
|
||||
amount = csv_row[amount_col_label]
|
||||
type = csv_row["Transaction Type"]
|
||||
|
||||
if type == "credit"
|
||||
amount.to_d
|
||||
else
|
||||
amount.to_d * -1
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def set_mappings
|
||||
self.signage_convention = "inflows_positive"
|
||||
self.date_col_label = "Date"
|
||||
self.date_format = "%m/%d/%Y"
|
||||
self.name_col_label = "Description"
|
||||
self.amount_col_label = "Amount"
|
||||
self.currency_col_label = "Currency"
|
||||
self.account_col_label = "Account Name"
|
||||
self.category_col_label = "Category"
|
||||
self.tags_col_label = "Labels"
|
||||
self.notes_col_label = "Notes"
|
||||
self.entity_type_col_label = "Transaction Type"
|
||||
|
||||
save!
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,38 @@ class Provider::Synth
|
||||
response = client.get("#{base_url}/user")
|
||||
JSON.parse(response.body).dig("id").present?
|
||||
end
|
||||
def usage
|
||||
response = client.get("#{base_url}/user")
|
||||
|
||||
if response.status == 401
|
||||
return UsageResponse.new(
|
||||
success?: false,
|
||||
error: "Unauthorized: Invalid API key",
|
||||
raw_response: response
|
||||
)
|
||||
end
|
||||
|
||||
parsed = JSON.parse(response.body)
|
||||
|
||||
remaining = parsed.dig("api_calls_remaining")
|
||||
limit = parsed.dig("api_limit")
|
||||
used = limit - remaining
|
||||
|
||||
UsageResponse.new(
|
||||
used: used,
|
||||
limit: limit,
|
||||
utilization: used.to_f / limit * 100,
|
||||
plan: parsed.dig("plan"),
|
||||
success?: true,
|
||||
raw_response: response
|
||||
)
|
||||
rescue StandardError => error
|
||||
UsageResponse.new(
|
||||
success?: false,
|
||||
error: error,
|
||||
raw_response: error
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_security_prices(ticker:, start_date:, end_date:)
|
||||
prices = paginate(
|
||||
@@ -96,6 +128,7 @@ class Provider::Synth
|
||||
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
|
||||
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
|
||||
ExchangeRatesResponse = Struct.new :rates, :success?, :error, :raw_response, keyword_init: true
|
||||
UsageResponse = Struct.new :used, :limit, :utilization, :plan, :success?, :error, :raw_response, keyword_init: true
|
||||
|
||||
def base_url
|
||||
"https://api.synthfinance.com"
|
||||
|
||||
@@ -27,7 +27,6 @@ class Security::Price < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def upcase_ticker
|
||||
self.ticker = ticker.upcase
|
||||
end
|
||||
|
||||
8
app/models/session.rb
Normal file
8
app/models/session.rb
Normal file
@@ -0,0 +1,8 @@
|
||||
class Session < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
before_create do
|
||||
self.user_agent = Current.user_agent
|
||||
self.ip_address = Current.ip_address
|
||||
end
|
||||
end
|
||||
@@ -17,21 +17,7 @@ class Setting < RailsSettings::Base
|
||||
default: ENV.fetch("UPGRADES_TARGET", "release"),
|
||||
validates: { inclusion: { in: %w[release commit] } }
|
||||
|
||||
field :app_domain, type: :string, default: ENV["APP_DOMAIN"]
|
||||
field :email_sender, type: :string, default: ENV["EMAIL_SENDER"]
|
||||
|
||||
field :synth_api_key, type: :string, default: ENV["SYNTH_API_KEY"]
|
||||
|
||||
field :require_invite_for_signup, type: :boolean, default: false
|
||||
|
||||
scope :smtp_settings do
|
||||
field :smtp_host, type: :string, read_only: true, default: ENV["SMTP_ADDRESS"]
|
||||
field :smtp_port, type: :string, read_only: true, default: ENV["SMTP_PORT"]
|
||||
field :smtp_username, type: :string, read_only: true, default: ENV["SMTP_USERNAME"]
|
||||
field :smtp_password, type: :string, read_only: true, default: ENV["SMTP_PASSWORD"]
|
||||
end
|
||||
|
||||
def self.smtp_settings_populated?
|
||||
Setting.defined_fields.select { |f| f.scope == :smtp_settings }.map(&:read).all?(&:present?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ class Tag < ApplicationRecord
|
||||
belongs_to :family
|
||||
has_many :taggings, dependent: :destroy
|
||||
has_many :transactions, through: :taggings, source: :taggable, source_type: "Account::Transaction"
|
||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||
|
||||
validates :name, presence: true, uniqueness: { scope: :family }
|
||||
|
||||
|
||||
52
app/models/trade_import.rb
Normal file
52
app/models/trade_import.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
class TradeImport < Import
|
||||
def import!
|
||||
transaction do
|
||||
mappings.each(&:create_mappable!)
|
||||
|
||||
rows.each do |row|
|
||||
account = mappings.accounts.mappable_for(row.account)
|
||||
security = Security.find_or_create_by(ticker: row.ticker)
|
||||
|
||||
entry = account.entries.build \
|
||||
date: row.date_iso,
|
||||
amount: row.signed_amount,
|
||||
name: row.name,
|
||||
currency: row.currency,
|
||||
entryable: Account::Trade.new(security: security, qty: row.qty, currency: row.currency, price: row.price),
|
||||
import: self
|
||||
|
||||
entry.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def mapping_steps
|
||||
[ Import::AccountMapping ]
|
||||
end
|
||||
|
||||
def required_column_keys
|
||||
%i[date ticker qty price]
|
||||
end
|
||||
|
||||
def column_keys
|
||||
%i[date ticker qty price currency account name]
|
||||
end
|
||||
|
||||
def dry_run
|
||||
{
|
||||
transactions: rows.count,
|
||||
accounts: Import::AccountMapping.for_import(self).creational.count
|
||||
}
|
||||
end
|
||||
|
||||
def csv_template
|
||||
template = <<-CSV
|
||||
date*,ticker*,qty*,price*,currency,account,name
|
||||
05/15/2024,AAPL,10,150.00,USD,Trading Account,Apple Inc. Purchase
|
||||
05/16/2024,GOOGL,-5,2500.00,USD,Investment Account,Alphabet Inc. Sale
|
||||
05/17/2024,TSLA,2,700.50,USD,Retirement Account,Tesla Inc. Purchase
|
||||
CSV
|
||||
|
||||
CSV.parse(template, headers: true)
|
||||
end
|
||||
end
|
||||
46
app/models/transaction_import.rb
Normal file
46
app/models/transaction_import.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
class TransactionImport < Import
|
||||
def import!
|
||||
transaction do
|
||||
mappings.each(&:create_mappable!)
|
||||
|
||||
rows.each do |row|
|
||||
account = mappings.accounts.mappable_for(row.account)
|
||||
category = mappings.categories.mappable_for(row.category)
|
||||
tags = row.tags_list.map { |tag| mappings.tags.mappable_for(tag) }.compact
|
||||
|
||||
entry = account.entries.build \
|
||||
date: row.date_iso,
|
||||
amount: row.signed_amount,
|
||||
name: row.name,
|
||||
currency: row.currency,
|
||||
entryable: Account::Transaction.new(category: category, tags: tags, notes: row.notes),
|
||||
import: self
|
||||
|
||||
entry.save!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def required_column_keys
|
||||
%i[date amount]
|
||||
end
|
||||
|
||||
def column_keys
|
||||
%i[date amount name currency category tags account notes]
|
||||
end
|
||||
|
||||
def mapping_steps
|
||||
[ Import::CategoryMapping, Import::TagMapping, Import::AccountMapping ]
|
||||
end
|
||||
|
||||
def csv_template
|
||||
template = <<-CSV
|
||||
date*,amount*,name,currency,category,tags,account,notes
|
||||
05/15/2024,-45.99,Grocery Store,USD,Food,groceries|essentials,Checking Account,Monthly grocery run
|
||||
05/16/2024,1500.00,Salary,,Income,,Main Account,
|
||||
05/17/2024,-12.50,Coffee Shop,,,coffee,,
|
||||
CSV
|
||||
|
||||
CSV.parse(template, headers: true)
|
||||
end
|
||||
end
|
||||
@@ -2,9 +2,11 @@ class User < ApplicationRecord
|
||||
has_secure_password
|
||||
|
||||
belongs_to :family
|
||||
has_many :sessions, dependent: :destroy
|
||||
accepts_nested_attributes_for :family
|
||||
|
||||
validates :email, presence: true, uniqueness: true
|
||||
validate :ensure_valid_profile_image
|
||||
normalizes :email, with: ->(email) { email.strip.downcase }
|
||||
|
||||
normalizes :first_name, :last_name, with: ->(value) { value.strip.presence }
|
||||
@@ -72,6 +74,14 @@ class User < ApplicationRecord
|
||||
end
|
||||
|
||||
private
|
||||
def ensure_valid_profile_image
|
||||
return unless profile_image.attached?
|
||||
|
||||
unless profile_image.content_type.in?(%w[image/jpeg image/png])
|
||||
errors.add(:profile_image, "must be a JPEG or PNG")
|
||||
profile_image.purge
|
||||
end
|
||||
end
|
||||
|
||||
def last_user_in_family?
|
||||
family.users.count == 1
|
||||
|
||||
@@ -9,7 +9,11 @@
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{entries.size}" %>
|
||||
<p class="uppercase space-x-1.5">
|
||||
<%= tag.span I18n.l(date, format: :long) %>
|
||||
<span>·</span>
|
||||
<%= tag.span entries.size %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
|
||||
scope: :account_entry,
|
||||
url: entry.new_record? ? account_trades_path(entry.account) : account_entry_path(entry.account, entry) do |form| %>
|
||||
url: account_trades_path(entry.account) do |form| %>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
|
||||
@@ -13,7 +13,7 @@
|
||||
<%= form.date_field :date, label: true %>
|
||||
|
||||
<div data-trade-form-target="amountInput" hidden>
|
||||
<%= money_with_currency_field form, :amount_money, label: t(".amount"), disable_currency: true %>
|
||||
<%= form.money_field :amount, :currency, label: t(".amount"), disable_currency: true %>
|
||||
</div>
|
||||
|
||||
<div data-trade-form-target="transferAccountInput" hidden>
|
||||
@@ -25,7 +25,7 @@
|
||||
</div>
|
||||
|
||||
<div data-trade-form-target="priceInput">
|
||||
<%= money_with_currency_field form, :price_money, label: t(".price"), disable_currency: true %>
|
||||
<%= form.money_field :price, :currency, label: t(".price"), disable_currency: true %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,119 +1,162 @@
|
||||
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
|
||||
|
||||
<%= drawer do %>
|
||||
<div>
|
||||
<header class="mb-4 space-y-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
|
||||
<span class="text-lg text-gray-500"><%= entry.currency %></span>
|
||||
</h3>
|
||||
<header class="mb-4 space-y-1">
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl">
|
||||
<%= format_money -entry.amount_money %>
|
||||
</span>
|
||||
|
||||
<% if entry.marked_as_transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<span class="text-lg text-gray-500">
|
||||
<%= entry.currency %>
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
|
||||
</header>
|
||||
<% if entry.marked_as_transfer? %>
|
||||
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".overview") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
<span class="text-sm text-gray-500">
|
||||
<%= I18n.l(entry.date, format: :long) %>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<div class="flex space-x-2">
|
||||
<div>
|
||||
<%= f.select :nature, [["Expense", "expense"], ["Income", "income"]], { label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" }, "data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
<div class="flex-grow">
|
||||
<%= f.number_field :amount, value: entry.amount.abs, label: t(".amount"), step: "0.01", "data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= f.date_field :date, label: t(".date_label"), max: Date.current, "data-auto-submit-form-target": "auto" %>
|
||||
<div class="space-y-2">
|
||||
<!-- Overview Section -->
|
||||
<%= disclosure t(".overview") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_transaction_path(account, entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_field :name,
|
||||
label: t(".name_label"),
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<%= ef.collection_select :merchant_id, Current.family.merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".additional") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "space-y-2", data: { controller: "auto-submit-form" } do |f| %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.select :tag_ids,
|
||||
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids),
|
||||
{
|
||||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
class: "placeholder:text-gray-500"
|
||||
},
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= ef.text_area :notes, label: t(".note_label"), placeholder: t(".note_placeholder"), "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".settings") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_transaction_path(account, entry), class: "p-3 space-y-3", data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none">
|
||||
<%= ef.check_box :excluded, class: "sr-only peer", "data-auto-submit-form-target": "auto" %>
|
||||
<label for="account_entry_entryable_attributes_excluded" class="maybe-switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= f.date_field :date,
|
||||
label: t(".date_label"),
|
||||
max: Date.current,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Expense", "expense"], ["Income", "income"]],
|
||||
{ container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" },
|
||||
{ data: { "auto-submit-form-target": "auto" } } %>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
<%= f.money_field :amount, :currency, label: t(".amount"),
|
||||
container_class: "w-2/3",
|
||||
auto_submit: true,
|
||||
min: 0,
|
||||
value: entry.amount.abs %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<%= f.select :account,
|
||||
options_for_select(
|
||||
Current.family.accounts.alphabetically.pluck(:name, :id),
|
||||
entry.account_id
|
||||
),
|
||||
{ label: t(".account_label") },
|
||||
{ disabled: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_transaction_path(account, entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<%= ef.collection_select :category_id,
|
||||
Current.family.categories.alphabetically,
|
||||
:id, :name,
|
||||
{ prompt: t(".category_placeholder"),
|
||||
label: t(".category_label"),
|
||||
class: "text-gray-400" },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<%= ef.collection_select :merchant_id,
|
||||
Current.family.merchants.alphabetically,
|
||||
:id, :name,
|
||||
{ prompt: t(".merchant_placeholder"),
|
||||
label: t(".merchant_label"),
|
||||
class: "text-gray-400" },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
||||
<%= ef.select :tag_ids,
|
||||
options_for_select(
|
||||
Current.family.tags.alphabetically.pluck(:name, :id),
|
||||
transaction.tag_ids
|
||||
),
|
||||
{
|
||||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
container_class: "h-40"
|
||||
},
|
||||
{ "data-auto-submit-form-target": "auto" } %>
|
||||
|
||||
<%= ef.text_area :notes,
|
||||
label: t(".note_label"),
|
||||
placeholder: t(".note_placeholder"),
|
||||
rows: 5,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<%= disclosure t(".settings") do %>
|
||||
<div class="pb-4">
|
||||
<!-- Exclude Transaction Form -->
|
||||
<%= styled_form_with model: [account, entry],
|
||||
url: account_transaction_path(account, entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none">
|
||||
<%= ef.check_box :excluded,
|
||||
class: "sr-only peer",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<label for="account_entry_entryable_attributes_excluded"
|
||||
class="maybe-switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<!-- Delete Transaction Form -->
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
account_entry_path(account, entry),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm
|
||||
font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<%= f.text_field :name, value: transfer.name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||
<%= f.collection_select :from_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
|
||||
<%= f.collection_select :to_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
|
||||
<%= money_field f, :amount_money, label: t(".amount"), required: true %>
|
||||
<%= f.money_field :amount, :currency, label: t(".amount"), required: true %>
|
||||
<%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -5,7 +5,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") } %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), required: "required", default_currency: Current.family.currency %>
|
||||
<%= f.money_field :balance, :currency, label: t(".balance"), required: true, default_currency: Current.family.currency %>
|
||||
|
||||
<% if account.new_record? %>
|
||||
<div class="flex items-center gap-2 mt-3 mb-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= button_to sync_all_accounts_path, class: "btn btn--light flex items-center gap-2", title: "Sync All" do %>
|
||||
<%= button_to sync_all_accounts_path, class: "btn btn--outline flex items-center gap-2", title: "Sync All" do %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
|
||||
<span><%= t("accounts.sync_all.button_text") %></span>
|
||||
<% end %>
|
||||
|
||||
59
app/views/import/cleans/show.html.erb
Normal file
59
app/views/import/cleans/show.html.erb
Normal file
@@ -0,0 +1,59 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "imports/nav", import: @import %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, import_configuration_path(@import) %>
|
||||
|
||||
<div class="space-y-4 mx-auto max-w-screen-lg">
|
||||
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-4">
|
||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h2>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<% if @import.cleaned? %>
|
||||
<div class="bg-white border border-alpha-black-100 rounded-lg p-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= lucide_icon "check-circle", class: "w-4 h-4 text-green-500" %>
|
||||
<p class="text-green-500">Your data has been cleaned</p>
|
||||
</div>
|
||||
|
||||
<%= link_to "Next step", import_confirm_path(@import), class: "btn btn--primary" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white border border-alpha-black-100 rounded-lg p-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= lucide_icon "alert-triangle", class: "w-4 h-4 text-red-500" %>
|
||||
<p class="text-red-500">You have errors in your data</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
|
||||
<%= link_to "All rows", import_clean_path(@import, per_page: params[:per_page], view: "all"), class: "p-2 rounded-lg #{params[:view] != 'errors' ? 'bg-white' : ''}" %>
|
||||
<%= link_to "Error rows", import_clean_path(@import, per_page: params[:per_page], view: "errors"), class: "p-2 rounded-lg #{params[:view] == 'errors' ? 'bg-white' : ''}" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="pb-12">
|
||||
<div class="bg-gray-25 rounded-xl p-1 mb-6">
|
||||
<div style="grid-template-columns: repeat(<%= @import.column_keys.count %>, 1fr)" class="grid items-center uppercase text-xs font-medium text-gray-500 py-3">
|
||||
<% @import.column_keys.each do |key| %>
|
||||
<div class="px-5"><%= import_col_label(key) %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
|
||||
<% @rows.each do |row| %>
|
||||
<%= render "import/rows/form", row: row %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fixed bottom-0 left-1/2 -translate-x-1/2 w-full p-12">
|
||||
<div class="border border-alpha-black-100 rounded-lg p-3 max-w-screen-sm mx-auto bg-white shadow-xs">
|
||||
<%= render "application/pagination", pagy: @pagy %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
9
app/views/import/configurations/_account_import.html.erb
Normal file
9
app/views/import/configurations/_account_import.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" 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.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<% end %>
|
||||
25
app/views/import/configurations/_mint_import.html.erb
Normal file
25
app/views/import/configurations/_mint_import.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<div class="flex items-center justify-between border border-alpha-black-200 rounded-lg bg-green-500/5 p-5 gap-4">
|
||||
<%= lucide_icon("check-circle", class: "w-5 h-5 shrink-0 text-green-500") %>
|
||||
<p class="text-sm text-gray-900 italic">We have pre-configured your Mint import for you. Please proceed to the next step.</p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true, disabled: import.complete? %>
|
||||
<%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], { label: true }, required: true, disabled: import.complete? %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Amount" }, required: true, disabled: import.complete? %>
|
||||
<%= form.select :signage_convention, [["Incomes are negative", "inflows_negative"], ["Incomes are positive", "inflows_positive"]], { label: true }, disabled: import.complete? %>
|
||||
</div>
|
||||
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" }, disabled: import.complete? %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" }, disabled: import.complete? %>
|
||||
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" }, disabled: import.complete? %>
|
||||
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" }, disabled: import.complete? %>
|
||||
|
||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<% end %>
|
||||
20
app/views/import/configurations/_trade_import.html.erb
Normal file
20
app/views/import/configurations/_trade_import.html.erb
Normal file
@@ -0,0 +1,20 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||
<%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :qty_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Quantity" } %>
|
||||
<%= form.select :signage_convention, [["Buys are positive qty", "inflows_positive"], ["Buys are negative qty", "inflows_negative"]], label: true %>
|
||||
</div>
|
||||
|
||||
<%= form.select :ticker_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Ticker" } %>
|
||||
<%= form.select :price_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Price" } %>
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
|
||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<% end %>
|
||||
21
app/views/import/configurations/_transaction_import.html.erb
Normal file
21
app/views/import/configurations/_transaction_import.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<%= styled_form_with model: @import, url: import_configuration_path(@import), scope: :import, method: :patch, class: "space-y-2" do |form| %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :date_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Date" }, required: true %>
|
||||
<%= form.select :date_format, [["DD-MM-YYYY", "%d-%m-%Y"], ["MM-DD-YYYY", "%m-%d-%Y"], ["YYYY-MM-DD", "%Y-%m-%d"], ["DD/MM/YYYY", "%d/%m/%Y"], ["YYYY/MM/DD", "%Y/%m/%d"], ["MM/DD/YYYY", "%m/%d/%Y"]], label: true, required: true %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form.select :amount_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Amount" }, required: true %>
|
||||
<%= form.select :signage_convention, [["Incomes are positive", "inflows_positive"], ["Incomes are negative", "inflows_negative"]], label: true %>
|
||||
</div>
|
||||
|
||||
<%= form.select :account_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Account (optional)" } %>
|
||||
<%= form.select :name_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Name (optional)" } %>
|
||||
<%= form.select :category_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Category (optional)" } %>
|
||||
<%= form.select :tags_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Tags (optional)" } %>
|
||||
<%= form.select :notes_col_label, import.csv_headers, { include_blank: "Leave empty", label: "Notes (optional)" } %>
|
||||
|
||||
<%= form.submit "Apply configuration", class: "w-full btn btn--primary", disabled: import.complete? %>
|
||||
<% end %>
|
||||
22
app/views/import/configurations/show.html.erb
Normal file
22
app/views/import/configurations/show.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "imports/nav", import: @import %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, import_upload_path(@import) %>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h1>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg">
|
||||
<%= render partial: permitted_import_configuration_path(@import), locals: { import: @import } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-screen-lg my-12">
|
||||
<%= render "imports/table", headers: @import.csv_headers, rows: @import.csv_sample, caption: "Sample data from your uploaded CSV" %>
|
||||
</div>
|
||||
</div>
|
||||
29
app/views/import/confirms/_mappings.html.erb
Normal file
29
app/views/import/confirms/_mappings.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%# locals: (import:, mapping_class:, step_idx:) %>
|
||||
|
||||
<% mappings = mapping_class.for_import(import) %>
|
||||
<% is_last_step = step_idx == import.mapping_steps.count - 1 %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="bg-gray-25 rounded-xl p-1 space-y-1 w-[650px]">
|
||||
<div class="grid grid-cols-3 gap-2 text-xs font-medium text-gray-500 uppercase px-5 py-3">
|
||||
<p>CSV <%= mapping_label(mapping_class) %></p>
|
||||
<p>Maybe <%= mapping_label(mapping_class) %></p>
|
||||
<p class="justify-self-end">Rows</p>
|
||||
</div>
|
||||
|
||||
<div class="border border-alpha-black-25 rounded-md shadow-xs divide-y divide-alpha-black-100 text-sm">
|
||||
<% mappings.sort_by(&:key).each do |mapping| %>
|
||||
<div class="px-5 py-3 bg-white first:rounded-tl-xl first:rounded-tr-xl last:rounded-bl-xl last:rounded-br-xl">
|
||||
<%= render partial: "import/mappings/form", locals: { mapping: mapping } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<%= link_to is_last_step ? import_path(import) : url_for(step: step_idx + 2), class: "btn btn--primary w-36 flex items-center justify-between gap-2" do %>
|
||||
<span>Next</span>
|
||||
<%= lucide_icon "arrow-right", class: "w-5 h-5" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
33
app/views/import/confirms/show.html.erb
Normal file
33
app/views/import/confirms/show.html.erb
Normal file
@@ -0,0 +1,33 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "imports/nav", import: @import %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, import_clean_path(@import) %>
|
||||
|
||||
<% step_idx = (params[:step] || "1").to_i - 1 %>
|
||||
<% step_mapping_class = @import.mapping_steps[step_idx] %>
|
||||
|
||||
<div class="space-y-12 mx-auto max-w-md mb-6">
|
||||
<div class="flex justify-center items-center gap-2">
|
||||
<% @import.mapping_steps.each_with_index do |step_mapping_class, idx| %>
|
||||
<% is_active = step_idx == idx %>
|
||||
|
||||
<%= link_to url_for(step: idx + 1), class: "w-5 h-[3px] #{is_active ? 'bg-gray-900' : 'bg-gray-100'} rounded-xl hover:bg-gray-300 transition-colors duration-200" do %>
|
||||
<span class="sr-only">Step <%= idx + 1 %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-gray-900 font-medium">
|
||||
<%= t(".#{step_mapping_class.name.demodulize.underscore}_title", import_type: @import.type.underscore.humanize) %>
|
||||
</h1>
|
||||
<p class="text-gray-500 text-sm">
|
||||
<%= t(".#{step_mapping_class.name.demodulize.underscore}_description", import_type: @import.type.underscore.humanize) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-screen-md mx-auto flex justify-center">
|
||||
<%= render partial: "import/confirms/mappings", locals: { import: @import, mapping_class: step_mapping_class, step_idx: step_idx } %>
|
||||
</div>
|
||||
29
app/views/import/mappings/_form.html.erb
Normal file
29
app/views/import/mappings/_form.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<%# locals: (mapping:) %>
|
||||
|
||||
<%= styled_form_with model: mapping,
|
||||
scope: :import_mapping,
|
||||
url: import_mapping_path(mapping.import, mapping),
|
||||
class: "grid grid-cols-3 gap-2 items-center",
|
||||
data: { controller: "auto-submit-form" },
|
||||
html: { id: dom_id(mapping, :form) } do |form| %>
|
||||
<span><%= mapping.key.blank? ? "(unassigned)" : mapping.key %></span>
|
||||
|
||||
<% if mapping.mappable_class.present? %>
|
||||
<%= form.hidden_field :mappable_type, value: mapping.mappable_class, id: dom_id(mapping, :mappable_type) %>
|
||||
<%= form.select :mappable_id,
|
||||
mapping.selectable_values,
|
||||
{ container_class: mapping.invalid? ? "border-red-500" : nil, include_blank: mapping.requires_selection? ? "Select an option" : "Leave unassigned", selected: mapping.create_when_empty? ? mapping.class::CREATE_NEW_KEY : mapping.mappable_id },
|
||||
"data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change", disabled: mapping.import.complete?, id: dom_id(mapping, :mappable_id) %>
|
||||
<% else %>
|
||||
<%= form.select :value, mapping.selectable_values,
|
||||
{ container_class: mapping.invalid? ? "border-red-500" : nil, include_blank: mapping.requires_selection? ? "Select an option" : "Leave unassigned" },
|
||||
"data-auto-submit-form-target": "auto", "data-autosubmit-trigger-event": "change", disabled: mapping.import.complete?, id: dom_id(mapping, :value) %>
|
||||
<% end %>
|
||||
|
||||
<%= form.hidden_field :key, value: mapping.key, id: dom_id(mapping, :key) %>
|
||||
<%= form.hidden_field :type, value: mapping.type, id: dom_id(mapping, :type) %>
|
||||
|
||||
<span class="justify-self-end">
|
||||
<%= mapping.values_count %>
|
||||
</span>
|
||||
<% end %>
|
||||
27
app/views/import/rows/_form.html.erb
Normal file
27
app/views/import/rows/_form.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<%# locals: (row:) %>
|
||||
|
||||
<div style="grid-template-columns: repeat(<%= row.import.column_keys.count %>, 1fr)" class="first:rounded-tl-lg first:rounded-tr-lg last:rounded-bl-lg last:rounded-br-lg grid divide-x divide-alpha-black-200 group">
|
||||
<% row.import.column_keys.each_with_index do |key, idx| %>
|
||||
<%= turbo_frame_tag dom_id(row, key), title: row.valid? ? nil : row.errors.full_messages.join(", ") do %>
|
||||
<%= form_with(
|
||||
model: [row.import, row],
|
||||
scope: :import_row,
|
||||
url: import_row_path(row.import, row),
|
||||
method: :patch,
|
||||
data: {
|
||||
controller: "auto-submit-form",
|
||||
auto_submit_form_trigger_event_value: "blur"
|
||||
}
|
||||
) do |form| %>
|
||||
<%= form.text_field key,
|
||||
"data-auto-submit-form-target": "auto",
|
||||
class: [
|
||||
cell_class(row, key),
|
||||
idx == 0 ? "group-first:rounded-tl-lg group-last:rounded-bl-lg" : "",
|
||||
idx == row.import.column_keys.count - 1 ? "group-first:rounded-tr-lg group-last:rounded-br-lg" : "",
|
||||
],
|
||||
disabled: row.import.complete? %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
1
app/views/import/rows/show.html.erb
Normal file
1
app/views/import/rows/show.html.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= render "import/rows/form", row: @row %>
|
||||
69
app/views/import/uploads/show.html.erb
Normal file
69
app/views/import/uploads/show.html.erb
Normal file
@@ -0,0 +1,69 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "imports/nav", import: @import %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, imports_path %>
|
||||
|
||||
<div class="space-y-12">
|
||||
<div class="space-y-4 mx-auto max-w-md">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h1>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div data-controller="tabs" data-tabs-active-class="bg-white" data-tabs-default-tab-value="csv-paste-tab">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
|
||||
<button type="button" data-id="csv-paste-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Copy & Paste</button>
|
||||
<button type="button" data-id="csv-upload-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Upload CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-tabs-target="tab" id="csv-paste-tab">
|
||||
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
|
||||
<%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %>
|
||||
<%= form.text_area :raw_file_str,
|
||||
rows: 10,
|
||||
required: true,
|
||||
placeholder: "Paste your CSV file contents here",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<%= form.submit "Upload CSV", disabled: @import.complete? %>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
<div data-tabs-target="tab" id="csv-upload-tab" class="hidden">
|
||||
<%= styled_form_with model: @import, scope: :import, url: import_upload_path(@import), multipart: true, class: "space-y-2" do |form| %>
|
||||
<%= form.select :col_sep, [["Comma (,)", ","], ["Semicolon (;)", ";"]], label: true %>
|
||||
|
||||
<label for="import_csv_file" class="flex flex-col items-center justify-center w-full h-56 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<%= form.file_field :csv_file, class: "ml-32", "data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<%= form.submit "Upload CSV", disabled: @import.complete? %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5 mx-auto max-w-screen-xl">
|
||||
<div class="text-gray-500 p-2 mb-2">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||
<p class="text-sm"><%= t(".instructions_1") %></p>
|
||||
|
||||
</div>
|
||||
|
||||
<ul class="list-disc list-inside text-sm pl-8">
|
||||
<li><%= t(".instructions_2") %></li>
|
||||
<li><%= t(".instructions_3") %></li>
|
||||
<li><%= t(".instructions_4") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<%= render partial: "imports/table", locals: { headers: @import.csv_template.headers, rows: @import.csv_template } %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,25 +0,0 @@
|
||||
<%= styled_form_with model: @import, url: load_import_path(@import), class: "space-y-4" do |form| %>
|
||||
<%= form.text_area :raw_file_str,
|
||||
rows: 10,
|
||||
required: true,
|
||||
placeholder: "Paste your CSV file contents here",
|
||||
class: "rounded-md w-full border text-sm border-alpha-black-100 bg-white placeholder:text-gray-400" %>
|
||||
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium", data: { turbo_confirm: (@import.raw_file_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
|
||||
<div class="text-gray-500 p-2 mb-2">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||
<p class="text-sm"><%= t(".instructions") %></p>
|
||||
</div>
|
||||
|
||||
<ul class="list-disc text-sm pl-10">
|
||||
<li><%= t(".requirement1") %></li>
|
||||
<li><%= t(".requirement2") %></li>
|
||||
<li><%= t(".requirement3") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<%= render partial: "imports/sample_table" %>
|
||||
</div>
|
||||
@@ -1,39 +0,0 @@
|
||||
<%= styled_form_with model: @import, url: upload_import_path(@import), class: "dropzone space-y-4", data: { controller: "import-upload", import_upload_accepted_types_value: ["text/csv", "application/csv", ".csv"], import_upload_extension_value: "csv", import_upload_unacceptable_type_label_value: t(".allowed_filetypes") }, method: :patch, multipart: true do |form| %>
|
||||
<div class="flex items-center justify-center w-full">
|
||||
<label for="import_raw_file_str" class="raw-file-drop-box flex flex-col items-center justify-center w-full h-64 border-2 border-gray-300 border-dashed rounded-lg cursor-pointer bg-gray-50" data-action="dragover->import-upload#dragover dragleave->import-upload#dragleave drop->import-upload#drop">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<%= lucide_icon "plus", class: "w-5 h-5 text-gray-500" %>
|
||||
<%= form.file_field :raw_file_str, class: "hidden", direct_upload: false, accept: "text/csv,.csv,application/csv", data: { import_upload_target: "input", action: "change->import-upload#addFile" } %>
|
||||
<p class="mb-2 text-sm text-gray-500 mt-3">Drag and drop your csv file here or <span class="text-black">click to browse</span></p>
|
||||
<p class="text-xs text-gray-500">CSV (Max. 5MB)</p>
|
||||
<div class="csv-preview" data-import-upload-target="preview"></div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 mb-4 block w-full rounded-lg bg-alpha-black-25 text-gray text-sm font-medium", data: { import_upload_target: "submit", turbo_confirm: (@import.raw_file_str? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<% end %>
|
||||
|
||||
<div id="template-preview" class="hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<%= lucide_icon "file-text", class: "w-10 h-10 pt-2 text-black" %>
|
||||
<div class="flex flex-row items-center justify-center gap-0.5">
|
||||
<div><span data-import-upload-target="filename"></span></div>
|
||||
<div><span data-import-upload-target="filesize" class="font-semibold"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-alpha-black-25 rounded-xl p-1 mt-5">
|
||||
<div class="text-gray-500 p-2 mb-2">
|
||||
<div class="flex gap-2 mb-2">
|
||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||
<p class="text-sm">
|
||||
<%= t(".instructions") %>
|
||||
<span class="text-black underline">
|
||||
<%= link_to "download this template", "/transactions.csv", download: "" %>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<%= render partial: "imports/sample_table" %>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<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-[300px] gap-4">
|
||||
<p class="text-gray-900 mb-1 font-medium text-sm"><%= t(".message") %></p>
|
||||
<%= link_to new_import_path(enable_type_selector: true), 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 %>
|
||||
<%= link_to new_import_path(enable_type_selector: true), class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
||||
|
||||
18
app/views/imports/_failure.html.erb
Normal file
18
app/views/imports/_failure.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<div class="h-full flex flex-col justify-center items-center">
|
||||
<div class="space-y-6 max-w-sm">
|
||||
<div class="mx-auto bg-red-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||
<%= lucide_icon "alert-octagon", class: "w-5 h-5 text-red-500" %>
|
||||
</div>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="font-medium text-gray-900 text-center text-3xl">Import failed</h1>
|
||||
<p class="text-sm text-gray-500">Please check that your file format, for any errors and that all required fields are filled, then come back and try again.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= button_to "Try again", publish_import_path(import), class: "btn btn--primary text-center w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<%= styled_form_with model: @import do |form| %>
|
||||
<div class="mb-4 space-y-3">
|
||||
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
|
||||
<%= form.collection_select :col_sep, Import::Csv::COL_SEP_LIST, :to_s, -> { t(".col_sep_char.#{_1.ord}") }, { prompt: t(".select_col_sep"), label: t(".col_sep"), required: true } %>
|
||||
</div>
|
||||
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700" %>
|
||||
<% end %>
|
||||
@@ -1,51 +1,39 @@
|
||||
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-5 border-b last:border-b-0 border-alpha-black-50">
|
||||
<div>
|
||||
<div id="<%= dom_id import %>" class="flex items-center justify-between mx-4 py-4 border-b last:border-b-0 border-alpha-black-50">
|
||||
|
||||
<div class="flex items-center gap-1 mb-1">
|
||||
<p class="text-sm text-gray-900">
|
||||
<%= t(".label", account: import.account.name) %>
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<%= link_to import_path(import), class: "text-sm text-gray-900 hover:underline" do %>
|
||||
<%= t(".label", type: import.type.titleize, datetime: import.updated_at.strftime("%b %-d, %Y at %l:%M %p")) %>
|
||||
<% end %>
|
||||
|
||||
<% if import.pending? %>
|
||||
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
|
||||
<%= t(".in_progress") %>
|
||||
</span>
|
||||
<% elsif import.importing? %>
|
||||
<span class="px-1 py text-xs animate-pulse rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50">
|
||||
<%= t(".uploading") %>
|
||||
</span>
|
||||
<% elsif import.failed? %>
|
||||
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
||||
<%= t(".failed") %>
|
||||
</span>
|
||||
<% elsif import.complete? %>
|
||||
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
|
||||
<%= t(".complete") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if import.complete? %>
|
||||
<p class="text-xs text-gray-500"><%= t(".completed_on", datetime: import.updated_at.strftime("%Y-%m-%d")) %></p>
|
||||
<% else %>
|
||||
<p class="text-xs text-gray-500"><%= t(".started_on", datetime: import.created_at.strftime("%Y-%m-%d")) %></p>
|
||||
<% if import.pending? %>
|
||||
<span class="px-1 py text-xs rounded-full bg-gray-500/5 text-gray-500 border border-alpha-black-50">
|
||||
<%= t(".in_progress") %>
|
||||
</span>
|
||||
<% elsif import.importing? %>
|
||||
<span class="px-1 py text-xs animate-pulse rounded-full bg-orange-500/5 text-orange-500 border border-alpha-black-50">
|
||||
<%= t(".uploading") %>
|
||||
</span>
|
||||
<% elsif import.failed? %>
|
||||
<span class="px-1 py text-xs rounded-full bg-red-500/5 text-red-500 border border-alpha-black-50">
|
||||
<%= t(".failed") %>
|
||||
</span>
|
||||
<% elsif import.complete? %>
|
||||
<span class="px-1 py text-xs rounded-full bg-green-500/5 text-green-500 border border-alpha-black-50">
|
||||
<%= t(".complete") %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if import.complete? %>
|
||||
<div class="w-7 h-7 bg-green-500/5 flex items-center justify-center rounded-full">
|
||||
<%= lucide_icon("check", class: "text-green-500 w-4 h-4") %>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= link_to edit_import_path(import),
|
||||
<%= contextual_menu do %>
|
||||
<div class="w-48 p-1 text-sm leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= link_to import_path(import),
|
||||
class: "block w-full py-2 px-3 space-x-2 text-gray-900 hover:bg-gray-50 flex items-center rounded-lg" do %>
|
||||
<%= lucide_icon "pencil-line", class: "w-5 h-5 text-gray-500" %>
|
||||
<%= lucide_icon "eye", class: "w-5 h-5 text-gray-500" %>
|
||||
|
||||
<span><%= t(".edit") %></span>
|
||||
<% end %>
|
||||
<span><%= t(".view") %></span>
|
||||
<% end %>
|
||||
|
||||
<% unless import.complete? %>
|
||||
<%= button_to import_path(import),
|
||||
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",
|
||||
@@ -54,8 +42,7 @@
|
||||
|
||||
<span><%= t(".delete") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
|
||||
19
app/views/imports/_importing.html.erb
Normal file
19
app/views/imports/_importing.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<div class="h-full flex flex-col justify-center items-center">
|
||||
<div class="space-y-6 max-w-sm">
|
||||
<div class="mx-auto bg-gray-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||
<%= lucide_icon "loader", class: "animate-pulse w-5 h-5 text-gray-500" %>
|
||||
</div>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="font-medium text-gray-900 text-center text-3xl">Import in progress</h1>
|
||||
<p class="text-sm text-gray-500">Your import is in progress. Check the imports menu for status updates or click 'Check Status' to refresh the page for updates. Feel free to continue using the app.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= link_to "Check status", import_path(import), class: "block btn btn--primary text-center w-full" %>
|
||||
<%= link_to "Back to dashboard", root_path, class: "block btn btn--secondary text-center w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
40
app/views/imports/_nav.html.erb
Normal file
40
app/views/imports/_nav.html.erb
Normal file
@@ -0,0 +1,40 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<% steps = [
|
||||
{ name: "Upload", path: import_upload_path(import), is_complete: import.uploaded?, step_number: 1 },
|
||||
{ name: "Configure", path: import_configuration_path(import), is_complete: import.configured?, step_number: 2 },
|
||||
{ name: "Clean", path: import_clean_path(import), is_complete: import.cleaned?, step_number: 3 },
|
||||
{ name: "Map", path: import_confirm_path(import), is_complete: import.publishable?, step_number: 4 },
|
||||
{ name: "Confirm", path: import_path(import), is_complete: import.complete?, step_number: 5 }
|
||||
] %>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
<% steps.each_with_index do |step, idx| %>
|
||||
<li class="flex items-center gap-2 group">
|
||||
<% is_current = request.path == step[:path] %>
|
||||
|
||||
<% text_class = if is_current
|
||||
"text-gray-900"
|
||||
else
|
||||
step[:is_complete] ? "text-green-600" : "text-gray-500"
|
||||
end %>
|
||||
<% step_class = if is_current
|
||||
"bg-gray-900 text-white"
|
||||
else
|
||||
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
||||
end %>
|
||||
|
||||
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||
<%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
|
||||
</span>
|
||||
|
||||
<span><%= step[:name] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
@@ -1,18 +0,0 @@
|
||||
<% is_current = request.path == step[:path] %>
|
||||
<% text_class = if is_current
|
||||
"text-gray-900"
|
||||
else
|
||||
step[:complete] ? "text-green-600" : "text-gray-500"
|
||||
end %>
|
||||
<% step_class = if is_current
|
||||
"bg-gray-900 text-white"
|
||||
else
|
||||
step[:complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
||||
end %>
|
||||
|
||||
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||
<%= step[:complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : step_idx + 1 %>
|
||||
</span>
|
||||
<span><%= step[:name] %></span>
|
||||
</div>
|
||||
39
app/views/imports/_ready.html.erb
Normal file
39
app/views/imports/_ready.html.erb
Normal file
@@ -0,0 +1,39 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<div class="text-center space-y-2 mb-4 mx-auto max-w-md">
|
||||
<h1 class="text-3xl text-gray-900 font-medium"><%= t(".title") %></h1>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-screen-sm space-y-4">
|
||||
<div class="bg-gray-25 rounded-xl p-1 space-y-1">
|
||||
<div class="flex justify-between items-center text-xs font-medium text-gray-500 uppercase px-5 py-3">
|
||||
<p>item</p>
|
||||
<p class="justify-self-end">count</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-alpha-black-25 rounded-lg shadow-xs text-sm">
|
||||
<% import.dry_run.each do |key, count| %>
|
||||
<% resource = dry_run_resource(key) %>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 bg-white px-5 py-3 rounded-tl-lg rounded-tr-lg">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="<%= resource.bg_class %> w-8 h-8 rounded-full flex justify-center items-center">
|
||||
<%= lucide_icon resource.icon, class: "#{resource.text_class} w-5 h-5 shrink-0" %>
|
||||
</div>
|
||||
|
||||
<p><%= resource.label %></p>
|
||||
</div>
|
||||
|
||||
<p class="justify-self-end"><%= count %></p>
|
||||
</div>
|
||||
|
||||
<% if key != import.dry_run.keys.last %>
|
||||
<div class="h-px bg-alpha-black-50 ml-14 mr-5"></div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= button_to "Publish import", publish_import_path(import), class: "btn btn--primary w-full" %>
|
||||
</div>
|
||||
@@ -1,26 +0,0 @@
|
||||
<!--TODO: Once we have more styled tables for reference, refactor and DRY this up -->
|
||||
<div class="grid grid-cols-5 border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white">
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tl-md">date</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">name</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">category</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium">tags</div>
|
||||
<div class="bg-gray-25 px-3 py-2.5 border-b border-b-alpha-black-200 font-medium rounded-tr-md">amount</div>
|
||||
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-01-01</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Amazon</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Shopping</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Tag1|Tag2</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-24.99</div>
|
||||
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">2024-03-01</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Spotify</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200"></div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">-16.32</div>
|
||||
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-bl-md">2023-01-06</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Acme</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Income</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200">Tag3</div>
|
||||
<div class="px-3 py-2.5 border-b border-b-alpha-black-200 rounded-br-md">151.22</div>
|
||||
</div>
|
||||
18
app/views/imports/_success.html.erb
Normal file
18
app/views/imports/_success.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<%# locals: (import:) %>
|
||||
|
||||
<div class="h-full flex flex-col justify-center items-center">
|
||||
<div class="space-y-6 max-w-sm">
|
||||
<div class="mx-auto bg-green-500/5 h-8 w-8 rounded-full flex items-center justify-center">
|
||||
<%= lucide_icon "check", class: "w-5 h-5 text-green-500" %>
|
||||
</div>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="font-medium text-gray-900 text-center text-3xl">Import successful</h1>
|
||||
<p class="text-sm text-gray-500">Your imported data has been successfully added to the app and is now ready for use.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= link_to "Back to dashboard", root_path, class: "block btn btn--primary text-center w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
37
app/views/imports/_table.html.erb
Normal file
37
app/views/imports/_table.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<%# locals: (headers: [], rows: [], caption: nil) %>
|
||||
<div class="overflow-x-auto">
|
||||
<div class="border border-alpha-black-200 rounded-md shadow-xs text-sm bg-white w-full">
|
||||
<div class="grid border-b border-b-alpha-black-200" style="grid-template-columns: repeat(<%= headers.length %>, minmax(0, 1fr))">
|
||||
<% headers.each_with_index do |header, index| %>
|
||||
<div class="
|
||||
bg-gray-25 px-3 py-2.5 font-medium whitespace-nowrap overflow-x-auto
|
||||
first:rounded-tl-md last:rounded-tr-md
|
||||
<%= "border-r border-r-alpha-black-200" unless index == headers.length - 1 %>
|
||||
">
|
||||
<%= header %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% rows.each_with_index do |row, row_index| %>
|
||||
<div class="grid <%= "border-b border-b-alpha-black-200" if row_index < rows.length - 1 || caption %>" style="grid-template-columns: repeat(<%= headers.length %>, minmax(0, 1fr))">
|
||||
<% row.each_with_index do |(header, value), col_index| %>
|
||||
<div class="
|
||||
px-3 py-2.5 whitespace-nowrap overflow-x-auto flex items-start
|
||||
<%= "border-r border-r-alpha-black-200" unless col_index == row.length - 1 %>
|
||||
<%= "rounded-bl-md" if !caption && row_index == rows.length - 1 && col_index == 0 %>
|
||||
<%= "rounded-br-md" if !caption && row_index == rows.length - 1 && col_index == row.length - 1 %>
|
||||
">
|
||||
<%= value %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if caption %>
|
||||
<div class="px-3 py-2.5 text-center text-xs text-gray-900 rounded-b-md italic bg-gray-25 overflow-x-auto">
|
||||
<%= caption %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,90 +0,0 @@
|
||||
<div class="p-4 space-y-4 max-w-[420px]">
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-medium text-gray-900"><%= t(".import_transactions") %></h2>
|
||||
<button data-action="modal#close">
|
||||
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
|
||||
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
|
||||
<li>
|
||||
<% if Current.family.imports.pending.present? %>
|
||||
<%= link_to edit_import_path(Current.family.imports.pending.ordered.first), class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
|
||||
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".resume_latest_import") %>
|
||||
</span>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= link_to new_import_path, class: "flex items-center gap-3 p-4 group cursor-pointer", data: { turbo: false } do %>
|
||||
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_from_csv") %>
|
||||
</span>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500 ml-auto") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
||||
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_from_mint") %>
|
||||
</span>
|
||||
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
||||
</div>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
||||
<%= image_tag("empower-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 border border-alpha-black-100 rounded-md") %>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_from_empower") %>
|
||||
</span>
|
||||
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
||||
</div>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="flex items-center gap-3 p-4 group cursor-not-allowed">
|
||||
<%= image_tag("apple-logo.png", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_from_apple") %>
|
||||
</span>
|
||||
<span class="bg-indigo-500/5 rounded-full px-1.5 py-0.5 border border-alpha-black-25 uppercase text-xs font-medium text-indigo-500"><%= t(".soon") %></span>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-300 ml-auto") %>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1,48 +0,0 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
|
||||
<div class="mx-auto max-w-screen-md w-full py-24">
|
||||
<h1 class="sr-only"><%= t(".clean_import") %></h1>
|
||||
|
||||
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
|
||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".clean_and_edit") %></h2>
|
||||
<p class="text-gray-500 text-sm"><%= t(".clean_description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-25 rounded-xl p-1 mb-6">
|
||||
<div
|
||||
class="grid items-center uppercase text-xs font-medium text-gray-500 py-3"
|
||||
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
||||
<% @import.expected_fields.each do |field| %>
|
||||
<div class="px-5"><%= field.label %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white border border-alpha-black-200 rounded-xl shadow-xs divide-y divide-alpha-black-200">
|
||||
<% @import.csv.table.each_with_index do |row, row_index| %>
|
||||
<div
|
||||
class="grid divide-x divide-alpha-black-200"
|
||||
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
||||
<% row.fields.each_with_index do |value, col_index| %>
|
||||
<%= form_with model: @import,
|
||||
url: clean_import_url(@import),
|
||||
method: :patch,
|
||||
data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
|
||||
<%= form.fields_for :csv_update do |ff| %>
|
||||
<%= ff.hidden_field :row_idx, value: row_index %>
|
||||
<%= ff.hidden_field :col_idx, value: col_index %>
|
||||
<%= ff.text_field :value, value: value,
|
||||
id: "cell-#{row_index}-#{col_index}",
|
||||
class: "#{@import.csv.cell_valid?(row_index, col_index) ? "focus:border-transparent border-transparent" : "border-red-500"} border px-4 py-3.5 text-sm w-full bg-transparent focus:ring-gray-900 focus:ring-2 focus-visible:outline-none #{table_corner_class(row_index, col_index, @import.csv.table, row.fields)}",
|
||||
data: { "auto-submit-form-target" => "auto" } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @import.csv.valid? %>
|
||||
<%= link_to "Next", confirm_import_path(@import), class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium hover:bg-gray-700", data: { turbo: false } %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,23 +0,0 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
|
||||
<div class="mx-auto max-w-[400px] w-full py-24 space-y-4">
|
||||
<h1 class="sr-only"><%= t(".configure_title") %></h1>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".configure_subtitle") %></h2>
|
||||
<p class="text-gray-500 text-sm"><%= t(".configure_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= styled_form_with model: @import, url: configure_import_path(@import), class: "space-y-4" do |form| %>
|
||||
<%= form.fields_for :column_mappings do |mappings| %>
|
||||
<% @import.expected_fields.each do |field| %>
|
||||
<%= mappings.select field.key,
|
||||
options_for_select(@import.available_headers, @import.get_selected_header_for_field(field)),
|
||||
label: field.label,
|
||||
include_blank: field.optional? ? t(".optional") : false %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= form.submit t(".next"), class: "px-4 py-2 block w-full rounded-lg bg-gray-900 text-white text-sm font-medium cursor-pointer hover:bg-gray-700", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -1,18 +0,0 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
|
||||
<div class="mx-auto max-w-screen-md w-full py-24">
|
||||
<h1 class="sr-only"><%= t(".confirm_title") %></h1>
|
||||
|
||||
<div class="text-center space-y-2 max-w-[400px] mx-auto mb-8">
|
||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".confirm_subtitle") %></h2>
|
||||
<p class="text-gray-500 text-sm"><%= t(".confirm_description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 space-y-4">
|
||||
<%= entries_by_date(@import.dry_run, selectable: false) do |entries| %>
|
||||
<%= render entries, show_tags: true, selectable: false, editable: false %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= button_to "Import " + @import.csv.table.size.to_s + " transactions", confirm_import_path(@import), method: :patch, class: "px-4 py-2 block w-60 text-center mx-auto rounded-lg bg-gray-900 text-white text-sm font-medium hover:bg-gray-700", data: { turbo: false } %>
|
||||
</div>
|
||||
@@ -1,10 +0,0 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
|
||||
<div class="mx-auto max-w-[400px] w-full py-56">
|
||||
<h1 class="sr-only"><%= t(".edit_title") %></h1>
|
||||
<div class="space-y-2 mb-6 text-center">
|
||||
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
|
||||
</div>
|
||||
<%= render "form", import: @import %>
|
||||
</div>
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-medium text-gray-900"><%= t(".title") %></h1>
|
||||
|
||||
<%= link_to new_import_path(enable_type_selector: true), class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
|
||||
<%= link_to new_import_path, class: "btn btn--primary flex items-center gap-2", data: { turbo_frame: :modal } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
<span><%= t(".new") %></span>
|
||||
<% end %>
|
||||
@@ -19,7 +19,7 @@
|
||||
<h2 class="uppercase px-4 py-2 text-gray-500 text-xs"><%= t(".imports") %> · <%= @imports.size %></h2>
|
||||
|
||||
<div class="border border-alpha-gray-100 rounded-lg bg-white shadow-xs">
|
||||
<%= render @imports.ordered %>
|
||||
<%= render partial: "imports/import", collection: @imports.ordered %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
|
||||
<div class="mx-auto max-w-[550px] w-full py-24 space-y-4">
|
||||
<h1 class="sr-only"><%= t(".load_title") %></h1>
|
||||
|
||||
<div class="text-center space-y-2">
|
||||
<h2 class="text-3xl text-gray-900 font-medium"><%= t(".subtitle") %></h2>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div data-controller="tabs" data-tabs-active-class="bg-white" data-tabs-default-tab-value="csv-upload-tab">
|
||||
<div class="flex justify-center mb-4">
|
||||
<div class="bg-gray-50 rounded-lg inline-flex p-1 space-x-2 text-sm text-gray-900 font-medium">
|
||||
<button data-id="csv-upload-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Upload CSV</button>
|
||||
<button data-id="csv-paste-tab" class="p-2 rounded-lg" data-tabs-target="btn" data-action="click->tabs#select">Copy & Paste</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-tabs-target="tab" id="csv-upload-tab">
|
||||
<%= render partial: "imports/csv_upload", locals: { import: @import } %>
|
||||
</div>
|
||||
<div data-tabs-target="tab" id="csv-paste-tab" class="hidden">
|
||||
<%= render partial: "imports/csv_paste", locals: { import: @import } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,16 +1,108 @@
|
||||
<%= content_for :return_to_path, return_to_path(params, imports_path) %>
|
||||
<%= modal do %>
|
||||
<div class="p-4 space-y-4 max-w-[420px]">
|
||||
<div class="space-y-2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="font-medium text-gray-900"><%= t(".title") %></h2>
|
||||
<button data-action="modal#close" tabindex="-1">
|
||||
<%= lucide_icon("x", class: "w-5 h-5 text-gray-900") %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<% if params[:enable_type_selector].present? %>
|
||||
<%= modal do %>
|
||||
<%= render "type_selector" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[400px] w-full py-56">
|
||||
<h1 class="sr-only">New import</h1>
|
||||
<div class="space-y-2 mb-6 text-center">
|
||||
<p class="text-3xl font-medium text-gray-900"><%= t(".header_text") %></p>
|
||||
<p class="text-gray-500 text-sm"><%= t(".description_text") %></p>
|
||||
<div class="rounded-xl bg-gray-25 p-1">
|
||||
<h3 class="uppercase text-gray-500 text-xs font-medium px-3 py-1.5"><%= t(".sources") %></h3>
|
||||
<ul class="bg-white border border-alpha-black-25 rounded-lg shadow-xs">
|
||||
<li>
|
||||
<% if @pending_import.present? %>
|
||||
<%= link_to import_path(@pending_import), class: "flex items-center justify-between p-4 group cursor-pointer", data: { turbo: false } do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-orange-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||
<%= lucide_icon("loader", class: "w-5 h-5 text-orange-500") %>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".resume") %>
|
||||
</span>
|
||||
</div>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
<% end %>
|
||||
<li>
|
||||
<%= button_to imports_path(import: { type: "TransactionImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-indigo-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||
<%= lucide_icon("file-spreadsheet", class: "w-5 h-5 text-indigo-500") %>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_transactions") %>
|
||||
</span>
|
||||
</div>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= button_to imports_path(import: { type: "TradeImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-yellow-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||
<%= lucide_icon("square-percent", class: "w-5 h-5 text-yellow-500") %>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_portfolio") %>
|
||||
</span>
|
||||
</div>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= button_to imports_path(import: { type: "AccountImport" }), class: "flex items-center justify-between p-4 group cursor-pointer w-full", data: { turbo: false } do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-violet-500/5 rounded-md w-8 h-8 flex items-center justify-center">
|
||||
<%= lucide_icon("building", class: "w-5 h-5 text-violet-500") %>
|
||||
</div>
|
||||
<span class="text-sm text-gray-900 group-hover:text-gray-700">
|
||||
<%= t(".import_accounts") %>
|
||||
</span>
|
||||
</div>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<%= button_to imports_path(import: { type: "MintImport" }), class: "flex items-center justify-between p-4 group w-full", data: { turbo: false } do %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= image_tag("mint-logo.jpeg", alt: "Mint logo", class: "w-8 h-8 rounded-md") %>
|
||||
<span class="text-sm text-gray-900">
|
||||
<%= t(".import_mint") %>
|
||||
</span>
|
||||
</div>
|
||||
<%= lucide_icon("chevron-right", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
|
||||
<div class="pl-14 pr-3">
|
||||
<div class="h-px bg-alpha-black-50"></div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "form", import: @import %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user