mirror of
https://github.com/we-promise/sure.git
synced 2026-04-07 06:21:23 +00:00
Split UI (#1245)
* Initial split transaction support
* Add support to unsplit and edit split
* Update show.html.erb
* FIX address reviews
* Improve UX
* Update show.html.erb
* Reviews
* Update edit.html.erb
* Add parent category to dialog
* Update en.yml
* Add UI indication to totals
* FIX ui update
* Add category select like rest of app
* Add split ui
* Add settings configuration for split transactions
- Adds a new settings section for appearance changes
- Also adds extra checks for delete and API calls
- Also adds checks for parent/child changes
* fixes
- split transactions dark mode fix
- add split transactions to context menu
* Update entry.rb
1. New validation split_child_date_matches_parent — prevents saving a split child with a date different from its parent. This is the root-cause fix that
protects all flows at once.
2. Bulk update guard — bulk_update! now strips :date from attributes when processing split children, preventing the validation from raising and silently
skipping the date change instead.
* N+1 fix for split_parent?
* Update entry.rb
Problem: In bulk_update!, when a split child has :date removed from attrs (line 432) and the remaining attrs is empty (e.g., the bulk update only
changed the date), entry.update! {} still ran as a no-op. But lock_saved_attributes! and mark_user_modified! at lines 443-444 executed unconditionally,
incorrectly marking untouched split children as user-modified and opting them out of future syncs.
Fix:
1. Added a changed flag to track whether any actual modification happened
2. Wrapped entry.update! in an if attrs.present? check so no-op updates are skipped
3. Gated lock_saved_attributes! and mark_user_modified! behind if changed, so they only run when the entry was actually modified (either via attribute
update or tag update)
* fixes
1. Indentation in show.html.erb Settings section — The split button block and delete block had extra indentation making them appear nested inside guard
blocks they weren't part of. Fixed to match actual nesting.
2. Skip @split_parents query when grouping is off — The controller now only loads split parent entries when show_split_grouped? is true, saving a query
with joins when the feature is disabled.
This commit is contained in:
@@ -105,6 +105,16 @@ class Api::V1::TransactionsController < Api::V1::BaseController
|
||||
end
|
||||
|
||||
def update
|
||||
if @entry.split_child?
|
||||
render json: { error: "validation_failed", message: "Split child transactions cannot be edited directly. Use the split editor." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
if @entry.split_parent? && split_financial_fields_changed?
|
||||
render json: { error: "validation_failed", message: "Split parent amount, date, and type cannot be changed directly. Use the split editor." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
Entry.transaction do
|
||||
if @entry.update(entry_params_for_update)
|
||||
# Handle tags separately - only when explicitly provided in the request
|
||||
@@ -141,6 +151,11 @@ end
|
||||
end
|
||||
|
||||
def destroy
|
||||
if @entry.split_child?
|
||||
render json: { error: "validation_failed", message: "Split child transactions cannot be deleted individually." }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
@entry.destroy!
|
||||
@entry.sync_account_later
|
||||
|
||||
@@ -313,6 +328,12 @@ end
|
||||
params[:transaction].key?(:tag_ids)
|
||||
end
|
||||
|
||||
def split_financial_fields_changed?
|
||||
params.dig(:transaction, :amount).present? ||
|
||||
params.dig(:transaction, :date).present? ||
|
||||
params.dig(:transaction, :nature).present?
|
||||
end
|
||||
|
||||
def calculate_signed_amount
|
||||
amount = transaction_params[:amount].to_f
|
||||
nature = transaction_params[:nature]
|
||||
|
||||
18
app/controllers/settings/appearances_controller.rb
Normal file
18
app/controllers/settings/appearances_controller.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
class Settings::AppearancesController < ApplicationController
|
||||
layout "settings"
|
||||
|
||||
def show
|
||||
@user = Current.user
|
||||
end
|
||||
|
||||
def update
|
||||
@user = Current.user
|
||||
@user.transaction do
|
||||
@user.lock!
|
||||
updated_prefs = (@user.preferences || {}).deep_dup
|
||||
updated_prefs["show_split_grouped"] = params.dig(:user, :show_split_grouped) == "1"
|
||||
@user.update!(preferences: updated_prefs)
|
||||
end
|
||||
redirect_to settings_appearance_path
|
||||
end
|
||||
end
|
||||
@@ -27,6 +27,30 @@ class TransactionsController < ApplicationController
|
||||
|
||||
@pagy, @transactions = pagy(base_scope, limit: safe_per_page)
|
||||
|
||||
# Preload split parent data
|
||||
entry_ids = @transactions.map { |t| t.entry.id }
|
||||
|
||||
# Load split parent entries for grouped display (only when grouping is enabled)
|
||||
@split_parents = if Current.user.show_split_grouped?
|
||||
split_parent_ids = @transactions.filter_map { |t| t.entry.parent_entry_id }.uniq
|
||||
if split_parent_ids.any?
|
||||
Entry.where(id: split_parent_ids)
|
||||
.includes(:account, entryable: [ :category, :merchant ])
|
||||
.index_by(&:id)
|
||||
else
|
||||
{}
|
||||
end
|
||||
else
|
||||
{}
|
||||
end
|
||||
|
||||
# Preload which entries on this page are split parents (have children) to avoid N+1
|
||||
@split_parent_entry_ids = if entry_ids.any?
|
||||
Entry.where(parent_entry_id: entry_ids).distinct.pluck(:parent_entry_id).to_set
|
||||
else
|
||||
Set.new
|
||||
end
|
||||
|
||||
# Load projected recurring transactions for next 10 days
|
||||
@projected_recurring = Current.family.recurring_transactions
|
||||
.active
|
||||
|
||||
@@ -83,6 +83,8 @@ class UsersController < ApplicationController
|
||||
redirect_to goals_onboarding_path
|
||||
when "trial"
|
||||
redirect_to trial_onboarding_path
|
||||
when "appearance"
|
||||
redirect_to settings_appearance_path, notice: notice
|
||||
when "ai_prompts"
|
||||
redirect_to settings_ai_prompts_path, notice: notice
|
||||
else
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
module EntriesHelper
|
||||
SplitGroup = Data.define(:parent, :children)
|
||||
|
||||
def group_split_entries(entries, split_parents)
|
||||
return entries if split_parents.blank?
|
||||
|
||||
result = []
|
||||
seen_parent_ids = Set.new
|
||||
|
||||
entries.each do |entry|
|
||||
if entry.split_child? && split_parents[entry.parent_entry_id]
|
||||
parent_id = entry.parent_entry_id
|
||||
next if seen_parent_ids.include?(parent_id)
|
||||
|
||||
seen_parent_ids.add(parent_id)
|
||||
children = entries.select { |e| e.parent_entry_id == parent_id }
|
||||
result << SplitGroup.new(parent: split_parents[parent_id], children: children)
|
||||
else
|
||||
result << entry
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def entries_by_date(entries, totals: false)
|
||||
transfer_groups = entries.group_by do |entry|
|
||||
# Only check for transfer if it's a transaction
|
||||
|
||||
@@ -4,6 +4,7 @@ module SettingsHelper
|
||||
{ name: "Accounts", path: :accounts_path },
|
||||
{ name: "Bank Sync", path: :settings_bank_sync_path },
|
||||
{ name: "Preferences", path: :settings_preferences_path },
|
||||
{ name: "Appearance", path: :settings_appearance_path },
|
||||
{ name: "Profile Info", path: :settings_profile_path },
|
||||
{ name: "Security", path: :settings_security_path },
|
||||
{ name: "Payment", path: :settings_payment_path, condition: :not_self_hosted? },
|
||||
|
||||
@@ -143,13 +143,13 @@ export default class extends Controller {
|
||||
if (balanced) {
|
||||
this.remainingTarget.classList.remove("text-destructive")
|
||||
this.remainingTarget.classList.add("text-success")
|
||||
container.classList.remove("border-destructive", "bg-red-25")
|
||||
container.classList.add("border-green-200", "bg-green-25")
|
||||
container.classList.remove("border-destructive", "bg-red-tint-10")
|
||||
container.classList.add("border-green-200", "bg-green-tint-10")
|
||||
} else {
|
||||
this.remainingTarget.classList.remove("text-success")
|
||||
this.remainingTarget.classList.add("text-destructive")
|
||||
container.classList.remove("border-green-200", "bg-green-25")
|
||||
container.classList.add("border-destructive", "bg-red-25")
|
||||
container.classList.remove("border-green-200", "bg-green-tint-10")
|
||||
container.classList.add("border-destructive", "bg-red-tint-10")
|
||||
}
|
||||
|
||||
this.errorTarget.classList.toggle("hidden", balanced)
|
||||
|
||||
@@ -21,6 +21,7 @@ class Entry < ApplicationRecord
|
||||
validates :external_id, uniqueness: { scope: [ :account_id, :source ] }, if: -> { external_id.present? && source.present? }
|
||||
|
||||
validate :cannot_unexclude_split_parent
|
||||
validate :split_child_date_matches_parent
|
||||
|
||||
before_destroy :prevent_individual_child_deletion, if: :split_child?
|
||||
|
||||
@@ -423,10 +424,19 @@ class Entry < ApplicationRecord
|
||||
|
||||
transaction do
|
||||
all.each do |entry|
|
||||
changed = false
|
||||
|
||||
# Update standard attributes
|
||||
if bulk_attributes.present?
|
||||
bulk_attributes[:entryable_attributes][:id] = entry.entryable_id if bulk_attributes[:entryable_attributes].present?
|
||||
entry.update! bulk_attributes
|
||||
attrs = bulk_attributes.dup
|
||||
attrs.delete(:date) if entry.split_child?
|
||||
|
||||
if attrs.present?
|
||||
attrs[:entryable_attributes] = attrs[:entryable_attributes].dup if attrs[:entryable_attributes].present?
|
||||
attrs[:entryable_attributes][:id] = entry.entryable_id if attrs[:entryable_attributes].present?
|
||||
entry.update! attrs
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
|
||||
# Handle tags separately - only when explicitly requested
|
||||
@@ -434,10 +444,13 @@ class Entry < ApplicationRecord
|
||||
entry.transaction.tag_ids = tag_ids
|
||||
entry.transaction.save!
|
||||
entry.entryable.lock_attr!(:tag_ids) if entry.transaction.tags.any?
|
||||
changed = true
|
||||
end
|
||||
|
||||
entry.lock_saved_attributes!
|
||||
entry.mark_user_modified!
|
||||
if changed
|
||||
entry.lock_saved_attributes!
|
||||
entry.mark_user_modified!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -453,6 +466,14 @@ class Entry < ApplicationRecord
|
||||
errors.add(:excluded, "cannot be toggled off for a split transaction")
|
||||
end
|
||||
|
||||
def split_child_date_matches_parent
|
||||
return unless split_child? && date_changed?
|
||||
return unless parent_entry.present?
|
||||
return if date == parent_entry.date
|
||||
|
||||
errors.add(:date, "must match the parent transaction date for split children")
|
||||
end
|
||||
|
||||
def prevent_individual_child_deletion
|
||||
return if destroyed_by_association || unsplitting
|
||||
|
||||
|
||||
@@ -316,6 +316,10 @@ class User < ApplicationRecord
|
||||
preferences&.dig("transactions_collapsed_sections", section_key) == true
|
||||
end
|
||||
|
||||
def show_split_grouped?
|
||||
preferences&.dig("show_split_grouped") != false
|
||||
end
|
||||
|
||||
def update_transactions_preferences(prefs)
|
||||
transaction do
|
||||
lock!
|
||||
|
||||
@@ -65,6 +65,16 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @transaction.splittable? %>
|
||||
<%= link_to new_transaction_split_path(@transaction.entry),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2 hover:bg-container-inset-hover",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= icon("split") %>
|
||||
|
||||
<p><%= t("splits.show.button") %></p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-secondary w-full rounded-lg p-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= form_with url: transaction_path(@transaction.entry),
|
||||
|
||||
8
app/views/entries/_split_group.html.erb
Normal file
8
app/views/entries/_split_group.html.erb
Normal file
@@ -0,0 +1,8 @@
|
||||
<%# locals: (split_group:) %>
|
||||
<div class="split-group">
|
||||
<%= render "transactions/split_parent_row", entry: split_group.parent %>
|
||||
<% split_group.children.each do |child_entry| %>
|
||||
<%= render partial: child_entry.entryable.to_partial_path,
|
||||
locals: { entry: child_entry, in_split_group: true } %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@ nav_sections = [
|
||||
{ label: t(".accounts_label"), path: accounts_path, icon: "layers" },
|
||||
{ label: t(".bank_sync_label"), path: settings_bank_sync_path, icon: "banknote" },
|
||||
{ label: t(".preferences_label"), path: settings_preferences_path, icon: "bolt" },
|
||||
{ label: t(".appearance_label"), path: settings_appearance_path, icon: "palette" },
|
||||
{ label: t(".profile_label"), path: settings_profile_path, icon: "circle-user" },
|
||||
{ label: t(".security_label"), path: settings_security_path, icon: "shield-check" },
|
||||
{ label: t(".payment_label"), path: settings_payment_path, icon: "circle-dollar-sign", if: !self_hosted? && Current.family.can_manage_subscription? }
|
||||
|
||||
50
app/views/settings/appearances/show.html.erb
Normal file
50
app/views/settings/appearances/show.html.erb
Normal file
@@ -0,0 +1,50 @@
|
||||
<%= content_for :page_title, t(".page_title") %>
|
||||
|
||||
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
|
||||
<div data-controller="theme" data-theme-user-preference-value="<%= @user.theme %>">
|
||||
<%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form",
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.hidden_field :redirect_to, value: "appearance" %>
|
||||
|
||||
<% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %>
|
||||
|
||||
<% [
|
||||
{ value: "light", image: "light-mode-preview.png" },
|
||||
{ value: "dark", image: "dark-mode-preview.png" },
|
||||
{ value: "system", image: "system-mode-preview.png" }
|
||||
].each do |theme| %>
|
||||
<%= form.label :"theme_#{theme[:value]}", class: "group" do %>
|
||||
<div class="<%= theme_option_class %>">
|
||||
<%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
|
||||
<div class="<%= theme[:value] == "system" ? "flex items-center gap-2 justify-center" : "text-sm font-medium text-primary" %>">
|
||||
<%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %>
|
||||
<%= t(".theme_#{theme[:value]}") %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".transactions_title"), subtitle: t(".transactions_subtitle") do %>
|
||||
<div>
|
||||
<%= form_with url: settings_appearance_path, method: :patch,
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".split_grouped_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".split_grouped_description") %></p>
|
||||
</div>
|
||||
<%= render DS::Toggle.new(
|
||||
id: "user_show_split_grouped",
|
||||
name: "user[show_split_grouped]",
|
||||
checked: @user.show_split_grouped?,
|
||||
data: { auto_submit_form_target: "auto" }
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -56,31 +56,3 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".theme_title"), subtitle: t(".theme_subtitle") do %>
|
||||
<div data-controller="theme" data-theme-user-preference-value="<%= @user.theme %>">
|
||||
<%= form_with model: @user, class: "flex flex-col md:flex-row justify-between items-center gap-4", id: "theme_form",
|
||||
data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "change" } do |form| %>
|
||||
<%= form.hidden_field :redirect_to, value: "preferences" %>
|
||||
|
||||
<% theme_option_class = "text-center transition-all duration-200 p-3 rounded-lg hover:bg-surface-hover cursor-pointer [&:has(input:checked)]:bg-surface-hover [&:has(input:checked)]:border [&:has(input:checked)]:border-primary [&:has(input:checked)]:shadow-xs" %>
|
||||
|
||||
<% [
|
||||
{ value: "light", image: "light-mode-preview.png" },
|
||||
{ value: "dark", image: "dark-mode-preview.png" },
|
||||
{ value: "system", image: "system-mode-preview.png" }
|
||||
].each do |theme| %>
|
||||
<%= form.label :"theme_#{theme[:value]}", class: "group" do %>
|
||||
<div class="<%= theme_option_class %>">
|
||||
<%= image_tag(theme[:image], alt: "#{theme[:value].titleize} Theme Preview", class: "max-h-44 mb-2") %>
|
||||
<div class="<%= theme[:value] == "system" ? "flex items-center gap-2 justify-center" : "text-sm font-medium text-primary" %>">
|
||||
<%= form.radio_button :theme, theme[:value], checked: @user.theme == theme[:value], class: "sr-only",
|
||||
data: { auto_submit_form_target: "auto", autosubmit_trigger_event: "change", action: "theme#updateTheme" } %>
|
||||
<%= t(".theme_#{theme[:value]}") %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
</button>
|
||||
|
||||
<%# Remaining balance indicator %>
|
||||
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-secondary text-sm">
|
||||
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-secondary bg-container text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-secondary font-medium"><%= t("splits.new.remaining") %></span>
|
||||
<span data-split-transaction-target="remaining" class="font-semibold text-primary">
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
</button>
|
||||
|
||||
<%# Remaining balance indicator %>
|
||||
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-destructive bg-red-25 text-sm">
|
||||
<div data-split-transaction-target="remainingContainer" class="p-3 rounded-lg border border-destructive bg-red-tint-10 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-secondary font-medium"><%= t("splits.new.remaining") %></span>
|
||||
<span data-split-transaction-target="remaining" class="font-semibold text-destructive">
|
||||
|
||||
@@ -39,7 +39,19 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<%= entries_by_date(@transactions.map(&:entry), totals: true) do |entries| %>
|
||||
<%= render entries %>
|
||||
<% if Current.user.show_split_grouped? %>
|
||||
<% group_split_entries(entries, @split_parents).each do |item| %>
|
||||
<% if item.is_a?(EntriesHelper::SplitGroup) %>
|
||||
<%= render "entries/split_group", split_group: item %>
|
||||
<% else %>
|
||||
<%= render item %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<% entries.each do |entry| %>
|
||||
<%= render entry %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
69
app/views/transactions/_split_parent_row.html.erb
Normal file
69
app/views/transactions/_split_parent_row.html.erb
Normal file
@@ -0,0 +1,69 @@
|
||||
<%# locals: (entry:) %>
|
||||
<% transaction = entry.entryable %>
|
||||
|
||||
<div class="group flex lg:grid lg:grid-cols-12 items-center text-sm font-medium p-3 lg:p-4 opacity-50 text-secondary">
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
|
||||
<%# Empty space where checkbox would be, for alignment %>
|
||||
<div class="hidden lg:block w-4 shrink-0"></div>
|
||||
|
||||
<div class="max-w-full">
|
||||
<div class="flex items-center gap-3 lg:gap-4">
|
||||
<div class="hidden lg:flex">
|
||||
<% if transaction.merchant&.logo_url.present? %>
|
||||
<%= image_tag Setting.transform_brand_fetch_url(transaction.merchant.logo_url),
|
||||
class: "w-9 h-9 rounded-full border border-secondary",
|
||||
loading: "lazy" %>
|
||||
<% else %>
|
||||
<div class="hidden lg:flex">
|
||||
<%= render DS::FilledIcon.new(
|
||||
variant: :text,
|
||||
text: entry.name,
|
||||
size: "lg",
|
||||
rounded: true
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="truncate">
|
||||
<div class="space-y-0.5">
|
||||
<div class="flex items-center gap-1 min-w-0">
|
||||
<div class="truncate flex-shrink">
|
||||
<%= link_to entry.name,
|
||||
entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline" %>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 flex-shrink-0">
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-accent bg-accent/10 text-accent">
|
||||
<%= icon "split", size: "sm", color: "current" %>
|
||||
<%= t("transactions.split_parent_row.split_label") %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-secondary text-xs font-normal">
|
||||
<% if transaction.merchant&.present? %>
|
||||
<span class="hidden lg:inline truncate"><%= transaction.merchant.name %> • </span>
|
||||
<% end %>
|
||||
<span class="text-secondary hidden lg:inline">
|
||||
<%= link_to entry.account.name,
|
||||
account_path(entry.account, tab: "transactions"),
|
||||
data: { turbo_frame: "_top" },
|
||||
class: "hover:underline" %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-1 col-span-2">
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 col-span-4 lg:col-span-2 ml-auto flex items-center justify-end gap-2">
|
||||
<%= content_tag :p, format_money(-entry.amount_money) %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,10 +1,10 @@
|
||||
<%# locals: (entry:, balance_trend: nil, view_ctx: "global") %>
|
||||
<%# locals: (entry:, balance_trend: nil, view_ctx: "global", in_split_group: false) %>
|
||||
|
||||
<% transaction = entry.entryable %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(entry) do %>
|
||||
<%= turbo_frame_tag dom_id(transaction) do %>
|
||||
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %>">
|
||||
<div class="group flex lg:grid lg:grid-cols-12 items-center text-primary text-sm font-medium p-3 lg:p-4 <%= entry.excluded ? "opacity-50 text-secondary" : "" %> <%= "pl-8 lg:pl-12" if in_split_group %>">
|
||||
|
||||
<div class="pr-4 lg:pr-10 flex items-center gap-3 lg:gap-4 col-span-8 min-w-0">
|
||||
<%= check_box_tag dom_id(entry, "selection"),
|
||||
@@ -102,13 +102,13 @@
|
||||
<% end %>
|
||||
|
||||
<%# Split indicator %>
|
||||
<% if entry.split_parent? %>
|
||||
<% if @split_parent_entry_ids ? @split_parent_entry_ids.include?(entry.id) : entry.split_parent? %>
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium rounded-full px-1.5 py-0.5 border border-accent bg-accent/10 text-accent" title="<%= t("transactions.transaction.split_tooltip") %>">
|
||||
<%= icon "split", size: "sm", color: "current" %>
|
||||
<%= t("transactions.transaction.split") %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% if entry.split_child? %>
|
||||
<% if entry.split_child? && !in_split_group %>
|
||||
<span class="text-secondary" title="<%= t("transactions.transaction.split_child_tooltip") %>">
|
||||
<%= icon "corner-down-right", size: "sm", color: "current" %>
|
||||
</span>
|
||||
|
||||
@@ -47,31 +47,33 @@
|
||||
<%= render "entries/protection_indicator", entry: @entry, unlock_path: unlock_transaction_path(@entry.transaction) %>
|
||||
<% dialog.with_section(title: t(".overview"), open: true) do %>
|
||||
<div>
|
||||
<% split_locked = @entry.split_child? || @entry.split_parent? %>
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "space-y-2",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.text_field :name,
|
||||
label: t(".name_label"),
|
||||
disabled: @entry.split_child?,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= f.date_field :date,
|
||||
label: t(".date_label"),
|
||||
max: Date.current,
|
||||
disabled: @entry.linked?,
|
||||
disabled: @entry.linked? || split_locked,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% unless @entry.transaction.transfer? %>
|
||||
<div class="flex items-center gap-2">
|
||||
<%= f.select :nature,
|
||||
[["Expense", "outflow"], ["Income", "inflow"]],
|
||||
{ container_class: "w-1/3", label: t(".nature"), selected: @entry.amount.negative? ? "inflow" : "outflow" },
|
||||
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? } %>
|
||||
{ data: { "auto-submit-form-target": "auto" }, disabled: @entry.linked? || split_locked } %>
|
||||
<%= f.money_field :amount, label: t(".amount"),
|
||||
container_class: "w-2/3",
|
||||
auto_submit: true,
|
||||
min: 0,
|
||||
value: @entry.amount.abs,
|
||||
disabled: @entry.linked?,
|
||||
disable_currency: @entry.linked? %>
|
||||
disabled: @entry.linked? || split_locked,
|
||||
disable_currency: @entry.linked? || split_locked %>
|
||||
</div>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<%= ef.collection_select :category_id,
|
||||
@@ -79,7 +81,7 @@
|
||||
:id, :name,
|
||||
{ label: t(".category_label"),
|
||||
class: "text-subdued", include_blank: t(".uncategorized"),
|
||||
variant: :badge, searchable: true },
|
||||
variant: :badge, searchable: true, disabled: @entry.split_child? },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -105,14 +107,15 @@
|
||||
:id, :name,
|
||||
{ include_blank: t(".none"),
|
||||
label: t(".merchant_label"),
|
||||
class: "text-subdued", variant: :logo, searchable: true },
|
||||
class: "text-subdued", variant: :logo, searchable: true, disabled: @entry.split_child? },
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<%= ef.select :tag_ids,
|
||||
Current.family.tags.alphabetically.pluck(:name, :id),
|
||||
{
|
||||
include_blank: t(".none"),
|
||||
multiple: true,
|
||||
label: t(".tags_label")
|
||||
label: t(".tags_label"),
|
||||
disabled: @entry.split_child?
|
||||
},
|
||||
{ "data-controller": "multi-select", "data-auto-submit-form-target": "auto" } %>
|
||||
<% end %>
|
||||
@@ -121,6 +124,7 @@
|
||||
label: t(".note_label"),
|
||||
placeholder: t(".note_placeholder"),
|
||||
rows: 5,
|
||||
disabled: @entry.split_child?,
|
||||
"data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
@@ -260,7 +264,7 @@
|
||||
<% end %>
|
||||
|
||||
<% dialog.with_section(title: t(".settings")) do %>
|
||||
<% unless @entry.split_parent? %>
|
||||
<% unless @entry.split_parent? || @entry.split_child? %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
@@ -301,40 +305,44 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
|
||||
<p class="text-secondary"><%= t(".one_time_description") %></p>
|
||||
<% unless @entry.split_child? %>
|
||||
<div class="pb-4">
|
||||
<%= styled_form_with model: @entry,
|
||||
url: transaction_path(@entry),
|
||||
class: "p-3",
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
<%= f.fields_for :entryable do |ef| %>
|
||||
<div class="flex cursor-pointer items-center gap-4 justify-between">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".one_time_title", type: @entry.amount.negative? ? t("transactions.form.income") : t("transactions.form.expense")) %></h4>
|
||||
<p class="text-secondary"><%= t(".one_time_description") %></p>
|
||||
</div>
|
||||
<%= ef.toggle :kind, {
|
||||
checked: @entry.transaction.one_time?,
|
||||
data: { auto_submit_form_target: "auto" }
|
||||
}, "one_time", "standard" %>
|
||||
</div>
|
||||
<%= ef.toggle :kind, {
|
||||
checked: @entry.transaction.one_time?,
|
||||
data: { auto_submit_form_target: "auto" }
|
||||
}, "one_time", "standard" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%# Split Transaction %>
|
||||
<% if @entry.transaction.splittable? %>
|
||||
<div class="flex items-center justify-between gap-4 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t("splits.show.button_title") %></h4>
|
||||
<p class="text-secondary"><%= t("splits.show.button_description") %></p>
|
||||
</div>
|
||||
<%= render DS::Link.new(
|
||||
text: t("splits.show.button"),
|
||||
icon: "split",
|
||||
variant: "outline",
|
||||
href: new_transaction_split_path(@entry),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%# Split Transaction %>
|
||||
<% if @entry.transaction.splittable? %>
|
||||
<div class="flex items-center justify-between gap-4 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t("splits.show.button_title") %></h4>
|
||||
<p class="text-secondary"><%= t("splits.show.button_description") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("splits.show.button"),
|
||||
icon: "split",
|
||||
variant: "outline",
|
||||
href: new_transaction_split_path(@entry),
|
||||
frame: :modal
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% unless @entry.split_child? %>
|
||||
<div class="flex items-center justify-between gap-4 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary">Transfer or Debt Payment?</h4>
|
||||
@@ -396,23 +404,24 @@
|
||||
frame: "_top"
|
||||
) %>
|
||||
</div>
|
||||
<%# Delete Transaction Form - hidden for split children %>
|
||||
<% unless @entry.split_child? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<%= render DS::Button.new(
|
||||
text: t(".delete"),
|
||||
variant: "outline-destructive",
|
||||
href: entry_path(@entry),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion("transaction"),
|
||||
frame: "_top"
|
||||
) %>
|
||||
<% end %>
|
||||
<%# Delete Transaction Form - hidden for split children %>
|
||||
<% unless @entry.split_child? %>
|
||||
<div class="flex items-center justify-between gap-2 p-3">
|
||||
<div class="text-sm space-y-1">
|
||||
<h4 class="text-primary"><%= t(".delete_title") %></h4>
|
||||
<p class="text-secondary"><%= t(".delete_subtitle") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render DS::Button.new(
|
||||
text: t(".delete"),
|
||||
variant: "outline-destructive",
|
||||
href: entry_path(@entry),
|
||||
method: :delete,
|
||||
confirm: CustomConfirm.for_resource_deletion("transaction"),
|
||||
frame: "_top"
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -26,6 +26,18 @@ en:
|
||||
page_title: Payments
|
||||
subscription_subtitle: Update your credit card details
|
||||
subscription_title: Manage contributions
|
||||
appearances:
|
||||
show:
|
||||
page_title: Appearance
|
||||
theme_title: Theme
|
||||
theme_subtitle: Choose a preferred theme for the app
|
||||
theme_dark: Dark
|
||||
theme_light: Light
|
||||
theme_system: System
|
||||
transactions_title: Transactions
|
||||
transactions_subtitle: Customize how transactions are displayed
|
||||
split_grouped_title: Group split transactions
|
||||
split_grouped_description: Show split transactions grouped under their parent in the transaction list. When off, split children appear as individual rows.
|
||||
preferences:
|
||||
show:
|
||||
country: Country
|
||||
@@ -38,11 +50,6 @@ en:
|
||||
language: Language
|
||||
language_auto: Browser language
|
||||
page_title: Preferences
|
||||
theme_dark: Dark
|
||||
theme_light: Light
|
||||
theme_subtitle: Choose a preferred theme for the app
|
||||
theme_system: System
|
||||
theme_title: Theme
|
||||
timezone: Timezone
|
||||
month_start_day: Budget month starts on
|
||||
month_start_day_hint: Set when your budget month starts (e.g., payday)
|
||||
@@ -142,6 +149,7 @@ en:
|
||||
transactions_section_title: Transactions
|
||||
whats_new_label: What's new
|
||||
api_keys_label: API Key
|
||||
appearance_label: Appearance
|
||||
bank_sync_label: Bank Sync
|
||||
settings_nav_link_large:
|
||||
next: Next
|
||||
|
||||
@@ -81,6 +81,8 @@ en:
|
||||
potential_duplicate_description: This pending transaction may be the same as the posted transaction below. If so, merge them to avoid double-counting.
|
||||
merge_duplicate: Yes, merge them
|
||||
keep_both: No, keep both
|
||||
split_parent_row:
|
||||
split_label: "Split"
|
||||
transaction:
|
||||
pending: Pending
|
||||
pending_tooltip: Pending transaction — may change when posted
|
||||
|
||||
@@ -167,6 +167,7 @@ Rails.application.routes.draw do
|
||||
namespace :settings do
|
||||
resource :profile, only: [ :show, :destroy ]
|
||||
resource :preferences, only: :show
|
||||
resource :appearance, only: %i[show update]
|
||||
resource :hosting, only: %i[show update] do
|
||||
delete :clear_cache, on: :collection
|
||||
delete :disconnect_external_assistant, on: :collection
|
||||
|
||||
Reference in New Issue
Block a user