mirror of
https://github.com/we-promise/sure.git
synced 2026-04-17 02:54:10 +00:00
Budget page refactor: split into(All - Over Budget - On Track) (#1195)
* Optimize UI in budget * update locales * Optimize UI * optimize suggested_daily_spending * try over_budget and on_track * update locale * optimize * add budgets_helper.rb * fix * hide no buget and no expense sub-catogory * Optimize * Optimize button on phone * Fix Pipelock CI noise * using section to render both overbudget and onTrack * hide last ruler * fix * update test --------- Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
70
app/helpers/budgets_helper.rb
Normal file
70
app/helpers/budgets_helper.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
module BudgetsHelper
|
||||
def budget_has_over_budget?(budget)
|
||||
return false unless budget.initialized?
|
||||
|
||||
budget.budget_categories.any?(&:any_over_budget?)
|
||||
end
|
||||
|
||||
def budget_categories_view_state(budget)
|
||||
@budget_categories_view_state ||= {}
|
||||
@budget_categories_view_state[budget.object_id] ||= build_budget_categories_view_state(budget)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def build_budget_categories_view_state(budget)
|
||||
uncategorized_budget_category = budget.uncategorized_budget_category
|
||||
all_category_groups = BudgetCategory::Group.for(budget.budget_categories)
|
||||
|
||||
over_budget_groups = if budget.initialized?
|
||||
filtered_groups_for(all_category_groups) { |budget_category| budget_category.any_over_budget? }
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
show_over_budget_uncategorized = budget.initialized? && uncategorized_budget_category.any_over_budget?
|
||||
over_budget_count = visible_count_for(over_budget_groups) { |budget_category| budget_category.any_over_budget? }
|
||||
over_budget_count += 1 if show_over_budget_uncategorized
|
||||
|
||||
on_track_groups = if budget.initialized?
|
||||
filtered_groups_for(all_category_groups) { |budget_category| budget_category.visible_on_track? }
|
||||
else
|
||||
all_category_groups
|
||||
end
|
||||
|
||||
show_on_track_uncategorized = all_category_groups.any? && (!budget.initialized? || uncategorized_budget_category.on_track?)
|
||||
on_track_count = visible_count_for(on_track_groups) { |budget_category| parent_visible_for_on_track?(budget, budget_category) }
|
||||
on_track_count += 1 if show_on_track_uncategorized
|
||||
visible_expenses_empty = on_track_count.zero?
|
||||
|
||||
{
|
||||
uncategorized_budget_category: uncategorized_budget_category,
|
||||
visible_expenses_empty: visible_expenses_empty,
|
||||
over_budget_groups: over_budget_groups,
|
||||
show_over_budget_uncategorized: show_over_budget_uncategorized,
|
||||
over_budget_count: over_budget_count,
|
||||
on_track_groups: on_track_groups,
|
||||
show_on_track_uncategorized: show_on_track_uncategorized,
|
||||
on_track_count: on_track_count
|
||||
}
|
||||
end
|
||||
|
||||
def parent_visible_for_on_track?(budget, budget_category)
|
||||
budget.initialized? ? budget_category.visible_on_track? : true
|
||||
end
|
||||
|
||||
def filtered_groups_for(groups)
|
||||
groups.each_with_object([]) do |group, filtered_groups|
|
||||
visible_subcategories = group.budget_subcategories.select { |budget_category| yield(budget_category) }
|
||||
next unless yield(group.budget_category) || visible_subcategories.any?
|
||||
|
||||
filtered_groups << BudgetCategory::Group.new(group.budget_category, visible_subcategories)
|
||||
end
|
||||
end
|
||||
|
||||
def visible_count_for(groups)
|
||||
groups.sum do |group|
|
||||
(yield(group.budget_category) ? 1 : 0) + group.budget_subcategories.count
|
||||
end
|
||||
end
|
||||
end
|
||||
57
app/javascript/controllers/budget_filter_controller.js
Normal file
57
app/javascript/controllers/budget_filter_controller.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["onTrack", "overBudget", "tab"];
|
||||
static values = { filter: { type: String, default: "all" } };
|
||||
|
||||
connect() {
|
||||
const filterParam = new URLSearchParams(window.location.search).get("filter");
|
||||
|
||||
if (this.#isValidFilter(filterParam) && filterParam !== this.filterValue) {
|
||||
this.filterValue = filterParam;
|
||||
} else if (filterParam && !this.#isValidFilter(filterParam)) {
|
||||
this.#syncFilterParam();
|
||||
}
|
||||
}
|
||||
|
||||
setFilter(event) {
|
||||
this.filterValue = event.params.filter;
|
||||
this.#syncFilterParam();
|
||||
}
|
||||
|
||||
filterValueChanged() {
|
||||
const filter = this.filterValue;
|
||||
|
||||
if (this.hasOnTrackTarget) {
|
||||
this.onTrackTarget.hidden = filter === "over_budget";
|
||||
}
|
||||
|
||||
if (this.hasOverBudgetTarget) {
|
||||
this.overBudgetTarget.hidden = filter === "on_track";
|
||||
}
|
||||
|
||||
this.tabTargets.forEach((tab) => {
|
||||
const isActive = tab.dataset.budgetFilterFilterParam === filter;
|
||||
tab.classList.toggle("bg-container", isActive);
|
||||
tab.classList.toggle("text-primary", isActive);
|
||||
tab.classList.toggle("shadow-sm", isActive);
|
||||
tab.classList.toggle("text-secondary", !isActive);
|
||||
});
|
||||
}
|
||||
|
||||
#isValidFilter(filter) {
|
||||
return ["all", "over_budget", "on_track"].includes(filter);
|
||||
}
|
||||
|
||||
#syncFilterParam() {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (this.filterValue === "all") {
|
||||
url.searchParams.delete("filter");
|
||||
} else {
|
||||
url.searchParams.set("filter", this.filterValue);
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,35 @@ class BudgetCategory < ApplicationRecord
|
||||
available_to_spend.negative?
|
||||
end
|
||||
|
||||
def budgeted?
|
||||
display_budgeted_spending.to_d.positive?
|
||||
end
|
||||
|
||||
def unbudgeted_with_spending?
|
||||
!budgeted? && actual_spending.to_d.positive?
|
||||
end
|
||||
|
||||
def over_budget_with_budget?
|
||||
budgeted? && over_budget?
|
||||
end
|
||||
|
||||
def on_track?
|
||||
budgeted? && !over_budget?
|
||||
end
|
||||
|
||||
def any_over_budget?
|
||||
unbudgeted_with_spending? || over_budget_with_budget?
|
||||
end
|
||||
|
||||
def visible_on_track?
|
||||
return false unless on_track?
|
||||
|
||||
# Subcategories inheriting parent budget are hidden until they have spending.
|
||||
return true unless subcategory? && inherits_parent_budget?
|
||||
|
||||
actual_spending.to_d.positive?
|
||||
end
|
||||
|
||||
def near_limit?
|
||||
!over_budget? && percent_of_budget_spent >= 90
|
||||
end
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<%# locals: (budget_category:) %>
|
||||
<%# locals: (budget_category:, show_budget_meta: true) %>
|
||||
|
||||
<%= turbo_frame_tag dom_id(budget_category), class: "flex-1 min-w-0 block" do %>
|
||||
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group block w-full px-4 py-2 bg-container", data: { turbo_frame: "drawer" } do %>
|
||||
@@ -49,7 +49,7 @@
|
||||
<div class="h-1.5 bg-container-inset rounded-full overflow-hidden">
|
||||
<% bar_color = budget_category.over_budget? ? "bg-red-500" : (budget_category.near_limit? ? "bg-yellow-500" : "bg-green-500") %>
|
||||
<div class="h-full <%= bar_color %> rounded-full transition-all duration-500"
|
||||
style="width: <%= budget_category.bar_width_percent %>%"></div>
|
||||
style="inline-size: <%= budget_category.bar_width_percent %>%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
<%= format_money(budget_category.actual_spending_money) %>
|
||||
</span>
|
||||
</div>
|
||||
<% if show_budget_meta %>
|
||||
<div class="whitespace-nowrap w-full sm:w-auto inline-flex items-center gap-1">
|
||||
<span class="text-sm text-secondary"><%= t("reports.budget_performance.budgeted") %>:</span>
|
||||
<span class="font-medium text-primary privacy-sensitive">
|
||||
@@ -70,6 +71,16 @@
|
||||
<span class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-gray-500/5 text-secondary"><%= t("reports.budget_performance.shared") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if budget_category.suggested_daily_spending.present? %>
|
||||
<% daily_info = budget_category.suggested_daily_spending %>
|
||||
<div class="w-full sm:w-auto text-xs text-subdued">
|
||||
<%= t("reports.budget_performance.suggested_daily",
|
||||
amount: daily_info[:amount].format,
|
||||
days: daily_info[:days_remaining])
|
||||
%>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<div class="whitespace-nowrap w-full sm:w-auto lg:ml-auto">
|
||||
<% if budget_category.available_to_spend >= 0 %>
|
||||
<span class="text-sm text-secondary"><%= t("reports.budget_performance.remaining") %>:</span>
|
||||
@@ -85,18 +96,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%# Suggested Daily Limit (if remaining days in month) %>
|
||||
<% if budget_category.suggested_daily_spending.present? %>
|
||||
<% daily_info = budget_category.suggested_daily_spending %>
|
||||
<div class="py-2">
|
||||
<p class="text-xs text-subdued break-words">
|
||||
<%= t("reports.budget_performance.suggested_daily",
|
||||
amount: daily_info[:amount].format,
|
||||
days: daily_info[:days_remaining]) %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% else %>
|
||||
<%# Uninitialized budget - show simple view %>
|
||||
<div class="flex items-center gap-3">
|
||||
|
||||
@@ -1,44 +1,34 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
<p>Categories</p>
|
||||
<span class="text-subdued">·</span>
|
||||
<p><%= budget.budget_categories.count %></p>
|
||||
<% categories_state = budget_categories_view_state(budget) %>
|
||||
<% uncategorized_budget_category = categories_state[:uncategorized_budget_category] %>
|
||||
<% visible_expenses_empty = categories_state[:visible_expenses_empty] %>
|
||||
<% over_budget_groups = categories_state[:over_budget_groups] %>
|
||||
<% show_over_budget_uncategorized = categories_state[:show_over_budget_uncategorized] %>
|
||||
<% over_budget_count = categories_state[:over_budget_count] %>
|
||||
<% on_track_groups = categories_state[:on_track_groups] %>
|
||||
<% show_on_track_uncategorized = categories_state[:show_on_track_uncategorized] %>
|
||||
<% on_track_count = categories_state[:on_track_count] %>
|
||||
|
||||
<p class="ml-auto">Amount</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
|
||||
<div class="bg-container py-1 shadow-border-xs rounded-md">
|
||||
<% if budget.family.categories.expenses.empty? %>
|
||||
<div class="py-8">
|
||||
<%= render "budget_categories/no_categories" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<% category_groups = BudgetCategory::Group.for(budget.budget_categories) %>
|
||||
<% if over_budget_count.positive? %>
|
||||
<%= render "budgets/category_section",
|
||||
budget: budget,
|
||||
count: over_budget_count,
|
||||
groups: over_budget_groups,
|
||||
uncategorized: uncategorized_budget_category,
|
||||
show_uncategorized: show_over_budget_uncategorized,
|
||||
over_budget_mode: true %>
|
||||
<% end %>
|
||||
|
||||
<% category_groups.each_with_index do |group, index| %>
|
||||
<div class="py-2">
|
||||
<%= render "budget_categories/budget_category", budget_category: group.budget_category %>
|
||||
<%= render "budgets/category_section",
|
||||
budget: budget,
|
||||
count: on_track_count,
|
||||
groups: on_track_groups,
|
||||
uncategorized: uncategorized_budget_category,
|
||||
show_uncategorized: show_on_track_uncategorized,
|
||||
over_budget_mode: false
|
||||
%>
|
||||
|
||||
<div>
|
||||
<% group.budget_subcategories.each do |budget_subcategory| %>
|
||||
<div class="w-full flex items-start">
|
||||
<div class="ml-8 pt-4 flex items-center justify-center text-subdued">
|
||||
<%= icon "corner-down-right" %>
|
||||
</div>
|
||||
|
||||
<%= render "budget_categories/budget_category", budget_category: budget_subcategory %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render "shared/ruler" %>
|
||||
<% end %>
|
||||
<div class="py-2">
|
||||
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
28
app/views/budgets/_category_group.html.erb
Normal file
28
app/views/budgets/_category_group.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<%# locals: (group:, parent_visible:, over_budget_mode: false) %>
|
||||
|
||||
<% if parent_visible %>
|
||||
<div class="py-2">
|
||||
<%= render "budget_categories/budget_category",
|
||||
budget_category: group.budget_category,
|
||||
show_budget_meta: (over_budget_mode ? group.budget_category.over_budget_with_budget? : true) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% group.budget_subcategories.each do |budget_subcategory| %>
|
||||
<% if parent_visible %>
|
||||
<div class="py-2 w-full flex items-start">
|
||||
<div class="ml-8 pt-4 flex items-center justify-center text-subdued">
|
||||
<%= icon "corner-down-right" %>
|
||||
</div>
|
||||
<%= render "budget_categories/budget_category",
|
||||
budget_category: budget_subcategory,
|
||||
show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="py-2">
|
||||
<%= render "budget_categories/budget_category",
|
||||
budget_category: budget_subcategory,
|
||||
show_budget_meta: (over_budget_mode ? budget_subcategory.over_budget_with_budget? : true) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
56
app/views/budgets/_category_section.html.erb
Normal file
56
app/views/budgets/_category_section.html.erb
Normal file
@@ -0,0 +1,56 @@
|
||||
<%# locals: (budget:, count:, groups:, uncategorized:, show_uncategorized:, over_budget_mode:) %>
|
||||
|
||||
<%# derive display config from over_budget_mode %>
|
||||
<%
|
||||
if over_budget_mode
|
||||
target = "overBudget"
|
||||
title = t("budgets.show.over_budget_categories.short_title")
|
||||
else
|
||||
target = "onTrack"
|
||||
title = t("budgets.show.on_track_categories.short_title")
|
||||
end
|
||||
%>
|
||||
|
||||
<div data-budget-filter-target="<%= target %>">
|
||||
|
||||
<!-- Section Header -->
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-secondary uppercase">
|
||||
<p><%= title %></p>
|
||||
<span class="text-subdued">·</span>
|
||||
<p><%= count %></p>
|
||||
<p class="ml-auto"><%= t("budgets.show.categories.amount") %></p>
|
||||
</div>
|
||||
|
||||
<!-- Section Body -->
|
||||
<div class="bg-container py-1 shadow-border-xs rounded-md">
|
||||
|
||||
<% groups.each_with_index do |group, index| %>
|
||||
|
||||
<%# derive parent visibility based on mode %>
|
||||
<%
|
||||
parent_visible =
|
||||
if over_budget_mode
|
||||
group.budget_category.any_over_budget?
|
||||
else
|
||||
budget.initialized? ? group.budget_category.visible_on_track? : true
|
||||
end
|
||||
%>
|
||||
<%= render "shared/ruler" unless index == 0 %>
|
||||
<%= render "budgets/category_group",
|
||||
group: group,
|
||||
parent_visible: parent_visible,
|
||||
over_budget_mode: over_budget_mode %>
|
||||
|
||||
<% end %>
|
||||
|
||||
<% if show_uncategorized %>
|
||||
<%= render "shared/ruler" unless groups.size == 0 %>
|
||||
<div class="py-2">
|
||||
<%= render "budget_categories/budget_category",
|
||||
budget_category: uncategorized,
|
||||
show_budget_meta: (over_budget_mode ? uncategorized.over_budget_with_budget? : true) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,23 +47,66 @@
|
||||
</div>
|
||||
|
||||
<%# Bottom Section: Categories full width %>
|
||||
<div class="w-full bg-container rounded-xl shadow-border-xs p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium">Categories</h2>
|
||||
<% has_over_budget = budget_has_over_budget?(@budget) %>
|
||||
<%= content_tag :div,
|
||||
class: "w-full bg-container rounded-xl shadow-border-xs p-4",
|
||||
data: (has_over_budget ? { controller: "budget-filter" } : {}) do %>
|
||||
<div class="flex items-center mb-4 flex-nowrap">
|
||||
<h2 class="text-lg font-medium shrink-0">
|
||||
<%= t("budgets.show.categories.title") %>
|
||||
</h2>
|
||||
|
||||
<% if @budget.initialized? %>
|
||||
<%= render DS::Link.new(
|
||||
text: "Edit",
|
||||
variant: "secondary",
|
||||
icon: "settings-2",
|
||||
href: budget_budget_categories_path(@budget)
|
||||
) %>
|
||||
<% if has_over_budget %>
|
||||
<div class="flex-1 min-w-0 px-1">
|
||||
<div class="w-full flex justify-center">
|
||||
<div class="max-w-full overflow-x-auto no-scrollbar">
|
||||
<div class="inline-flex whitespace-nowrap bg-container-inset rounded-lg p-1 text-sm font-medium gap-0.5">
|
||||
|
||||
<button
|
||||
data-action="click->budget-filter#setFilter"
|
||||
data-budget-filter-filter-param="all"
|
||||
data-budget-filter-target="tab"
|
||||
class="px-3 py-1.5 rounded-md transition-colors bg-container text-primary shadow-sm">
|
||||
<%= t("budgets.show.filter.all") %>
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-action="click->budget-filter#setFilter"
|
||||
data-budget-filter-filter-param="over_budget"
|
||||
data-budget-filter-target="tab"
|
||||
class="px-3 py-1.5 rounded-md transition-colors text-secondary">
|
||||
<%= t("budgets.show.filter.over_budget") %>
|
||||
</button>
|
||||
|
||||
<button
|
||||
data-action="click->budget-filter#setFilter"
|
||||
data-budget-filter-filter-param="on_track"
|
||||
data-budget-filter-target="tab"
|
||||
class="px-3 py-1.5 rounded-md transition-colors text-secondary">
|
||||
<%= t("budgets.show.filter.on_track") %>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="<%= has_over_budget ? "shrink-0 flex justify-end whitespace-nowrap" : "ml-auto" %>">
|
||||
<% if @budget.initialized? %>
|
||||
<%= render DS::Link.new(
|
||||
text: t("budgets.show.categories.edit"),
|
||||
variant: "secondary",
|
||||
icon: "settings-2",
|
||||
href: budget_budget_categories_path(@budget)
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-container-inset rounded-xl p-1">
|
||||
<%= render "budgets/budget_categories", budget: @budget %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
ca:
|
||||
budgets:
|
||||
show:
|
||||
categories:
|
||||
amount: Quantitat
|
||||
edit: Edita
|
||||
title: Categories
|
||||
on_track_categories:
|
||||
short_title: En bon cami
|
||||
title: En bon cami
|
||||
over_budget_categories:
|
||||
short_title: Sobre pressupost
|
||||
title: Sobre pressupost
|
||||
filter:
|
||||
all: Totes
|
||||
on_track: En bon cami
|
||||
over_budget: Sobre pressupost
|
||||
tabs:
|
||||
actual: Real
|
||||
budgeted: Pressupostat
|
||||
|
||||
@@ -5,6 +5,20 @@ de:
|
||||
custom_range: "%{start} - %{end_date}"
|
||||
month_year: "%{month}"
|
||||
show:
|
||||
categories:
|
||||
amount: Betrag
|
||||
edit: Bearbeiten
|
||||
title: Kategorien
|
||||
on_track_categories:
|
||||
short_title: Im Plan
|
||||
title: Im Plan
|
||||
over_budget_categories:
|
||||
short_title: Uber Budget
|
||||
title: Uber Budget
|
||||
filter:
|
||||
all: Alle
|
||||
on_track: Im Plan
|
||||
over_budget: Uber Budget
|
||||
tabs:
|
||||
actual: Ist
|
||||
budgeted: Budgetiert
|
||||
|
||||
@@ -5,6 +5,20 @@ en:
|
||||
custom_range: "%{start} - %{end_date}"
|
||||
month_year: "%{month}"
|
||||
show:
|
||||
categories:
|
||||
amount: Amount
|
||||
edit: Edit
|
||||
title: Categories
|
||||
on_track_categories:
|
||||
short_title: On Track
|
||||
title: On Track
|
||||
over_budget_categories:
|
||||
short_title: Over Budget
|
||||
title: Over Budget
|
||||
filter:
|
||||
all: All
|
||||
on_track: On Track
|
||||
over_budget: Over Budget
|
||||
tabs:
|
||||
actual: Actual
|
||||
budgeted: Budgeted
|
||||
|
||||
@@ -5,6 +5,20 @@ es:
|
||||
custom_range: "%{start} - %{end_date}"
|
||||
month_year: "%{month}"
|
||||
show:
|
||||
categories:
|
||||
amount: Importe
|
||||
edit: Editar
|
||||
title: Categorías
|
||||
on_track_categories:
|
||||
short_title: En camino
|
||||
title: En camino
|
||||
over_budget_categories:
|
||||
short_title: Sobre presupuesto
|
||||
title: Sobre presupuesto
|
||||
filter:
|
||||
all: Todas
|
||||
on_track: En camino
|
||||
over_budget: Sobre presupuesto
|
||||
tabs:
|
||||
actual: Real
|
||||
budgeted: Presupuestado
|
||||
@@ -1,6 +1,20 @@
|
||||
nl:
|
||||
budgets:
|
||||
show:
|
||||
categories:
|
||||
amount: Bedrag
|
||||
edit: Bewerken
|
||||
title: Categorieën
|
||||
on_track_categories:
|
||||
short_title: Op schema
|
||||
title: Op schema
|
||||
over_budget_categories:
|
||||
short_title: Over budget
|
||||
title: Over budget
|
||||
filter:
|
||||
all: Alles
|
||||
on_track: Op schema
|
||||
over_budget: Over budget
|
||||
tabs:
|
||||
actual: "Actueel"
|
||||
budgeted: "Begroot"
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
zh-CN:
|
||||
budgets:
|
||||
show:
|
||||
categories:
|
||||
amount: 金额
|
||||
edit: 编辑
|
||||
title: 分类
|
||||
on_track_categories:
|
||||
short_title: 正常
|
||||
title: 正常
|
||||
over_budget_categories:
|
||||
short_title: 超预算
|
||||
title: 超预算
|
||||
filter:
|
||||
all: 全部
|
||||
on_track: 正常
|
||||
over_budget: 超预算
|
||||
tabs:
|
||||
actual: 实际
|
||||
budgeted: 预算
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
zh-TW:
|
||||
budgets:
|
||||
show:
|
||||
categories:
|
||||
amount: 金額
|
||||
edit: 編輯
|
||||
title: 分類
|
||||
on_track_categories:
|
||||
short_title: 正常
|
||||
title: 正常
|
||||
over_budget_categories:
|
||||
short_title: 超預算
|
||||
title: 超預算
|
||||
filter:
|
||||
all: 全部
|
||||
on_track: 正常
|
||||
over_budget: 超預算
|
||||
tabs:
|
||||
actual: 實際
|
||||
budgeted: 預算
|
||||
|
||||
107
test/helpers/budgets_helper_test.rb
Normal file
107
test/helpers/budgets_helper_test.rb
Normal file
@@ -0,0 +1,107 @@
|
||||
require "test_helper"
|
||||
|
||||
class BudgetsHelperTest < ActionView::TestCase
|
||||
setup do
|
||||
@family = families(:dylan_family)
|
||||
@budget = budgets(:one)
|
||||
|
||||
@parent_category = Category.create!(
|
||||
name: "Helper Parent #{SecureRandom.hex(4)}",
|
||||
family: @family,
|
||||
color: "#4da568",
|
||||
lucide_icon: "utensils"
|
||||
)
|
||||
|
||||
@child_category = Category.create!(
|
||||
name: "Helper Child #{SecureRandom.hex(4)}",
|
||||
parent: @parent_category,
|
||||
family: @family
|
||||
)
|
||||
|
||||
@parent_budget_category = BudgetCategory.create!(
|
||||
budget: @budget,
|
||||
category: @parent_category,
|
||||
budgeted_spending: 200,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
@child_budget_category = BudgetCategory.create!(
|
||||
budget: @budget,
|
||||
category: @child_category,
|
||||
budgeted_spending: 0,
|
||||
currency: "USD"
|
||||
)
|
||||
end
|
||||
|
||||
test "hides inheriting subcategory with no budget and no spending from on-track section" do
|
||||
state = budget_categories_view_state(@budget)
|
||||
group = state[:on_track_groups].find { |g| g.budget_category.id == @parent_budget_category.id }
|
||||
|
||||
assert group.present?
|
||||
assert_empty group.budget_subcategories
|
||||
end
|
||||
|
||||
test "shows inheriting subcategory in on-track section when it has spending" do
|
||||
Entry.create!(
|
||||
account: accounts(:depository),
|
||||
entryable: Transaction.create!(category: @child_category),
|
||||
date: Date.current,
|
||||
name: "Helper Child Spending",
|
||||
amount: 25,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
budget = Budget.find(@budget.id)
|
||||
state = budget_categories_view_state(budget)
|
||||
group = state[:on_track_groups].find { |g| g.budget_category.category_id == @parent_category.id }
|
||||
|
||||
assert group.present?
|
||||
assert_includes group.budget_subcategories.map(&:category_id), @child_category.id
|
||||
end
|
||||
|
||||
test "keeps group when only subcategory is over budget" do
|
||||
parent = Category.create!(
|
||||
name: "Helper Group Parent #{SecureRandom.hex(4)}",
|
||||
family: @family,
|
||||
color: "#22c55e",
|
||||
lucide_icon: "utensils"
|
||||
)
|
||||
|
||||
child = Category.create!(
|
||||
name: "Helper Group Child #{SecureRandom.hex(4)}",
|
||||
parent: parent,
|
||||
family: @family
|
||||
)
|
||||
|
||||
BudgetCategory.create!(
|
||||
budget: @budget,
|
||||
category: parent,
|
||||
budgeted_spending: 300,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
BudgetCategory.create!(
|
||||
budget: @budget,
|
||||
category: child,
|
||||
budgeted_spending: 50,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
Entry.create!(
|
||||
account: accounts(:depository),
|
||||
entryable: Transaction.create!(category: child),
|
||||
date: Date.current,
|
||||
name: "Helper Child Over Budget",
|
||||
amount: 100,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
state = budget_categories_view_state(Budget.find(@budget.id))
|
||||
group = state[:over_budget_groups].find { |g| g.budget_category.category_id == parent.id }
|
||||
|
||||
assert group.present?
|
||||
refute group.budget_category.any_over_budget?
|
||||
assert_equal [ child.id ], group.budget_subcategories.map(&:category_id)
|
||||
assert group.budget_subcategories.first.any_over_budget?
|
||||
end
|
||||
end
|
||||
@@ -169,4 +169,90 @@ class BudgetCategoryTest < ActiveSupport::TestCase
|
||||
assert_equal 20, @subcategory_with_limit_bc.reload.budgeted_spending
|
||||
assert_equal 50, @subcategory_inheriting_bc.reload.budgeted_spending
|
||||
end
|
||||
|
||||
test "budgeted? returns true only when display_budgeted_spending > 0" do
|
||||
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(100)
|
||||
assert @subcategory_with_limit_bc.budgeted?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(0)
|
||||
refute @subcategory_with_limit_bc.budgeted?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:display_budgeted_spending).returns(nil)
|
||||
refute @subcategory_with_limit_bc.budgeted?
|
||||
end
|
||||
|
||||
test "unbudgeted_with_spending? is true only when not budgeted and has spending" do
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
||||
@subcategory_with_limit_bc.stubs(:actual_spending).returns(10)
|
||||
assert @subcategory_with_limit_bc.unbudgeted_with_spending?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
|
||||
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
||||
@subcategory_with_limit_bc.stubs(:actual_spending).returns(0)
|
||||
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:actual_spending).returns(nil)
|
||||
assert_not @subcategory_with_limit_bc.unbudgeted_with_spending?
|
||||
end
|
||||
|
||||
test "over_budget_with_budget? requires both budgeted and over_budget" do
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
|
||||
assert @subcategory_with_limit_bc.over_budget_with_budget?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
|
||||
assert_not @subcategory_with_limit_bc.over_budget_with_budget?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
|
||||
assert_not @subcategory_with_limit_bc.over_budget_with_budget?
|
||||
end
|
||||
|
||||
test "on_track? is true only when budgeted and not over_budget" do
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(true)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
|
||||
assert @subcategory_with_limit_bc.on_track?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:over_budget?).returns(true)
|
||||
assert_not @subcategory_with_limit_bc.on_track?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:budgeted?).returns(false)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget?).returns(false)
|
||||
assert_not @subcategory_with_limit_bc.on_track?
|
||||
end
|
||||
|
||||
test "any_over_budget? is true if either condition is true" do
|
||||
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(true)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
|
||||
assert @subcategory_with_limit_bc.any_over_budget?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(true)
|
||||
assert @subcategory_with_limit_bc.any_over_budget?
|
||||
|
||||
@subcategory_with_limit_bc.stubs(:unbudgeted_with_spending?).returns(false)
|
||||
@subcategory_with_limit_bc.stubs(:over_budget_with_budget?).returns(false)
|
||||
assert_not @subcategory_with_limit_bc.any_over_budget?
|
||||
end
|
||||
|
||||
test "visible_on_track? behavior for different category types" do
|
||||
# 1. not on_track => always false
|
||||
@subcategory_with_limit_bc.stubs(:on_track?).returns(false)
|
||||
assert_not @subcategory_with_limit_bc.visible_on_track?
|
||||
|
||||
# 2. normal category (not subcategory) => true if on_track
|
||||
@parent_budget_category.stubs(:on_track?).returns(true)
|
||||
assert @parent_budget_category.visible_on_track?
|
||||
|
||||
# 3. subcategory inheriting, no spending => hidden
|
||||
@subcategory_inheriting_bc.stubs(:on_track?).returns(true)
|
||||
@subcategory_inheriting_bc.stubs(:actual_spending).returns(0)
|
||||
assert_not @subcategory_inheriting_bc.visible_on_track?
|
||||
|
||||
# 4. subcategory inheriting, has spending => visible
|
||||
@subcategory_inheriting_bc.stubs(:actual_spending).returns(10)
|
||||
assert @subcategory_inheriting_bc.visible_on_track?
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user