Compare commits
14 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0e0c2bf62 | ||
|
|
fa08f027c7 | ||
|
|
b200b71284 | ||
|
|
ef0f910b9b | ||
|
|
e9f42c1a65 | ||
|
|
e51806b98b | ||
|
|
47523f64c2 | ||
|
|
d0bc959bee | ||
|
|
cdbca5aff3 | ||
|
|
41f9e23f8c | ||
|
|
12123449b7 | ||
|
|
a70c6666dc | ||
|
|
1bd5397701 | ||
|
|
37d5c149ba |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,6 +51,9 @@
|
||||
# Ignore .devcontainer files
|
||||
compose-dev.yaml
|
||||
|
||||
# Ignore asdf ruby version file
|
||||
.tool-versions
|
||||
|
||||
# Ignore GCP keyfile
|
||||
gcp-storage-keyfile.json
|
||||
|
||||
|
||||
1
Gemfile
1
Gemfile
@@ -44,6 +44,7 @@ gem "pagy"
|
||||
gem "rails-settings-cached"
|
||||
gem "tzinfo-data", platforms: %i[ windows jruby ]
|
||||
gem "csv"
|
||||
gem "redcarpet"
|
||||
|
||||
group :development, :test do
|
||||
gem "debug", platforms: %i[ mri windows ]
|
||||
|
||||
125
Gemfile.lock
125
Gemfile.lock
@@ -7,32 +7,32 @@ GIT
|
||||
|
||||
GIT
|
||||
remote: https://github.com/rails/rails.git
|
||||
revision: 8075866ae8dfee76e1c6099b9eea6dcb7df70803
|
||||
revision: 8035bece705f60e6bddca70ee7d88e935a242bf8
|
||||
branch: 7-2-stable
|
||||
specs:
|
||||
actioncable (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actioncable (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionmailbox (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionmailer (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionpack (7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
nokogiri (>= 1.8.5)
|
||||
racc
|
||||
rack (>= 2.2.4)
|
||||
@@ -41,35 +41,35 @@ GIT
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actiontext (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
actionview (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activejob (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activerecord (7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activemodel (7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
activerecord (7.2.0.beta3)
|
||||
activemodel (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
activestorage (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
marcel (~> 1.0)
|
||||
activesupport (7.2.0.beta2)
|
||||
activesupport (7.2.0.beta3)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -79,23 +79,23 @@ GIT
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
rails (7.2.0.beta2)
|
||||
actioncable (= 7.2.0.beta2)
|
||||
actionmailbox (= 7.2.0.beta2)
|
||||
actionmailer (= 7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
actiontext (= 7.2.0.beta2)
|
||||
actionview (= 7.2.0.beta2)
|
||||
activejob (= 7.2.0.beta2)
|
||||
activemodel (= 7.2.0.beta2)
|
||||
activerecord (= 7.2.0.beta2)
|
||||
activestorage (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
rails (7.2.0.beta3)
|
||||
actioncable (= 7.2.0.beta3)
|
||||
actionmailbox (= 7.2.0.beta3)
|
||||
actionmailer (= 7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
actiontext (= 7.2.0.beta3)
|
||||
actionview (= 7.2.0.beta3)
|
||||
activejob (= 7.2.0.beta3)
|
||||
activemodel (= 7.2.0.beta3)
|
||||
activerecord (= 7.2.0.beta3)
|
||||
activestorage (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.2.0.beta2)
|
||||
railties (7.2.0.beta2)
|
||||
actionpack (= 7.2.0.beta2)
|
||||
activesupport (= 7.2.0.beta2)
|
||||
railties (= 7.2.0.beta3)
|
||||
railties (7.2.0.beta3)
|
||||
actionpack (= 7.2.0.beta3)
|
||||
activesupport (= 7.2.0.beta3)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -180,8 +180,9 @@ GEM
|
||||
tzinfo
|
||||
faker (3.4.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.9.2)
|
||||
faraday (2.10.0)
|
||||
faraday-net_http (>= 2.0, < 3.2)
|
||||
logger
|
||||
faraday-net_http (3.1.0)
|
||||
net-http
|
||||
faraday-retry (2.2.1)
|
||||
@@ -192,7 +193,7 @@ GEM
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
good_job (4.0.0)
|
||||
good_job (4.0.3)
|
||||
activejob (>= 6.1.0)
|
||||
activerecord (>= 6.1.0)
|
||||
concurrent-ruby (>= 1.3.1)
|
||||
@@ -305,7 +306,7 @@ GEM
|
||||
nio4r (~> 2.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.8.0)
|
||||
rack (3.1.6)
|
||||
rack (3.1.7)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.1.0)
|
||||
@@ -331,10 +332,11 @@ GEM
|
||||
rb-fsevent (0.11.2)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rbs (3.5.1)
|
||||
rbs (3.5.2)
|
||||
logger
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
redcarpet (3.6.0)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
@@ -369,12 +371,12 @@ GEM
|
||||
rubocop-minitest
|
||||
rubocop-performance
|
||||
rubocop-rails
|
||||
ruby-lsp (0.17.4)
|
||||
ruby-lsp (0.17.7)
|
||||
language_server-protocol (~> 3.17.0)
|
||||
prism (>= 0.29.0, < 0.31)
|
||||
rbs (>= 3, < 4)
|
||||
sorbet-runtime (>= 0.5.10782)
|
||||
ruby-lsp-rails (0.3.8)
|
||||
ruby-lsp-rails (0.3.10)
|
||||
ruby-lsp (>= 0.17.2, < 0.18.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-vips (2.2.1)
|
||||
@@ -403,7 +405,7 @@ GEM
|
||||
simplecov-html (0.12.3)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sorbet-runtime (0.5.11473)
|
||||
sorbet-runtime (0.5.11481)
|
||||
stackprof (0.2.26)
|
||||
stimulus-rails (1.3.3)
|
||||
railties (>= 6.0.0)
|
||||
@@ -491,6 +493,7 @@ DEPENDENCIES
|
||||
puma (>= 5.0)
|
||||
rails!
|
||||
rails-settings-cached
|
||||
redcarpet
|
||||
rubocop-rails-omakase
|
||||
ruby-lsp-rails
|
||||
selenium-webdriver
|
||||
|
||||
@@ -15,16 +15,16 @@
|
||||
|
||||
@layer components {
|
||||
.form-field {
|
||||
@apply relative rounded-md border bg-white border-alpha-black-100 shadow-xs;
|
||||
@apply flex flex-col gap-1 relative px-3 py-2 rounded-md border bg-white border-alpha-black-100 shadow-xs w-full;
|
||||
@apply focus-within:border-gray-900 focus-within:shadow-none focus-within:ring-4 focus-within:ring-gray-100;
|
||||
}
|
||||
|
||||
.form-field__label {
|
||||
@apply block px-3 pt-2 pb-0 text-xs text-gray-500;
|
||||
@apply block text-xs text-gray-500;
|
||||
}
|
||||
|
||||
.form-field__input {
|
||||
@apply w-full border-none bg-transparent px-3 pt-1 pb-2 text-sm opacity-100;
|
||||
@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;
|
||||
@@ -58,6 +58,19 @@
|
||||
background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='111827' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
||||
select[multiple="multiple"] {
|
||||
@apply py-2 pr-2 space-y-0.5;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option {
|
||||
@apply p-2 rounded-md;
|
||||
}
|
||||
|
||||
select[multiple="multiple"] option:checked {
|
||||
@apply bg-gray-50;
|
||||
@apply after:content-['\2713'] after:float-right after:text-gray-500;
|
||||
}
|
||||
|
||||
.maybe-switch {
|
||||
@apply block bg-gray-100 w-9 h-5 rounded-full cursor-pointer;
|
||||
@apply after:content-[''] after:block after:absolute after:top-0.5 after:left-0.5 after:bg-white after:w-4 after:h-4 after:rounded-full after:transition-transform after:duration-300 after:ease-in-out;
|
||||
|
||||
@@ -31,7 +31,7 @@ class Account::EntriesController < ApplicationController
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @entry.errors.full_messages.to_sentence
|
||||
flash[:alert] = @entry.errors.full_messages.to_sentence
|
||||
redirect_to account_path(@account)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,7 +23,7 @@ class Account::TransfersController < ApplicationController
|
||||
else
|
||||
# TODO: this is not an ideal way to handle errors and should eventually be improved.
|
||||
# See: https://github.com/hotwired/turbo-rails/pull/367
|
||||
flash[:error] = @transfer.errors.full_messages.to_sentence
|
||||
flash[:alert] = @transfer.errors.full_messages.to_sentence
|
||||
redirect_to transactions_path
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,13 +19,11 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def list
|
||||
render layout: false
|
||||
end
|
||||
|
||||
def new
|
||||
@account = Account.new(
|
||||
balance: nil,
|
||||
accountable: Accountable.from_type(params[:type])&.new
|
||||
)
|
||||
@account = Account.new(accountable: Accountable.from_type(params[:type])&.new)
|
||||
|
||||
if params[:institution_id]
|
||||
@account.institution = Current.family.institutions.find_by(id: params[:institution_id])
|
||||
@@ -40,8 +38,10 @@ class AccountsController < ApplicationController
|
||||
end
|
||||
|
||||
def update
|
||||
@account.update! account_params.except(:accountable_type, :balance)
|
||||
@account.update_balance!(account_params[:balance]) if account_params[:balance]
|
||||
Account.transaction do
|
||||
@account.update! account_params.except(:accountable_type, :balance)
|
||||
@account.update_balance!(account_params[:balance]) if account_params[:balance]
|
||||
end
|
||||
@account.sync_later
|
||||
redirect_back_or_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
@@ -68,8 +68,6 @@ class AccountsController < ApplicationController
|
||||
unless @account.syncing?
|
||||
@account.sync_later
|
||||
end
|
||||
|
||||
redirect_to account_path(@account), notice: t(".success")
|
||||
end
|
||||
|
||||
def sync_all
|
||||
|
||||
@@ -2,8 +2,6 @@ class ApplicationController < ActionController::Base
|
||||
include Authentication, Invitable, SelfHostable
|
||||
include Pagy::Backend
|
||||
|
||||
default_form_builder ApplicationFormBuilder
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class CurrenciesController < ApplicationController
|
||||
def show
|
||||
@currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
|
||||
render json: { step: @currency.step, placeholder: Money.new(0, @currency).format }
|
||||
currency = Money::Currency.all_instances.find { |currency| currency.iso_code == params[:id] }
|
||||
render json: currency.as_json.merge({ step: currency.step })
|
||||
end
|
||||
end
|
||||
|
||||
11
app/controllers/help/articles_controller.rb
Normal file
11
app/controllers/help/articles_controller.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class Help::ArticlesController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
def show
|
||||
@article = Help::Article.find(params[:id])
|
||||
|
||||
unless @article
|
||||
head :not_found
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -38,11 +38,26 @@ class ImportsController < ApplicationController
|
||||
def load
|
||||
end
|
||||
|
||||
def upload_csv
|
||||
begin
|
||||
@import.raw_csv_str = import_params[:raw_csv_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[:error] = @import.errors.full_messages.to_sentence
|
||||
flash.now[:alert] = @import.errors.full_messages.to_sentence
|
||||
render :load, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -19,7 +19,7 @@ class Settings::HostingsController < SettingsController
|
||||
|
||||
def send_test_email
|
||||
unless Setting.smtp_settings_populated?
|
||||
flash[:error] = t(".missing_smtp_setting_error")
|
||||
flash[:alert] = t(".missing_smtp_setting_error")
|
||||
render(:show, status: :unprocessable_entity)
|
||||
return
|
||||
end
|
||||
@@ -27,7 +27,7 @@ class Settings::HostingsController < SettingsController
|
||||
begin
|
||||
NotificationMailer.with(user: Current.user).test_email.deliver_now
|
||||
rescue => _e
|
||||
flash[:error] = t(".error")
|
||||
flash[:alert] = t(".error")
|
||||
render :show, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
||||
def initialize(object_name, object, template, options)
|
||||
options[:html] ||= {}
|
||||
options[:html][:class] ||= "space-y-4"
|
||||
|
||||
super(object_name, object, template, options)
|
||||
end
|
||||
|
||||
(field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]).each do |selector|
|
||||
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
|
||||
def #{selector}(method, options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(options)
|
||||
|
||||
return super(method, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
# See `Monetizable` concern, which adds a _money suffix to the attribute name
|
||||
# For a monetized field, the setter will always be the attribute name without the _money suffix
|
||||
def money_field(method, options = {})
|
||||
money = @object && @object.respond_to?(method) ? @object.send(method) : nil
|
||||
raise ArgumentError, "The value of #{method} is not a Money object" unless money.is_a?(Money) || money.nil?
|
||||
|
||||
money_amount_method = method.to_s.chomp("_money").to_sym
|
||||
money_currency_method = :currency
|
||||
|
||||
readonly_currency = options[:readonly_currency] || false
|
||||
|
||||
currency = money&.currency || Money::Currency.new(Current.family.currency) || Money.default_currency
|
||||
default_options = {
|
||||
class: "form-field__input",
|
||||
value: money&.amount,
|
||||
"data-money-field-target" => "amount",
|
||||
placeholder: Money.new(0, currency).format,
|
||||
min: -99999999999999,
|
||||
max: 99999999999999,
|
||||
step: currency.step
|
||||
}
|
||||
|
||||
merged_options = default_options.merge(options)
|
||||
|
||||
grouped_options = currency_options_for_select
|
||||
selected_currency = money&.currency&.iso_code || currency.iso_code
|
||||
|
||||
@template.form_field_tag data: { controller: "money-field" } do
|
||||
(label(method, *label_args(options)).to_s if options[:label]) +
|
||||
@template.tag.div(class: "flex items-center") do
|
||||
number_field(money_amount_method, merged_options.except(:label)) +
|
||||
grouped_select(money_currency_method, grouped_options, { selected: selected_currency }, disabled: readonly_currency, class: "ml-auto form-field__input w-fit pr-8", data: { "money-field-target" => "currency", action: "change->money-field#handleCurrencyChange" })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
default_options = { class: "form-field__radio" }
|
||||
merged_options = default_options.merge(options)
|
||||
super(method, tag_value, merged_options)
|
||||
end
|
||||
|
||||
def grouped_select(method, grouped_choices, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_html_options = default_options.merge(html_options)
|
||||
|
||||
label_html = label(method, *label_args(options)).to_s if options[:label]
|
||||
select_html = @template.grouped_collection_select(@object_name, method, grouped_choices, :last, :first, :last, :first, options, merged_html_options)
|
||||
|
||||
@template.content_tag(:div, class: "flex items-center") do
|
||||
label_html.to_s.html_safe + select_html
|
||||
end
|
||||
end
|
||||
|
||||
def currency_select(method, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
choices = currency_options_for_select
|
||||
|
||||
return @template.grouped_collection_select(@object_name, method, choices, :last, :first, :last, :first, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
@template.grouped_collection_select(@object_name, method, choices, :last, :first, :last, :first, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def select(method, choices, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
return super(method, choices, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, choices, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def collection_select(method, collection, value_method, text_method, options = {}, html_options = {})
|
||||
default_options = { class: "form-field__input" }
|
||||
merged_options = default_options.merge(html_options)
|
||||
|
||||
return super(method, collection, value_method, text_method, options, merged_options) unless options[:label]
|
||||
|
||||
@template.form_field_tag do
|
||||
label(method, *label_args(options)) +
|
||||
super(method, collection, value_method, text_method, options, merged_options.except(:label))
|
||||
end
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
default_options = { class: "form-field__submit" }
|
||||
merged_options = default_options.merge(options)
|
||||
super(value, merged_options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def currency_options_for_select
|
||||
popular_currencies = Money::Currency.popular.map { |currency| [ currency.iso_code, currency.iso_code ] }
|
||||
all_currencies = Money::Currency.all_instances.map { |currency| [ currency.iso_code, currency.iso_code ] }
|
||||
all_other_currencies = all_currencies.reject { |c| popular_currencies.map(&:last).include?(c.last) }.sort_by(&:last)
|
||||
|
||||
{
|
||||
I18n.t("accounts.new.currency.popular") => popular_currencies,
|
||||
I18n.t("accounts.new.currency.all_others") => all_other_currencies
|
||||
}
|
||||
end
|
||||
|
||||
def label_args(options)
|
||||
case options[:label]
|
||||
when Array
|
||||
options[:label]
|
||||
when String
|
||||
[ options[:label], { class: "form-field__label" } ]
|
||||
when Hash
|
||||
[ nil, options[:label] ]
|
||||
else
|
||||
[ nil, { class: "form-field__label" } ]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -13,11 +13,18 @@ module ApplicationHelper
|
||||
name.underscore
|
||||
end
|
||||
|
||||
def notification(text, **options, &block)
|
||||
content = tag.p(text)
|
||||
content = capture &block if block_given?
|
||||
def family_notifications_stream
|
||||
turbo_stream_from [ Current.family, :notifications ] if Current.family
|
||||
end
|
||||
|
||||
render partial: "shared/notification", locals: { type: options[:type], content: { body: content } }
|
||||
def render_flash_notifications
|
||||
notifications = flash.flat_map do |type, message_or_messages|
|
||||
Array(message_or_messages).map do |message|
|
||||
render partial: "shared/notification", locals: { type: type, message: message }
|
||||
end
|
||||
end
|
||||
|
||||
safe_join(notifications)
|
||||
end
|
||||
|
||||
##
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
module FormsHelper
|
||||
def styled_form_with(**options, &block)
|
||||
options[:builder] = StyledFormBuilder
|
||||
form_with(**options, &block)
|
||||
end
|
||||
|
||||
def form_field_tag(options = {}, &block)
|
||||
options[:class] = [ "form-field", options[:class] ].compact.join(" ")
|
||||
tag.div **options, &block
|
||||
tag.div(**options, &block)
|
||||
end
|
||||
|
||||
def radio_tab_tag(form:, name:, value:, label:, icon:, checked: false, disabled: false)
|
||||
@@ -11,23 +16,60 @@ module FormsHelper
|
||||
end
|
||||
end
|
||||
|
||||
def selectable_categories
|
||||
Current.family.categories.alphabetically
|
||||
def period_select(form:, selected:, classes: "border border-alpha-black-100 shadow-xs rounded-lg text-sm pr-7 cursor-pointer text-gray-900 focus:outline-none focus:ring-0")
|
||||
periods_for_select = [ [ "7D", "last_7_days" ], [ "1M", "last_30_days" ], [ "1Y", "last_365_days" ], [ "All", "all" ] ]
|
||||
form.select(:period, periods_for_select, { selected: selected }, class: classes, data: { "auto-submit-form-target": "auto" })
|
||||
end
|
||||
|
||||
def selectable_merchants
|
||||
Current.family.merchants.alphabetically
|
||||
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 selectable_accounts
|
||||
Current.family.accounts.alphabetically
|
||||
def money_field(form, method, options = {})
|
||||
value = form.object.send(method)
|
||||
|
||||
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 selectable_tags
|
||||
Current.family.tags.alphabetically.pluck(:name, :id)
|
||||
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
|
||||
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")
|
||||
|
||||
55
app/helpers/styled_form_builder.rb
Normal file
55
app/helpers/styled_form_builder.rb
Normal file
@@ -0,0 +1,55 @@
|
||||
class StyledFormBuilder < ActionView::Helpers::FormBuilder
|
||||
# Fields that visually inherit from "text field"
|
||||
class_attribute :text_field_helpers, default: field_helpers - [ :label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field ]
|
||||
|
||||
# Wraps "text" inputs with custom structure + base styles
|
||||
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
|
||||
end
|
||||
RUBY_EVAL
|
||||
end
|
||||
|
||||
def radio_button(method, tag_value, options = {})
|
||||
super(method, tag_value, merged_options(options, "form-field__radio"))
|
||||
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
|
||||
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
|
||||
end
|
||||
|
||||
def submit(value = nil, options = {})
|
||||
value, options = nil, value if value.is_a?(Hash)
|
||||
super(value, merged_options(options, "form-field__submit"))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_form_field_wrapper(input_html, **options)
|
||||
@template.form_field_tag(**options) do
|
||||
input_html
|
||||
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)
|
||||
options[:label] ? label(method, options[:label], class: "form-field__label") : "".html_safe
|
||||
end
|
||||
end
|
||||
94
app/javascript/controllers/csv_upload_controller.js
Normal file
94
app/javascript/controllers/csv_upload_controller.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["input", "preview", "submit", "filename", "filesize"]
|
||||
|
||||
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._isCSVFile(file)) {
|
||||
this._setFileInput(file);
|
||||
this._fileAdded(file)
|
||||
} else {
|
||||
this.previewTarget.classList.add("text-red-500")
|
||||
this.previewTarget.textContent = "Only CSV files are allowed."
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = "File size exceeds the limit of 5MB"
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
_isCSVFile(file) {
|
||||
const acceptedTypes = ["text/csv", "application/csv", ".csv"]
|
||||
const extension = file.name.split('.').pop().toLowerCase()
|
||||
return acceptedTypes.includes(file.type) || extension === "csv"
|
||||
}
|
||||
|
||||
_setFileInput(file) {
|
||||
const dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
this.inputTarget.files = dataTransfer.files;
|
||||
}
|
||||
}
|
||||
@@ -4,17 +4,23 @@ import { CurrenciesService } from "services/currencies_service";
|
||||
// Connects to data-controller="money-field"
|
||||
// when currency select change, update the input value with the correct placeholder and step
|
||||
export default class extends Controller {
|
||||
static targets = ["amount", "currency"];
|
||||
static targets = ["amount", "currency", "symbol"];
|
||||
|
||||
handleCurrencyChange() {
|
||||
const selectedCurrency = event.target.value;
|
||||
handleCurrencyChange(e) {
|
||||
const selectedCurrency = e.target.value;
|
||||
this.updateAmount(selectedCurrency);
|
||||
}
|
||||
|
||||
updateAmount(currency) {
|
||||
(new CurrenciesService).get(currency).then((data) => {
|
||||
this.amountTarget.placeholder = data.placeholder;
|
||||
this.amountTarget.step = data.step;
|
||||
(new CurrenciesService).get(currency).then((currency) => {
|
||||
console.log(currency)
|
||||
this.amountTarget.step = currency.step;
|
||||
|
||||
if (isFinite(this.amountTarget.value)) {
|
||||
this.amountTarget.value = parseFloat(this.amountTarget.value).toFixed(currency.default_precision)
|
||||
}
|
||||
|
||||
this.symbolTarget.innerText = currency.symbol;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
/**
|
||||
* A custom "select" element that follows accessibility patterns of a native select element.
|
||||
*
|
||||
* - If you need to display arbitrary content including non-clickable items, links, buttons, and forms, use the "popover" controller instead.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static classes = ["active"];
|
||||
static targets = ["option", "button", "list", "input", "buttonText"];
|
||||
static values = { selected: String };
|
||||
|
||||
initialize() {
|
||||
this.show = false;
|
||||
|
||||
const selectedElement = this.optionTargets.find(
|
||||
(option) => option.dataset.value === this.selectedValue
|
||||
);
|
||||
if (selectedElement) {
|
||||
this.updateAriaAttributesAndClasses(selectedElement);
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.syncButtonTextWithInput();
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.addEventListener("click", this.toggleList);
|
||||
}
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
this.element.addEventListener("turbo:load", this.handleTurboLoad);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.removeEventListener("click", this.toggleList);
|
||||
}
|
||||
}
|
||||
|
||||
selectedValueChanged() {
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
|
||||
handleOutsideClick = (event) => {
|
||||
if (this.show && !this.element.contains(event.target)) {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
handleTurboLoad = () => {
|
||||
this.close();
|
||||
this.syncButtonTextWithInput();
|
||||
};
|
||||
|
||||
handleKeydown = (event) => {
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
case "Enter":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
if (
|
||||
this.hasButtonTarget &&
|
||||
document.activeElement === this.buttonTarget
|
||||
) {
|
||||
this.toggleList();
|
||||
} else {
|
||||
this.selectOption(event);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
this.focusNextOption();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
this.focusPreviousOption();
|
||||
break;
|
||||
case "Escape":
|
||||
this.close();
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
focusNextOption() {
|
||||
this.focusOptionInDirection(1);
|
||||
}
|
||||
|
||||
focusPreviousOption() {
|
||||
this.focusOptionInDirection(-1);
|
||||
}
|
||||
|
||||
focusOptionInDirection(direction) {
|
||||
const currentFocusedIndex = this.optionTargets.findIndex(
|
||||
(option) => option === document.activeElement
|
||||
);
|
||||
const optionsCount = this.optionTargets.length;
|
||||
const nextIndex =
|
||||
(currentFocusedIndex + direction + optionsCount) % optionsCount;
|
||||
this.optionTargets[nextIndex].focus();
|
||||
}
|
||||
|
||||
toggleList = () => {
|
||||
if (!this.hasButtonTarget) return; // Ensure button target is present before toggling
|
||||
|
||||
this.show = !this.show;
|
||||
this.listTarget.classList.toggle("hidden", !this.show);
|
||||
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
|
||||
|
||||
if (this.show) {
|
||||
// Focus the first option or the selected option when the list is shown
|
||||
const selectedOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("aria-selected") === "true"
|
||||
);
|
||||
(selectedOption || this.optionTargets[0]).focus();
|
||||
}
|
||||
};
|
||||
|
||||
close() {
|
||||
if (this.hasButtonTarget) {
|
||||
this.show = false;
|
||||
this.listTarget.classList.add("hidden");
|
||||
this.buttonTarget.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
selectOption(event) {
|
||||
const selectedOption =
|
||||
event.type === "keydown" ? document.activeElement : event.currentTarget;
|
||||
this.updateAriaAttributesAndClasses(selectedOption);
|
||||
if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) {
|
||||
this.updateInputValueAndEmitEvent(selectedOption);
|
||||
}
|
||||
this.close(); // Close the list after selection
|
||||
}
|
||||
|
||||
updateAriaAttributesAndClasses(selectedOption) {
|
||||
this.optionTargets.forEach((option) => {
|
||||
option.setAttribute("aria-selected", "false");
|
||||
option.setAttribute("tabindex", "-1");
|
||||
option.classList.remove(...this.activeClasses);
|
||||
});
|
||||
selectedOption.classList.add(...this.activeClasses);
|
||||
selectedOption.setAttribute("aria-selected", "true");
|
||||
selectedOption.focus();
|
||||
}
|
||||
|
||||
updateInputValueAndEmitEvent(selectedOption) {
|
||||
// Update the hidden input's value
|
||||
const selectedValue = selectedOption.getAttribute("data-value");
|
||||
this.inputTarget.value = selectedValue;
|
||||
this.syncButtonTextWithInput();
|
||||
|
||||
// Emit an input event for auto-submit functionality
|
||||
const inputEvent = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
this.inputTarget.dispatchEvent(inputEvent);
|
||||
}
|
||||
|
||||
syncButtonTextWithInput() {
|
||||
const matchingOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("data-value") === this.inputTarget.value
|
||||
);
|
||||
if (matchingOption && this.hasButtonTextTarget) {
|
||||
this.buttonTextTarget.textContent = matchingOption.textContent.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,7 @@ class Account < ApplicationRecord
|
||||
include Syncable
|
||||
include Monetizable
|
||||
|
||||
broadcasts_refreshes
|
||||
|
||||
validates :family, presence: true
|
||||
validates :name, :balance, :currency, presence: true
|
||||
|
||||
belongs_to :family
|
||||
belongs_to :institution, optional: true
|
||||
@@ -12,6 +10,8 @@ class Account < ApplicationRecord
|
||||
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
|
||||
@@ -107,4 +107,12 @@ class Account < ApplicationRecord
|
||||
entryable: Account::Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def holding_qty(security, date: Date.current)
|
||||
entries.account_trades
|
||||
.joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id")
|
||||
.where(account_trades: { security_id: security.id })
|
||||
.where("account_entries.date <= ?", date)
|
||||
.sum("account_trades.qty")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,13 +27,9 @@ class Account::Balance::Syncer
|
||||
attr_reader :sync_start_date, :account
|
||||
|
||||
def upsert_balances!(balances)
|
||||
current_time = Time.now
|
||||
balances_to_upsert = balances.map do |balance|
|
||||
{
|
||||
date: balance.date,
|
||||
balance: balance.balance,
|
||||
currency: balance.currency,
|
||||
updated_at: Time.now
|
||||
}
|
||||
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
||||
@@ -49,9 +45,9 @@ class Account::Balance::Syncer
|
||||
return valuation.amount if valuation
|
||||
return derived_sync_start_balance(entries) unless prior_balance
|
||||
|
||||
transactions = entries.select { |e| e.date == date && e.account_transaction? }
|
||||
entries = entries.select { |e| e.date == date }
|
||||
|
||||
prior_balance - net_transaction_flows(transactions)
|
||||
prior_balance - net_entry_flows(entries)
|
||||
end
|
||||
|
||||
def calculate_daily_balances
|
||||
@@ -95,19 +91,19 @@ class Account::Balance::Syncer
|
||||
end
|
||||
|
||||
def derived_sync_start_balance(entries)
|
||||
transactions = entries.select { |e| e.account_transaction? && e.date > sync_start_date }
|
||||
transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date }
|
||||
|
||||
account.balance + net_transaction_flows(transactions)
|
||||
account.balance + net_entry_flows(transactions_and_trades)
|
||||
end
|
||||
|
||||
def find_prior_balance
|
||||
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
||||
end
|
||||
|
||||
def net_transaction_flows(transactions, target_currency = account.currency)
|
||||
converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
||||
def net_entry_flows(entries, target_currency = account.currency)
|
||||
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
||||
|
||||
flows = converted_transaction_amounts.sum(&:amount)
|
||||
flows = converted_entry_amounts.sum(&:amount)
|
||||
|
||||
account.liability? ? flows * -1 : flows
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ class Account::Entry < ApplicationRecord
|
||||
|
||||
validates :date, :amount, :currency, presence: true
|
||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validate :trade_valid?, if: -> { account_trade? }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
@@ -123,7 +124,7 @@ class Account::Entry < ApplicationRecord
|
||||
|
||||
def income_total(currency = "USD")
|
||||
without_transfers.account_transactions.includes(:entryable)
|
||||
.where("account_entries.amount <= 0")
|
||||
.where("account_entries.amount <= 0")
|
||||
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||
.sum
|
||||
end
|
||||
@@ -137,7 +138,7 @@ class Account::Entry < ApplicationRecord
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = query.where("account_entries.name ILIKE ?", "%#{params[:search]}%") if params[:search].present?
|
||||
query = query.where("account_entries.name ILIKE ?", "%#{sanitize_sql_like(params[:search])}%") if params[:search].present?
|
||||
query = query.where("account_entries.date >= ?", params[:start_date]) if params[:start_date].present?
|
||||
query = query.where("account_entries.date <= ?", params[:end_date]) if params[:end_date].present?
|
||||
|
||||
@@ -191,4 +192,14 @@ 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, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.symbol} because you only own #{current_qty} shares")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
module Account::Entryable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
TYPES = %w[ Account::Valuation Account::Transaction ]
|
||||
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
|
||||
|
||||
def self.from_type(entryable_type)
|
||||
entryable_type.presence_in(TYPES).constantize
|
||||
|
||||
6
app/models/account/holding.rb
Normal file
6
app/models/account/holding.rb
Normal file
@@ -0,0 +1,6 @@
|
||||
class Account::Holding < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :security
|
||||
|
||||
scope :chronological, -> { order(:date) }
|
||||
end
|
||||
96
app/models/account/holding/syncer.rb
Normal file
96
app/models/account/holding/syncer.rb
Normal file
@@ -0,0 +1,96 @@
|
||||
class Account::Holding::Syncer
|
||||
attr_reader :warnings
|
||||
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@warnings = []
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..Date.current
|
||||
@portfolio = {}
|
||||
|
||||
load_prior_portfolio if start_date
|
||||
end
|
||||
|
||||
def run
|
||||
holdings = []
|
||||
|
||||
sync_date_range.each do |date|
|
||||
holdings += build_holdings_for_date(date)
|
||||
end
|
||||
|
||||
upsert_holdings holdings
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :account, :sync_date_range
|
||||
|
||||
def sync_entries
|
||||
@sync_entries ||= account.entries
|
||||
.account_trades
|
||||
.includes(entryable: :security)
|
||||
.where("date >= ?", sync_date_range.begin)
|
||||
.order(:date)
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |isin, holding|
|
||||
price = Security::Price.find_by!(date: date, isin: isin).price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
security_id: holding[:security_id],
|
||||
qty: holding[:qty],
|
||||
price: price,
|
||||
amount: price * holding[:qty]
|
||||
end
|
||||
end
|
||||
|
||||
def generate_next_portfolio(prior_portfolio, trade_entries)
|
||||
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.isin, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.isin] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
security_id: trade.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_holdings(holdings)
|
||||
current_time = Time.now
|
||||
holdings_to_upsert = holdings.map do |holding|
|
||||
holding.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
|
||||
end
|
||||
|
||||
def load_prior_portfolio
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.isin] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
security_id: holding.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(start_date)
|
||||
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
end
|
||||
@@ -17,6 +17,7 @@ class Account::Sync < ApplicationRecord
|
||||
start!
|
||||
|
||||
sync_balances
|
||||
sync_holdings
|
||||
|
||||
complete!
|
||||
rescue StandardError => error
|
||||
@@ -33,19 +34,49 @@ class Account::Sync < ApplicationRecord
|
||||
append_warnings(syncer.warnings)
|
||||
end
|
||||
|
||||
def sync_holdings
|
||||
syncer = Account::Holding::Syncer.new(account, start_date: start_date)
|
||||
|
||||
syncer.run
|
||||
|
||||
append_warnings(syncer.warnings)
|
||||
end
|
||||
|
||||
def append_warnings(new_warnings)
|
||||
update! warnings: warnings + new_warnings
|
||||
end
|
||||
|
||||
def start!
|
||||
update! status: "syncing", last_ran_at: Time.now
|
||||
broadcast_start
|
||||
end
|
||||
|
||||
def complete!
|
||||
update! status: "completed"
|
||||
broadcast_result type: "notice", message: "Sync complete"
|
||||
end
|
||||
|
||||
def fail!(error)
|
||||
update! status: "failed", error: error.message
|
||||
broadcast_result type: "alert", message: error.message
|
||||
end
|
||||
|
||||
def broadcast_start
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { id: id, type: "processing", message: "Syncing account balances" }
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast_result(type:, message:)
|
||||
broadcast_remove_to account.family, :notifications, target: id # Remove persistent syncing notification
|
||||
broadcast_append_to(
|
||||
[ account.family, :notifications ],
|
||||
target: "notification-tray",
|
||||
partial: "shared/notification",
|
||||
locals: { type: type, message: message }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
26
app/models/account/trade.rb
Normal file
26
app/models/account/trade.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class Account::Trade < ApplicationRecord
|
||||
include Account::Entryable
|
||||
|
||||
belongs_to :security
|
||||
|
||||
validates :qty, presence: true, numericality: { other_than: 0 }
|
||||
validates :price, presence: true
|
||||
|
||||
class << self
|
||||
def search(_params)
|
||||
all
|
||||
end
|
||||
|
||||
def requires_search?(_params)
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
def sell?
|
||||
qty < 0
|
||||
end
|
||||
|
||||
def buy?
|
||||
qty > 0
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user
|
||||
|
||||
delegate :family, to: :user
|
||||
delegate :family, to: :user, allow_nil: true
|
||||
end
|
||||
|
||||
@@ -13,31 +13,33 @@ class Demo::Generator
|
||||
end
|
||||
|
||||
def reset_data!
|
||||
clear_data!
|
||||
create_user!
|
||||
Family.transaction do
|
||||
clear_data!
|
||||
create_user!
|
||||
|
||||
puts "user reset"
|
||||
puts "user reset"
|
||||
|
||||
create_tags!
|
||||
create_categories!
|
||||
create_merchants!
|
||||
create_tags!
|
||||
create_categories!
|
||||
create_merchants!
|
||||
|
||||
puts "tags, categories, merchants created"
|
||||
puts "tags, categories, merchants created"
|
||||
|
||||
create_credit_card_account!
|
||||
create_checking_account!
|
||||
create_savings_account!
|
||||
create_credit_card_account!
|
||||
create_checking_account!
|
||||
create_savings_account!
|
||||
|
||||
create_investment_account!
|
||||
create_house_and_mortgage!
|
||||
create_car_and_loan!
|
||||
create_investment_account!
|
||||
create_house_and_mortgage!
|
||||
create_car_and_loan!
|
||||
|
||||
puts "accounts created"
|
||||
puts "accounts created"
|
||||
|
||||
family.sync
|
||||
family.sync
|
||||
|
||||
puts "balances synced"
|
||||
puts "Demo data loaded successfully!"
|
||||
puts "balances synced"
|
||||
puts "Demo data loaded successfully!"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
@@ -55,6 +57,8 @@ class Demo::Generator
|
||||
|
||||
def clear_data!
|
||||
ExchangeRate.destroy_all
|
||||
Security.destroy_all
|
||||
Security::Price.destroy_all
|
||||
end
|
||||
|
||||
def create_user!
|
||||
@@ -96,6 +100,7 @@ class Demo::Generator
|
||||
accountable: CreditCard.new,
|
||||
name: "Chase Credit Card",
|
||||
balance: 2300,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
|
||||
50.times do
|
||||
@@ -122,6 +127,7 @@ class Demo::Generator
|
||||
accountable: Depository.new,
|
||||
name: "Chase Checking",
|
||||
balance: 15000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
|
||||
10.times do
|
||||
@@ -145,6 +151,7 @@ class Demo::Generator
|
||||
accountable: Depository.new,
|
||||
name: "Demo Savings",
|
||||
balance: 40000,
|
||||
currency: "USD",
|
||||
subtype: "savings",
|
||||
institution: family.institutions.find_or_create_by(name: "Chase")
|
||||
|
||||
@@ -161,23 +168,72 @@ class Demo::Generator
|
||||
end
|
||||
end
|
||||
|
||||
def load_securities!
|
||||
securities = [
|
||||
{ isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 },
|
||||
{ isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
|
||||
{ isin: "US5949181045", symbol: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
|
||||
]
|
||||
|
||||
securities.each do |security_attributes|
|
||||
security = Security.create! security_attributes.except(:reference_price)
|
||||
|
||||
# Load prices for last 2 years
|
||||
(730.days.ago.to_date..Date.current).each do |date|
|
||||
reference = security_attributes[:reference_price]
|
||||
low_price = reference - 20
|
||||
high_price = reference + 20
|
||||
Security::Price.create! \
|
||||
isin: security.isin,
|
||||
date: date,
|
||||
price: Faker::Number.positive(from: low_price, to: high_price)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_investment_account!
|
||||
load_securities!
|
||||
|
||||
account = family.accounts.create! \
|
||||
accountable: Investment.new,
|
||||
name: "Robinhood",
|
||||
balance: 100000,
|
||||
currency: "USD",
|
||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||
|
||||
create_valuation!(account, 2.years.ago.to_date, 60000)
|
||||
create_valuation!(account, 1.year.ago.to_date, 70000)
|
||||
create_valuation!(account, 3.months.ago.to_date, 92000)
|
||||
aapl = Security.find_by(symbol: "AAPL")
|
||||
tm = Security.find_by(symbol: "TM")
|
||||
msft = Security.find_by(symbol: "MSFT")
|
||||
|
||||
trades = [
|
||||
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
|
||||
{ security: msft, qty: -5 }, { security: tm, qty: 10 }, { security: msft, qty: 5 },
|
||||
{ security: tm, qty: 10 }, { security: aapl, qty: -5 }, { security: msft, qty: -5 },
|
||||
{ security: tm, qty: 10 }, { security: msft, qty: 5 }, { security: aapl, qty: -10 }
|
||||
]
|
||||
|
||||
trades.each do |trade|
|
||||
date = Faker::Number.positive(to: 730).days.ago.to_date
|
||||
security = trade[:security]
|
||||
qty = trade[:qty]
|
||||
price = Security::Price.find_by!(isin: security.isin, date: date).price
|
||||
name_prefix = qty < 0 ? "Sell " : "Buy "
|
||||
|
||||
account.entries.create! \
|
||||
date: date,
|
||||
amount: qty * price,
|
||||
currency: "USD",
|
||||
name: name_prefix + "#{qty} shares of #{security.symbol}",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, security: security)
|
||||
end
|
||||
end
|
||||
|
||||
def create_house_and_mortgage!
|
||||
house = family.accounts.create! \
|
||||
accountable: Property.new,
|
||||
name: "123 Maybe Way",
|
||||
balance: 560000
|
||||
balance: 560000,
|
||||
currency: "USD"
|
||||
|
||||
create_valuation!(house, 3.years.ago.to_date, 520000)
|
||||
create_valuation!(house, 2.years.ago.to_date, 540000)
|
||||
@@ -186,19 +242,22 @@ class Demo::Generator
|
||||
family.accounts.create! \
|
||||
accountable: Loan.new,
|
||||
name: "Mortgage",
|
||||
balance: 495000
|
||||
balance: 495000,
|
||||
currency: "USD"
|
||||
end
|
||||
|
||||
def create_car_and_loan!
|
||||
family.accounts.create! \
|
||||
accountable: Vehicle.new,
|
||||
name: "Honda Accord",
|
||||
balance: 18000
|
||||
balance: 18000,
|
||||
currency: "USD"
|
||||
|
||||
family.accounts.create! \
|
||||
accountable: Loan.new,
|
||||
name: "Car Loan",
|
||||
balance: 8000
|
||||
balance: 8000,
|
||||
currency: "USD"
|
||||
end
|
||||
|
||||
def create_transaction!(attributes = {})
|
||||
@@ -262,6 +321,10 @@ class Demo::Generator
|
||||
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
|
||||
end
|
||||
|
||||
def securities
|
||||
@securities ||= Security.all.to_a
|
||||
end
|
||||
|
||||
def merchants
|
||||
@merchants ||= family.merchants
|
||||
end
|
||||
|
||||
54
app/models/help/article.rb
Normal file
54
app/models/help/article.rb
Normal file
@@ -0,0 +1,54 @@
|
||||
class Help::Article
|
||||
attr_reader :frontmatter, :content
|
||||
|
||||
def initialize(frontmatter:, content:)
|
||||
@frontmatter = frontmatter
|
||||
@content = content
|
||||
end
|
||||
|
||||
def title
|
||||
frontmatter["title"]
|
||||
end
|
||||
|
||||
def html
|
||||
render_markdown(content)
|
||||
end
|
||||
|
||||
class << self
|
||||
def root_path
|
||||
Rails.root.join("docs", "help")
|
||||
end
|
||||
|
||||
def find(slug)
|
||||
Dir.glob(File.join(root_path, "*.md")).each do |file_path|
|
||||
file_content = File.read(file_path)
|
||||
frontmatter, markdown_content = parse_frontmatter(file_content)
|
||||
|
||||
return new(frontmatter:, content: markdown_content) if frontmatter["slug"] == slug
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_frontmatter(content)
|
||||
if content =~ /\A---(.+?)---/m
|
||||
frontmatter = YAML.safe_load($1)
|
||||
markdown_content = content[($~.end(0))..-1].strip
|
||||
else
|
||||
frontmatter = {}
|
||||
markdown_content = content
|
||||
end
|
||||
|
||||
[ frontmatter, markdown_content ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_markdown(content)
|
||||
markdown = Redcarpet::Markdown.new(Redcarpet::Render::HTML)
|
||||
markdown.render(content)
|
||||
end
|
||||
end
|
||||
14
app/models/security.rb
Normal file
14
app/models/security.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
class Security < ApplicationRecord
|
||||
before_save :normalize_identifiers
|
||||
|
||||
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
|
||||
|
||||
validates :isin, presence: true, uniqueness: { case_sensitive: false }
|
||||
|
||||
private
|
||||
|
||||
def normalize_identifiers
|
||||
self.isin = isin.upcase
|
||||
self.symbol = symbol.upcase
|
||||
end
|
||||
end
|
||||
2
app/models/security/price.rb
Normal file
2
app/models/security/price.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
class Security::Price < ApplicationRecord
|
||||
end
|
||||
@@ -9,7 +9,6 @@
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= form_with url: mark_transfers_transactions_path,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
scope: "bulk_update",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
@@ -36,7 +35,7 @@
|
||||
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: bulk_delete_transactions_path, builder: ActionView::Helpers::FormBuilder, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
|
||||
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
|
||||
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
|
||||
@@ -27,30 +27,28 @@
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="space-y-2">
|
||||
<%= 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>
|
||||
<%= styled_form_with model: [account, entry], url: account_entry_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>
|
||||
<% end %>
|
||||
<%= f.date_field :date, label: t(".date_label"), max: Date.current, "data-auto-submit-form-target": "auto" %>
|
||||
<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" %>
|
||||
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<% unless entry.marked_as_transfer? %>
|
||||
<%= ef.collection_select :category_id, selectable_categories, :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, selectable_merchants, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<%= 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, selectable_accounts, :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" } %>
|
||||
</div>
|
||||
<%= 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>
|
||||
@@ -61,12 +59,12 @@
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6 space-y-2">
|
||||
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="pb-6">
|
||||
<%= styled_form_with model: [account, entry], url: account_entry_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(selectable_tags, transaction.tag_ids),
|
||||
options_for_select(Current.family.tags.alphabetically.pluck(:name, :id), transaction.tag_ids),
|
||||
{
|
||||
multiple: true,
|
||||
label: t(".tags_label"),
|
||||
@@ -86,7 +84,7 @@
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= form_with model: [account, entry], url: account_entry_path(account, entry), html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= styled_form_with model: [account, entry], url: account_entry_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">
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<% if unconfirmed_transfer?(entry) %>
|
||||
<% if editable %>
|
||||
<%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: {
|
||||
<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: {
|
||||
turbo_confirm: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<%# locals: (entry:) %>
|
||||
<%= form_with model: [entry.account, entry],
|
||||
data: { turbo_frame: "_top" },
|
||||
url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry),
|
||||
builder: ActionView::Helpers::FormBuilder do |f| %>
|
||||
url: entry.new_record? ? account_entries_path(entry.account) : account_entry_path(entry.account, entry) do |f| %>
|
||||
<div class="grid grid-cols-10 p-4 items-center">
|
||||
<div class="col-span-7 flex items-center gap-4">
|
||||
<div class="w-8 h-8 rounded-full p-1.5 flex items-center justify-center bg-gray-500/5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with model: transfer, data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= styled_form_with model: transfer, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<section>
|
||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||
<%= link_to new_transaction_path(nature: "expense"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
|
||||
@@ -22,7 +22,8 @@
|
||||
<%= 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 %>
|
||||
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
||||
<%= money_field f, :amount_money, label: t(".amount"), required: true %>
|
||||
<%= f.hidden_field :currency, value: Current.family.currency %>
|
||||
<%= f.date_field :date, value: transfer.date, label: t(".date"), required: true, max: Date.current %>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
|
||||
<%= form_with model: account,
|
||||
namespace: account.id,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
data: { controller: "auto-submit-form", turbo_frame: "_top" } do |form| %>
|
||||
<div class="relative inline-block select-none">
|
||||
<%= form.check_box :is_active, { class: "sr-only peer", data: { "auto-submit-form-target": "auto" } } %>
|
||||
|
||||
19
app/views/accounts/_alert.html.erb
Normal file
19
app/views/accounts/_alert.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<%# locals: (message:, help_path: nil) -%>
|
||||
<%= tag.div class: "flex gap-6 items-center rounded-xl px-4 py-3 bg-error/5",
|
||||
data: { controller: "element-removal" },
|
||||
role: "alert" do %>
|
||||
<div class="flex gap-3 items-center text-red-500 grow overflow-x-scroll">
|
||||
<%= lucide_icon("alert-octagon", class: "w-5 h-5 shrink-0") %>
|
||||
<p class="text-sm whitespace-nowrap"><%= message %></p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 ml-auto">
|
||||
<% if help_path %>
|
||||
<%= link_to "Troubleshoot", help_path, class: "text-red-500 font-medium hover:underline", data: { turbo_frame: :drawer } %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.button data: { action: "click->element-removal#remove" } do %>
|
||||
<%= lucide_icon("x", class: "w-5 h-5 shrink-0 text-red-500") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1,8 +0,0 @@
|
||||
<%# locals: (is_syncing:) %>
|
||||
<% if is_syncing %>
|
||||
<div class="my-4 px-8 py-4 rounded-lg bg-yellow-500/10 flex items-center justify-between">
|
||||
<p class="text-gray-900 text-sm">
|
||||
Syncing your account balances.
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -5,9 +5,9 @@
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<%= form_with model: @account, data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= styled_form_with model: @account, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name") %>
|
||||
<%= f.money_field :balance_money, label: t(".balance"), readonly_currency: true %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), default_currency: @account.currency, disable_currency: true %>
|
||||
|
||||
<div class="relative">
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<turbo-frame id="account-list" target="_top">
|
||||
<%= turbo_frame_tag "account-list" do %>
|
||||
<% account_groups(period: @period).each do |group| %>
|
||||
<%= render "accounts/account_list", group: group %>
|
||||
<% end %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
|
||||
@@ -73,13 +73,13 @@
|
||||
<% end %>
|
||||
<span>Add <%= @account.accountable.model_name.human.downcase %></span>
|
||||
</div>
|
||||
<%= form_with model: @account, url: accounts_path, scope: :account, html: { class: "m-5 mt-1 flex flex-col justify-between grow", data: { turbo: false } } do |f| %>
|
||||
<%= styled_form_with model: @account, url: accounts_path, scope: :account, class: "m-5 mt-1 flex flex-col justify-between grow", data: { turbo: false } do |f| %>
|
||||
<div class="space-y-4 grow">
|
||||
<%= f.hidden_field :accountable_type %>
|
||||
<%= f.text_field :name, placeholder: t(".name.placeholder"), required: "required", label: t(".name.label"), autofocus: true %>
|
||||
<%= f.collection_select :institution_id, Current.family.institutions.alphabetically, :id, :name, { include_blank: t(".ungrouped"), label: t(".institution") } %>
|
||||
<%= render "accounts/accountables/#{permitted_accountable_partial(@account.accountable_type)}", f: f %>
|
||||
<%= f.money_field :balance_money, label: t(".balance"), required: "required" %>
|
||||
<%= money_with_currency_field f, :balance_money, label: t(".balance"), required: "required", default_currency: Current.family.currency %>
|
||||
|
||||
<div>
|
||||
<%= check_box_tag :add_start_values, class: "maybe-checkbox maybe-checkbox--light peer mb-1" %>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
<%= turbo_stream_from @account %>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -45,12 +43,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= turbo_frame_tag "sync_message" do %>
|
||||
<%= render partial: "accounts/sync_message", locals: { is_syncing: @account.syncing? } %>
|
||||
<% end %>
|
||||
|
||||
<% if @account.alert %>
|
||||
<%= render partial: "shared/alert", locals: { type: "error", content: t("." + @account.alert) } %>
|
||||
<%= render "alert", message: @account.alert, help_path: help_article_path("troubleshooting") %>
|
||||
<% end %>
|
||||
|
||||
<div class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
|
||||
@@ -69,8 +63,8 @@
|
||||
<%= tag.span period_label(@period), class: "text-gray-500" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_with url: account_path(@account), method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
|
||||
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
|
||||
<%= form_with url: account_path(@account), method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= period_select form: form, selected: @period.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="h-96 flex items-center justify-center text-2xl font-bold">
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<div class="space-y-2 grow">
|
||||
<%= render partial: "shared/value_heading", locals: {
|
||||
label: "Assets",
|
||||
period: @period,
|
||||
value: Current.family.assets,
|
||||
trend: @asset_series.trend
|
||||
} %>
|
||||
period: @period,
|
||||
value: Current.family.assets,
|
||||
trend: @asset_series.trend
|
||||
} %>
|
||||
</div>
|
||||
<div
|
||||
id="assetsChart"
|
||||
@@ -26,11 +26,11 @@
|
||||
<div class="space-y-2 grow">
|
||||
<%= render partial: "shared/value_heading", locals: {
|
||||
label: "Liabilities",
|
||||
period: @period,
|
||||
size: "md",
|
||||
value: Current.family.liabilities,
|
||||
trend: @liability_series.trend
|
||||
} %>
|
||||
period: @period,
|
||||
size: "md",
|
||||
value: Current.family.liabilities,
|
||||
trend: @liability_series.trend
|
||||
} %>
|
||||
</div>
|
||||
<div
|
||||
id="liabilitiesChart"
|
||||
@@ -48,8 +48,8 @@
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
||||
<p><%= t(".new") %></p>
|
||||
<% end %>
|
||||
<%= form_with url: summary_accounts_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
|
||||
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
|
||||
<%= form_with url: summary_accounts_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= period_select form: form, selected: @period.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -64,8 +64,8 @@
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
||||
<p><%= t(".new") %></p>
|
||||
<% end %>
|
||||
<%= form_with url: summary_accounts_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
|
||||
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
|
||||
<%= form_with url: summary_accounts_path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= period_select form: form, selected: @period.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with model: category, data: { turbo: false } do |form| %>
|
||||
<%= styled_form_with model: category, data: { turbo: false } do |form| %>
|
||||
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= category.color %>">
|
||||
<fieldset class="relative">
|
||||
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
|
||||
|
||||
@@ -11,22 +11,23 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: category_deletions_path(@category),
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
<%= styled_form_with url: category_deletions_path(@category),
|
||||
class: "space-y-4",
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", category_name: @category.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_category_id,
|
||||
Current.family.categories.alphabetically.without(@category),
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||
:id, :name,
|
||||
{ prompt: t(".replacement_category_prompt"), label: t(".category") },
|
||||
{ data: { deletion_target: "replacementField", action: "deletion#updateSubmitButton" } } %>
|
||||
|
||||
<%= f.submit t(".delete_and_leave_uncategorized", category_name: @category.name),
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
data: { deletion_target: "submitButton" } %>
|
||||
<% end %>
|
||||
</article>
|
||||
|
||||
7
app/views/help/articles/show.html.erb
Normal file
7
app/views/help/articles/show.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<%= drawer do %>
|
||||
<div class="prose">
|
||||
<%= tag.h1 @article.title %>
|
||||
|
||||
<%= sanitize(@article.html).html_safe %>
|
||||
</div>
|
||||
<% end %>
|
||||
25
app/views/imports/_csv_paste.html.erb
Normal file
25
app/views/imports/_csv_paste.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<%= styled_form_with model: @import, url: load_import_path(@import), class: "space-y-4" do |form| %>
|
||||
<%= form.text_area :raw_csv_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_csv_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>
|
||||
39
app/views/imports/_csv_upload.html.erb
Normal file
39
app/views/imports/_csv_upload.html.erb
Normal file
@@ -0,0 +1,39 @@
|
||||
<%= styled_form_with model: @import, url: upload_import_path(@import), class: "dropzone space-y-4", data: { controller: "csv-upload" }, method: :patch, multipart: true do |form| %>
|
||||
<div class="flex items-center justify-center w-full">
|
||||
<label for="import_raw_csv_str" class="csv-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->csv-upload#dragover dragleave->csv-upload#dragleave drop->csv-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_csv_str, class: "hidden", direct_upload: false, accept: "text/csv,.csv,application/csv", data: { csv_upload_target: "input", action: "change->csv-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-csv-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: { csv_upload_target: "submit", turbo_confirm: (@import.raw_csv_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-csv-upload-target="filename"></span></div>
|
||||
<div><span data-csv-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,4 +1,4 @@
|
||||
<%= form_with model: @import do |form| %>
|
||||
<%= styled_form_with model: @import do |form| %>
|
||||
<div class="mb-4">
|
||||
<%= form.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".account"), required: true } %>
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
style="grid-template-columns: repeat(<%= @import.expected_fields.size %>, 1fr);">
|
||||
<% row.fields.each_with_index do |value, col_index| %>
|
||||
<%= form_with model: @import,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
url: clean_import_url(@import),
|
||||
method: :patch,
|
||||
data: { turbo: false, controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
<p class="text-gray-500 text-sm"><%= t(".configure_description") %></p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @import, url: configure_import_path(@import) do |form| %>
|
||||
<div class="mb-4 space-y-4">
|
||||
<%= 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,
|
||||
@@ -18,7 +17,6 @@
|
||||
include_blank: field.optional? ? t(".optional") : false %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</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", data: { turbo_confirm: (@import.column_mappings? ? { title: t(".confirm_title"), body: t(".confirm_body"), accept: t(".confirm_accept") } : nil) } %>
|
||||
<% end %>
|
||||
|
||||
@@ -8,33 +8,18 @@
|
||||
<p class="text-gray-500 text-sm"><%= t(".description") %></p>
|
||||
</div>
|
||||
|
||||
<%= form_with model: @import, url: load_import_path(@import) do |form| %>
|
||||
<div>
|
||||
<%= form.text_area :raw_csv_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 p-4" %>
|
||||
</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", data: { turbo_confirm: (@import.raw_csv_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">
|
||||
<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 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>
|
||||
|
||||
<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 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,5 +1,4 @@
|
||||
<%= form_with model: institution, data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %>
|
||||
|
||||
<%= styled_form_with model: institution, class: "space-y-4", data: { turbo_frame: "_top", controller: "profile-image-preview" } do |f| %>
|
||||
<div class="flex justify-center items-center py-4">
|
||||
<%= f.label :logo do %>
|
||||
<div class="relative cursor-pointer hover:opacity-80 w-16 h-16 rounded-full bg-gray-50">
|
||||
|
||||
@@ -91,17 +91,18 @@
|
||||
<%= t(".portfolio") %>
|
||||
<% end %>
|
||||
<span class="font-bold tracking-wide">•</span>
|
||||
<%= form_with url: list_accounts_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form", turbo_frame: "account-list" } do %>
|
||||
<%= render partial: "shared/period_select", locals: { button_class: "flex items-center gap-1 w-full cursor-pointer font-bold tracking-wide" } %>
|
||||
<%= form_with url: list_accounts_path, method: :get, data: { controller: "auto-submit-form", turbo_frame: "account-list" } do |form| %>
|
||||
<%= period_select form: form, selected: "last_7_days", classes: "w-full border-none pl-2 pr-7 text-xs bg-transparent gap-1 cursor-pointer font-semibold tracking-wide focus:outline-none focus:ring-0" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to new_account_path, id: "sidebar-new-account", class: "block hover:bg-gray-100 p-2 text-sm font-semibold text-gray-900 flex items-center rounded", title: t(".new_account"), data: { turbo_frame: "modal" } do %>
|
||||
<%= link_to new_account_path, id: "sidebar-new-account", class: "block hover:bg-gray-100 font-semibold text-gray-900 flex items-center rounded", title: t(".new_account"), data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
</div>
|
||||
<turbo-frame id="account-list" target="_top">
|
||||
|
||||
<%= turbo_frame_tag "account-list", target: "_top" do %>
|
||||
<% account_groups.each do |group| %>
|
||||
<%= render "accounts/account_list", group: group %>
|
||||
<% end %>
|
||||
</turbo-frame>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -25,8 +25,13 @@
|
||||
</head>
|
||||
|
||||
<body class="h-full">
|
||||
<div id="notification-tray" class="fixed z-50 space-y-1 top-6 right-10"></div>
|
||||
<%= safe_join(flash.map { |type, message| notification(message, type: type) }) %>
|
||||
<div class="fixed z-50 space-y-1 top-6 right-10">
|
||||
<div id="notification-tray">
|
||||
<%= render_flash_notifications %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= family_notifications_stream %>
|
||||
|
||||
<%= content_for?(:content) ? yield(:content) : yield %>
|
||||
|
||||
@@ -39,5 +44,4 @@
|
||||
<%= render "shared/app_version" %>
|
||||
<% end %>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<% is_editing = @merchant.id.present? %>
|
||||
<div data-controller="merchant-avatar">
|
||||
<%= form_with model: @merchant, url: is_editing ? merchant_path(@merchant) : merchants_path, method: is_editing ? :patch : :post, scope: :merchant, data: { turbo: false } do |f| %>
|
||||
<%= styled_form_with model: @merchant, url: is_editing ? merchant_path(@merchant) : merchants_path, method: is_editing ? :patch : :post, scope: :merchant, class: "space-y-4", data: { turbo: false } do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<%= render partial: "merchants/avatar", locals: { merchant: } %>
|
||||
|
||||
@@ -26,8 +26,8 @@
|
||||
trend: @net_worth_series.trend
|
||||
} %>
|
||||
</div>
|
||||
<%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do %>
|
||||
<%= render partial: "shared/period_select", locals: { value: @period.name } %>
|
||||
<%= form_with url: root_path, method: :get, class: "flex items-center gap-4", data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= period_select form: form, selected: @period.name %>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render partial: "pages/dashboard/net_worth_chart", locals: { series: @net_worth_series } %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
header_title t(".title")
|
||||
%>
|
||||
|
||||
<%= form_with model: @user, url: password_reset_path(token: params[:token]), method: :patch, html: {class: "space-y-6"} do |form| %>
|
||||
<%= styled_form_with model: @user, url: password_reset_path(token: params[:token]), method: :patch, class: "space-y-4" do |form| %>
|
||||
<%= auth_messages form %>
|
||||
|
||||
<div class="relative border border-gray-100 bg-gray-25 rounded-xl focus-within:bg-white focus-within:shadow focus-within:opacity-100">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
header_title t(".title")
|
||||
%>
|
||||
|
||||
<%= form_with url: password_reset_path do |form| %>
|
||||
<%= styled_form_with url: password_reset_path, class: "space-y-4" do |form| %>
|
||||
<%= auth_messages form %>
|
||||
|
||||
<%= form.email_field :email, label: true, autofocus: false, autocomplete: "email", required: "required", placeholder: "you@example.com" %>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<h1><% t(".title") %></h1>
|
||||
|
||||
<%= form_with model: Current.user, url: password_path do |form| %>
|
||||
<%= styled_form_with model: Current.user, url: password_path, class: "space-y-4" do |form| %>
|
||||
<%= auth_messages form %>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%
|
||||
header_title t(".title")
|
||||
%>
|
||||
<%= form_with model: @user, url: registration_path do |form| %>
|
||||
<%= styled_form_with model: @user, url: registration_path, class: "space-y-4" do |form| %>
|
||||
<%= auth_messages form %>
|
||||
<%= form.email_field :email, autofocus: false, autocomplete: "email", required: "required", placeholder: "you@example.com", label: true %>
|
||||
<%= form.password_field :password, autocomplete: "new-password", required: "required", label: true %>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
header_title t(".title")
|
||||
%>
|
||||
|
||||
<%= form_with url: session_path do |form| %>
|
||||
<%= styled_form_with url: session_path, class: "space-y-4" do |form| %>
|
||||
<%= auth_messages form %>
|
||||
|
||||
<%= form.email_field :email, label: t(".email"), autofocus: false, autocomplete: "email", required: "required", placeholder: t(".email_placeholder") %>
|
||||
|
||||
<%= form.password_field :password, label: true, required: "required" %>
|
||||
<%= form.password_field :password, label: t(".password"), required: "required" %>
|
||||
|
||||
<%= form.submit t(".submit") %>
|
||||
<% end %>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="space-y-4">
|
||||
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
|
||||
<%= settings_section title: t(".general_settings_title") do %>
|
||||
<%= form_with model: Setting.new, url: settings_hosting_path, method: :patch, local: true, html: { class: "space-y-6", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } } do |form| %>
|
||||
<%= styled_form_with model: Setting.new, url: settings_hosting_path, method: :patch, local: true, class: "space-y-6", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value" => "blur" } do |form| %>
|
||||
|
||||
<% if ENV["HOSTING_PLATFORM"] == "render" %>
|
||||
<div>
|
||||
|
||||
@@ -5,16 +5,16 @@
|
||||
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
|
||||
<%= settings_section title: t(".general_title"), subtitle: t(".general_subtitle") do %>
|
||||
<div>
|
||||
<%= form_with model: Current.user, url: settings_preferences_path, html: { class: "space-y-4", data: { controller: "auto-submit-form" } } do |form| %>
|
||||
<%= form.fields_for :family_attributes do |family_fields| %>
|
||||
<%= family_fields.currency_select :currency, { selected: Current.family.currency, label: "Currency" }, { data: { auto_submit_form_target: "auto" } } %>
|
||||
<%= styled_form_with model: Current.user, url: settings_preferences_path, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %>
|
||||
<%= form.fields_for :family_attributes do |family_form| %>
|
||||
<%= currency_select_full family_form, :currency, { label: "Currency", selected: Current.family.currency }, { data: { auto_submit_form_target: "auto" } } %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
|
||||
<div>
|
||||
<%= form_with model: Current.user, url: settings_preferences_path, local: true, html: { class: "flex justify-between items-center" } do |form| %>
|
||||
<%= styled_form_with model: Current.user, url: settings_preferences_path, local: true, class: "flex justify-between items-center" do |form| %>
|
||||
<div class="text-center">
|
||||
<%= image_tag("light-mode-preview.png", alt: "Light Theme Preview", class: "h-44 mb-4") %>
|
||||
<div class="flex justify-center items-center gap-2">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<h1 class="text-gray-900 text-xl font-medium mb-4"><%= t(".page_title") %></h1>
|
||||
<div class="space-y-4">
|
||||
<%= settings_section title: t(".profile_title"), subtitle: t(".profile_subtitle") do %>
|
||||
<%= form_with model: Current.user, url: settings_profile_path, html: {data: { controller: "profile-image-preview" }} do |form| %>
|
||||
<%= styled_form_with model: Current.user, url: settings_profile_path, class: "space-y-4", data: { controller: "profile-image-preview" } do |form| %>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative flex justify-center items-center bg-gray-50 w-24 h-24 rounded-full border border-alpha-black-25">
|
||||
<div data-profile-image-preview-target="imagePreview" class="h-full w-full flex justify-center items-center">
|
||||
@@ -43,7 +43,7 @@
|
||||
<% end %>
|
||||
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
|
||||
<div class="space-y-4">
|
||||
<%= form_with model: Current.user, url: settings_profile_path, html: { class: "space-y-4", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } } do |form| %>
|
||||
<%= styled_form_with model: Current.user, url: settings_profile_path, class: "space-y-4", data: { controller: "auto-submit-form", "auto-submit-form-trigger-event-value": "blur" } do |form| %>
|
||||
<%= form.fields_for :family_attributes do |family_fields| %>
|
||||
<%= family_fields.text_field :name, placeholder: t(".household_form_input_placeholder"), value: Current.family.name, label: t(".household_form_label"), disabled: !Current.user.admin?, "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
<%# locals: (type: "error", content: "") -%>
|
||||
<%= content_tag :div,
|
||||
class: "flex justify-between rounded-xl p-3 #{type == "error" ? "bg-red-50" : "bg-yellow-50"}",
|
||||
data: {controller: "element-removal" },
|
||||
role: type == "error" ? "alert" : "status" do %>
|
||||
<div class="flex gap-3 items-center <%= type == "error" ? "text-red-500" : "text-yellow-500" %>">
|
||||
<%= lucide_icon("info", class: "w-5 h-5 shrink-0") %>
|
||||
<p class="text-sm"><%= content %></p>
|
||||
</div>
|
||||
<%= content_tag :a, lucide_icon("x", class: "w-5 h-5 shrink-0 #{type == "error" ? "text-red-500" : "text-yellow-500"}"), data: { action: "click->element-removal#remove" }, class:"flex gap-1 font-medium items-center text-gray-900 px-3 py-1.5 rounded-lg cursor-pointer" %>
|
||||
<% end %>
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= turbo_frame_tag "drawer" do %>
|
||||
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<div class="flex justify-end items-center h-9">
|
||||
<div data-action="click->modal#close" class="cursor-pointer">
|
||||
|
||||
27
app/views/shared/_money_field.html.erb
Normal file
27
app/views/shared/_money_field.html.erb
Normal file
@@ -0,0 +1,27 @@
|
||||
<%# locals: (form:, money_method:, default_currency:, disable_currency: false, hide_currency: false, label: nil) %>
|
||||
|
||||
<% fallback_label = t(".money-label") %>
|
||||
<% currency = form.object.send(money_method)&.currency || Money::Currency.new(default_currency) %>
|
||||
|
||||
<div class="form-field pr-0" data-controller="money-field">
|
||||
<%= form.label label || fallback_label, { class: "form-field__label" } %>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="flex items-center grow gap-1">
|
||||
<span class="text-gray-500 text-sm" data-money-field-target="symbol"><%= currency.symbol %></span>
|
||||
<%= money_field form, money_method, { inline: true, "data-money-field-target" => "amount", default_currency: currency } %>
|
||||
</div>
|
||||
<% unless hide_currency %>
|
||||
<div>
|
||||
<%= currency_select form, :currency, { inline: true, selected: currency.iso_code }, {
|
||||
class: "form-field__input text-right pr-8 disabled:text-gray-500",
|
||||
disabled: disable_currency,
|
||||
data: {
|
||||
"money-field-target" => "currency",
|
||||
action: "money-field#handleCurrencyChange"
|
||||
}
|
||||
} %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,45 +1,45 @@
|
||||
<%# locals: (type: "success", content: { title: '', body: ''}, action: { label:'' , url:'' }, options: { auto_dismiss: true }) -%>
|
||||
<%# locals: (message:, type: "notice", id: nil, **_opts) %>
|
||||
|
||||
<turbo-stream action="append" target="notification-tray">
|
||||
<template>
|
||||
<% actions = options[:auto_dismiss] ? "animationend->element-removal#remove" : "" %>
|
||||
<% animation = options[:auto_dismiss] ? "animate-[appear-then-fades_5s_300ms_both]" : "animate-[appear_5s_300ms_both]" %>
|
||||
<%= content_tag :div,
|
||||
class: "max-w-80 bg-white shadow-xs border border-alpha-black-50 border-solid py-4 px-4 rounded-[10px] text-sm font-medium flex gap-4 #{animation} group",
|
||||
data: {controller: "element-removal", action: actions },
|
||||
role: type == "error" ? "alert" : "status" do -%>
|
||||
<% base_class = "w-5 h-5 p-1 text-white flex shrink-0 items-center justify-center rounded-full" %>
|
||||
<%= type.in?(["error", "alert"]) ? lucide_icon("x", class: "#{base_class} bg-error") : lucide_icon("check", class: "#{base_class} bg-success") %>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col">
|
||||
<% if content[:title].present? %>
|
||||
<h1 class="text-sm text-gray-900 font-medium"><%= content[:title] %></h1>
|
||||
<% end %>
|
||||
<p class="text-sm text-gray-500 font-normal"><%= content[:body] %></p>
|
||||
</div>
|
||||
<% type = type.to_sym %>
|
||||
<% action = "animationend->element-removal#remove" if type == :notice %>
|
||||
|
||||
<div class="flex flex-row justify-end gap-2">
|
||||
<% if !options[:auto_dismiss] %>
|
||||
<%= content_tag :a, t(".dismiss"), data: { action: "click->element-removal#remove" }, class:"flex gap-1 font-medium items-center text-gray-900 px-3 py-1.5 rounded-lg cursor-pointer" %>
|
||||
<% end %>
|
||||
<% if action[:label].present? && action[:url].present? %>
|
||||
<%= link_to action[:label], action[:url], class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 px-3 py-1.5 rounded-lg" %>
|
||||
<% end %>
|
||||
<%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25",
|
||||
id: id,
|
||||
data: {
|
||||
controller: "element-removal",
|
||||
action: action
|
||||
} do %>
|
||||
|
||||
<div class="h-5 w-5 shrink-0 p-px text-white">
|
||||
<% case type %>
|
||||
<% when :notice %>
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-success">
|
||||
<%= lucide_icon "check", class: "w-3 h-3" %>
|
||||
</div>
|
||||
<% when :alert %>
|
||||
<div class="flex h-full items-center justify-center rounded-full bg-error">
|
||||
<%= lucide_icon "x", class: "w-3 h-3" %>
|
||||
</div>
|
||||
<% when :processing %>
|
||||
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= tag.p message, class: "text-gray-900 text-sm font-medium" %>
|
||||
|
||||
<div class="ml-auto">
|
||||
<% if type.to_sym == :notice %>
|
||||
<div class="h-5 shrink-0">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
|
||||
<path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z" fill="#E5E5E5" />
|
||||
<circle class="origin-center -rotate-90 animate-[stroke-fill_5s_300ms_forwards]" stroke="#141414" stroke-opacity="0.4" r="7.2" cx="10" cy="10" stroke-dasharray="43.9822971503" stroke-dashoffset="43.9822971503" />
|
||||
</svg>
|
||||
<div class="absolute -top-2 -right-2">
|
||||
<%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-gray-400 cursor-pointer", data: { action: "click->element-removal#remove" } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if options[:auto_dismiss] %>
|
||||
<div class="shrink-0 h-5">
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="shrink-0">
|
||||
<path d="M18 10C18 14.4183 14.4183 18 10 18C5.58172 18 2 14.4183 2 10C2 5.58172 5.58172 2 10 2C14.4183 2 18 5.58172 18 10ZM3.6 10C3.6 13.5346 6.46538 16.4 10 16.4C13.5346 16.4 16.4 13.5346 16.4 10C16.4 6.46538 13.5346 3.6 10 3.6C6.46538 3.6 3.6 6.46538 3.6 10Z" fill="#E5E5E5" />
|
||||
<circle class="origin-center -rotate-90 animate-[stroke-fill_5s_300ms_forwards]" stroke="#141414" stroke-opacity="0.4" r="7.2" cx="10" cy="10" stroke-dasharray="43.9822971503" stroke-dashoffset="43.9822971503" />
|
||||
</svg>
|
||||
<div class="absolute -top-2 -right-2">
|
||||
<%= lucide_icon "x", class: "w-5 h-5 p-0.5 hidden group-hover:inline-block border border-alpha-black-50 border-solid rounded-lg bg-white text-gray-400 cursor-pointer", data: { action: "click->element-removal#remove" } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end -%>
|
||||
<% elsif type.to_sym == :alert %>
|
||||
<%= lucide_icon "x", data: { action: "click->element-removal#remove" }, class: "w-5 h-5 text-gray-500 hover:text-gray-600 cursor-pointer" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</template>
|
||||
</turbo-stream>
|
||||
<% end %>
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<%# locals: (value: 'last_30_days', button_class: '') -%>
|
||||
<% options = [["7D", "last_7_days"], ["1M", "last_30_days"], ["1Y", "last_365_days"], ["All", "all"]] %>
|
||||
<div data-controller="select" data-select-active-class="bg-alpha-black-50" class="relative" data-select-selected-value="<%= value %>">
|
||||
<%=
|
||||
tag.button(
|
||||
type: "button",
|
||||
data: { "select-target": "button" },
|
||||
class: button_class.presence || "flex items-center gap-1 w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm p-2 cursor-pointer text-gray-900 text-sm"
|
||||
) do
|
||||
%>
|
||||
<span data-select-target="buttonText"><%= options.find { |option| option[1] == value }[0] %></span>
|
||||
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
|
||||
<% end %>
|
||||
<input type="hidden" name="period" data-select-target="input" data-auto-submit-form-target="auto">
|
||||
<ul data-select-target="list" class="hidden absolute z-10 top-[100%] right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
|
||||
<% options.each do |label, value| %>
|
||||
<li tabindex="0" data-select-target="option" data-action="click->select#selectOption" data-value="<%= value %>" class="text-sm text-gray-900 rounded-lg cursor-pointer hover:bg-alpha-black-50 px-5 py-1">
|
||||
<%= label %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -11,14 +11,15 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= form_with url: tag_deletions_path(@tag),
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
|
||||
<%= styled_form_with url: tag_deletions_path(@tag),
|
||||
class: "space-y-4",
|
||||
data: {
|
||||
turbo: false,
|
||||
controller: "deletion",
|
||||
deletion_dangerous_action_class: "form-field__submit bg-white text-red-600 border hover:bg-red-50",
|
||||
deletion_safe_action_class: "form-field__submit border border-transparent",
|
||||
deletion_submit_text_when_not_replacing_value: t(".delete_and_leave_uncategorized", tag_name: @tag.name),
|
||||
deletion_submit_text_when_replacing_value: t(".delete_and_recategorize", tag_name: @tag.name) } do |f| %>
|
||||
<%= f.collection_select :replacement_tag_id,
|
||||
Current.family.tags.alphabetically.without(@tag),
|
||||
:id, :name,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with model: tag, data: { turbo: false } do |form| %>
|
||||
<%= styled_form_with model: tag, data: { turbo: false } do |form| %>
|
||||
<div class="flex flex-col space-y-4 w-96" data-controller="color-select" data-color-select-selection-value="<%= tag.color %>">
|
||||
<fieldset class="relative">
|
||||
<span data-color-select-target="decoration" class="pointer-events-none absolute inset-y-3.5 left-3 flex items-center pl-1 block w-1 rounded-lg"></span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%= form_with model: @entry, url: transactions_path, data: { turbo_frame: "_top" } do |f| %>
|
||||
<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
|
||||
<section>
|
||||
<fieldset class="bg-gray-50 rounded-lg p-1 grid grid-flow-col justify-stretch gap-x-2">
|
||||
<%= radio_tab_tag form: f, name: :nature, value: :expense, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "expense" || params[:nature].nil? %>
|
||||
@@ -13,7 +13,7 @@
|
||||
<section class="space-y-2">
|
||||
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
|
||||
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true %>
|
||||
<%= f.money_field :amount_money, label: t(".amount"), required: true %>
|
||||
<%= money_with_currency_field f, :amount_money, label: t(".amount"), required: true, default_currency: @entry.account&.currency || Current.family.currency %>
|
||||
<%= f.hidden_field :entryable_type, value: "Account::Transaction" %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<%= form_with url: transactions_path,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
method: :get,
|
||||
class: "flex items-center gap-4",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<dialog data-controller="modal"
|
||||
data-action="click->modal#clickOutside"
|
||||
class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4">
|
||||
<%= form_with url: bulk_update_transactions_path, scope: "bulk_update", html: { class: "h-full" }, data: { turbo_frame: "_top" } do |form| %>
|
||||
<%= styled_form_with url: bulk_update_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
|
||||
<div class="flex h-full flex-col justify-between p-4">
|
||||
<div>
|
||||
<div class="flex h-9 items-center justify-end">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%= modal do %>
|
||||
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
||||
<header class="flex justify-between">
|
||||
<h2 class="font-medium text-xl">New transaction</h2>
|
||||
<h2 class="font-medium text-xl"><%= t(".new_transaction") %></h2>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<div class="relative flex items-center bg-white border border-alpha-black-200 rounded-lg focus-within:border-alpha-black-500">
|
||||
<%= form.text_field :search,
|
||||
placeholder: "Search transactions by name",
|
||||
value: @q[:search],
|
||||
class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg",
|
||||
class: "placeholder:text-sm placeholder:text-gray-500 relative pl-10 w-full border-none rounded-lg focus:outline-none focus:ring-0",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ module Maybe
|
||||
|
||||
private
|
||||
def semver
|
||||
"0.1.0-alpha.10"
|
||||
"0.1.0-alpha.11"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,9 +37,6 @@ en:
|
||||
other_accounts: Other accounts
|
||||
new:
|
||||
balance: Current balance
|
||||
currency:
|
||||
all_others: All Others
|
||||
popular: Popular
|
||||
institution: Financial institution
|
||||
name:
|
||||
label: Account name
|
||||
@@ -69,8 +66,6 @@ en:
|
||||
value: Value
|
||||
summary:
|
||||
new: New account
|
||||
sync:
|
||||
success: Account sync started
|
||||
sync_all:
|
||||
success: Successfully queued accounts for syncing.
|
||||
update:
|
||||
|
||||
@@ -27,6 +27,25 @@ en:
|
||||
invalid_data: You have invalid data, please fix before continuing
|
||||
create:
|
||||
import_created: Import created
|
||||
csv_paste:
|
||||
confirm_accept: Yep, start over!
|
||||
confirm_body: This will reset your import. Any changes you have made to the
|
||||
CSV will be erased.
|
||||
confirm_title: Are you sure?
|
||||
instructions: Your CSV should have the following columns and formats for the
|
||||
best import experience.
|
||||
next: Next
|
||||
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
|
||||
requirement2: Negative transaction is an "outflow" (expense), positive is an
|
||||
"inflow" (income)
|
||||
requirement3: Can have 0 or more tags separated by |
|
||||
csv_upload:
|
||||
confirm_accept: Yep, start over!
|
||||
confirm_body: This will reset your import. Any changes you have made to the
|
||||
CSV will be erased.
|
||||
confirm_title: Are you sure?
|
||||
instructions: The csv file must be in the format below. You can also reuse and
|
||||
next: Next
|
||||
destroy:
|
||||
import_destroyed: Import destroyed
|
||||
edit:
|
||||
@@ -56,20 +75,9 @@ en:
|
||||
new: New import
|
||||
title: Imports
|
||||
load:
|
||||
confirm_accept: Yep, start over!
|
||||
confirm_body: This will reset your import. Any changes you have made to the
|
||||
CSV will be erased.
|
||||
confirm_title: Are you sure?
|
||||
description: Create a spreadsheet or upload an exported CSV from your financial
|
||||
institution.
|
||||
instructions: Your CSV should have the following columns and formats for the
|
||||
best import experience.
|
||||
load_title: Load import
|
||||
next: Next
|
||||
requirement1: Dates must be in ISO 8601 format (YYYY-MM-DD)
|
||||
requirement2: Negative transaction is an "outflow" (expense), positive is an
|
||||
"inflow" (income)
|
||||
requirement3: Can have 0 or more tags separated by |
|
||||
subtitle: Import your transactions
|
||||
load_csv:
|
||||
import_loaded: Import CSV loaded
|
||||
@@ -95,3 +103,5 @@ en:
|
||||
import_updated: Import updated
|
||||
update_mappings:
|
||||
column_mappings_saved: Column mappings saved
|
||||
upload_csv:
|
||||
import_loaded: CSV File loaded
|
||||
|
||||
@@ -9,6 +9,7 @@ en:
|
||||
email: Email address
|
||||
email_placeholder: you@example.com
|
||||
forgot_password: Forgot your password?
|
||||
password: Password
|
||||
reset_password: Reset it
|
||||
submit: Log in
|
||||
title: Sign in to your account
|
||||
|
||||
@@ -6,13 +6,13 @@ en:
|
||||
body_html: "<p>You will not be able to undo this decision</p>"
|
||||
cancel: Cancel
|
||||
title: Are you sure?
|
||||
money_field:
|
||||
money-label: Amount
|
||||
no_account_empty_state:
|
||||
new_account: New account
|
||||
no_account_subtitle: Since no accounts have been added, there's no data to display.
|
||||
Add your first accounts to start viewing dashboard data.
|
||||
no_account_title: No accounts yet
|
||||
notification:
|
||||
dismiss: Dismiss
|
||||
upgrade_notification:
|
||||
app_upgraded: The app has been upgraded to %{version}.
|
||||
dismiss: Dismiss
|
||||
|
||||
@@ -40,6 +40,8 @@ en:
|
||||
transaction: transaction
|
||||
mark_transfers:
|
||||
success: Marked as transfer
|
||||
new:
|
||||
new_transaction: New transaction
|
||||
pagination:
|
||||
rows_per_page: Rows per page
|
||||
unmark_transfers:
|
||||
|
||||
@@ -10,6 +10,10 @@ Rails.application.routes.draw do
|
||||
resource :password_reset
|
||||
resource :password
|
||||
|
||||
namespace :help do
|
||||
resources :articles, only: :show
|
||||
end
|
||||
|
||||
namespace :settings do
|
||||
resource :profile, only: %i[show update destroy]
|
||||
resource :preferences, only: %i[show update]
|
||||
@@ -25,6 +29,7 @@ Rails.application.routes.draw do
|
||||
member do
|
||||
get "load"
|
||||
patch "load" => "imports#load_csv"
|
||||
patch "upload" => "imports#upload_csv"
|
||||
|
||||
get "configure"
|
||||
patch "configure" => "imports#update_mappings"
|
||||
|
||||
11
db/migrate/20240710182529_create_securities.rb
Normal file
11
db/migrate/20240710182529_create_securities.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateSecurities < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :securities, id: :uuid do |t|
|
||||
t.string :isin, null: false
|
||||
t.string :symbol
|
||||
t.string :name
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
12
db/migrate/20240710182728_create_security_prices.rb
Normal file
12
db/migrate/20240710182728_create_security_prices.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
class CreateSecurityPrices < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :security_prices, id: :uuid do |t|
|
||||
t.string :isin
|
||||
t.date :date
|
||||
t.decimal :price, precision: 19, scale: 4
|
||||
t.string :currency, default: "USD"
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
11
db/migrate/20240710184048_create_account_trades.rb
Normal file
11
db/migrate/20240710184048_create_account_trades.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class CreateAccountTrades < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :account_trades, id: :uuid do |t|
|
||||
t.references :security, null: false, foreign_key: true, type: :uuid
|
||||
t.decimal :qty, precision: 19, scale: 4
|
||||
t.decimal :price, precision: 19, scale: 4
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
17
db/migrate/20240710184249_create_account_holdings.rb
Normal file
17
db/migrate/20240710184249_create_account_holdings.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
class CreateAccountHoldings < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :account_holdings, id: :uuid do |t|
|
||||
t.references :account, null: false, foreign_key: true, type: :uuid
|
||||
t.references :security, null: false, foreign_key: true, type: :uuid
|
||||
t.date :date
|
||||
t.decimal :qty, precision: 19, scale: 4
|
||||
t.decimal :price, precision: 19, scale: 4
|
||||
t.decimal :amount, precision: 19, scale: 4
|
||||
t.string :currency
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :account_holdings, %i[account_id security_id date currency], unique: true
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,6 @@
|
||||
class RemoveDefaultFromAccountBalance < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_column_default :accounts, :balance, from: "0.0", to: nil
|
||||
change_column_default :accounts, :currency, from: "USD", to: nil
|
||||
end
|
||||
end
|
||||
52
db/schema.rb
generated
52
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_07_17_113535) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -48,6 +48,21 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
|
||||
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
|
||||
end
|
||||
|
||||
create_table "account_holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.uuid "security_id", null: false
|
||||
t.date "date"
|
||||
t.decimal "qty", precision: 19, scale: 4
|
||||
t.decimal "price", precision: 19, scale: 4
|
||||
t.decimal "amount", precision: 19, scale: 4
|
||||
t.string "currency"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_234024c8e3", unique: true
|
||||
t.index ["account_id"], name: "index_account_holdings_on_account_id"
|
||||
t.index ["security_id"], name: "index_account_holdings_on_security_id"
|
||||
end
|
||||
|
||||
create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "account_id", null: false
|
||||
t.string "status", default: "pending", null: false
|
||||
@@ -60,6 +75,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
|
||||
t.index ["account_id"], name: "index_account_syncs_on_account_id"
|
||||
end
|
||||
|
||||
create_table "account_trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "security_id", null: false
|
||||
t.decimal "qty", precision: 19, scale: 4
|
||||
t.decimal "price", precision: 19, scale: 4
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["security_id"], name: "index_account_trades_on_security_id"
|
||||
end
|
||||
|
||||
create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
@@ -89,12 +113,12 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "accountable_type"
|
||||
t.uuid "accountable_id"
|
||||
t.decimal "balance", precision: 19, scale: 4, default: "0.0"
|
||||
t.string "currency", default: "USD"
|
||||
t.decimal "balance", precision: 19, scale: 4
|
||||
t.string "currency"
|
||||
t.boolean "is_active", default: true, null: false
|
||||
t.date "last_sync_date"
|
||||
t.uuid "institution_id"
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
|
||||
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
|
||||
t.index ["family_id"], name: "index_accounts_on_family_id"
|
||||
t.index ["institution_id"], name: "index_accounts_on_institution_id"
|
||||
@@ -321,6 +345,23 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "isin", null: false
|
||||
t.string "symbol"
|
||||
t.string "name"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "isin"
|
||||
t.date "date"
|
||||
t.decimal "price", precision: 19, scale: 4
|
||||
t.string "currency", default: "USD"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "settings", force: :cascade do |t|
|
||||
t.string "var", null: false
|
||||
t.text "value"
|
||||
@@ -373,7 +414,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do
|
||||
add_foreign_key "account_balances", "accounts", on_delete: :cascade
|
||||
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
|
||||
add_foreign_key "account_entries", "accounts"
|
||||
add_foreign_key "account_holdings", "accounts"
|
||||
add_foreign_key "account_holdings", "securities"
|
||||
add_foreign_key "account_syncs", "accounts"
|
||||
add_foreign_key "account_trades", "securities"
|
||||
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
||||
add_foreign_key "account_transactions", "merchants"
|
||||
add_foreign_key "accounts", "families"
|
||||
|
||||
10
docs/help/placeholder.md
Normal file
10
docs/help/placeholder.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: Troubleshooting
|
||||
slug: troubleshooting
|
||||
---
|
||||
|
||||
Coming soon...
|
||||
|
||||
We're working on new guides to help troubleshoot various issues within the app.
|
||||
|
||||
Help us out by reporting [issues on Github](https://github.com/maybe-finance/maybe/issues).
|
||||
4
public/transactions.csv
Normal file
4
public/transactions.csv
Normal file
@@ -0,0 +1,4 @@
|
||||
date,name,category,tags,amount
|
||||
2024-01-01,Amazon,Shopping,Tag1|Tag2,-24.99
|
||||
2024-03-01,Spotify,,,-16.32
|
||||
2023-01-06,Acme,Income,Tag3,151.22
|
||||
|
@@ -69,7 +69,7 @@ class Account::EntriesControllerTest < ActionDispatch::IntegrationTest
|
||||
}
|
||||
end
|
||||
|
||||
assert_equal "Date has already been taken", flash[:error]
|
||||
assert_equal "Date has already been taken", flash[:alert]
|
||||
assert_redirected_to account_path(@valuation.account)
|
||||
end
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
test "can sync an account" do
|
||||
post sync_account_path(@account)
|
||||
assert_redirected_to account_url(@account)
|
||||
assert_response :no_content
|
||||
end
|
||||
|
||||
test "can sync all accounts" do
|
||||
@@ -84,8 +84,10 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_difference [ "Account.count", "Account::Valuation.count", "Account::Entry.count" ], 1 do
|
||||
post accounts_path, params: {
|
||||
account: {
|
||||
name: "Test",
|
||||
accountable_type: "Depository",
|
||||
balance: 200,
|
||||
currency: "USD",
|
||||
subtype: "checking",
|
||||
institution_id: institutions(:chase).id
|
||||
}
|
||||
@@ -100,8 +102,10 @@ class AccountsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_difference -> { Account.count } => 1, -> { Account::Valuation.count } => 2 do
|
||||
post accounts_path, params: {
|
||||
account: {
|
||||
name: "Test",
|
||||
accountable_type: "Depository",
|
||||
balance: 200,
|
||||
currency: "USD",
|
||||
subtype: "checking",
|
||||
institution_id: institutions(:chase).id,
|
||||
start_balance: 100,
|
||||
|
||||
18
test/controllers/help/articles_controller_test.rb
Normal file
18
test/controllers/help/articles_controller_test.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
require "test_helper"
|
||||
|
||||
class Help::ArticlesControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in @user = users(:family_admin)
|
||||
|
||||
@article = Help::Article.new(frontmatter: { title: "Test Article", slug: "test-article" }, content: "")
|
||||
|
||||
Help::Article.stubs(:find).returns(@article)
|
||||
end
|
||||
|
||||
test "can view help article" do
|
||||
get help_article_path(@article)
|
||||
|
||||
assert_response :success
|
||||
assert_dom "h1", text: @article.title, count: 1
|
||||
end
|
||||
end
|
||||
@@ -65,11 +65,40 @@ class ImportsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_equal "Import CSV loaded", flash[:notice]
|
||||
end
|
||||
|
||||
test "should upload CSV file if valid" do
|
||||
Tempfile.open([ "transactions.csv", ".csv" ]) do |temp|
|
||||
CSV.open(temp, "wb", headers: true) do |csv|
|
||||
valid_csv_str.split("\n").each { |row| csv << row.split(",") }
|
||||
end
|
||||
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_csv_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
||||
assert_redirected_to configure_import_path(@empty_import)
|
||||
assert_equal "CSV File loaded", flash[:notice]
|
||||
end
|
||||
end
|
||||
|
||||
test "should flash error message if invalid CSV input" do
|
||||
patch load_import_url(@empty_import), params: { import: { raw_csv_str: malformed_csv_str } }
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Raw csv str is not a valid CSV format", flash[:error]
|
||||
assert_equal "Raw csv str is not a valid CSV format", flash[:alert]
|
||||
end
|
||||
|
||||
test "should flash error message if invalid CSV file upload" do
|
||||
Tempfile.open([ "transactions.csv", ".csv" ]) do |temp|
|
||||
temp.write(malformed_csv_str)
|
||||
temp.rewind
|
||||
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_csv_str: Rack::Test::UploadedFile.new(temp, ".csv") } }
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Raw csv str is not a valid CSV format", flash[:alert]
|
||||
end
|
||||
end
|
||||
|
||||
test "should flash error message if no fileprovided for upload" do
|
||||
patch upload_import_url(@empty_import), params: { import: { raw_csv_str: nil } }
|
||||
assert_response :unprocessable_entity
|
||||
assert_equal "Please select a file to upload", flash[:alert]
|
||||
end
|
||||
|
||||
test "should get configure" do
|
||||
|
||||
@@ -76,7 +76,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
post send_test_email_settings_hosting_path
|
||||
assert_response :unprocessable_entity
|
||||
assert controller.flash[:error].present?
|
||||
assert controller.flash[:alert].present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -87,7 +87,7 @@ class Settings::HostingsControllerTest < ActionDispatch::IntegrationTest
|
||||
|
||||
post send_test_email_settings_hosting_path
|
||||
assert_response :unprocessable_entity
|
||||
assert controller.flash[:error].present?
|
||||
assert controller.flash[:alert].present?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -88,7 +88,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "transaction count represents filtered total" do
|
||||
family = families(:empty)
|
||||
sign_in family.users.first
|
||||
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
|
||||
3.times do
|
||||
create_transaction(account: account)
|
||||
@@ -110,7 +110,7 @@ class TransactionsControllerTest < ActionDispatch::IntegrationTest
|
||||
test "can paginate" do
|
||||
family = families(:empty)
|
||||
sign_in family.users.first
|
||||
account = family.accounts.create! name: "Test", balance: 0, accountable: Depository.new
|
||||
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
|
||||
|
||||
11.times do
|
||||
create_transaction(account: account)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user