Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions app/controllers/splits_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
class SplitsController < ApplicationController
before_action :set_entry

def new
@categories = Current.family.categories.alphabetically
end

def create
unless @entry.transaction.splittable?
redirect_back_or_to transactions_path, alert: "This transaction cannot be split."
return
end

raw_splits = split_params[:splits]
raw_splits = raw_splits.values if raw_splits.respond_to?(:values)

splits = raw_splits.map do |s|
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] }
end

@entry.split!(splits)
@entry.sync_account_later

redirect_back_or_to transactions_path, notice: "Transaction successfully split."
rescue ActiveRecord::RecordInvalid => e
redirect_back_or_to transactions_path, alert: e.message
end

def edit
resolve_to_parent!

unless @entry.split_parent?
redirect_to transactions_path, alert: "This transaction is not split."
return
end

@categories = Current.family.categories.alphabetically
@children = @entry.child_entries.includes(:entryable)
end

def update
resolve_to_parent!

unless @entry.split_parent?
redirect_to transactions_path, alert: "This transaction is not split."
return
end

raw_splits = split_params[:splits]
raw_splits = raw_splits.values if raw_splits.respond_to?(:values)

splits = raw_splits.map do |s|
{ name: s[:name], amount: s[:amount].to_d * -1, category_id: s[:category_id].presence, excluded: s[:excluded] }
end

Entry.transaction do
@entry.unsplit!
@entry.split!(splits)
end

@entry.sync_account_later

redirect_to transactions_path, notice: "Split transaction updated."
rescue ActiveRecord::RecordInvalid => e
redirect_to transactions_path, alert: e.message
end

def destroy
resolve_to_parent!

unless @entry.split_parent?
redirect_to transactions_path, alert: "This transaction is not split."
return
end

@entry.unsplit!
@entry.sync_account_later

redirect_to transactions_path, notice: "Transaction unsplit."
end

private

def set_entry
@entry = Current.family.entries.find(params[:transaction_id])
end

def resolve_to_parent!
@entry = @entry.parent_entry if @entry.split_child?
end

def split_params
params.require(:split).permit(splits: [ :name, :amount, :category_id, :excluded ])
end
end
24 changes: 24 additions & 0 deletions app/controllers/transactions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,30 @@ def index
.references(:entries, :accounts) # Force join for better performance

@pagy, @transactions = pagy(:offset, base_scope, limit: per_page)

# Preload split parent data
entry_ids = @transactions.map { |t| t.entry.id }

# Load split parent entries for grouped display
@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
end

def clear_filter
Expand Down
24 changes: 24 additions & 0 deletions app/helpers/entries_helper.rb
Original file line number Diff line number Diff line change
@@ -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
Expand Down
134 changes: 134 additions & 0 deletions app/javascript/controllers/split_transaction_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { Controller } from "@hotwired/stimulus";

export default class extends Controller {
static targets = [
"rowsContainer",
"row",
"amountInput",
"remaining",
"remainingContainer",
"error",
"submitButton",
"nameInput",
];
static values = { total: Number, currency: String };

connect() {
this.updateRemaining();
}

get rowCount() {
return this.rowTargets.length;
}

addRow() {
const index = this.rowCount;
const container = this.rowsContainerTarget;

const row = document.createElement("div");
row.classList.add("p-3", "rounded-lg", "border", "border-secondary", "bg-container");
row.dataset.splitTransactionTarget = "row";

// Clone category select from the first row
const existingCategorySelect = container.querySelector(".category-select-container");
let categorySelectHTML = "";
if (existingCategorySelect) {
const cloned = existingCategorySelect.cloneNode(true);

// Update select element name and value
const select = cloned.querySelector("select");
if (select) {
select.name = `split[splits][${index}][category_id]`;
select.value = "";
}

categorySelectHTML = cloned.outerHTML;
}

row.innerHTML = `
<div class="flex flex-wrap md:flex-nowrap items-end gap-2">
<div class="w-full md:flex-1 md:w-auto min-w-0">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1">Name</label>
<input type="text"
name="split[splits][${index}][name]"
placeholder="Split name"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
data-split-transaction-target="nameInput">
</div>
<div class="flex-1 md:flex-none md:w-28">
<label class="text-xs font-medium text-secondary uppercase tracking-wide block mb-1">Amount</label>
<input type="number"
name="split[splits][${index}][amount]"
placeholder="0.00"
step="0.01"
class="form-field__input border border-secondary rounded-md px-2.5 py-1.5 w-full text-sm text-primary bg-container"
required
autocomplete="off"
data-split-transaction-target="amountInput"
data-action="input->split-transaction#updateRemaining">
</div>
${categorySelectHTML}
<button type="button"
class="w-8 h-8 shrink-0 flex items-center justify-center rounded-md text-secondary hover:text-primary hover:bg-surface-hover transition-colors"
data-action="click->split-transaction#removeRow">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
`;

container.appendChild(row);
this.updateRemaining();
}

removeRow(event) {
event.stopPropagation();
const row = event.target.closest("[data-split-transaction-target='row']");
if (row && this.rowCount > 1) {
row.remove();
this.reindexRows();
this.updateRemaining();
}
}

reindexRows() {
this.rowTargets.forEach((row, index) => {
// Update input and select names
row.querySelectorAll("[name]").forEach((input) => {
input.name = input.name.replace(/splits\[\d+\]/, `splits[${index}]`);
});
});
}

updateRemaining() {
const total = this.totalValue;
const sum = this.amountInputTargets.reduce((acc, input) => {
return acc + (Number.parseFloat(input.value) || 0);
}, 0);

const remaining = total - sum;
const absRemaining = Math.abs(remaining);
const balanced = absRemaining < 0.005;

this.remainingTarget.textContent = balanced ? "0.00" : remaining.toFixed(2);

// Visual feedback on remaining balance
const container = this.remainingContainerTarget;

if (balanced) {
this.remainingTarget.classList.remove("text-destructive");
this.remainingTarget.classList.add("text-success");
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-tint-10");
container.classList.add("border-destructive", "bg-red-tint-10");
}

this.errorTarget.classList.toggle("hidden", balanced);
this.submitButtonTarget.disabled = !balanced;
}
}
2 changes: 1 addition & 1 deletion app/models/balance/sync_cache.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ def get_entries(date)

def converted_entries
@converted_entries ||= begin
scope = account.entries.order(:date)
scope = account.entries.excluding_split_parents.order(:date)
scope = scope.where("date >= ?", min_date) if min_date
scope = scope.where("date <= ?", max_date) if max_date

Expand Down
Loading
Loading