1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-05 13:35:21 +02:00

Improve account transaction, trade, and valuation editing and sync experience (#1506)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Consolidate entry controller logic

* Transaction builder

* Update trades controller to use new params

* Load account charts in turbo frames, fix PG overflow

* Consolidate tests

* Tests passing

* Remove unused code

* Add client side trade form validations
This commit is contained in:
Zach Gollwitzer 2024-11-27 16:01:50 -05:00 committed by GitHub
parent 76f2714006
commit c3248cd796
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
97 changed files with 1103 additions and 1159 deletions

View file

@ -1,14 +1,7 @@
class Account::CashesController < ApplicationController
layout :with_sidebar
before_action :set_account
def index
@account = Current.family.accounts.find(params[:account_id])
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
end

View file

@ -2,56 +2,21 @@ class Account::EntriesController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: %i[edit update show destroy]
def index
@q = search_params
@pagy, @entries = pagy(@account.entries.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
def edit
render entryable_view_path(:edit)
end
def update
prev_amount = @entry.amount
prev_date = @entry.date
@entry.update!(entry_params)
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
def show
render entryable_view_path(:show)
end
def destroy
@entry.destroy!
@entry.sync_account_later
redirect_to account_url(@entry.account), notice: t(".success")
@pagy, @entries = pagy(entries_scope.search(@q).reverse_chronological, limit: params[:per_page] || "10")
end
private
def entryable_view_path(action)
@entry.entryable_type.underscore.pluralize + "/" + action.to_s
end
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_entry
@entry = @account.entries.find(params[:id])
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency, :notes)
def entries_scope
scope = Current.family.entries
scope = scope.where(account: @account) if @account
scope
end
def search_params

View file

@ -1,11 +1,12 @@
class Account::HoldingsController < ApplicationController
layout :with_sidebar
before_action :set_account
before_action :set_holding, only: %i[show destroy]
def index
@holdings = @account.holdings.current
@account = Current.family.accounts.find(params[:account_id])
@holdings = Current.family.holdings.current
@holdings = @holdings.where(account: @account) if @account
end
def show
@ -13,16 +14,17 @@ class Account::HoldingsController < ApplicationController
def destroy
@holding.destroy_holding_and_entries!
redirect_back_or_to account_holdings_path(@account)
flash[:notice] = t(".success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@holding.account) }
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, account_path(@holding.account)) }
end
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_holding
@holding = @account.holdings.current.find(params[:id])
@holding = Current.family.holdings.current.find(params[:id])
end
end

View file

@ -1,69 +1,37 @@
class Account::TradesController < ApplicationController
layout :with_sidebar
include EntryableResource
before_action :set_account
before_action :set_entry, only: :update
def new
@entry = @account.entries.account_trades.new(
currency: @account.currency,
entryable_attributes: {}
)
end
def index
@entries = @account.entries.reverse_chronological.where(entryable_type: %w[Account::Trade Account::Transaction])
end
def create
@builder = Account::EntryBuilder.new(entry_params)
if entry = @builder.save
entry.sync_account_later
redirect_to @account, notice: t(".success")
else
flash[:alert] = t(".failure")
redirect_back_or_to @account
end
end
def update
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream { render turbo_stream: turbo_stream.replace(@entry) }
end
end
def securities
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security::SynthComboboxOption.find_in_synth(query)
end
permitted_entryable_attributes :id, :qty, :price
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
def build_entry
Account::TradeBuilder.new(create_entry_params)
end
def set_entry
@entry = @account.entries.find(params[:id])
def create_entry_params
params.require(:account_entry).permit(
:account_id, :date, :amount, :currency, :qty, :price, :ticker, :type, :transfer_account_id
).tap do |params|
account_id = params.delete(:account_id)
params[:account] = Current.family.accounts.find(account_id)
end
end
def entry_params
params.require(:account_entry)
.permit(
:type, :date, :qty, :ticker, :price, :amount, :notes, :excluded, :currency, :transfer_account_id, :entryable_type,
entryable_attributes: [
:id,
:qty,
:ticker,
:price
]
)
.merge(account: @account)
def update_entry_params
return entry_params unless entry_params[:entryable_attributes].present?
update_params = entry_params
update_params = update_params.merge(entryable_type: "Account::Trade")
qty = update_params[:entryable_attributes][:qty]
price = update_params[:entryable_attributes][:price]
if qty.present? && price.present?
qty = update_params[:nature] == "inflow" ? -qty.to_d : qty.to_d
update_params[:entryable_attributes][:qty] = qty
update_params[:amount] = qty * price.to_d
end
update_params.except(:nature)
end
end

View file

@ -0,0 +1,22 @@
class Account::TransactionCategoriesController < ApplicationController
def update
@entry = Current.family.entries.account_transactions.find(params[:transaction_id])
@entry.update!(entry_params)
respond_to do |format|
format.html { redirect_back_or_to account_transaction_path(@entry) }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"category_menu_account_transaction_#{@entry.account_transaction_id}",
partial: "categories/menu",
locals: { transaction: @entry.account_transaction }
)
end
end
end
private
def entry_params
params.require(:account_entry).permit(:entryable_type, entryable_attributes: [ :id, :category_id ])
end
end

View file

@ -1,74 +1,55 @@
class Account::TransactionsController < ApplicationController
layout :with_sidebar
include EntryableResource
before_action :set_account
before_action :set_entry, only: :update
permitted_entryable_attributes :id, :category_id, :merchant_id, { tag_ids: [] }
def index
@pagy, @entries = pagy(
@account.entries.account_transactions.reverse_chronological,
limit: params[:per_page] || "10"
)
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end
def update
prev_amount = @entry.amount
prev_date = @entry.date
def bulk_edit
end
@entry.update!(entry_params.except(:origin))
@entry.sync_account_later if prev_amount != @entry.amount || prev_date != @entry.date
def bulk_update
updated = Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params)
respond_to do |format|
format.html { redirect_to account_entry_path(@account, @entry), notice: t(".success") }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
@entry,
partial: "account/entries/entry",
locals: entry_locals.merge(entry: @entry)
)
end
end
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])
end
def set_entry
@entry = @account.entries.find(params[:id])
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
end
def entry_locals
{
selectable: entry_params[:origin].present?,
show_balance: entry_params[:origin] == "account",
origin: entry_params[:origin]
}
end
def entry_params
params.require(:account_entry)
.permit(
:name, :date, :amount, :currency, :excluded, :notes, :entryable_type, :nature, :origin,
entryable_attributes: [
:id,
:category_id,
:merchant_id,
{ tag_ids: [] }
]
).tap do |permitted_params|
nature = permitted_params.delete(:nature)
if permitted_params[:amount]
amount_value = permitted_params[:amount].to_d
if nature == "income"
amount_value *= -1
end
permitted_params[:amount] = amount_value
end
end
def search_params
params.fetch(:q, {})
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
end
end

View file

@ -1,38 +1,3 @@
class Account::ValuationsController < ApplicationController
layout :with_sidebar
before_action :set_account
def new
@entry = @account.entries.account_valuations.new(
currency: @account.currency,
entryable_attributes: {}
)
end
def create
@entry = @account.entries.account_valuations.new(entry_params.merge(entryable_attributes: {}))
if @entry.save
@entry.sync_account_later
redirect_back_or_to account_valuations_path(@account), notice: t(".success")
else
flash[:alert] = @entry.errors.full_messages.to_sentence
redirect_to @account
end
end
def index
@entries = @account.entries.account_valuations.reverse_chronological
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def entry_params
params.require(:account_entry).permit(:name, :date, :amount, :currency)
end
include EntryableResource
end

View file

@ -31,6 +31,11 @@ class AccountsController < ApplicationController
redirect_to account_path(@account)
end
def chart
@account = Current.family.accounts.find(params[:id])
render layout: "application"
end
def sync_all
unless Current.family.syncing?
Current.family.sync_later

View file

@ -0,0 +1,126 @@
module EntryableResource
extend ActiveSupport::Concern
included do
layout :with_sidebar
before_action :set_entry, only: %i[show update destroy]
end
class_methods do
def permitted_entryable_attributes(*attrs)
@permitted_entryable_attributes = attrs if attrs.any?
@permitted_entryable_attributes ||= [ :id ]
end
end
def show
end
def new
account = Current.family.accounts.find_by(id: params[:account_id])
@entry = Current.family.entries.new(
account: account,
currency: account ? account.currency : Current.family.currency,
entryable: entryable_type.new
)
end
def create
@entry = build_entry
if @entry.save
@entry.sync_account_later
flash[:notice] = t("account.entries.create.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account) }
redirect_target_url = request.referer || account_path(@entry.account)
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
else
render :new, status: :unprocessable_entity
end
end
def update
if @entry.update(update_entry_params)
@entry.sync_account_later
respond_to do |format|
format.html { redirect_back_or_to account_path(@entry.account), notice: t("account.entries.update.success") }
format.turbo_stream do
render turbo_stream: turbo_stream.replace(
"header_account_entry_#{@entry.id}",
partial: "#{entryable_type.name.underscore.pluralize}/header",
locals: { entry: @entry }
)
end
end
else
render :show, status: :unprocessable_entity
end
end
def destroy
account = @entry.account
@entry.destroy!
@entry.sync_account_later
flash[:notice] = t("account.entries.destroy.success")
respond_to do |format|
format.html { redirect_back_or_to account_path(account) }
redirect_target_url = request.referer || account_path(@entry.account)
format.turbo_stream { render turbo_stream: turbo_stream.action(:redirect, redirect_target_url) }
end
end
private
def entryable_type
permitted_entryable_types = %w[Account::Transaction Account::Valuation Account::Trade]
klass = params[:entryable_type] || "Account::#{controller_name.classify}"
klass.constantize if permitted_entryable_types.include?(klass)
end
def set_entry
@entry = Current.family.entries.find(params[:id])
end
def build_entry
Current.family.entries.new(create_entry_params)
end
def update_entry_params
prepared_entry_params
end
def create_entry_params
prepared_entry_params.merge({
entryable_type: entryable_type.name,
entryable_attributes: entry_params[:entryable_attributes] || {}
})
end
def prepared_entry_params
default_params = entry_params.except(:nature)
default_params = default_params.merge(entryable_type: entryable_type.name) if entry_params[:entryable_attributes].present?
if entry_params[:nature].present? && entry_params[:amount].present?
signed_amount = entry_params[:nature] == "inflow" ? -entry_params[:amount].to_d : entry_params[:amount].to_d
default_params = default_params.merge(amount: signed_amount)
end
default_params
end
def entry_params
params.require(:account_entry).permit(
:account_id, :name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: self.class.permitted_entryable_attributes
)
end
end

View file

@ -1,5 +1,18 @@
class SecuritiesController < ApplicationController
def import
SecuritiesImportJob.perform_later(params[:exchange_mic])
def index
query = params[:q]
return render json: [] if query.blank? || query.length < 2 || query.length > 100
@securities = Security.search({
search: query,
country: country_code_filter
})
end
private
def country_code_filter
filter = params[:country_code]
filter = "#{filter},US" unless filter == "US"
filter
end
end

View file

@ -13,94 +13,13 @@ class TransactionsController < ApplicationController
}
end
def new
@entry = Current.family.entries.new(entryable: Account::Transaction.new).tap do |e|
if params[:account_id]
e.account = Current.family.accounts.find(params[:account_id])
e.currency = e.account.currency
else
e.currency = Current.family.currency
end
end
end
def create
@entry = Current.family
.accounts
.find(params[:account_entry][:account_id])
.entries
.create!(transaction_entry_params.merge(amount: amount))
@entry.sync_account_later
redirect_back_or_to @entry.account, notice: t(".success")
end
def bulk_delete
destroyed = Current.family.entries.destroy_by(id: bulk_delete_params[:entry_ids])
destroyed.map(&:account).uniq.each(&:sync_later)
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
end
def bulk_edit
end
def bulk_update
updated = Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.bulk_update!(bulk_update_params)
redirect_back_or_to transactions_url, notice: t(".success", count: updated)
end
def mark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.mark_transfers!
redirect_back_or_to transactions_url, notice: t(".success")
end
def unmark_transfers
Current.family
.entries
.where(id: bulk_update_params[:entry_ids])
.update_all marked_as_transfer: false
redirect_back_or_to transactions_url, notice: t(".success")
end
private
def amount
if nature.income?
transaction_entry_params[:amount].to_d * -1
else
transaction_entry_params[:amount].to_d
end
end
def nature
params[:account_entry][:nature].to_s.inquiry
end
def bulk_delete_params
params.require(:bulk_delete).permit(entry_ids: [])
end
def bulk_update_params
params.require(:bulk_update).permit(:date, :notes, :category_id, :merchant_id, entry_ids: [])
end
def search_params
params.fetch(:q, {})
.permit(:start_date, :end_date, :search, :amount, :amount_operator, accounts: [], account_ids: [], categories: [], merchants: [], types: [], tags: [])
end
def transaction_entry_params
params.require(:account_entry)
.permit(:name, :date, :amount, :currency, :entryable_type, entryable_attributes: [ :category_id ])
.with_defaults(entryable_type: "Account::Transaction", entryable_attributes: {})
.permit(
:start_date, :end_date, :search, :amount,
:amount_operator, accounts: [], account_ids: [],
categories: [], merchants: [], types: [], tags: []
)
end
end

View file

@ -62,9 +62,9 @@ module ApplicationHelper
# <div>Content here</div>
# <% end %>
#
def drawer(&block)
def drawer(reload_on_close: false, &block)
content = capture &block
render partial: "shared/drawer", locals: { content: content }
render partial: "shared/drawer", locals: { content:, reload_on_close: }
end
def disclosure(title, &block)

View file

@ -1,3 +1,7 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails";
import "controllers";
Turbo.StreamActions.redirect = function () {
Turbo.visit(this.target);
};

View file

@ -6,7 +6,7 @@ const application = Application.start();
application.debug = false;
window.Stimulus = application;
Turbo.setConfirmMethod((message) => {
Turbo.config.forms.confirm = (message) => {
const dialog = document.getElementById("turbo-confirm");
try {
@ -52,6 +52,6 @@ Turbo.setConfirmMethod((message) => {
{ once: true },
);
});
});
};
export { application };

View file

@ -2,6 +2,10 @@ import { Controller } from "@hotwired/stimulus";
// Connects to data-controller="modal"
export default class extends Controller {
static values = {
reloadOnClose: { type: Boolean, default: false },
};
connect() {
if (this.element.open) return;
this.element.showModal();
@ -10,11 +14,15 @@ export default class extends Controller {
// Hide the dialog when the user clicks outside of it
clickOutside(e) {
if (e.target === this.element) {
this.element.close();
this.close();
}
}
close() {
this.element.close();
if (this.reloadOnCloseValue) {
window.location.reload();
}
}
}

View file

@ -1,71 +1,11 @@
import { Controller } from "@hotwired/stimulus";
const TRADE_TYPES = {
BUY: "buy",
SELL: "sell",
TRANSFER_IN: "transfer_in",
TRANSFER_OUT: "transfer_out",
INTEREST: "interest",
};
const FIELD_VISIBILITY = {
[TRADE_TYPES.BUY]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.SELL]: { ticker: true, qty: true, price: true },
[TRADE_TYPES.TRANSFER_IN]: { amount: true, transferAccount: true },
[TRADE_TYPES.TRANSFER_OUT]: { amount: true, transferAccount: true },
[TRADE_TYPES.INTEREST]: { amount: true },
};
// Connects to data-controller="trade-form"
export default class extends Controller {
static targets = [
"typeInput",
"tickerInput",
"amountInput",
"transferAccountInput",
"qtyInput",
"priceInput",
];
connect() {
this.handleTypeChange = this.handleTypeChange.bind(this);
this.typeInputTarget.addEventListener("change", this.handleTypeChange);
this.updateFields(this.typeInputTarget.value || TRADE_TYPES.BUY);
}
disconnect() {
this.typeInputTarget.removeEventListener("change", this.handleTypeChange);
}
handleTypeChange(event) {
this.updateFields(event.target.value);
}
updateFields(type) {
const visibleFields = FIELD_VISIBILITY[type] || {};
Object.entries(this.fieldTargets).forEach(([field, target]) => {
const isVisible = visibleFields[field] || false;
// Update visibility
target.hidden = !isVisible;
// Update required status based on visibility
if (isVisible) {
target.setAttribute("required", "");
} else {
target.removeAttribute("required");
}
});
}
get fieldTargets() {
return {
ticker: this.tickerInputTarget,
amount: this.amountInputTarget,
transferAccount: this.transferAccountInputTarget,
qty: this.qtyInputTarget,
price: this.priceInputTarget,
};
// Reloads the page with a new type without closing the modal
async changeType(event) {
const url = new URL(event.params.url, window.location.origin);
url.searchParams.set(event.params.key, event.target.value);
Turbo.visit(url, { frame: "modal" });
}
}

View file

@ -12,7 +12,7 @@ class Account < ApplicationRecord
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 :holdings, dependent: :destroy, class_name: "Account::Holding"
has_many :balances, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy

View file

@ -30,10 +30,10 @@ class Account::Entry < ApplicationRecord
}
def sync_account_later
if destroyed?
sync_start_date = previous_entry&.date
sync_start_date = if destroyed?
previous_entry&.date
else
sync_start_date = [ date_previously_was, date ].compact.min
[ date_previously_was, date ].compact.min
end
account.sync_later(start_date: sync_start_date)

View file

@ -1,46 +0,0 @@
class Account::EntryBuilder
include ActiveModel::Model
TYPES = %w[income expense buy sell interest transfer_in transfer_out].freeze
attr_accessor :type, :date, :qty, :ticker, :price, :amount, :currency, :account, :transfer_account_id
validates :type, inclusion: { in: TYPES }
def save
if valid?
create_builder.save
end
end
private
def create_builder
case type
when "buy", "sell"
create_trade_builder
else
create_transaction_builder
end
end
def create_trade_builder
Account::TradeBuilder.new \
type: type,
date: date,
qty: qty,
ticker: ticker,
price: price,
account: account
end
def create_transaction_builder
Account::TransactionBuilder.new \
type: type,
date: date,
amount: amount,
account: account,
currency: currency,
transfer_account_id: transfer_account_id
end
end

View file

@ -28,8 +28,7 @@ class Account::Trade < ApplicationRecord
def name
prefix = sell? ? "Sell " : "Buy "
generated = prefix + "#{qty.abs} shares of #{security.ticker}"
entry.name || generated
prefix + "#{qty.abs} shares of #{security.ticker}"
end
def unrealized_gain_loss

View file

@ -1,33 +1,103 @@
class Account::TradeBuilder < Account::EntryBuilder
class Account::TradeBuilder
include ActiveModel::Model
TYPES = %w[buy sell].freeze
attr_accessor :type, :qty, :price, :ticker, :date, :account
validates :type, :qty, :price, :ticker, :date, presence: true
validates :price, numericality: { greater_than: 0 }
validates :type, inclusion: { in: TYPES }
attr_accessor :account, :date, :amount, :currency, :qty,
:price, :ticker, :type, :transfer_account_id
def save
if valid?
create_entry
end
buildable.save
end
def errors
buildable.errors
end
def sync_account_later
buildable.sync_account_later
end
private
def buildable
case type
when "buy", "sell"
build_trade
when "deposit", "withdrawal"
build_transfer
when "interest"
build_interest
else
raise "Unknown trade type: #{type}"
end
end
def create_entry
account.entries.account_trades.create! \
def build_trade
account.entries.new(
date: date,
amount: amount,
currency: account.currency,
amount: signed_amount,
currency: currency,
entryable: Account::Trade.new(
security: security,
qty: signed_qty,
price: price.to_d,
currency: account.currency
price: price,
currency: currency,
security: security
)
)
end
def build_transfer
transfer_account = family.accounts.find(transfer_account_id) if transfer_account_id.present?
if transfer_account
from_account = type == "withdrawal" ? account : transfer_account
to_account = type == "withdrawal" ? transfer_account : account
Account::Transfer.build_from_accounts(
from_account,
to_account,
date: date,
amount: signed_amount
)
else
account.entries.build(
name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}",
date: date,
amount: signed_amount,
currency: currency,
marked_as_transfer: true,
entryable: Account::Transaction.new
)
end
end
def build_interest
account.entries.build(
name: "Interest payment",
date: date,
amount: signed_amount,
currency: currency,
entryable: Account::Transaction.new
)
end
def signed_qty
return nil unless type.in?([ "buy", "sell" ])
type == "sell" ? -qty.to_d : qty.to_d
end
def signed_amount
case type
when "buy", "sell"
signed_qty * price.to_d
when "deposit", "withdrawal"
type == "deposit" ? -amount.to_d : amount.to_d
when "interest"
amount.to_d * -1
end
end
def family
account.family
end
def security
@ -40,14 +110,4 @@ class Account::TradeBuilder < Account::EntryBuilder
security
end
def amount
price.to_d * signed_qty
end
def signed_qty
_qty = qty.to_d
_qty = _qty * -1 if type == "sell"
_qty
end
end

View file

@ -1,64 +0,0 @@
class Account::TransactionBuilder
include ActiveModel::Model
TYPES = %w[income expense interest transfer_in transfer_out].freeze
attr_accessor :type, :amount, :date, :account, :currency, :transfer_account_id
validates :type, :amount, :date, presence: true
validates :type, inclusion: { in: TYPES }
def save
if valid?
transfer? ? create_transfer : create_transaction
end
end
private
def transfer?
%w[transfer_in transfer_out].include?(type)
end
def create_transfer
return create_unlinked_transfer(account.id, signed_amount) if transfer_account_id.blank?
from_account_id = type == "transfer_in" ? transfer_account_id : account.id
to_account_id = type == "transfer_in" ? account.id : transfer_account_id
outflow = create_unlinked_transfer(from_account_id, signed_amount.abs)
inflow = create_unlinked_transfer(to_account_id, signed_amount.abs * -1)
Account::Transfer.create! entries: [ outflow, inflow ]
inflow
end
def create_unlinked_transfer(account_id, amount)
build_entry(account_id, amount, marked_as_transfer: true).tap(&:save!)
end
def create_transaction
build_entry(account.id, signed_amount).tap(&:save!)
end
def build_entry(account_id, amount, marked_as_transfer: false)
Account::Entry.new \
account_id: account_id,
name: marked_as_transfer ? (amount < 0 ? "Deposit" : "Withdrawal") : "Interest",
amount: amount,
currency: currency,
date: date,
marked_as_transfer: marked_as_transfer,
entryable: Account::Transaction.new
end
def signed_amount
case type
when "expense", "transfer_out"
amount.to_d
else
amount.to_d * -1
end
end
end

View file

@ -48,6 +48,10 @@ class Account::Transfer < ApplicationRecord
end
end
def sync_account_later
entries.each(&:sync_account_later)
end
class << self
def build_from_accounts(from_account, to_account, date:, amount:)
outflow = from_account.entries.build \

View file

@ -35,8 +35,9 @@ module Accountable
end
def post_sync
broadcast_remove_to(account, target: "syncing-notification")
broadcast_remove_to(account.family, target: "syncing-notice")
# Broadcast a simple replace event that the controller can handle
broadcast_replace_to(
account,
target: "chart_account_#{account.id}",

View file

@ -15,6 +15,7 @@ class Family < ApplicationRecord
has_many :categories, dependent: :destroy
has_many :merchants, dependent: :destroy
has_many :issues, through: :accounts
has_many :holdings, through: :accounts
has_many :plaid_items, dependent: :destroy
validates :locale, inclusion: { in: I18n.available_locales.map(&:to_s) }

View file

@ -56,7 +56,7 @@ class Investment < ApplicationRecord
end
def post_sync
broadcast_remove_to(account, target: "syncing-notification")
broadcast_remove_to(account, target: "syncing-notice")
broadcast_replace_to(
account,

View file

@ -134,12 +134,12 @@ class Provider::Synth
securities = parsed.dig("data").map do |security|
{
symbol: security.dig("symbol"),
ticker: security.dig("symbol"),
name: security.dig("name"),
logo_url: security.dig("logo_url"),
exchange_acronym: security.dig("exchange", "acronym"),
exchange_mic: security.dig("exchange", "mic_code"),
exchange_country_code: security.dig("exchange", "country_code")
country_code: security.dig("exchange", "country_code")
}
end

View file

@ -8,17 +8,33 @@ class Security < ApplicationRecord
validates :ticker, presence: true
validates :ticker, uniqueness: { scope: :exchange_mic, case_sensitive: false }
class << self
def search(query)
security_prices_provider.search_securities(
query: query[:search],
dataset: "limited",
country_code: query[:country]
).securities.map { |attrs| new(**attrs) }
end
end
def current_price
@current_price ||= Security::Price.find_price(security: self, date: Date.current)
return nil if @current_price.nil?
Money.new(@current_price.price, @current_price.currency)
end
def to_combobox_display
"#{ticker} (#{exchange_acronym})"
def to_combobox_option
SynthComboboxOption.new(
symbol: ticker,
name: name,
logo_url: logo_url,
exchange_acronym: exchange_acronym,
exchange_mic: exchange_mic,
exchange_country_code: country_code
)
end
private
def upcase_ticker

View file

@ -1,22 +1,8 @@
class Security::SynthComboboxOption
include ActiveModel::Model
include Providable
attr_accessor :symbol, :name, :logo_url, :exchange_acronym, :exchange_mic, :exchange_country_code
class << self
def find_in_synth(query)
country = Current.family.country
country = "#{country},US" unless country == "US"
security_prices_provider.search_securities(
query:,
dataset: "limited",
country_code: country
).securities.map { |attrs| new(**attrs) }
end
end
def id
"#{symbol}|#{exchange_mic}|#{exchange_acronym}|#{exchange_country_code}" # submitted by combobox as value
end

View file

@ -1,5 +1,5 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance:, origin: } %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %>
<% end %>

View file

@ -6,7 +6,7 @@
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_account_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>

View file

@ -9,13 +9,13 @@
<%= tag.span t(".new") %>
</button>
<div data-menu-target="content" class="z-10 hidden bg-white rounded-lg border border-alpha-black-25 shadow-xs p-1">
<%= link_to new_account_valuation_path(@account), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= link_to new_account_valuation_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("circle-dollar-sign", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_balance"), class: "text-sm" %>
<% end %>
<% unless @account.crypto? %>
<%= link_to @account.investment? ? new_account_trade_path(@account) : new_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= link_to @account.investment? ? new_account_trade_path(account_id: @account.id) : new_account_transaction_path(account_id: @account.id), data: { turbo_frame: :modal }, class: "block p-2 rounded-lg hover:bg-gray-50 flex items-center gap-2" do %>
<%= lucide_icon("credit-card", class: "text-gray-500 w-5 h-5") %>
<%= tag.span t(".new_transaction"), class: "text-sm" %>
<% end %>
@ -75,7 +75,7 @@
<div class="space-y-4">
<%= entries_by_date(@entries) do |entries| %>
<%= render entries, show_balance: true, origin: "account" %>
<%= render entries, show_balance: true %>
<% end %>
</div>

View file

@ -6,7 +6,7 @@
<%= image_tag "https://logo.synthfinance.com/ticker/#{holding.ticker}", class: "w-9 h-9 rounded-full" %>
<div class="space-y-0.5">
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= link_to holding.name, account_holding_path(holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<% if holding.amount %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>

View file

@ -101,10 +101,10 @@
</div>
<%= button_to t(".delete"),
account_holding_path(@holding.account, @holding),
account_holding_path(@holding),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
data: { turbo_confirm: true } %>
</div>
</div>
</details>

View file

@ -1,35 +1,51 @@
<%# locals: (entry:) %>
<%= styled_form_with data: { turbo_frame: "_top", controller: "trade-form" },
model: entry,
scope: :account_entry,
url: account_trades_path(entry.account) do |form| %>
<% type = params[:type] || "buy" %>
<%= styled_form_with model: entry, url: account_trades_path, data: { controller: "trade-form" } do |form| %>
<%= form.hidden_field :account_id %>
<div class="space-y-4">
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<div class="space-y-2">
<%= form.select :type, options_for_select([%w[Buy buy], %w[Sell sell], %w[Deposit transfer_in], %w[Withdrawal transfer_out], %w[Interest interest]], "buy"), { label: t(".type") }, { data: { "trade-form-target": "typeInput" } } %>
<div data-trade-form-target="tickerInput">
<%= form.select :type, [
["Buy", "buy"],
["Sell", "sell"],
["Deposit", "deposit"],
["Withdrawal", "withdrawal"],
["Interest", "interest"]
],
{ label: t(".type"), selected: type },
{ data: {
action: "trade-form#changeType",
trade_form_url_param: new_account_trade_path(account_id: entry.account_id),
trade_form_key_param: "type",
}} %>
<% if %w[buy sell].include?(type) %>
<div class="form-field combobox">
<%= form.combobox :ticker, securities_account_trades_path(entry.account), label: t(".holding"), placeholder: t(".ticker_placeholder") %>
<%= form.combobox :ticker, securities_path(country_code: Current.family.country), label: t(".holding"), placeholder: t(".ticker_placeholder"), required: true %>
</div>
</div>
<% end %>
<%= form.date_field :date, label: true, value: Date.today %>
<%= form.date_field :date, label: true, value: Date.today, required: true %>
<div data-trade-form-target="amountInput" hidden>
<%= form.money_field :amount, label: t(".amount") %>
</div>
<% unless %w[buy sell].include?(type) %>
<%= form.money_field :amount, label: t(".amount"), required: true %>
<% end %>
<div data-trade-form-target="transferAccountInput" hidden>
<% if %w[deposit withdrawal].include?(type) %>
<%= form.collection_select :transfer_account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
</div>
<% end %>
<div data-trade-form-target="qtyInput">
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any" %>
</div>
<div data-trade-form-target="priceInput">
<%= form.money_field :price, label: t(".price"), currency_value_override: "USD", disable_currency: true %>
</div>
<% if %w[buy sell].include?(type) %>
<%= form.number_field :qty, label: t(".qty"), placeholder: "10", min: 0.000000000000000001, step: "any", required: true %>
<%= form.money_field :price, label: t(".price"), required: true %>
<% end %>
</div>
<%= form.submit t(".submit") %>

View file

@ -0,0 +1,68 @@
<%# locals: (entry:) %>
<div id="<%= dom_id(entry, :header) %>">
<%= tag.header class: "mb-4 space-y-1" do %>
<span class="text-gray-500 text-sm">
<%= entry.amount.negative? ? t(".sell") : t(".buy") %>
</span>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
<% end %>
<% trade = entry.account_trade %>
<div class="mb-2">
<%= disclosure t(".overview") do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".symbol_label") %></dt>
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
</div>
<% if trade.buy? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.price_money %></dd>
</div>
<% end %>
<% if trade.security.current_price.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".current_market_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.security.current_price %></dd>
</div>
<% end %>
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
<%= render "shared/trend_change", trend: trade.unrealized_gain_loss %>
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
</div>
</div>

View file

@ -1,11 +0,0 @@
<div class="flex items-center">
<%= image_tag(security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
<div class="flex flex-col">
<span class="text-sm font-medium">
<%= security.name.presence || security.symbol %>
</span>
<span class="text-xs text-gray-500">
<%= "#{security.symbol} (#{security.exchange_acronym})" %>
</span>
</div>
</div>

View file

@ -6,7 +6,7 @@
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_account_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>

View file

@ -1,8 +1,8 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
<div class="col-span-8 flex items-center gap-4">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
@ -16,12 +16,12 @@
<%= trade.name.first.upcase %>
</div>
<div class="truncate text-gray-900">
<div class="truncate">
<% if entry.new_record? %>
<%= content_tag :p, trade.name %>
<% else %>
<%= link_to trade.name,
account_entry_path(account, entry),
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@ -31,7 +31,9 @@
</div>
<div class="col-span-2 justify-self-end font-medium text-sm">
<%= tag.span format_money(entry.amount_money) %>
<%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.amount.negative?] %>
</div>
<div class="col-span-2 justify-self-end">

View file

@ -1,2 +0,0 @@
<%= async_combobox_options @securities,
render_in: { partial: "account/trades/security" } %>

View file

@ -1,83 +1,37 @@
<% entry, trade, account = @entry, @entry.account_trade, @entry.account %>
<%= drawer(reload_on_close: true) do %>
<%= render "account/trades/header", entry: @entry %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money -entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
</header>
<% trade = @entry.account_trade %>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".symbol_label") %></dt>
<dd class="text-gray-900"><%= trade.security.ticker %></dd>
</div>
<% if trade.buy? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_qty_label") %></dt>
<dd class="text-gray-900"><%= trade.qty.abs %></dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".purchase_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.price_money %></dd>
</div>
<% end %>
<% if trade.security.current_price.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".current_market_price_label") %></dt>
<dd class="text-gray-900"><%= format_money trade.security.current_price %></dd>
</div>
<% end %>
<% if trade.buy? && trade.unrealized_gain_loss.present? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500"><%= t(".total_return_label") %></dt>
<dd style="color: <%= trade.unrealized_gain_loss.color %>;">
<%= render "shared/trend_change", trend: trade.unrealized_gain_loss %>
</dd>
</div>
<% end %>
</dl>
</div>
<% end %>
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_trade_path(account, entry),
<%= styled_form_with model: @entry,
url: account_trade_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,
max: Date.today,
"data-auto-submit-form-target": "auto" %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.number_field :qty,
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Buy", "outflow"], ["Sell", "inflow"]],
{ container_class: "w-1/3", label: "Type", selected: @entry.amount.negative? ? "inflow" : "outflow" },
{ data: { "auto-submit-form-target": "auto" } } %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.number_field :qty,
label: t(".quantity_label"),
step: "any",
value: trade.qty.abs,
"data-auto-submit-form-target": "auto" %>
<% end %>
</div>
<%= f.fields_for :entryable do |ef| %>
<%= ef.money_field :price,
label: t(".cost_per_share_label"),
disable_currency: true,
@ -91,8 +45,8 @@
<!-- Additional Section -->
<%= disclosure t(".additional") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_trade_path(account, entry),
<%= styled_form_with model: @entry,
url: account_trade_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_area :notes,
@ -108,8 +62,8 @@
<%= disclosure t(".settings") do %>
<div class="pb-4">
<!-- Exclude Trade Form -->
<%= styled_form_with model: [account, entry],
url: account_trade_path(account, entry),
<%= styled_form_with model: @entry,
url: account_trade_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
@ -136,11 +90,11 @@
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
account_entry_path(@entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm
font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>
data: { turbo_confirm: true } %>
</div>
</div>
<% end %>

View file

@ -1,8 +1,13 @@
<%= styled_form_with model: @entry, url: transactions_path, class: "space-y-4", data: { turbo_frame: "_top" } do |f| %>
<%= styled_form_with model: @entry, url: account_transactions_path, class: "space-y-4" do |f| %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<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? %>
<%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "income" %>
<%= radio_tab_tag form: f, name: :nature, value: :outflow, label: t(".expense"), icon: "minus-circle", checked: params[:nature] == "outflow" || params[:nature].nil? %>
<%= radio_tab_tag form: f, name: :nature, value: :inflow, label: t(".income"), icon: "plus-circle", checked: params[:nature] == "inflow" %>
<%= link_to new_account_transfer_path, data: { turbo_frame: :modal }, 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 %>
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
<%= tag.span t(".transfer") %>
@ -12,9 +17,14 @@
<section class="space-y-2 overflow-hidden">
<%= f.text_field :name, label: t(".description"), placeholder: t(".description_placeholder"), required: true %>
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<% if @entry.account_id %>
<%= f.hidden_field :account_id %>
<% else %>
<%= f.collection_select :account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") }, required: true, class: "form-field__input text-ellipsis" %>
<% end %>
<%= f.money_field :amount, label: t(".amount"), required: true %>
<%= 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") } %>
<% end %>

View file

@ -0,0 +1,23 @@
<%# locals: (entry:) %>
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money -entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
<% if entry.marked_as_transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
<% end %>

View file

@ -8,7 +8,7 @@
<div class="flex items-center gap-1 text-gray-500">
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
<%= form_with url: mark_transfers_transactions_path,
<%= form_with url: mark_transfers_account_transactions_path,
scope: "bulk_update",
data: {
turbo_frame: "_top",
@ -28,14 +28,14 @@
</button>
<% end %>
<%= link_to bulk_edit_transactions_path,
<%= link_to bulk_edit_account_transactions_path,
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
title: "Edit",
data: { turbo_frame: "bulk_transaction_edit_drawer" } do %>
<%= lucide_icon "pencil-line", class: "w-5 group-hover:text-white" %>
<% end %>
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<%= form_with url: bulk_delete_account_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>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<% transaction, account = entry.account_transaction, entry.account %>
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
@ -20,7 +20,7 @@
<%= content_tag :p, transaction.name %>
<% else %>
<%= link_to transaction.name,
entry.transfer.present? ? account_transfer_path(entry.transfer, origin:) : account_entry_path(account, entry, origin:),
entry.transfer.present? ? account_transfer_path(entry.transfer) : account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
@ -43,7 +43,7 @@
</div>
<% else %>
<div class="flex items-center gap-1 col-span-2">
<%= render "categories/menu", transaction: transaction, origin: origin %>
<%= render "categories/menu", transaction: transaction %>
</div>
<% unless show_balance %>

View file

@ -1,8 +1,8 @@
<%= turbo_frame_tag "bulk_transaction_edit_drawer" do %>
<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">
<%= styled_form_with url: bulk_update_transactions_path, scope: "bulk_update", class: "h-full", data: { turbo_frame: "_top" } do |form| %>
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">
<%= styled_form_with url: bulk_update_account_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">

View file

@ -2,7 +2,7 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex justify-between items-center">
<h3 class="font-medium text-lg"><%= t(".transactions") %></h3>
<%= link_to new_transaction_path(account_id: @account),
<%= link_to new_account_transaction_path(account_id: @account),
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>

View file

@ -1,3 +1,3 @@
<%= modal_form_wrapper title: t(".new_transaction") do %>
<%= render "form", transaction: @transaction, entry: @entry %>
<%= render "form", entry: @entry %>
<% end %>

View file

@ -1,39 +1,14 @@
<% entry, transaction, account = @entry, @entry.account_transaction, @entry.account %>
<% origin = params[:origin] %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money -entry.amount_money %>
</span>
<span class="text-lg text-gray-500">
<%= entry.currency %>
</span>
</h3>
<% if entry.marked_as_transfer? %>
<%= lucide_icon "arrow-left-right", class: "text-gray-500 mt-1 w-5 h-5" %>
<% end %>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
</header>
<%= drawer(reload_on_close: true) do %>
<%= render "account/transactions/header", entry: @entry %>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.text_field :name,
label: t(".name_label"),
"data-auto-submit-form-target": "auto" %>
@ -43,25 +18,25 @@
max: Date.current,
"data-auto-submit-form-target": "auto" %>
<% unless entry.marked_as_transfer? %>
<% unless @entry.marked_as_transfer? %>
<div class="flex items-center gap-2">
<%= f.select :nature,
[["Expense", "expense"], ["Income", "income"]],
{ container_class: "w-1/3", label: t(".nature"), selected: entry.amount.negative? ? "income" : "expense" },
[["Expense", "outflow"], ["Income", "inflow"]],
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
{ data: { "auto-submit-form-target": "auto" } } %>
<%= f.money_field :amount, label: t(".amount"),
container_class: "w-2/3",
auto_submit: true,
min: 0,
value: entry.amount.abs %>
value: @entry.amount.abs %>
</div>
<% end %>
<%= f.select :account,
options_for_select(
Current.family.accounts.alphabetically.pluck(:name, :id),
entry.account_id
@entry.account_id
),
{ label: t(".account_label") },
{ disabled: true } %>
@ -72,55 +47,45 @@
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.fields_for :entryable do |ef| %>
<% unless entry.marked_as_transfer? %>
<% unless @entry.marked_as_transfer? %>
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id,
Current.family.categories.alphabetically,
Current.family.categories.alphabetically,
:id, :name,
{ prompt: t(".category_placeholder"),
label: t(".category_label"),
class: "text-gray-400" },
{ label: t(".category_label"),
class: "text-gray-400", include_blank: t(".uncategorized") },
"data-auto-submit-form-target": "auto" %>
<%= ef.collection_select :merchant_id,
Current.family.merchants.alphabetically,
Current.family.merchants.alphabetically,
:id, :name,
{ prompt: t(".merchant_placeholder"),
{ include_blank: t(".none"),
label: t(".merchant_label"),
class: "text-gray-400" },
"data-auto-submit-form-target": "auto" %>
<% end %>
<%= ef.select :tag_ids,
options_for_select(
<%= ef.select :tag_ids,
Current.family.tags.alphabetically.pluck(:name, :id),
transaction.tag_ids
),
{
multiple: true,
label: t(".tags_label"),
container_class: "h-40"
},
{
include_blank: t(".none"),
multiple: true,
label: t(".tags_label"),
container_class: "h-40"
},
{ "data-auto-submit-form-target": "auto" } %>
<% end %>
<% end %>
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<%= f.text_area :notes,
<%= f.text_area :notes,
label: t(".note_label"),
placeholder: t(".note_placeholder"),
rows: 5,
"data-auto-submit-form-target": "auto" %>
<% end %>
<% end %>
</div>
<% end %>
@ -129,11 +94,10 @@
<%= disclosure t(".settings") do %>
<div class="pb-4">
<!-- Exclude Transaction Form -->
<%= styled_form_with model: [account, entry],
url: account_transaction_path(account, entry),
<%= styled_form_with model: @entry,
url: account_transaction_path(@entry),
class: "p-3",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.hidden_field :origin, value: origin %>
<div class="flex cursor-pointer items-center gap-2 justify-between">
<div class="text-sm space-y-1">
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
@ -158,7 +122,7 @@
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
account_entry_path(@entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm
font-medium border border-alpha-black-200",

View file

@ -8,12 +8,12 @@
<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 %>
<%= link_to new_account_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 %>
<%= lucide_icon "minus-circle", class: "w-5 h-5" %>
<%= tag.span t(".expense") %>
<% end %>
<%= link_to new_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
<%= link_to new_account_transaction_path(nature: "income"), data: { turbo_frame: :modal }, class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400" do %>
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
<%= tag.span t(".income") %>
<% end %>

View file

@ -1,6 +1,6 @@
<%# locals: (entry:) %>
<%= form_with url: unmark_transfers_transactions_path, class: "flex items-center", data: {
<%= form_with url: unmark_transfers_account_transactions_path, class: "flex items-center", data: {
turbo_confirm: {
title: t(".remove_transfer"),
body: t(".remove_transfer_body"),

View file

@ -1,9 +1,12 @@
<%# locals: (entry:) %>
<%= styled_form_with model: [entry.account, entry],
url: entry.new_record? ? account_valuations_path(entry.account) : account_entry_path(entry.account, entry),
class: "space-y-4",
data: { turbo: false } do |form| %>
<%= styled_form_with model: entry, url: account_valuations_path, class: "space-y-4" do |form| %>
<%= form.hidden_field :account_id %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% end %>
<div class="space-y-3">
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %>
<%= form.money_field :amount, label: t(".amount"), required: true %>

View file

@ -0,0 +1,19 @@
<%# locals: (entry:) %>
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
<span class="text-gray-500 text-sm">
<%= t(".balance") %>
</span>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money entry.amount_money %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
<% end %>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false, origin: nil) %>
<%# locals: (entry:, selectable: true, show_balance: false) %>
<% account = entry.account %>
<% valuation = entry.account_valuation %>
@ -21,7 +21,7 @@
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to valuation.name,
account_entry_path(account, entry),
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>

View file

@ -1,30 +1,14 @@
<% entry, account = @entry, @entry.account %>
<%= drawer do %>
<header class="mb-4 space-y-1">
<span class="text-gray-500 text-sm">
<%= t(".balance") %>
</span>
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl">
<%= format_money entry.amount_money %>
</span>
</h3>
</div>
<span class="text-sm text-gray-500">
<%= I18n.l(entry.date, format: :long) %>
</span>
</header>
<%= drawer(reload_on_close: true) do %>
<%= render "account/valuations/header", entry: %>
<div class="space-y-2">
<!-- Overview Section -->
<%= disclosure t(".overview") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_entry_path(account, entry),
<%= styled_form_with model: entry,
url: account_entry_path(entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name,
@ -48,8 +32,8 @@
<!-- Details Section -->
<%= disclosure t(".details") do %>
<div class="pb-4">
<%= styled_form_with model: [account, entry],
url: account_entry_path(account, entry),
<%= styled_form_with model: entry,
url: account_entry_path(entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_area :notes,
@ -72,7 +56,7 @@
</div>
<%= button_to t(".delete"),
account_entry_path(account, entry),
account_entry_path(entry),
method: :delete,
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
data: { turbo_confirm: true, turbo_frame: "_top" } %>

View file

@ -0,0 +1,5 @@
<div class="h-10">
</div>
<div class="h-64 flex items-center justify-center">
<p class="text-gray-500 animate-pulse text-sm">Loading...</p>
</div>

View file

@ -0,0 +1,32 @@
<% period = Period.from_param(params[:period]) %>
<% series = @account.series(period: period) %>
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
<div class="px-4">
<% if trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %>
<% unless trend.percent.infinite? %>
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
<% end %>
<% end %>
<%= tag.span period_label(period), class: "text-gray-500" %>
</div>
<div class="h-64">
<% if series %>
<div
id="lineChart"
class="w-full h-full"
data-controller="time-series-chart"
data-time-series-chart-data-value="<%= series.to_json %>"></div>
<% else %>
<div class="w-full h-full flex items-center justify-center">
<p class="text-gray-500">No data available for the selected period.</p>
</div>
<% end %>
</div>
<% end %>

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account) do %>
<%= turbo_frame_tag dom_id(account, :entries), src: account_entries_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -1,12 +1,10 @@
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
<% period = Period.from_param(params[:period]) %>
<% series = account.series(period: period) %>
<% trend = series.trend %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
<div class="flex justify-between px-4 pt-4 mb-2">
<div class="space-y-2">
<div class="flex items-center gap-1">
<%= tag.p title || default_value_title, class: "text-sm font-medium text-gray-500" %>
@ -14,19 +12,6 @@
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
<div>
<% if trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span "#{trend.value.positive? ? "+" : ""}#{format_money(trend.value)}", style: "color: #{trend.color}" %>
<% unless trend.percent.infinite? %>
<%= tag.span "(#{trend.percent}%)", style: "color: #{trend.color}" %>
<% end %>
<% end %>
<%= tag.span period_label(period), class: "text-gray-500" %>
</div>
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
@ -34,7 +19,7 @@
<% end %>
</div>
<div class="h-64 flex items-center justify-center text-2xl font-bold">
<%= render "shared/line_chart", series: series %>
</div>
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.name) do %>
<%= render "accounts/chart_loader" %>
<% end %>
</div>

View file

@ -2,6 +2,7 @@
<%= link_to key.titleize,
account_path(account, tab: key),
data: { turbo: false },
class: [
"px-2 py-1.5 rounded-md border border-transparent",
"bg-white shadow-xs border-alpha-black-50": is_selected

View file

@ -1,11 +1,12 @@
<%# locals: (transaction:, origin: nil) %>
<div class="relative" data-controller="menu">
<%# locals: (transaction:) %>
<div class="relative" data-controller="menu" id="<%= dom_id(transaction, :category_menu) %>">
<button data-menu-target="button" class="flex cursor-pointer">
<%= render partial: "categories/badge", locals: { category: transaction.category } %>
</button>
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id, origin: origin), loading: :lazy do %>
<%= turbo_frame_tag "category_dropdown", src: category_dropdown_path(category_id: transaction.category_id, transaction_id: transaction.id), loading: :lazy do %>
<div class="p-6 flex items-center justify-center">
<p class="text-sm text-gray-500 animate-pulse"><%= t(".loading") %></p>
</div>

View file

@ -1,8 +1,17 @@
<%# locals: (category:, origin: nil) %>
<%# locals: (category:) %>
<% is_selected = category.id === @selected_category&.id %>
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
<%= button_to account_transaction_path(@transaction.entry.account, @transaction.entry, account_entry: { origin: origin,entryable_type: "Account::Transaction", entryable_attributes: { id: @transaction.id, category_id: category.id } }), method: :patch, data: { turbo_frame: dom_id(@transaction.entry) }, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<%= button_to account_transaction_category_path(
@transaction.entry,
account_entry: {
entryable_type: "Account::Transaction",
entryable_attributes: { id: @transaction.id, category_id: category.id }
}
),
method: :patch,
class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
<span class="w-5 h-5">
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
</span>

View file

@ -11,7 +11,7 @@
<%= t(".no_categories") %>
</div>
<% @categories.each do |category| %>
<%= render partial: "category/dropdowns/row", locals: { category:, origin: params[:origin] } %>
<%= render partial: "category/dropdowns/row", locals: { category: } %>
<% end %>
</div>
<hr>

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account) do %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -1,9 +1,5 @@
<%# locals: (account:, **args) %>
<% period = Period.from_param(params[:period]) %>
<% series = account.series(period: period) %>
<% trend = series.trend %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">

View file

@ -1,5 +1,5 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account) do %>
<%= turbo_frame_tag dom_id(account, :holdings), src: account_holdings_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -36,7 +36,7 @@
<%= render_flash_notifications %>
<% if Current.family&.syncing? %>
<%= render "shared/notification", id: "syncing-notification", type: :processing, message: t(".syncing") %>
<%= render "shared/syncing_notice" %>
<% end %>
</div>
</div>

View file

@ -0,0 +1,11 @@
<div class="flex items-center">
<%= image_tag(combobox_security.logo_url, class: "rounded-full h-8 w-8 inline-block mr-2" ) %>
<div class="flex flex-col">
<span class="text-sm font-medium">
<%= combobox_security.name.presence || combobox_security.symbol %>
</span>
<span class="text-xs text-gray-500">
<%= "#{combobox_security.symbol} (#{combobox_security.exchange_acronym})" %>
</span>
</div>
</div>

View file

@ -0,0 +1,2 @@
<%= async_combobox_options @securities.map(&:to_combobox_option),
render_in: { partial: "securities/combobox_security" } %>

View file

@ -1,5 +1,10 @@
<%# locals: (content:, reload_on_close: false) %>
<%= turbo_frame_tag "drawer" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl 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">
<dialog class="bg-white border border-alpha-black-25 rounded-2xl 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"
data-modal-reload-on-close-value="<%= reload_on_close %>">
<div class="flex flex-col h-full">
<div class="flex justify-end items-center p-4">
<div data-action="click->modal#close" class="cursor-pointer p-2">

View file

@ -0,0 +1,6 @@
<%# locals: (model:) %>
<div class="flex items-center gap-2">
<%= lucide_icon("alert-circle", class: "text-red-500 w-4 h-4 shrink-0") %>
<p class="text-red-500 text-sm"><%= model.errors.full_messages.to_sentence %></p>
</div>

View file

@ -1,10 +1,9 @@
<%# locals: (message:, type: "notice", id: nil, **_opts) %>
<%# locals: (message:, type: "notice", **_opts) %>
<% type = type.to_sym %>
<% action = "animationend->element-removal#remove" if type == :notice %>
<%= tag.div class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25",
id: type == :processing ? "syncing-notification" : id,
data: {
controller: "element-removal",
action: action
@ -20,8 +19,6 @@
<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>

View file

@ -0,0 +1,7 @@
<%= tag.div id: "syncing-notice", class: "flex gap-3 rounded-lg border bg-white p-4 group max-w-80 shadow-xs border-alpha-black-25" do %>
<div class="h-5 w-5 shrink-0 p-px text-white">
<%= lucide_icon "loader", class: "w-5 h-5 text-gray-500 animate-pulse" %>
</div>
<%= tag.p t(".syncing"), class: "text-gray-900 text-sm font-medium" %>
<% end %>

View file

@ -16,7 +16,7 @@
<p class="text-sm font-medium text-gray-900"><%= t(".import") %></p>
<% end %>
<%= link_to new_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= link_to new_account_transaction_path, class: "rounded-lg bg-gray-900 text-white flex items-center gap-1 justify-center hover:bg-gray-700 px-3 py-2", data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<p class="text-sm font-medium">New transaction</p>
<% end %>

View file

@ -29,7 +29,7 @@
</div>
<div class="space-y-6">
<%= entries_by_date(@transaction_entries, totals: true) do |entries| %>
<%= render entries, origin: "transactions" %>
<%= render entries %>
<% end %>
</div>
</div>

View file

@ -1,16 +0,0 @@
<% content_for :sidebar do %>
<%= render "settings/nav" %>
<% end %>
<div class="space-y-4">
<h1 class="text-gray-900 text-xl font-medium mb-4">Rules</h1>
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
<div class="flex justify-center items-center py-20">
<p class="text-gray-500">Transaction rules coming soon...</p>
</div>
</div>
<div class="flex justify-between gap-4">
<%= previous_setting("Merchants", merchants_path) %>
<%= next_setting("Imports", imports_path) %>
</div>
</div>