mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-03 04:25:21 +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
|
@ -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>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue