mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-25 08:09:38 +02:00
Start and end balance breakdown in activity view (#2466)
* Initial data objects * Remove trend calculator * Fill in balance reconciliation for entry group * Initial tooltip component * Balance trends in activity view * Lint fixes * trade partial alignment fix * Tweaks to balance calculation to acknowledge holdings value better * More lint fixes * Bump brakeman dep * Test fixes * Remove unused class
This commit is contained in:
parent
ab6fdbbb68
commit
e8eb32d2ae
27 changed files with 1088 additions and 119 deletions
9
app/components/DS/tooltip.html.erb
Normal file
9
app/components/DS/tooltip.html.erb
Normal file
|
@ -0,0 +1,9 @@
|
|||
<span data-controller="DS--tooltip" data-DS--tooltip-placement-value="<%= placement %>" data-DS--tooltip-offset-value="<%= offset %>" data-DS--tooltip-cross-axis-value="<%= cross_axis %>" class="inline-flex">
|
||||
<%= helpers.icon icon_name, size: size, color: color %>
|
||||
|
||||
<div role="tooltip" data-DS--tooltip-target="tooltip" class="hidden absolute z-50 bg-gray-700 text-sm px-1.5 py-1 rounded-md">
|
||||
<div class="fg-inverse font-normal max-w-[200px]">
|
||||
<%= tooltip_content %>
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
17
app/components/DS/tooltip.rb
Normal file
17
app/components/DS/tooltip.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
class DS::Tooltip < ApplicationComponent
|
||||
attr_reader :placement, :offset, :cross_axis, :icon_name, :size, :color
|
||||
|
||||
def initialize(text: nil, placement: "top", offset: 10, cross_axis: 0, icon: "info", size: "sm", color: "default")
|
||||
@text = text
|
||||
@placement = placement
|
||||
@offset = offset
|
||||
@cross_axis = cross_axis
|
||||
@icon_name = icon
|
||||
@size = size
|
||||
@color = color
|
||||
end
|
||||
|
||||
def tooltip_content
|
||||
content? ? content : @text
|
||||
end
|
||||
end
|
87
app/components/DS/tooltip_controller.js
Normal file
87
app/components/DS/tooltip_controller.js
Normal file
|
@ -0,0 +1,87 @@
|
|||
import {
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
} from "@floating-ui/dom";
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["tooltip"];
|
||||
static values = {
|
||||
placement: { type: String, default: "top" },
|
||||
offset: { type: Number, default: 10 },
|
||||
crossAxis: { type: Number, default: 0 },
|
||||
};
|
||||
|
||||
connect() {
|
||||
this._cleanup = null;
|
||||
this.boundUpdate = this.update.bind(this);
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.removeEventListeners();
|
||||
this.stopAutoUpdate();
|
||||
}
|
||||
|
||||
addEventListeners() {
|
||||
this.element.addEventListener("mouseenter", this.show);
|
||||
this.element.addEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
removeEventListeners() {
|
||||
this.element.removeEventListener("mouseenter", this.show);
|
||||
this.element.removeEventListener("mouseleave", this.hide);
|
||||
}
|
||||
|
||||
show = () => {
|
||||
this.tooltipTarget.classList.remove("hidden");
|
||||
this.startAutoUpdate();
|
||||
this.update();
|
||||
};
|
||||
|
||||
hide = () => {
|
||||
this.tooltipTarget.classList.add("hidden");
|
||||
this.stopAutoUpdate();
|
||||
};
|
||||
|
||||
startAutoUpdate() {
|
||||
if (!this._cleanup) {
|
||||
const reference = this.element.querySelector("[data-icon]");
|
||||
this._cleanup = autoUpdate(
|
||||
reference || this.element,
|
||||
this.tooltipTarget,
|
||||
this.boundUpdate
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
stopAutoUpdate() {
|
||||
if (this._cleanup) {
|
||||
this._cleanup();
|
||||
this._cleanup = null;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const reference = this.element.querySelector("[data-icon]");
|
||||
computePosition(reference || this.element, this.tooltipTarget, {
|
||||
placement: this.placementValue,
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: this.offsetValue,
|
||||
crossAxis: this.crossAxisValue,
|
||||
}),
|
||||
flip(),
|
||||
shift({ padding: 5 }),
|
||||
],
|
||||
}).then(({ x, y }) => {
|
||||
Object.assign(this.tooltipTarget.style, {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
103
app/components/UI/account/activity_date.html.erb
Normal file
103
app/components/UI/account/activity_date.html.erb
Normal file
|
@ -0,0 +1,103 @@
|
|||
<%= tag.div id: id, data: { bulk_select_target: "group" }, class: "bg-container-inset rounded-xl p-1 w-full" do %>
|
||||
<details class="group">
|
||||
<summary>
|
||||
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-secondary">
|
||||
<div class="flex pl-0.5 items-center gap-4">
|
||||
<%= check_box_tag "#{date}_entries_selection",
|
||||
class: ["checkbox checkbox--light", "hidden": entries.size == 0],
|
||||
id: "selection_entry_#{date}",
|
||||
data: { action: "bulk-select#toggleGroupSelection" } %>
|
||||
|
||||
<p class="uppercase space-x-1.5">
|
||||
<%= tag.span I18n.l(date, format: :long) %>
|
||||
<span>·</span>
|
||||
<%= tag.span entries.size %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium"><%= balance_trend.current.format %></span>
|
||||
<%= render DS::Tooltip.new(text: "The end of day balance, after all transactions and adjustments", placement: "left", size: "sm") %>
|
||||
</div>
|
||||
<%= helpers.icon "chevron-down", class: "group-open:rotate-180" %>
|
||||
</div>
|
||||
</div>
|
||||
</summary>
|
||||
|
||||
<div class="p-4 space-y-3">
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Start of day balance
|
||||
<%= render DS::Tooltip.new(text: "The account balance at the beginning of this day, before any transactions or value changes", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd class="font-bold"><%= start_balance_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<% if account.balance_type == :investment %>
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Cash
|
||||
<%= render DS::Tooltip.new(text: "Net change in cash from deposits, withdrawals, and other cash transactions during the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Holdings
|
||||
<%= render DS::Tooltip.new(text: "Net change in investment holdings value from buying, selling, or market price movements", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= holdings_change_money.format %></dd>
|
||||
</dl>
|
||||
<% else %>
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Cash
|
||||
<%= render DS::Tooltip.new(text: "Net change in cash balance from all transactions during the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= cash_change_money.format %></dd>
|
||||
</dl>
|
||||
<% end %>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
End of day balance
|
||||
<%= render DS::Tooltip.new(text: "The calculated balance after all transactions but before any manual adjustments or reconciliations", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd class="font-medium"><%= end_balance_before_adjustments_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<hr class="border border-primary">
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Δ Value adjustments
|
||||
<%= render DS::Tooltip.new(text: "Adjustments are either manual reconciliations made by the user or adjustments due to market price changes throughout the day", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-secondary">
|
||||
<dd><%= adjustments_money.format %></dd>
|
||||
</dl>
|
||||
|
||||
<dl class="flex gap-4 items-center text-sm text-primary">
|
||||
<dt class="flex items-center gap-2">
|
||||
Closing balance
|
||||
<%= render DS::Tooltip.new(text: "The final account balance for the day, after all transactions and adjustments have been applied", placement: "left", size: "sm") %>
|
||||
</dt>
|
||||
<hr class="grow border-dashed border-primary">
|
||||
<dd class="font-bold"><%= end_balance_money.format %></dd>
|
||||
</dl>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="bg-container shadow-border-xs rounded-lg">
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry, view_ctx: "account" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
51
app/components/UI/account/activity_date.rb
Normal file
51
app/components/UI/account/activity_date.rb
Normal file
|
@ -0,0 +1,51 @@
|
|||
class UI::Account::ActivityDate < ApplicationComponent
|
||||
attr_reader :account, :data
|
||||
|
||||
delegate :date, :entries, :balance_trend, :cash_balance_trend, :holdings_value_trend, :transfers, to: :data
|
||||
|
||||
def initialize(account:, data:)
|
||||
@account = account
|
||||
@data = data
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, "entries_#{date}")
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def start_balance_money
|
||||
balance_trend.previous
|
||||
end
|
||||
|
||||
def cash_change_money
|
||||
cash_balance_trend.value
|
||||
end
|
||||
|
||||
def holdings_change_money
|
||||
holdings_value_trend.value
|
||||
end
|
||||
|
||||
def end_balance_before_adjustments_money
|
||||
balance_trend.previous + cash_change_money + holdings_change_money
|
||||
end
|
||||
|
||||
def adjustments_money
|
||||
end_balance_money - end_balance_before_adjustments_money
|
||||
end
|
||||
|
||||
def end_balance_money
|
||||
balance_trend.current
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
end
|
94
app/components/UI/account/activity_feed.html.erb
Normal file
94
app/components/UI/account/activity_feed.html.erb
Normal file
|
@ -0,0 +1,94 @@
|
|||
<%= turbo_frame_tag dom_id(account, "entries") do %>
|
||||
<div class="bg-container p-5 shadow-border-xs rounded-xl">
|
||||
<div class="flex items-center justify-between mb-4" data-testid="activity-menu">
|
||||
<%= tag.h2 t(".title"), class: "font-medium text-lg" %>
|
||||
|
||||
<% if account.manual? %>
|
||||
<%= render DS::Menu.new(variant: "button") do |menu| %>
|
||||
<% menu.with_button(text: "New", variant: "secondary", icon: "plus") %>
|
||||
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New balance",
|
||||
icon: "circle-dollar-sign",
|
||||
href: new_valuation_path(account_id: account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
|
||||
<% unless account.crypto? %>
|
||||
<% menu.with_item(
|
||||
variant: "link",
|
||||
text: "New transaction",
|
||||
icon: "credit-card",
|
||||
href: account.investment? ? new_trade_path(account_id: account.id) : new_transaction_path(account_id: account.id),
|
||||
data: { turbo_frame: :modal }) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form_with url: account_path(account),
|
||||
id: "entries-search",
|
||||
scope: :q,
|
||||
method: :get,
|
||||
data: { controller: "auto-submit-form" } do |form| %>
|
||||
<div class="flex gap-2 mb-4">
|
||||
<div class="grow">
|
||||
<div class="flex items-center px-3 py-2 gap-2 border border-secondary rounded-lg focus-within:ring-gray-100 focus-within:border-gray-900">
|
||||
<%= helpers.icon("search") %>
|
||||
|
||||
<%= hidden_field_tag :account_id, account.id %>
|
||||
|
||||
<%= form.search_field :search,
|
||||
placeholder: "Search entries by name",
|
||||
value: search,
|
||||
class: "form-field__input placeholder:text-sm placeholder:text-secondary",
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if activity_dates.empty? %>
|
||||
<p class="text-secondary text-sm p-4">No entries yet</p>
|
||||
<% else %>
|
||||
<%= tag.div id: dom_id(account, "entries_bulk_select"),
|
||||
data: {
|
||||
controller: "bulk-select",
|
||||
bulk_select_singular_label_value: "entry",
|
||||
bulk_select_plural_label_value: "entries"
|
||||
} do %>
|
||||
<div id="entry-selection-bar" data-bulk-select-target="selectionBar" class="flex justify-center hidden">
|
||||
<%= render "entries/selection_bar" %>
|
||||
</div>
|
||||
|
||||
<div class="grid bg-container-inset rounded-xl grid-cols-12 items-center uppercase text-xs font-medium text-secondary px-5 py-3 mb-4">
|
||||
<div class="pl-0.5 col-span-8 flex items-center gap-4">
|
||||
<%= check_box_tag "selection_entry",
|
||||
class: "checkbox checkbox--light",
|
||||
data: { action: "bulk-select#togglePageSelection" } %>
|
||||
<p>Date</p>
|
||||
</div>
|
||||
|
||||
<%= tag.p "Amount", class: "col-span-4 justify-self-end" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<% activity_dates.each do |activity_date_data| %>
|
||||
<%= render UI::Account::ActivityDate.new(
|
||||
account: account,
|
||||
data: activity_date_data
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-container rounded-bl-lg rounded-br-lg">
|
||||
<%= render "shared/pagination", pagy: pagy %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
35
app/components/UI/account/activity_feed.rb
Normal file
35
app/components/UI/account/activity_feed.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
class UI::Account::ActivityFeed < ApplicationComponent
|
||||
attr_reader :feed_data, :pagy, :search
|
||||
|
||||
def initialize(feed_data:, pagy:, search: nil)
|
||||
@feed_data = feed_data
|
||||
@pagy = pagy
|
||||
@search = search
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, :activity_feed)
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(
|
||||
broadcast_channel,
|
||||
target: id,
|
||||
renderable: self,
|
||||
layout: false
|
||||
)
|
||||
end
|
||||
|
||||
def activity_dates
|
||||
feed_data.entries_by_date
|
||||
end
|
||||
|
||||
private
|
||||
def account
|
||||
feed_data.account
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
<%= turbo_stream_from account %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(account, :container) do %>
|
||||
<%= turbo_frame_tag id do %>
|
||||
<%= tag.div class: "space-y-4 pb-32" do %>
|
||||
<%= render "accounts/show/header", account: account, title: title, subtitle: subtitle %>
|
||||
|
||||
|
@ -17,12 +17,12 @@
|
|||
|
||||
<% tabs.each do |tab| %>
|
||||
<% tabs_container.with_panel(tab_id: tab) do %>
|
||||
<%= render tab_partial_name(tab), account: account %>
|
||||
<%= tab_content_for(tab) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= render tab_partial_name(tabs.first), account: account %>
|
||||
<%= tab_content_for(tabs.first) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
class UI::AccountPage < ApplicationComponent
|
||||
attr_reader :account, :chart_view, :chart_period
|
||||
|
||||
renders_one :activity_feed, ->(feed_data:, pagy:, search:) { UI::Account::ActivityFeed.new(feed_data: feed_data, pagy: pagy, search: search) }
|
||||
|
||||
def initialize(account:, chart_view: nil, chart_period: nil, active_tab: nil)
|
||||
@account = account
|
||||
@chart_view = chart_view
|
||||
|
@ -8,6 +10,18 @@ class UI::AccountPage < ApplicationComponent
|
|||
@active_tab = active_tab
|
||||
end
|
||||
|
||||
def id
|
||||
dom_id(account, :container)
|
||||
end
|
||||
|
||||
def broadcast_channel
|
||||
account
|
||||
end
|
||||
|
||||
def broadcast_refresh!
|
||||
Turbo::StreamsChannel.broadcast_replace_to(broadcast_channel, target: id, renderable: self, layout: false)
|
||||
end
|
||||
|
||||
def title
|
||||
account.name
|
||||
end
|
||||
|
@ -33,13 +47,13 @@ class UI::AccountPage < ApplicationComponent
|
|||
end
|
||||
end
|
||||
|
||||
def tab_partial_name(tab)
|
||||
def tab_content_for(tab)
|
||||
case tab
|
||||
when :activity
|
||||
"accounts/show/activity"
|
||||
activity_feed
|
||||
when :holdings, :overview
|
||||
# Accountable is responsible for implementing the partial in the correct folder
|
||||
"#{account.accountable_type.downcase.pluralize}/tabs/#{tab}"
|
||||
render "#{account.accountable_type.downcase.pluralize}/tabs/#{tab}", account: account
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue