mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Transaction transfers, payments, and matching (#883)
* Add transfer model and clean up family snapshot fixtures * Ignore transfers in income and expense snapshots * Add transfer validations * Implement basic transfer matching UI * Fix merge conflicts * Add missing translations * Tweak selection states for transfer types * Add missing i18n translation
This commit is contained in:
parent
b462bc8f8c
commit
ca39b26070
57 changed files with 991 additions and 427 deletions
|
@ -7,6 +7,10 @@
|
|||
details > summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
@apply list-none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
|
|
@ -9,9 +9,9 @@ class TransactionsController < ApplicationController
|
|||
@pagy, @transactions = pagy(result, items: 50)
|
||||
|
||||
@totals = {
|
||||
count: result.count,
|
||||
income: result.inflows.sum(&:amount_money).abs,
|
||||
expense: result.outflows.sum(&:amount_money).abs
|
||||
count: result.select { |t| t.currency == Current.family.currency }.count,
|
||||
income: result.income_total(Current.family.currency).abs,
|
||||
expense: result.expense_total(Current.family.currency)
|
||||
}
|
||||
end
|
||||
|
||||
|
@ -54,7 +54,7 @@ class TransactionsController < ApplicationController
|
|||
|
||||
def bulk_delete
|
||||
destroyed = Current.family.transactions.destroy_by(id: bulk_delete_params[:transaction_ids])
|
||||
redirect_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: destroyed.count)
|
||||
end
|
||||
|
||||
def bulk_edit
|
||||
|
@ -63,13 +63,31 @@ class TransactionsController < ApplicationController
|
|||
def bulk_update
|
||||
transactions = Current.family.transactions.where(id: bulk_update_params[:transaction_ids])
|
||||
if transactions.update_all(bulk_update_params.except(:transaction_ids).to_h.compact_blank!)
|
||||
redirect_to transactions_url, notice: t(".success", count: transactions.count)
|
||||
redirect_back_or_to transactions_url, notice: t(".success", count: transactions.count)
|
||||
else
|
||||
flash.now[:error] = t(".failure")
|
||||
render :index, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def mark_transfers
|
||||
Current.family
|
||||
.transactions
|
||||
.where(id: bulk_update_params[:transaction_ids])
|
||||
.mark_transfers!
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
def unmark_transfers
|
||||
Current.family
|
||||
.transactions
|
||||
.where(id: bulk_update_params[:transaction_ids])
|
||||
.update_all marked_as_transfer: false
|
||||
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transaction
|
||||
|
|
41
app/controllers/transfers_controller.rb
Normal file
41
app/controllers/transfers_controller.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
class TransfersController < ApplicationController
|
||||
layout "with_sidebar"
|
||||
|
||||
before_action :set_transfer, only: :destroy
|
||||
|
||||
def new
|
||||
@transfer = Transfer.new
|
||||
end
|
||||
|
||||
def create
|
||||
from_account = Current.family.accounts.find(transfer_params[:from_account_id])
|
||||
to_account = Current.family.accounts.find(transfer_params[:to_account_id])
|
||||
|
||||
@transfer = Transfer.build_from_accounts from_account, to_account, \
|
||||
date: transfer_params[:date],
|
||||
amount: transfer_params[:amount].to_d,
|
||||
currency: transfer_params[:currency],
|
||||
name: transfer_params[:name]
|
||||
|
||||
if @transfer.save
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@transfer.destroy_and_remove_marks!
|
||||
redirect_back_or_to transactions_url, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_transfer
|
||||
@transfer = Transfer.find(params[:id])
|
||||
end
|
||||
|
||||
def transfer_params
|
||||
params.require(:transfer).permit(:from_account_id, :to_account_id, :amount, :currency, :date, :name)
|
||||
end
|
||||
end
|
|
@ -25,7 +25,7 @@ class ApplicationFormBuilder < ActionView::Helpers::FormBuilder
|
|||
# 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.send(method)
|
||||
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
|
||||
|
|
|
@ -17,4 +17,27 @@ module TransactionsHelper
|
|||
content: content
|
||||
}
|
||||
end
|
||||
|
||||
def unconfirmed_transfer?(transaction)
|
||||
transaction.marked_as_transfer && transaction.transfer.nil?
|
||||
end
|
||||
|
||||
def group_transactions_by_date(transactions)
|
||||
grouped_by_date = {}
|
||||
|
||||
transactions.each do |transaction|
|
||||
if transaction.transfer
|
||||
transfer_date = transaction.transfer.inflow_transaction.date
|
||||
grouped_by_date[transfer_date] ||= { transactions: [], transfers: [] }
|
||||
unless grouped_by_date[transfer_date][:transfers].include?(transaction.transfer)
|
||||
grouped_by_date[transfer_date][:transfers] << transaction.transfer
|
||||
end
|
||||
else
|
||||
grouped_by_date[transaction.date] ||= { transactions: [], transfers: [] }
|
||||
grouped_by_date[transaction.date][:transactions] << transaction
|
||||
end
|
||||
end
|
||||
|
||||
grouped_by_date
|
||||
end
|
||||
end
|
||||
|
|
2
app/helpers/transfers_helper.rb
Normal file
2
app/helpers/transfers_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module TransfersHelper
|
||||
end
|
|
@ -113,7 +113,7 @@ export default class extends Controller {
|
|||
#updateGroups() {
|
||||
this.groupTargets.forEach(group => {
|
||||
const rows = this.rowTargets.filter(row => group.contains(row))
|
||||
const groupSelected = rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
||||
const groupSelected = rows.length > 0 && rows.every(row => this.selectedIdsValue.includes(row.dataset.id))
|
||||
group.querySelector("input[type='checkbox']").checked = groupSelected
|
||||
})
|
||||
}
|
||||
|
|
|
@ -10,16 +10,16 @@ class Family < ApplicationRecord
|
|||
|
||||
def snapshot(period = Period.all)
|
||||
query = accounts.active.joins(:balances)
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
.select(
|
||||
"account_balances.currency",
|
||||
"account_balances.date",
|
||||
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
|
||||
)
|
||||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
.where("account_balances.currency = ?", self.currency)
|
||||
.select(
|
||||
"account_balances.currency",
|
||||
"account_balances.date",
|
||||
"SUM(CASE WHEN accounts.classification = 'liability' THEN account_balances.balance ELSE 0 END) AS liabilities",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance ELSE 0 END) AS assets",
|
||||
"SUM(CASE WHEN accounts.classification = 'asset' THEN account_balances.balance WHEN accounts.classification = 'liability' THEN -account_balances.balance ELSE 0 END) AS net_worth",
|
||||
)
|
||||
.group("account_balances.date, account_balances.currency")
|
||||
.order("account_balances.date")
|
||||
|
||||
query = query.where("account_balances.date >= ?", period.date_range.begin) if period.date_range.begin
|
||||
query = query.where("account_balances.date <= ?", period.date_range.end) if period.date_range.end
|
||||
|
@ -35,15 +35,16 @@ class Family < ApplicationRecord
|
|||
def snapshot_account_transactions
|
||||
period = Period.last_30_days
|
||||
results = accounts.active.joins(:transactions)
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("transactions.date >= ?", period.date_range.begin)
|
||||
.where("transactions.date <= ?", period.date_range.end)
|
||||
.group("id")
|
||||
.to_a
|
||||
.select(
|
||||
"accounts.*",
|
||||
"COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-amount) FILTER (WHERE amount < 0), 0) AS income"
|
||||
)
|
||||
.where("transactions.date >= ?", period.date_range.begin)
|
||||
.where("transactions.date <= ?", period.date_range.end)
|
||||
.where("transactions.marked_as_transfer = ?", false)
|
||||
.group("id")
|
||||
.to_a
|
||||
|
||||
results.each do |r|
|
||||
r.define_singleton_method(:savings_rate) do
|
||||
|
|
|
@ -4,6 +4,7 @@ class Transaction < ApplicationRecord
|
|||
monetize :amount
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :transfer, optional: true
|
||||
belongs_to :category, optional: true
|
||||
belongs_to :merchant, optional: true
|
||||
has_many :taggings, as: :taggable, dependent: :destroy
|
||||
|
@ -42,6 +43,10 @@ class Transaction < ApplicationRecord
|
|||
amount > 0
|
||||
end
|
||||
|
||||
def transfer?
|
||||
marked_as_transfer
|
||||
end
|
||||
|
||||
def sync_account_later
|
||||
if destroyed?
|
||||
sync_start_date = previous_transaction_date
|
||||
|
@ -53,6 +58,21 @@ class Transaction < ApplicationRecord
|
|||
end
|
||||
|
||||
class << self
|
||||
def income_total(currency = "USD")
|
||||
inflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def expense_total(currency = "USD")
|
||||
outflows.reject(&:transfer?).select { |t| t.currency == currency }.sum(&:amount_money)
|
||||
end
|
||||
|
||||
def mark_transfers!
|
||||
update_all marked_as_transfer: true
|
||||
|
||||
# Attempt to "auto match" and save a transfer if 2 transactions selected
|
||||
Transfer.new(transactions: all).save if all.count == 2
|
||||
end
|
||||
|
||||
def daily_totals(transactions, period: Period.last_30_days, currency: Current.family.currency)
|
||||
# Sum spending and income for each day in the period with the given currency
|
||||
select(
|
||||
|
@ -60,7 +80,7 @@ class Transaction < ApplicationRecord
|
|||
"COALESCE(SUM(converted_amount) FILTER (WHERE converted_amount > 0), 0) AS spending",
|
||||
"COALESCE(SUM(-converted_amount) FILTER (WHERE converted_amount < 0), 0) AS income"
|
||||
)
|
||||
.from(transactions.with_converted_amount(currency), :t)
|
||||
.from(transactions.with_converted_amount(currency).where(marked_as_transfer: false), :t)
|
||||
.joins(sanitize_sql([ "RIGHT JOIN generate_series(?, ?, interval '1 day') AS gs(date) ON t.date = gs.date", period.date_range.first, period.date_range.last ]))
|
||||
.group("gs.date")
|
||||
end
|
||||
|
@ -83,7 +103,7 @@ class Transaction < ApplicationRecord
|
|||
end
|
||||
|
||||
def search(params)
|
||||
query = all
|
||||
query = all.includes(:transfer)
|
||||
query = query.by_name(params[:search]) if params[:search].present?
|
||||
query = query.with_categories(params[:categories]) if params[:categories].present?
|
||||
query = query.with_accounts(params[:accounts]) if params[:accounts].present?
|
||||
|
@ -96,7 +116,6 @@ class Transaction < ApplicationRecord
|
|||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_transaction_date
|
||||
self.account
|
||||
.transactions
|
||||
|
|
57
app/models/transfer.rb
Normal file
57
app/models/transfer.rb
Normal file
|
@ -0,0 +1,57 @@
|
|||
class Transfer < ApplicationRecord
|
||||
has_many :transactions, dependent: :nullify
|
||||
|
||||
validate :transaction_count, :from_different_accounts, :net_zero_flows, :all_transactions_marked
|
||||
|
||||
def inflow_transaction
|
||||
transactions.find { |t| t.inflow? }
|
||||
end
|
||||
|
||||
def outflow_transaction
|
||||
transactions.find { |t| t.outflow? }
|
||||
end
|
||||
|
||||
def destroy_and_remove_marks!
|
||||
transaction do
|
||||
transactions.each do |t|
|
||||
t.update! marked_as_transfer: false
|
||||
end
|
||||
|
||||
destroy!
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
def build_from_accounts(from_account, to_account, date:, amount:, currency:, name:)
|
||||
outflow = from_account.transactions.build(amount: amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
inflow = to_account.transactions.build(amount: -amount.abs, currency: currency, date: date, name: name, marked_as_transfer: true)
|
||||
|
||||
new transactions: [ outflow, inflow ]
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transaction_count
|
||||
unless transactions.size == 2
|
||||
errors.add :transactions, "must have exactly 2 transactions"
|
||||
end
|
||||
end
|
||||
|
||||
def from_different_accounts
|
||||
accounts = transactions.map(&:account_id).uniq
|
||||
errors.add :transactions, "must be from different accounts" if accounts.size < transactions.size
|
||||
end
|
||||
|
||||
def net_zero_flows
|
||||
unless transactions.sum(&:amount).zero?
|
||||
errors.add :transactions, "must have an inflow and outflow that net to zero"
|
||||
end
|
||||
end
|
||||
|
||||
def all_transactions_marked
|
||||
unless transactions.all?(&:marked_as_transfer)
|
||||
errors.add :transactions, "must be marked as transfer"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,7 +4,14 @@
|
|||
</div>
|
||||
|
||||
<div class="col-span-3">
|
||||
<%= render "transactions/categories/badge", category: transaction.category %>
|
||||
<% if transaction.marked_as_transfer %>
|
||||
<div class="flex items-center gap-1 text-gray-500 pl-5">
|
||||
<%= lucide_icon "arrow-right-left", class: "w-4 h-4 text-gray-500" %>
|
||||
<p>Transfer</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= render "transactions/categories/badge", category: transaction.category %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= link_to transaction.account.name,
|
||||
|
|
|
@ -1,17 +1,26 @@
|
|||
<%# locals: (date:, transactions:) %>
|
||||
<%# locals: (date:, group:) %>
|
||||
<div class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<%= check_box_tag "#{date}_transactions_selection",
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
class: ["maybe-checkbox maybe-checkbox--light", "hidden": group[:transactions].count == 0],
|
||||
id: "selection_transaction_#{date}",
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{transactions.size}" %>
|
||||
|
||||
<%= tag.span "#{date.strftime('%b %d, %Y')} · #{group[:transactions].size + (group[:transfers].size * 2)}" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<% transactions_by_currency = group[:transactions].group_by(&:currency) %>
|
||||
<% transactions_by_currency.each_with_index do |(_currency, transactions), idx| %>
|
||||
<%= tag.span format_money(-transactions.sum(&:amount_money)) %>
|
||||
<%= tag.span "|", class: "mx-2" if idx < transactions_by_currency.count - 1 %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= tag.span format_money(-transactions.sum(&:amount_money)) %>
|
||||
</div>
|
||||
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
|
||||
<%= render transactions %>
|
||||
<%= render group[:transactions] %>
|
||||
<%= render group[:transfers] %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
<%= form_with model: @transaction, data: { turbo: false } do |f| %>
|
||||
<%= form_with model: @transaction, 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: true %>
|
||||
<%= radio_tab_tag form: f, name: :nature, value: :income, label: t(".income"), icon: "plus-circle" %>
|
||||
<%= radio_tab_tag form: f, name: :nature, value: :transfer, label: t(".transfer"), icon: "arrow-right-left", disabled: true %>
|
||||
<%= 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" %>
|
||||
<%= link_to new_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") %>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
|
@ -11,7 +14,7 @@
|
|||
<%= 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 %>
|
||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") }, required: true %>
|
||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
|
||||
<%= f.date_field :date, label: t(".date"), required: true, max: Date.today %>
|
||||
</section>
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
<% else %>
|
||||
<%= link_to transaction.name,
|
||||
transaction_path(transaction),
|
||||
data: { turbo_frame: "drawer" },
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
@ -8,6 +8,21 @@
|
|||
<div class="flex items-center gap-1 text-gray-500">
|
||||
<%= turbo_frame_tag "bulk_transaction_edit_drawer" %>
|
||||
|
||||
<%= form_with url: mark_transfers_transactions_path,
|
||||
builder: ActionView::Helpers::FormBuilder,
|
||||
scope: "bulk_update",
|
||||
data: {
|
||||
turbo_confirm: {
|
||||
title: t(".mark_transfers"),
|
||||
body: t(".mark_transfers_message"),
|
||||
accept: t(".mark_transfers_confirm"),
|
||||
}
|
||||
} do |f| %>
|
||||
<button type="button" data-bulk-select-scope-param="bulk_update" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Mark as transfer">
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 group-hover:text-white" %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= link_to bulk_edit_transactions_path,
|
||||
class: "p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md",
|
||||
title: "Edit",
|
||||
|
|
|
@ -1,22 +1,41 @@
|
|||
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 py-4 text-sm font-medium px-4" do %>
|
||||
<div class="col-span-4 flex items-center gap-4">
|
||||
<%= turbo_frame_tag dom_id(transaction), class: "grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4" do %>
|
||||
<div class="pr-10 flex items-center gap-4 <%= unconfirmed_transfer?(transaction) ? "col-span-10" : "col-span-4" %>">
|
||||
<%= check_box_tag dom_id(transaction, "selection"),
|
||||
class: "maybe-checkbox maybe-checkbox--light",
|
||||
data: { id: transaction.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
|
||||
|
||||
<div class="max-w-full pr-10">
|
||||
<div class="max-w-full">
|
||||
<%= render "transactions/name", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<% if unconfirmed_transfer?(transaction) %>
|
||||
<%= form_with url: unmark_transfers_transactions_path, builder: ActionView::Helpers::FormBuilder, class: "flex items-center", data: {
|
||||
turbo_confirm: {
|
||||
title: t(".remove_transfer"),
|
||||
body: t(".remove_transfer_body"),
|
||||
accept: t(".remove_transfer_confirm"),
|
||||
},
|
||||
turbo_frame: "_top"
|
||||
} do |f| %>
|
||||
<%= f.hidden_field "bulk_update[transaction_ids][]", value: transaction.id %>
|
||||
<%= f.button class: "flex items-center justify-center group", title: "Remove transfer" do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "group-hover:hidden text-gray-500 w-4 h-4" %>
|
||||
<%= lucide_icon "unlink", class: "hidden group-hover:inline-block text-gray-900 w-4 h-4" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-3">
|
||||
<%= render "transactions/categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
<% unless unconfirmed_transfer?(transaction) %>
|
||||
<div class="col-span-3">
|
||||
<%= render "transactions/categories/menu", transaction: transaction %>
|
||||
</div>
|
||||
|
||||
<%= link_to transaction.account.name,
|
||||
account_path(transaction.account),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: ["col-span-3 hover:underline"] %>
|
||||
<%= link_to transaction.account.name,
|
||||
account_path(transaction.account),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: ["col-span-3 hover:underline"] %>
|
||||
<% end %>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
<%= render "transactions/amount", transaction: transaction %>
|
||||
|
|
|
@ -25,8 +25,8 @@
|
|||
<p class="col-span-2 justify-self-end">amount</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<% @transactions.group_by(&:date).each do |date, transactions| %>
|
||||
<%= render partial: "date_group", locals: { date:, transactions: } %>
|
||||
<% group_transactions_by_date(@transactions).each do |date, group| %>
|
||||
<%= render partial: "date_group", locals: { date:, group: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
<%= drawer do %>
|
||||
<div>
|
||||
<header class="mb-4 space-y-1">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl"><%= format_money @transaction.amount_money %></span>
|
||||
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-4">
|
||||
<h3 class="font-medium">
|
||||
<span class="text-2xl"><%= format_money -@transaction.amount_money %></span>
|
||||
<span class="text-lg text-gray-500"><%= @transaction.currency %></span>
|
||||
</h3>
|
||||
|
||||
<% if @transaction.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"><%= @transaction.date.strftime("%A %d %B") %></span>
|
||||
</header>
|
||||
|
@ -19,28 +25,20 @@
|
|||
<div class="pb-6">
|
||||
<%= form_with model: @transaction, 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" %>
|
||||
<%= f.date_field :date, label: t(".date_label"), max: Date.today, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :merchant_id, Current.family.transaction_merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
|
||||
<% unless @transaction.marked_as_transfer %>
|
||||
<%= f.collection_select :category_id, Current.family.transaction_categories.alphabetically, :id, :name, { prompt: t(".category_placeholder"), label: t(".category_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<%= f.collection_select :merchant_id, Current.family.transaction_merchants.alphabetically, :id, :name, { prompt: t(".merchant_placeholder"), label: t(".merchant_label"), class: "text-gray-400" }, "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
||||
<%= f.collection_select :account_id, Current.family.accounts.alphabetically, :id, :name, { prompt: t(".account_placeholder"), label: t(".account_label"), class: "text-gray-500" }, { class: "form-field__input cursor-not-allowed text-gray-400", disabled: "disabled" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".description") %></h4>
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pb-6">
|
||||
<%= form_with model: @transaction, html: { data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<%= f.text_field :name, label: t(".name_label"), "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="group space-y-2" open>
|
||||
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
|
||||
<h4><%= t(".additional") %></h4>
|
||||
|
@ -70,8 +68,8 @@
|
|||
|
||||
<div class="pb-6">
|
||||
|
||||
<%= form_with model: @transaction, html: { class: "p-3", data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="flex cursor-pointer items-center justify-between">
|
||||
<%= form_with model: @transaction, html: { class: "p-3 space-y-3", data: { controller: "auto-submit-form" } } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-2 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".exclude_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".exclude_subtitle") %></p>
|
||||
|
@ -84,18 +82,20 @@
|
|||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<% unless @transaction.transfer? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-gray-900"><%= t(".delete_title") %></h4>
|
||||
<p class="text-gray-500"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
|
||||
<%= button_to t(".delete"),
|
||||
transaction_path(@transaction),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<%= button_to t(".delete"),
|
||||
transaction_path(@transaction),
|
||||
method: :delete,
|
||||
class: "rounded-lg px-3 py-2 text-red-500 text-sm font-medium border border-alpha-black-200",
|
||||
data: { turbo_confirm: true, turbo_frame: "_top" } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
|
32
app/views/transfers/_form.html.erb
Normal file
32
app/views/transfers/_form.html.erb
Normal file
|
@ -0,0 +1,32 @@
|
|||
<%= form_with model: transfer 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 %>
|
||||
<%= 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 %>
|
||||
<%= lucide_icon "plus-circle", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".income") %>
|
||||
<% end %>
|
||||
|
||||
<%= tag.div class: "flex px-4 py-1 rounded-lg items-center space-x-2 justify-center text-gray-400 bg-white text-gray-800 shadow-sm" do %>
|
||||
<%= lucide_icon "arrow-right-left", class: "w-5 h-5" %>
|
||||
<%= tag.span t(".transfer") %>
|
||||
<% end %>
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<section class="space-y-2">
|
||||
<%= f.text_field :name, value: transfer.transactions.first&.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 %>
|
||||
<%= f.date_field :date, value: transfer.transactions.first&.date, label: t(".date"), required: true, max: Date.current %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<%= f.submit t(".submit") %>
|
||||
</section>
|
||||
<% end %>
|
37
app/views/transfers/_transfer.html.erb
Normal file
37
app/views/transfers/_transfer.html.erb
Normal file
|
@ -0,0 +1,37 @@
|
|||
<%= turbo_frame_tag dom_id(transfer), class: "block" do %>
|
||||
<details class="group flex items-center text-gray-900 p-4 text-sm font-medium">
|
||||
<summary class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<%= button_to transfer_path(transfer),
|
||||
method: :delete,
|
||||
class: "flex items-center group/transfer",
|
||||
data: {
|
||||
turbo_frame: "_top",
|
||||
turbo_confirm: {
|
||||
title: t(".remove_title"),
|
||||
body: t(".remove_body"),
|
||||
confirm: t(".remove_confirm")
|
||||
}
|
||||
} do %>
|
||||
<%= lucide_icon "arrow-left-right", class: "group-hover/transfer:hidden w-5 h-5 text-gray-500" %>
|
||||
<%= lucide_icon "unlink", class: "group-hover/transfer:inline-block hidden w-5 h-5 text-gray-500" %>
|
||||
<% end %>
|
||||
|
||||
<div class="max-w-full pr-10 select-none">
|
||||
<%= tag.p t(".transfer_name", from_account: transfer.outflow_transaction&.account&.name, to_account: transfer.inflow_transaction&.account&.name) %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
|
||||
</summary>
|
||||
|
||||
<div class="pt-2 divide-y divide-alpha-black-200">
|
||||
<% transfer.transactions.each do |transaction| %>
|
||||
<div class="py-3 flex items-center justify-between">
|
||||
<%= render "transactions/name", transaction: transaction %>
|
||||
<%= render "transactions/amount", transaction: transaction %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% end %>
|
17
app/views/transfers/new.html.erb
Normal file
17
app/views/transfers/new.html.erb
Normal file
|
@ -0,0 +1,17 @@
|
|||
<%= modal do %>
|
||||
<article class="mx-auto p-4 space-y-4 w-screen max-w-xl">
|
||||
<header class="flex justify-between">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-xl" %>
|
||||
<%= lucide_icon "x", class: "w-5 h-5 text-gray-500", data: { action: "click->modal#close" } %>
|
||||
</header>
|
||||
|
||||
<% if @transfer.errors.present? %>
|
||||
<div class="text-red-600 flex items-center gap-2">
|
||||
<%= lucide_icon "circle-alert", class: "w-5 h-5" %>
|
||||
<p class="text-sm"><%= @transfer.errors.full_messages.to_sentence %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "form", transfer: @transfer %>
|
||||
</article>
|
||||
<% end %>
|
Loading…
Add table
Add a link
Reference in a new issue