1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-07 14:35:23 +02:00

Budgeting V1 (#1609)

* Budgeting V1

* Basic UI template

* Fully scaffolded budgeting v1

* Basic working budget

* Finalize donut chart for budgets

* Allow categorization of loan payments for budget

* Include loan payments in incomes_and_expenses scope

* Add budget allocations progress

* Empty states

* Clean up budget methods

* Category aggregation queries

* Handle overage scenarios in form

* Finalize budget donut chart controller

* Passing tests

* Fix allocation naming

* Add income category migration

* Native support for uncategorized budget category

* Formatting

* Fix subcategory sort order, padding

* Fix calculation for category rollups in budget
This commit is contained in:
Zach Gollwitzer 2025-01-16 14:36:37 -05:00 committed by GitHub
parent 413ec6cbed
commit 195ec85d96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
61 changed files with 2044 additions and 140 deletions

View file

@ -0,0 +1,26 @@
<%# locals: (budget:) %>
<div class="space-y-2 mb-6">
<div class="flex items-center gap-2">
<div class="rounded-full w-1.5 h-1.5 <%= budget.allocated_spending > 0 ? "bg-gray-900" : "bg-gray-100" %>"></div>
<p class="text-gray-500 text-sm">
<%= number_to_percentage(budget.allocated_percent, precision: 0) %> set
</p>
<p class="ml-auto text-sm space-x-1">
<span class="text-gray-900"><%= format_money(budget.allocated_spending_money) %></span>
<span class="text-gray-500"> / </span>
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
</p>
</div>
<div class="relative h-1.5 rounded-2xl bg-gray-100">
<div class="absolute inset-0 bg-gray-900 rounded-2xl" style="width: <%= budget.allocated_percent %>%;"></div>
</div>
<div class="text-sm">
<span class="text-gray-900"><%= format_money(budget.available_to_allocate_money) %></span>
<span class="text-gray-500">left to allocate</span>
</div>
</div>

View file

@ -0,0 +1,25 @@
<%# locals: (budget:) %>
<div class="space-y-2 mb-6">
<div class="flex items-center gap-2">
<div class="rounded-full w-1.5 h-1.5 bg-red-500"></div>
<p class="text-gray-900 text-sm">&gt; 100% set</p>
<p class="ml-auto text-sm space-x-1">
<span class="text-red-500"><%= format_money(budget.allocated_spending_money) %></span>
<span class="text-gray-500"> / </span>
<span class="text-gray-500"><%= format_money(budget.budgeted_spending_money) %></span>
</p>
</div>
<div class="relative h-1.5 rounded-2xl bg-gray-100">
<div class="absolute inset-0 bg-red-500 rounded-2xl" style="width: 100%;"></div>
</div>
<div class="text-sm">
<p class="text-gray-500">
Budget exceeded by <span class="text-red-500"><%= format_money(budget.available_to_allocate_money.abs) %></span>
</p>
</div>
</div>

View file

@ -0,0 +1,48 @@
<%# locals: (budget_category:) %>
<%= turbo_frame_tag dom_id(budget_category), class: "w-full" do %>
<%= link_to budget_budget_category_path(budget_category.budget, budget_category), class: "group w-full p-4 flex items-center gap-3 bg-white", data: { turbo_frame: "drawer" } do %>
<% if budget_category.initialized? %>
<div class="w-10 h-10 group-hover:scale-105 transition-all duration-300">
<%= render "budget_categories/budget_category_donut", budget_category: budget_category %>
</div>
<% else %>
<div class="w-8 h-8 group-hover:scale-105 transition-all duration-300 rounded-full flex justify-center items-center" style="<%= mixed_hex_styles(budget_category.category.color) %>">
<% if budget_category.category.lucide_icon %>
<%= icon(budget_category.category.lucide_icon) %>
<% else %>
<%= render "shared/circle_logo", name: budget_category.category.name, hex: budget_category.category.color %>
<% end %>
</div>
<% end %>
<div>
<p class="text-sm font-medium text-gray-900"><%= budget_category.category.name %></p>
<% if budget_category.initialized? %>
<% if budget_category.available_to_spend.negative? %>
<p class="text-sm font-medium text-red-500"><%= format_money(budget_category.available_to_spend_money.abs) %> over</p>
<% elsif budget_category.available_to_spend.zero? %>
<p class="text-sm font-medium <%= budget_category.budgeted_spending.positive? ? "text-orange-500" : "text-gray-500" %>">
<%= format_money(budget_category.available_to_spend_money) %> left
</p>
<% else %>
<p class="text-sm text-gray-500 font-medium"><%= format_money(budget_category.available_to_spend_money) %> left</p>
<% end %>
<% else %>
<p class="text-sm text-gray-500 font-medium">
<%= format_money(budget_category.category.avg_monthly_total) %> avg
</p>
<% end %>
</div>
<div class="ml-auto text-right">
<p class="text-sm font-medium text-gray-900"><%= format_money(budget_category.actual_spending_money) %></p>
<% if budget_category.initialized? %>
<p class="text-sm text-gray-500">from <%= format_money(budget_category.budgeted_spending_money) %></p>
<% end %>
</div>
<% end %>
<% end %>

View file

@ -0,0 +1,22 @@
<%# locals: (budget_category:) %>
<%= tag.div data: {
controller: "donut-chart",
donut_chart_segments_value: budget_category.to_donut_segments_json,
donut_chart_segment_height_value: 5,
donut_chart_segment_opacity_value: 0.2
}, class: "relative h-full" do %>
<div data-donut-chart-target="chartContainer" class="absolute inset-0 pointer-events-none"></div>
<div data-donut-chart-target="contentContainer" class="flex justify-center items-center h-full p-1">
<div data-donut-chart-target="defaultContent" class="h-full w-full rounded-full flex flex-col items-center justify-center" style="background-color: <%= hex_with_alpha(budget_category.category.color, 0.05) %>">
<% if budget_category.category.lucide_icon %>
<%= lucide_icon budget_category.category.lucide_icon, class: "w-4 h-4 shrink-0", style: "color: #{budget_category.category.color}" %>
<% else %>
<span class="text-sm uppercase" style="color: <%= budget_category.category.color %>">
<%= budget_category.category.name.first.upcase %>
</span>
<% end %>
</div>
</div>
<% end %>

View file

@ -0,0 +1,29 @@
<%# locals: (budget_category:) %>
<% currency = Money::Currency.new(budget_category.budget.currency) %>
<div class="w-full flex gap-3">
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
<div class="text-sm mr-3">
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
<p class="text-gray-500"><%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average</p>
</div>
<div class="ml-auto">
<%= form_with model: [budget_category.budget, budget_category], data: { controller: "auto-submit-form", auto_submit_form_trigger_event_value: "blur", turbo_frame: :_top } do |f| %>
<div class="form-field w-[120px]">
<div class="flex items-center">
<span class="text-gray-500 text-sm mr-2"><%= currency.symbol %></span>
<%= f.number_field :budgeted_spending,
class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none",
placeholder: "0",
step: currency.step,
min: 0,
data: { auto_submit_form_target: "auto" } %>
</div>
</div>
<% end %>
</div>
</div>

View file

@ -0,0 +1,17 @@
<div class="flex justify-center items-center">
<div class="text-center flex flex-col items-center max-w-[500px]">
<h2 class="text-lg text-gray-900 font-medium">Oops!</h2>
<p class="text-gray-500 text-sm max-w-sm mx-auto mb-4">
You have not created or assigned any expense categories to your transactions yet.
</p>
<div class="flex items-center gap-2">
<%= button_to "Use default categories", bootstrap_categories_path, class: "btn btn--primary" %>
<%= link_to new_category_path, class: "btn btn--outline flex items-center gap-1", data: { turbo_frame: "modal" } do %>
<%= lucide_icon("plus", class: "w-5 h-5") %>
<span>New category</span>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
<%# locals: (budget:) %>
<% budget_category = budget.uncategorized_budget_category %>
<div class="flex gap-3">
<div class="w-1 h-3 rounded-xl mt-1" style="background-color: <%= budget_category.category.color %>"></div>
<div class="text-sm mr-3">
<p class="text-gray-900 font-medium mb-0.5"><%= budget_category.category.name %></p>
<p class="text-gray-500"><%= format_money(budget_category.category.avg_monthly_total, precision: 0) %>/m average</p>
</div>
<div class="ml-auto">
<div class="form-field w-[120px]">
<div class="flex items-center">
<span class="text-gray-400 text-sm mr-2">$</span>
<%= text_field_tag :uncategorized, budget_category.budgeted_spending, autocomplete: "off", class: "form-field__input text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none", disabled: true %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,65 @@
<%= content_for :header_nav do %>
<%= render "budgets/budget_nav", budget: @budget %>
<% end %>
<%= content_for :previous_path, edit_budget_path(@budget) %>
<%= content_for :cancel_path, budget_path(@budget) %>
<div>
<div class="space-y-6">
<div class="text-center space-y-2">
<h1 class="text-3xl text-gray-900 font-medium">Edit your category budgets</h1>
<p class="text-gray-500 text-sm max-w-md mx-auto">
Adjust category budgets to set spending limits. Unallocated funds will be automatically assigned as uncategorized.
</p>
</div>
<div class="mx-auto max-w-lg">
<% if @budget.family.categories.empty? %>
<div class="bg-white shadow-xs border border-gray-200 rounded-lg p-4">
<%= render "budget_categories/no_categories" %>
</div>
<% else %>
<div class="max-w-md mx-auto">
<% if @budget.available_to_allocate.negative? %>
<%= render "budget_categories/allocation_progress_overage", budget: @budget %>
<% else %>
<%= render "budget_categories/allocation_progress", budget: @budget %>
<% end %>
<div class="space-y-4 mb-4">
<% BudgetCategory::Group.for(@budget.budget_categories).sort_by(&:name).each do |group| %>
<div class="space-y-4">
<%= render "budget_categories/budget_category_form", budget_category: group.budget_category %>
<div class="space-y-4">
<% group.budget_subcategories.each do |budget_subcategory| %>
<div class="w-full flex items-center gap-4">
<div class="ml-4 flex items-center justify-center text-gray-400">
<%= lucide_icon "corner-down-right", class: "w-5 h-5 shrink-0" %>
</div>
<%= render "budget_categories/budget_category_form", budget_category: budget_subcategory %>
</div>
<% end %>
</div>
</div>
<% end %>
<%= render "budget_categories/uncategorized_budget_category_form", budget: @budget %>
</div>
<% if @budget.allocations_valid? %>
<%= link_to "Confirm",
budget_path(@budget),
class: "block btn btn--primary w-full text-center" %>
<% else %>
<span class="block btn btn--secondary w-full text-center text-gray-400 cursor-not-allowed">
Confirm
</span>
<% end %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,150 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<p class="text-sm text-gray-500">Category</p>
<h3 class="text-2xl font-medium text-gray-900">
<%= @budget_category.category.name %>
</h3>
<% if @budget_category.budget.initialized? %>
<p class="text-sm text-gray-500">
<span class="text-gray-900">
<%= format_money(@budget_category.actual_spending) %>
</span>
<span>/</span>
<span><%= format_money(@budget_category.budgeted_spending) %></span>
</p>
<% end %>
</div>
<% if @budget_category.budget.initialized? %>
<div class="ml-auto w-10 h-10">
<%= render "budget_categories/budget_category_donut",
budget_category: @budget_category %>
</div>
<% end %>
</header>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4>Overview</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-4">
<dl class="space-y-3 px-3 py-2">
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">
<%= @budget_category.budget.start_date.strftime("%b %Y") %> spending
</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.actual_spending_money %>
</dd>
</div>
<% if @budget_category.budget.initialized? %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Status</dt>
<% if @budget_category.available_to_spend.negative? %>
<dd class="text-red-500 flex items-center gap-1 text-red-500 font-medium">
<%= lucide_icon "alert-circle", class: "shrink-0 w-4 h-4 text-red-500" %>
<%= format_money @budget_category.available_to_spend_money.abs %>
<span>overspent</span>
</dd>
<% elsif @budget_category.available_to_spend.zero? %>
<dd class="text-orange-500 flex items-center gap-1 text-orange-500 font-medium">
<%= lucide_icon "x-circle", class: "shrink-0 w-4 h-4 text-orange-500" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% else %>
<dd class="text-gray-900 flex items-center gap-1 text-green-500 font-medium">
<%= lucide_icon "check-circle-2", class: "shrink-0 w-4 h-4 text-green-500" %>
<%= format_money @budget_category.available_to_spend_money %>
<span>left</span>
</dd>
<% end %>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Budgeted</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.budgeted_spending %>
</dd>
</div>
<% end %>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Monthly average spending</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.category.avg_monthly_total %>
</dd>
</div>
<div class="flex items-center justify-between text-sm">
<dt class="text-gray-500">Monthly median spending</dt>
<dd class="text-gray-900 font-medium">
<%= format_money @budget_category.category.median_monthly_total %>
</dd>
</div>
</dl>
</div>
</details>
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2
text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4>Recent Transactions</h4>
<%= lucide_icon "chevron-down",
class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="space-y-2">
<div class="px-3 py-4 space-y-2">
<% if @recent_transactions.any? %>
<ul class="space-y-2 mb-4">
<% @recent_transactions.each_with_index do |entry, index| %>
<li class="flex gap-4 text-sm space-y-1">
<div class="flex flex-col items-center gap-1.5 pt-2">
<div class="rounded-full h-1.5 w-1.5 bg-gray-300"></div>
<% unless index == @recent_transactions.length - 1 %>
<div class="h-12 w-px bg-alpha-black-200"></div>
<% end %>
</div>
<div class="flex justify-between w-full">
<div>
<p class="text-gray-500 text-xs uppercase">
<%= entry.date.strftime("%b %d") %>
</p>
<p class="text-gray-900"><%= entry.name %></p>
</div>
<p class="text-gray-900 font-medium">
<%= format_money entry.amount_money %>
</p>
</div>
</li>
<% end %>
</ul>
<%= link_to "View all category transactions",
transactions_path(q: {
categories: [@budget_category.category.name],
start_date: @budget.start_date,
end_date: @budget.end_date
}),
data: { turbo_frame: :_top },
class: "block text-center btn btn--outline w-full" %>
<% else %>
<p class="text-gray-500 text-sm mb-4">
No transactions found for this budget period.
</p>
<% end %>
</div>
</div>
</details>
</div>
<% end %>