1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-07 06:25:19 +02:00

Multi-step account forms + clearer balance editing (#2427)
Some checks failed
Publish Docker image / ci (push) Has been cancelled
Publish Docker image / Build docker image (push) Has been cancelled

* Initial multi-step property form

* Improve form structure, add optional tooltip help icons to form fields

* Add basic inline alert component

* Clean up and improve property form lifecycle

* Implement Account status concept

* Lint fixes

* Remove whitespace

* Balance editing, scope updates for account

* Passing tests

* Fix brakeman warning

* Remove stale columns

* data constraint tweaks

* Redundant property
This commit is contained in:
Zach Gollwitzer 2025-07-03 09:33:07 -04:00 committed by GitHub
parent ba7e8d3893
commit 662f2c04ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 1036 additions and 427 deletions

View file

@ -6,7 +6,7 @@
<%= render "accounts/logo", account: account, size: "md" %>
<div>
<% if account.scheduled_for_deletion? %>
<% if account.pending_deletion? %>
<p class="text-sm font-medium text-primary">
<span>
<%= account.name %>
@ -16,31 +16,45 @@
</span>
</p>
<% else %>
<%= link_to account.name, account, class: [(account.is_active ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<%= link_to account.name, account, class: [(account.active? ? "text-primary" : "text-subdued"), "text-sm font-medium hover:underline"], data: { turbo_frame: "_top" } %>
<% if account.long_subtype_label %>
<p class="text-sm text-secondary truncate"><%= account.long_subtype_label %></p>
<% end %>
<% end %>
</div>
<% unless account.scheduled_for_deletion? %>
<% unless account.pending_deletion? %>
<%= link_to edit_account_path(account, return_to: return_to), data: { turbo_frame: :modal }, class: "group-hover/account:flex hidden hover:opacity-80 items-center justify-center" do %>
<%= icon("pencil-line", size: "sm") %>
<% end %>
<% end %>
</div>
<div class="flex items-center gap-8">
<% if account.syncing? %>
<% if account.draft? %>
<!-- Balance hidden for draft accounts -->
<% elsif account.syncing? %>
<div class="w-16 h-6 bg-loader rounded-full animate-pulse"></div>
<% else %>
<p class="text-sm font-medium <%= account.is_active ? "text-primary" : "text-subdued" %>">
<p class="text-sm font-medium <%= account.active? ? "text-primary" : "text-subdued" %>">
<%= format_money account.balance_money %>
</p>
<% end %>
<% unless account.scheduled_for_deletion? %>
<%= styled_form_with model: account, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= f.toggle :is_active, { data: { auto_submit_form_target: "auto" } } %>
<% if account.draft? %>
<%= render LinkComponent.new(
text: "Complete setup",
href: edit_account_path(account, return_to: return_to),
variant: :outline,
frame: :modal
) %>
<% elsif account.active? || account.disabled? %>
<%= form_with model: account, url: toggle_active_account_path(account), method: :patch, data: { turbo_frame: "_top", controller: "auto-submit-form" } do |f| %>
<%= render ToggleComponent.new(
id: "account_#{account.id}_active",
name: "active",
checked: account.active?,
data: { auto_submit_form_target: "auto" }
) %>
<% end %>
<% end %>
</div>

View file

@ -1,5 +1,9 @@
<%# locals: (account:, url:) %>
<% if @error_message.present? %>
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
<% end %>
<%= styled_form_with model: account, url: url, scope: :account, data: { turbo: false }, class: "flex flex-col gap-4 justify-between grow text-primary" do |form| %>
<div class="grow space-y-2">
<%= form.hidden_field :accountable_type %>

View file

@ -12,7 +12,18 @@
<div class="flex items-center gap-2">
<div class="truncate">
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2>
<div class="flex items-center gap-3">
<h2 class="font-medium text-xl truncate <%= "animate-pulse" if account.syncing? %>"><%= title || account.name %></h2>
<% if account.draft? %>
<%= render LinkComponent.new(
text: "Complete setup",
href: edit_account_path(account),
variant: :outline,
size: :sm,
frame: :modal
) %>
<% end %>
</div>
<% if subtitle.present? %>
<p class="text-sm text-secondary"><%= subtitle %></p>
<% end %>

View file

@ -22,7 +22,7 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %>
<div class="flex flex-col items-center justify-center w-full h-64 border border-secondary border-dashed rounded-xl cursor-pointer" data-controller="file-upload" data-action="click->file-upload#triggerFileInput" data-file-upload-target="uploadArea">
@ -54,7 +54,7 @@
<%= form.select :col_sep, Import::SEPARATORS, label: true %>
<% if @import.type == "TransactionImport" || @import.type == "TradeImport" %>
<%= form.select :account_id, @import.family.accounts.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<%= form.select :account_id, @import.family.accounts.visible.pluck(:name, :id), { label: "Account (optional)", include_blank: "Multi-account import", selected: @import.account_id } %>
<% end %>
<%= form.text_area :raw_file_str,

View file

@ -0,0 +1,7 @@
<%# locals: (notice: nil, error: nil) %>
<% if notice.present? %>
<%= render AlertComponent.new(message: notice, variant: :success) %>
<% elsif error.present? %>
<%= render AlertComponent.new(message: error, variant: :error) %>
<% end %>

View file

@ -0,0 +1,16 @@
<%# locals: (label:, href: nil, active: false) %>
<% classes = class_names(
"flex items-center px-3 py-2 rounded-lg text-sm font-medium",
active ? "bg-surface-inset text-primary" : "text-secondary",
) %>
<% if href.present? %>
<%= link_to href, data: { turbo_frame: :modal }, class: class_names(classes, "cursor-pointer hover:bg-surface-inset-hover hover:text-primary") do %>
<%= label %>
<% end %>
<% else %>
<%= tag.span class: classes do %>
<%= label %>
<% end %>
<% end %>

View file

@ -0,0 +1,7 @@
<%# locals: (account:, active_tab:) %>
<div class="flex flex-col gap-0.5 w-[156px] shrink-0">
<%= render "properties/form_tab", label: "Overview", href: account.new_record? ? nil : edit_property_path(@account), active: active_tab == "overview" %>
<%= render "properties/form_tab", label: "Value", href: account.new_record? ? nil : balances_property_path(@account), active: active_tab == "value" %>
<%= render "properties/form_tab", label: "Address", href: account.new_record? ? nil : address_property_path(@account), active: active_tab == "address" %>
</div>

View file

@ -0,0 +1,35 @@
<%# locals: (form:) %>
<div class="flex flex-col gap-2">
<%= form.text_field :name,
label: "Name",
placeholder: "Vacation home",
required: true %>
<%= form.select :subtype,
Property::SUBTYPES.map { |k, v| [v[:long], k] },
{ prompt: "Select type", label: "Property type" }, required: true %>
<%= form.hidden_field :accountable_type, value: "Property" %>
<%= form.fields_for :accountable do |property_form| %>
<div class="flex items-center gap-2">
<%= property_form.number_field :year_built,
label: "Year Built (optional)",
placeholder: "1990",
min: 1800,
max: Time.current.year %>
</div>
<div class="flex items-center gap-2">
<%= property_form.number_field :area_value,
label: "Area (optional)",
placeholder: "1200",
min: 0 %>
<%= property_form.select :area_unit,
[["Square Feet", "sqft"], ["Square Meters", "sqm"]],
{ label: "Area Unit" } %>
</div>
<% end %>
</div>

View file

@ -0,0 +1,50 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "address" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @property, url: update_address_property_path(@account), method: :patch, data: { turbo_frame: @property.address.persisted? ? nil : :_top } do |form| %>
<div class="flex flex-col gap-2 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= form.fields_for :address do |address_form| %>
<%= address_form.text_field :line1,
label: "Address Line 1",
placeholder: "123 Main Street" %>
<div class="flex items-center gap-2">
<%= address_form.text_field :locality,
label: "City",
placeholder: "San Francisco" %>
<%= address_form.text_field :region,
label: "State/Region",
placeholder: "CA" %>
</div>
<div class="flex items-center gap-2">
<%= address_form.text_field :postal_code,
label: "Postal Code",
placeholder: "12345" %>
<%= address_form.text_field :country,
label: "Country",
placeholder: "USA" %>
</div>
<% end %>
</div>
<!-- Save button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: "Save",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,30 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<div class="flex gap-4">
<%= render "properties/form_tabs", account: @account, active_tab: "value" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @account, url: update_balances_property_path(@account), method: :patch do |form| %>
<div class="flex flex-col gap-4 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= form.money_field :balance,
label: "Estimated market value",
label_tooltip: "The estimated market value of your property. This number can often be found on sites like Zillow or Redfin, and is never an exact number.",
placeholder: "0" %>
</div>
<!-- Next button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: @account.active? ? "Save" : "Next",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

@ -1,6 +1,27 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".edit", account: @account.name)) %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<%= render "form", account: @account, url: property_path(@account) %>
<div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "overview" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @account, url: property_path(@account), method: :patch do |form| %>
<div class="flex flex-col gap-2 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= render "properties/overview_fields", form: form %>
</div>
<!-- Save button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: @account.active? ? "Save" : "Next",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

@ -1,6 +1,27 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_header(title: "Enter property manually") %>
<% dialog.with_body do %>
<%= render "properties/form", account: @account, url: properties_path(return_to: params[:return_to]) %>
<div class="flex gap-4">
<!-- Left sidebar with tabs -->
<%= render "properties/form_tabs", account: @account, active_tab: "overview" %>
<!-- Right content area with form -->
<div class="flex-1">
<%= styled_form_with model: @account, url: properties_path do |form| %>
<div class="flex flex-col gap-2 min-h-[320px]">
<%= render "properties/form_alert", notice: @success_message, error: @error_message %>
<%= render "properties/overview_fields", form: form %>
</div>
<!-- Create button -->
<div class="flex justify-end mt-4">
<%= render ButtonComponent.new(
text: "Next",
variant: "primary",
) %>
</div>
<% end %>
</div>
</div>
<% end %>
<% end %>

View file

@ -1,62 +1,85 @@
<%# locals: (form:, amount_method:, currency_method:, **options) %>
<% currency_value = if options[:currency_value_override].present?
options[:currency_value_override]
elsif form.object && form.object.respond_to?(currency_method)
form.object.public_send(currency_method)
end
options[:currency_value_override]
elsif form.object && form.object.respond_to?(currency_method)
form.object.public_send(currency_method)
end
currency = Money::Currency.new(currency_value || options[:default_currency] || "USD") %>
<div class="form-field pr-0 <%= options[:container_class] %>" data-controller="money-field">
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<span class="text-red-500">*</span>
<% end %>
<div class="form-field <%= options[:container_class] %>" data-controller="money-field">
<% if options[:label_tooltip] %>
<div class="form-field__header">
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<span class="text-red-500 ml-0.5">*</span>
<% end %>
<% end %>
<div class="form-field__actions">
<div data-controller="tooltip">
<%= icon "help-circle", size: "sm", color: "default", class: "cursor-help" %>
<div role="tooltip" data-tooltip-target="tooltip" class="tooltip bg-gray-700 text-sm p-2 rounded w-64 text-white">
<%= options[:label_tooltip] %>
</div>
</div>
</div>
</div>
<% end %>
<div class="flex items-center gap-1">
<div class="flex items-center grow gap-1">
<span class="text-subdued text-sm" data-money-field-target="symbol">
<%= currency.symbol %>
</span>
<%= form.number_field amount_method,
class: "form-field__input",
inline: true,
placeholder: "100",
value: if options[:value]
sprintf("%.#{currency.default_precision}f", options[:value])
elsif form.object && form.object.respond_to?(amount_method)
val = form.object.public_send(amount_method)
sprintf("%.#{currency.default_precision}f", val) if val.present?
end,
min: options[:min] || -99999999999999,
max: options[:max] || 99999999999999,
step: currency.step,
disabled: options[:disabled],
data: {
"money-field-target": "amount",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact,
required: options[:required] %>
</div>
<% unless options[:hide_currency] %>
<div>
<%= form.select currency_method,
Money::Currency.as_options.map(&:iso_code),
{ inline: true, selected: currency.iso_code },
{
class: "w-fit pr-5 disabled:text-subdued form-field__input",
disabled: options[:disable_currency],
data: {
"money-field-target": "currency",
action: "change->money-field#handleCurrencyChange",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact
} %>
</div>
<div class="form-field__body">
<% unless options[:label_tooltip] %>
<%= form.label options[:label] || t(".label"), class: "form-field__label" do %>
<%= options[:label] || t(".label") %>
<% if options[:required] %>
<span class="text-red-500 ml-0.5">*</span>
<% end %>
<% end %>
<% end %>
<div class="flex items-center gap-1">
<div class="flex items-center grow gap-1">
<span class="text-subdued text-sm" data-money-field-target="symbol">
<%= currency.symbol %>
</span>
<%= form.number_field amount_method,
class: "form-field__input",
inline: true,
placeholder: "100",
value: if options[:value]
sprintf("%.#{currency.default_precision}f", options[:value])
elsif form.object && form.object.respond_to?(amount_method)
val = form.object.public_send(amount_method)
sprintf("%.#{currency.default_precision}f", val) if val.present?
end,
min: options[:min] || -99999999999999,
max: options[:max] || 99999999999999,
step: currency.step,
disabled: options[:disabled],
data: {
"money-field-target": "amount",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact,
required: options[:required] %>
</div>
<% unless options[:hide_currency] %>
<div>
<%= form.select currency_method,
Money::Currency.as_options.map(&:iso_code),
{ inline: true, selected: currency.iso_code },
{
class: "w-fit pr-5 disabled:text-subdued form-field__input",
disabled: options[:disable_currency],
data: {
"money-field-target": "currency",
action: "change->money-field#handleCurrencyChange",
"auto-submit-form-target": ("auto" if options[:auto_submit])
}.compact
} %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -45,7 +45,7 @@
<% end %>
<% 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") } %>
<%= form.collection_select :transfer_account_id, Current.family.accounts.visible.alphabetically, :id, :name, { prompt: t(".account_prompt"), label: t(".account") } %>
<% end %>
<% if %w[buy sell].include?(type) %>

View file

@ -1,10 +1,10 @@
<%# locals: (entry:) %>
<%# locals: (entry:, error_message:) %>
<%= styled_form_with model: entry, url: valuations_path, class: "space-y-4" do |form| %>
<%= form.hidden_field :account_id %>
<% if entry.errors.any? %>
<%= render "shared/form_errors", model: entry %>
<% if error_message.present? %>
<%= render AlertComponent.new(message: error_message, variant: :error) %>
<% end %>
<div class="space-y-3">

View file

@ -2,7 +2,7 @@
<%= tag.header class: "mb-4 space-y-1", id: dom_id(entry, :header) do %>
<span class="text-secondary text-sm">
<%= t(".balance") %>
<%= entry.name %>
</span>
<div class="flex items-center gap-4">

View file

@ -25,15 +25,7 @@
</div>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm">
<% if balance_trend&.trend %>
<%= tag.span format_money(balance_trend.trend.value), style: "color: #{balance_trend.trend.color}" %>
<% else %>
<%= tag.span "--", class: "text-gray-400" %>
<% end %>
</div>
<div class="col-span-2 justify-self-end">
<div class="col-span-4 justify-self-end">
<%= tag.p format_money(entry.amount_money), class: "font-medium text-sm text-primary" %>
</div>
</div>

View file

@ -1,6 +1,6 @@
<%= render DialogComponent.new do |dialog| %>
<% dialog.with_header(title: t(".title")) %>
<% dialog.with_body do %>
<%= render "form", entry: @entry %>
<%= render "form", entry: @entry, error_message: @error_message %>
<% end %>
<% end %>

View file

@ -6,17 +6,18 @@
<% end %>
<% dialog.with_body do %>
<% if @error_message.present? %>
<div class="mb-4">
<%= render AlertComponent.new(message: @error_message, variant: :error) %>
</div>
<% end %>
<% dialog.with_section(title: t(".overview"), open: true) do %>
<div class="pb-4">
<%= styled_form_with model: entry,
url: entry_path(entry),
class: "space-y-2",
data: { controller: "auto-submit-form" } do |f| %>
<%= f.text_field :name,
label: t(".name_label"),
placeholder: t(".name_placeholder"),
"data-auto-submit-form-target": "auto" %>
<%= f.date_field :date,
label: t(".date_label"),
max: Date.current,