mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00: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:
@@ -24,7 +24,7 @@
|
||||
<% if entry.new_record? %>
|
||||
<%= content_tag :p, entry.display_name %>
|
||||
<% else %>
|
||||
<%= link_to entry.display_name,
|
||||
<%= link_to entry.account_transaction.transfer? ? entry.account_transaction.transfer.name : entry.display_name,
|
||||
entry.account_transaction.transfer? ? transfer_path(entry.account_transaction.transfer) : account_entry_path(entry),
|
||||
data: { turbo_frame: "drawer", turbo_prefetch: false },
|
||||
class: "hover:underline hover:text-gray-800" %>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<%# locals: (entry:) %>
|
||||
|
||||
<div id="<%= dom_id(entry, "category_menu") %>">
|
||||
<% if entry.account_transaction.transfer? %>
|
||||
<%= render "categories/badge", category: entry.account_transaction.transfer.payment? ? payment_category : transfer_category %>
|
||||
<% else %>
|
||||
<% if entry.account_transaction.transfer&.categorizable? || entry.account_transaction.transfer.nil? %>
|
||||
<%= render "categories/menu", transaction: entry.account_transaction %>
|
||||
<% else %>
|
||||
<%= render "categories/badge", category: entry.account_transaction.transfer&.payment? ? payment_category : transfer_category %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<%= tag.div class: "w-6 h-6 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
|
||||
<%= lucide_icon icon, class: "w-4 h-4" %>
|
||||
<%= lucide_icon icon, class: "w-4 h-4 shrink-0" %>
|
||||
<% end %>
|
||||
|
||||
<div class="truncate text-gray-900">
|
||||
|
||||
26
app/views/budget_categories/_allocation_progress.erb
Normal file
26
app/views/budget_categories/_allocation_progress.erb
Normal 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>
|
||||
@@ -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">> 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>
|
||||
48
app/views/budget_categories/_budget_category.html.erb
Normal file
48
app/views/budget_categories/_budget_category.html.erb
Normal 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 %>
|
||||
22
app/views/budget_categories/_budget_category_donut.html.erb
Normal file
22
app/views/budget_categories/_budget_category_donut.html.erb
Normal 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 %>
|
||||
29
app/views/budget_categories/_budget_category_form.html.erb
Normal file
29
app/views/budget_categories/_budget_category_form.html.erb
Normal 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>
|
||||
17
app/views/budget_categories/_no_categories.html.erb
Normal file
17
app/views/budget_categories/_no_categories.html.erb
Normal 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>
|
||||
@@ -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>
|
||||
65
app/views/budget_categories/index.html.erb
Normal file
65
app/views/budget_categories/index.html.erb
Normal 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>
|
||||
150
app/views/budget_categories/show.html.erb
Normal file
150
app/views/budget_categories/show.html.erb
Normal 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 %>
|
||||
62
app/views/budgets/_actuals_summary.html.erb
Normal file
62
app/views/budgets/_actuals_summary.html.erb
Normal file
@@ -0,0 +1,62 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Income</h3>
|
||||
|
||||
<% income_totals = budget.income_categories_with_totals %>
|
||||
<% income_categories = income_totals.category_totals.reject { |ct| ct.amount_money.zero? }.sort_by { |ct| ct.percentage }.reverse %>
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
|
||||
<%= format_money(income_totals.total_money) %>
|
||||
</span>
|
||||
|
||||
<% if income_categories.any? %>
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% income_categories.each do |item| %>
|
||||
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
|
||||
<% income_categories.each do |item| %>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
|
||||
<span class="text-gray-500"><%= item.category.name %></span>
|
||||
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Expenses</h3>
|
||||
|
||||
<% expense_totals = budget.expense_categories_with_totals %>
|
||||
<% expense_categories = expense_totals.category_totals.reject { |ct| ct.amount_money.zero? || ct.category.subcategory? }.sort_by { |ct| ct.percentage }.reverse %>
|
||||
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900"><%= format_money(expense_totals.total_money) %></span>
|
||||
|
||||
<% if expense_categories.any? %>
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% expense_categories.each do |item| %>
|
||||
<div class="h-full rounded-xs" style="background-color: <%= item.category.color %>; width: <%= item.percentage %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-x-2.5 gap-y-1 text-xs">
|
||||
<% expense_categories.each do |item| %>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background-color: <%= item.category.color %>"></div>
|
||||
<span class="text-gray-500"><%= item.category.name %></span>
|
||||
<span class="text-gray-900"><%= number_to_percentage(item.percentage, precision: 0) %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
45
app/views/budgets/_budget_categories.html.erb
Normal file
45
app/views/budgets/_budget_categories.html.erb
Normal file
@@ -0,0 +1,45 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||
<p>Categories</p>
|
||||
<span class="text-gray-400">·</span>
|
||||
<p><%= budget.budget_categories.count %></p>
|
||||
|
||||
<p class="ml-auto">Amount</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white py-1 shadow-xs border border-gray-100 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) %>
|
||||
|
||||
<% category_groups.each_with_index do |group, index| %>
|
||||
<div>
|
||||
<%= render "budget_categories/budget_category", budget_category: group.budget_category %>
|
||||
|
||||
<div>
|
||||
<% group.budget_subcategories.each do |budget_subcategory| %>
|
||||
<div class="w-full flex items-center -mt-4">
|
||||
<div class="ml-8 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", budget_category: budget_subcategory %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4">
|
||||
<div class="h-px w-full bg-alpha-black-50"></div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render "budget_categories/budget_category", budget_category: budget.uncategorized_budget_category %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
61
app/views/budgets/_budget_donut.html.erb
Normal file
61
app/views/budgets/_budget_donut.html.erb
Normal file
@@ -0,0 +1,61 @@
|
||||
<%= tag.div data: { controller: "donut-chart", donut_chart_segments_value: budget.to_donut_segments_json }, 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">
|
||||
<div data-donut-chart-target="defaultContent" class="flex flex-col items-center">
|
||||
<% if budget.initialized? %>
|
||||
<div class="text-gray-600 text-sm mb-2">
|
||||
<span>Spent</span>
|
||||
</div>
|
||||
|
||||
<div class="text-3xl font-medium <%= budget.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
|
||||
<%= format_money(budget.actual_spending) %>
|
||||
</div>
|
||||
|
||||
<%= link_to edit_budget_path(budget), class: "btn btn--secondary flex items-center gap-1 mt-2" do %>
|
||||
<span class="text-gray-900 font-medium">
|
||||
of <%= format_money(budget.budgeted_spending_money) %>
|
||||
</span>
|
||||
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-gray-400 text-3xl mb-2">
|
||||
<span><%= format_money Money.new(0, budget.currency || budget.family.currency) %></span>
|
||||
</div>
|
||||
<%= link_to edit_budget_path(budget), class: "flex items-center gap-2 btn btn--primary" do %>
|
||||
<%= lucide_icon "plus", class: "w-4 h-4 text-white" %>
|
||||
New budget
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% budget.budget_categories.each do |bc| %>
|
||||
<div id="segment_<%= bc.id %>" class="hidden">
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-1 h-3 rounded-xl" style="background-color: <%= bc.category.color %>"></div>
|
||||
<p class="text-sm text-gray-500"><%= bc.category.name %></p>
|
||||
</div>
|
||||
|
||||
<p class="text-3xl font-medium <%= bc.available_to_spend.negative? ? "text-red-500" : "text-gray-900" %>">
|
||||
<%= format_money(bc.actual_spending_money) %>
|
||||
</p>
|
||||
|
||||
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
|
||||
<span>of <%= format_money(bc.budgeted_spending_money, precision: 0) %></span>
|
||||
|
||||
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 shrink-0" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div id="segment_unused" class="hidden">
|
||||
<p class="text-sm text-gray-500 text-center mb-2">Unused</p>
|
||||
|
||||
<p class="text-3xl font-medium text-gray-900">
|
||||
<%= format_money(budget.available_to_spend_money) %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
40
app/views/budgets/_budget_header.html.erb
Normal file
40
app/views/budgets/_budget_header.html.erb
Normal file
@@ -0,0 +1,40 @@
|
||||
<%# locals: (budget:, previous_budget:, next_budget:, latest_budget:) %>
|
||||
|
||||
<div class="flex items-center gap-1 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<% if @previous_budget %>
|
||||
<%= link_to budget_path(@previous_budget) do %>
|
||||
<%= lucide_icon "chevron-left" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= lucide_icon "chevron-left", class: "text-gray-400" %>
|
||||
<% end %>
|
||||
|
||||
<% if @next_budget %>
|
||||
<%= link_to budget_path(@next_budget) do %>
|
||||
<%= lucide_icon "chevron-right" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= lucide_icon "chevron-right", class: "text-gray-400" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div data-controller="menu" data-menu-placement-value="bottom-start">
|
||||
<%= tag.button data: { menu_target: "button" }, class: "flex items-center gap-1 hover:bg-gray-50 rounded-md p-2" do %>
|
||||
<span class="text-gray-900 font-medium"><%= @budget.name %></span>
|
||||
<%= lucide_icon "chevron-down", class: "w-5 h-5 shrink-0 text-gray-500" %>
|
||||
<% end %>
|
||||
|
||||
<div data-menu-target="content" class="hidden z-10">
|
||||
<%= render "budgets/picker", family: Current.family, year: Date.current.year %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ml-auto">
|
||||
<% if @budget.current? %>
|
||||
<span class="border border-alpha-black-200 text-gray-900 text-sm font-medium px-3 py-2 rounded-lg">Today</span>
|
||||
<% else %>
|
||||
<%= link_to "Today", budget_path(@latest_budget), class: "btn btn--outline" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
37
app/views/budgets/_budget_nav.html.erb
Normal file
37
app/views/budgets/_budget_nav.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<% steps = [
|
||||
{ name: "Setup", path: edit_budget_path(budget), is_complete: budget.initialized?, step_number: 1 },
|
||||
{ name: "Categories", path: budget_budget_categories_path(budget), is_complete: budget.allocations_valid?, step_number: 2 },
|
||||
] %>
|
||||
|
||||
<ul class="flex items-center gap-2">
|
||||
<% steps.each_with_index do |step, idx| %>
|
||||
<li class="flex items-center gap-2 group">
|
||||
<% is_current = request.path == step[:path] %>
|
||||
|
||||
<% text_class = if is_current
|
||||
"text-gray-900"
|
||||
else
|
||||
step[:is_complete] ? "text-green-600" : "text-gray-500"
|
||||
end %>
|
||||
<% step_class = if is_current
|
||||
"bg-gray-900 text-white"
|
||||
else
|
||||
step[:is_complete] ? "bg-green-600/10 border-alpha-black-25" : "bg-gray-50"
|
||||
end %>
|
||||
|
||||
<%= link_to step[:path], class: "flex items-center gap-3" do %>
|
||||
<div class="flex items-center gap-2 text-sm font-medium <%= text_class %>">
|
||||
<span class="<%= step_class %> w-7 h-7 rounded-full shrink-0 inline-flex items-center justify-center border border-transparent">
|
||||
<%= step[:is_complete] && !is_current ? lucide_icon("check", class: "w-4 h-4") : idx + 1 %>
|
||||
</span>
|
||||
|
||||
<span><%= step[:name] %></span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="h-px bg-alpha-black-200 w-12 group-last:hidden"></div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
63
app/views/budgets/_budgeted_summary.html.erb
Normal file
63
app/views/budgets/_budgeted_summary.html.erb
Normal file
@@ -0,0 +1,63 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div>
|
||||
<div class="p-4 border-b border-gray-100">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Expected income</h3>
|
||||
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
|
||||
<%= format_money(budget.expected_income_money) %>
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% if budget.remaining_expected_income.negative? %>
|
||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= 100 - budget.surplus_percent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.surplus_percent %>%"></div>
|
||||
<% else %>
|
||||
<div class="rounded-md h-1.5 bg-green-500" style="width: <%= budget.actual_income_percent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.actual_income_percent %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<p class="text-gray-500"><%= format_money(budget.actual_income_money) %> earned</p>
|
||||
<p class="font-medium">
|
||||
<% if budget.remaining_expected_income.negative? %>
|
||||
<span class="text-green-500"><%= format_money(budget.remaining_expected_income.abs) %> over</span>
|
||||
<% else %>
|
||||
<span class="text-gray-900"><%= format_money(budget.remaining_expected_income) %> left</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<h3 class="text-sm text-gray-500 mb-2">Budgeted</h3>
|
||||
|
||||
<span class="inline-block mb-2 text-xl font-medium text-gray-900">
|
||||
<%= format_money(budget.budgeted_spending_money) %>
|
||||
</span>
|
||||
|
||||
<div>
|
||||
<div class="flex h-1.5 mb-3 gap-1">
|
||||
<% if budget.available_to_spend.negative? %>
|
||||
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= 100 - budget.overage_percent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-red-500" style="width: <%= budget.overage_percent %>%"></div>
|
||||
<% else %>
|
||||
<div class="rounded-md h-1.5 bg-gray-900" style="width: <%= budget.percent_of_budget_spent %>%"></div>
|
||||
<div class="rounded-md h-1.5 bg-gray-100" style="width: <%= 100 - budget.percent_of_budget_spent %>%"></div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<p class="text-gray-500"><%= format_money(budget.actual_spending_money) %> spent</p>
|
||||
<p class="font-medium">
|
||||
<% if budget.available_to_spend.negative? %>
|
||||
<span class="text-red-500"><%= format_money(budget.available_to_spend_money.abs) %> over</span>
|
||||
<% else %>
|
||||
<span class="text-gray-900"><%= format_money(budget.available_to_spend_money) %> left</span>
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
13
app/views/budgets/_over_allocation_warning.html.erb
Normal file
13
app/views/budgets/_over_allocation_warning.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<%# locals: (budget:) %>
|
||||
|
||||
<div class="flex flex-col gap-4 items-center justify-center h-full">
|
||||
<%= lucide_icon "alert-triangle", class: "w-6 h-6 text-red-500" %>
|
||||
<p class="text-gray-500 text-sm text-center">You have over-allocated your budget. Please fix your allocations.</p>
|
||||
|
||||
<%= link_to budget_budget_categories_path(budget), class: "btn btn--secondary flex items-center gap-1" do %>
|
||||
<span class="text-gray-900 font-medium">
|
||||
Fix allocations
|
||||
</span>
|
||||
<%= lucide_icon "pencil", class: "w-4 h-4 text-gray-500 hover:text-gray-600" %>
|
||||
<% end %>
|
||||
</div>
|
||||
49
app/views/budgets/_picker.html.erb
Normal file
49
app/views/budgets/_picker.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<%# locals: (family:, year:) %>
|
||||
|
||||
<%= turbo_frame_tag "budget_picker" do %>
|
||||
<div class="bg-white shadow-md border border-alpha-black-25 p-3 rounded-xl space-y-4">
|
||||
<div class="flex items-center gap-2 justify-between">
|
||||
<% if year > family.oldest_entry_date.year %>
|
||||
<%= link_to picker_budgets_path(year: year - 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
|
||||
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-500" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
|
||||
<%= lucide_icon "chevron-left", class: "w-5 h-5 shrink-0 text-gray-400" %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<span class="w-40 text-center px-3 py-2 border border-alpha-black-100 rounded-md" data-budget-picker-target="year">
|
||||
<%= year %>
|
||||
</span>
|
||||
|
||||
<% if year < Date.current.year %>
|
||||
<%= link_to picker_budgets_path(year: year + 1), data: { turbo_frame: "budget_picker" }, class: "p-2 flex items-center justify-center hover:bg-alpha-black-25 rounded-md" do %>
|
||||
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-500" %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="p-2 flex items-center justify-center text-gray-300 rounded-md">
|
||||
<%= lucide_icon "chevron-right", class: "w-5 h-5 shrink-0 text-gray-400" %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-3 gap-2 text-sm text-center font-medium">
|
||||
<% Date::ABBR_MONTHNAMES.compact.each_with_index do |month_name, index| %>
|
||||
<% month_number = index + 1 %>
|
||||
<% start_date = Date.new(year, month_number) %>
|
||||
<% budget = family.budgets.for_date(start_date) %>
|
||||
|
||||
<% if budget %>
|
||||
<%= link_to month_name, budget_path(budget), data: { turbo_frame: "_top" }, class: "block px-3 py-2 text-sm text-gray-900 hover:bg-gray-100 rounded-md" %>
|
||||
<% elsif start_date >= family.oldest_entry_date.beginning_of_month && start_date <= Date.current %>
|
||||
<%= button_to budgets_path(budget: { start_date: start_date }), data: { turbo_frame: "_top" }, class: "block w-full px-3 py-2 text-gray-900 hover:bg-gray-100 rounded-md" do %>
|
||||
<%= month_name %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<span class="px-3 py-2 text-gray-400 rounded-md"><%= month_name %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
47
app/views/budgets/edit.html.erb
Normal file
47
app/views/budgets/edit.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<%= content_for :header_nav do %>
|
||||
<%= render "budgets/budget_nav", budget: @budget %>
|
||||
<% end %>
|
||||
|
||||
<%= content_for :previous_path, budget_path(@budget) %>
|
||||
<%= content_for :cancel_path, budget_path(@budget) %>
|
||||
|
||||
<div>
|
||||
<div class="space-y-4">
|
||||
<div class="text-center space-y-2">
|
||||
<h1 class="text-3xl text-gray-900 font-medium">Setup your budget</h1>
|
||||
<p class="text-gray-500 text-sm max-w-sm mx-auto">
|
||||
Enter your monthly earnings and planned spending below to setup your budget.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg">
|
||||
<%= styled_form_with model: @budget, class: "space-y-3", data: { controller: "budget-form" } do |f| %>
|
||||
<%= f.money_field :budgeted_spending, label: "Budgeted spending", required: true, disable_currency: true %>
|
||||
<%= f.money_field :expected_income, label: "Expected income", required: true, disable_currency: true %>
|
||||
|
||||
<% if @budget.estimated_income && @budget.estimated_spending %>
|
||||
<div class="border border-alpha-black-100 rounded-lg p-3 flex">
|
||||
<%= lucide_icon "sparkles", class: "w-5 h-5 text-gray-500 shrink-0" %>
|
||||
<div class="ml-2 space-y-1 text-sm">
|
||||
<h4 class="text-gray-900">Autosuggest income & spending budget</h4>
|
||||
<p class="text-gray-500">
|
||||
This will be based on transaction history. AI can make mistakes, verify before continuing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-block select-none ml-6">
|
||||
<%= check_box_tag :auto_fill, "1", params[:auto_fill].present?, class: "sr-only peer", data: {
|
||||
action: "change->budget-form#toggleAutoFill",
|
||||
budget_form_income_param: { key: "budget_expected_income", value: @budget.estimated_income },
|
||||
budget_form_spending_param: { key: "budget_budgeted_spending", value: @budget.estimated_spending }
|
||||
} %>
|
||||
<label for="auto_fill" class="maybe-switch"></label>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= f.submit "Continue", class: "btn btn--primary w-full" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
67
app/views/budgets/show.html.erb
Normal file
67
app/views/budgets/show.html.erb
Normal file
@@ -0,0 +1,67 @@
|
||||
<div class="pb-12">
|
||||
<%= render "budgets/budget_header",
|
||||
budget: @budget,
|
||||
previous_budget: @previous_budget,
|
||||
next_budget: @next_budget,
|
||||
latest_budget: @latest_budget %>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-[300px] space-y-4">
|
||||
<div class="h-[300px] bg-white rounded-xl shadow-xs p-8 border border-gray-100">
|
||||
<% if @budget.available_to_allocate.negative? %>
|
||||
<%= render "budgets/over_allocation_warning", budget: @budget %>
|
||||
<% else %>
|
||||
<%= render "budgets/budget_donut", budget: @budget %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<% if @budget.initialized? && @budget.available_to_allocate.positive? %>
|
||||
<div class="flex gap-2 mb-2 rounded-lg bg-alpha-black-25 p-1">
|
||||
<% base_classes = "rounded-md px-2 py-1 flex-1 text-center" %>
|
||||
<% selected_tab = params[:tab].presence || "budgeted" %>
|
||||
|
||||
<%= link_to "Budgeted",
|
||||
budget_path(@budget, tab: "budgeted"),
|
||||
class: class_names(
|
||||
base_classes,
|
||||
"bg-white shadow-xs text-gray-900": selected_tab == "budgeted",
|
||||
"text-gray-500": selected_tab != "budgeted"
|
||||
) %>
|
||||
|
||||
<%= link_to "Actual",
|
||||
budget_path(@budget, tab: "actuals"),
|
||||
class: class_names(
|
||||
base_classes,
|
||||
"bg-white shadow-xs text-gray-900": selected_tab == "actuals",
|
||||
"text-gray-500": selected_tab != "actuals"
|
||||
) %>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
|
||||
<%= render selected_tab == "budgeted" ? "budgets/budgeted_summary" : "budgets/actuals_summary", budget: @budget %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-white rounded-xl shadow-xs border border-gray-100">
|
||||
<%= render "budgets/actuals_summary", budget: @budget %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grow bg-white rounded-xl shadow-xs p-4 border border-gray-100">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-medium">Categories</h2>
|
||||
|
||||
<%= link_to budget_budget_categories_path(@budget), class: "btn btn--secondary flex items-center gap-2" do %>
|
||||
<%= icon "settings-2", color: "gray" %>
|
||||
<span>Edit</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-25 rounded-xl p-1">
|
||||
<%= render "budgets/budget_categories", budget: @budget %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<%# locals: (category:) %>
|
||||
<% category ||= null_category %>
|
||||
<% category ||= Category.uncategorized %>
|
||||
|
||||
<div>
|
||||
<span class="flex items-center gap-1 text-sm font-medium rounded-full px-1.5 py-1 border truncate"
|
||||
@@ -7,6 +7,9 @@
|
||||
background-color: color-mix(in srgb, <%= category.color %> 5%, white);
|
||||
border-color: color-mix(in srgb, <%= category.color %> 30%, white);
|
||||
color: <%= category.color %>;">
|
||||
<% if category.lucide_icon.present? %>
|
||||
<%= lucide_icon category.lucide_icon, class: "w-4 h-4 shrink-0" %>
|
||||
<% end %>
|
||||
<%= category.name %>
|
||||
</span>
|
||||
|
||||
|
||||
25
app/views/categories/_category_list_group.html.erb
Normal file
25
app/views/categories/_category_list_group.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<%# locals: (title:, categories:) %>
|
||||
|
||||
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||
<p><%= title %></p>
|
||||
<span class="text-gray-400">·</span>
|
||||
<p><%= categories.count %></p>
|
||||
</div>
|
||||
|
||||
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
<% Category::Group.for(categories).each_with_index do |group, idx| %>
|
||||
<%= render group.category %>
|
||||
|
||||
<% group.subcategories.each do |subcategory| %>
|
||||
<%= render subcategory %>
|
||||
<% end %>
|
||||
|
||||
<% unless idx == Category::Group.for(categories).count - 1 %>
|
||||
<%= render "categories/ruler" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<%# locals: (category:, categories:) %>
|
||||
|
||||
<div data-controller="color-avatar">
|
||||
<%= styled_form_with model: category, class: "space-y-4", data: { turbo_frame: :_top } do |f| %>
|
||||
<%= styled_form_with model: category, class: "space-y-4" do |f| %>
|
||||
<section class="space-y-4">
|
||||
<div class="w-fit m-auto">
|
||||
<%= render partial: "shared/color_avatar", locals: { name: category.name, color: category.color } %>
|
||||
@@ -20,7 +20,19 @@
|
||||
<%= render "shared/form_errors", model: category %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-center mb-4">
|
||||
<% Category.icon_codes.each do |icon| %>
|
||||
<label class="relative">
|
||||
<%= f.radio_button :lucide_icon, icon, class: "sr-only peer" %>
|
||||
<div class="p-1 rounded cursor-pointer hover:bg-gray-100 peer-checked:bg-gray-100 border-1 border-transparent peer-checked:border-gray-500">
|
||||
<%= lucide_icon icon, class: "w-5 h-5" %>
|
||||
</div>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<%= f.select :classification, [["Income", "income"], ["Expense", "expense"]], { label: "Classification" }, required: true %>
|
||||
<%= f.text_field :name, placeholder: t(".placeholder"), required: true, autofocus: true, label: "Name", data: { color_avatar_target: "name" } %>
|
||||
<%= f.select :parent_id, categories.pluck(:name, :id), { include_blank: "(unassigned)", label: "Parent category (optional)" } %>
|
||||
</div>
|
||||
|
||||
@@ -14,28 +14,14 @@
|
||||
|
||||
<div class="bg-white shadow-xs border border-alpha-black-25 rounded-xl p-4">
|
||||
<% if @categories.any? %>
|
||||
<div class="rounded-xl bg-gray-25 space-y-1 p-1">
|
||||
<div class="flex items-center gap-1.5 px-4 py-2 text-xs font-medium text-gray-500 uppercase">
|
||||
<p><%= t(".categories") %></p>
|
||||
<span class="text-gray-400">·</span>
|
||||
<p><%= @categories.count %></p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<% if @categories.incomes.any? %>
|
||||
<%= render "categories/category_list_group", title: t(".categories_incomes"), categories: @categories.incomes %>
|
||||
<% end %>
|
||||
|
||||
<div class="border border-alpha-black-25 rounded-md bg-white shadow-xs">
|
||||
<div class="overflow-hidden rounded-md">
|
||||
<% Category::Group.for(@categories).each_with_index do |group, idx| %>
|
||||
<%= render group.category %>
|
||||
|
||||
<% group.subcategories.each do |subcategory| %>
|
||||
<%= render subcategory %>
|
||||
<% end %>
|
||||
|
||||
<% unless idx == Category::Group.for(@categories).count - 1 %>
|
||||
<%= render "categories/ruler" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% if @categories.expenses.any? %>
|
||||
<%= render "categories/category_list_group", title: t(".categories_expenses"), categories: @categories.expenses %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="flex justify-center items-center py-20">
|
||||
|
||||
@@ -41,12 +41,14 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= link_to new_account_transaction_transfer_match_path(@transaction.entry),
|
||||
<% unless @transaction.transfer? %>
|
||||
<%= link_to new_account_transaction_transfer_match_path(@transaction.entry),
|
||||
class: "flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo_frame: "modal" } do %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
|
||||
<%= lucide_icon "refresh-cw", class: "w-5 h-5" %>
|
||||
|
||||
<p>Match transfer/payment</p>
|
||||
<p>Match transfer/payment</p>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2">
|
||||
|
||||
@@ -81,6 +81,9 @@
|
||||
<li>
|
||||
<%= sidebar_link_to t(".transactions"), transactions_path, icon: "credit-card" %>
|
||||
</li>
|
||||
<li>
|
||||
<%= sidebar_link_to t(".budgeting"), budgets_path, icon: "map" %>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="flex flex-col mt-6">
|
||||
|
||||
23
app/views/layouts/wizard.html.erb
Normal file
23
app/views/layouts/wizard.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<%= content_for :content do %>
|
||||
<div class="flex flex-col h-dvh">
|
||||
<header class="flex items-center justify-between p-8">
|
||||
<%= link_to content_for(:previous_path) || root_path do %>
|
||||
<%= lucide_icon "arrow-left", class: "w-5 h-5 text-gray-500" %>
|
||||
<% end %>
|
||||
|
||||
<nav>
|
||||
<%= yield :header_nav %>
|
||||
</nav>
|
||||
|
||||
<%= link_to content_for(:cancel_path) || root_path do %>
|
||||
<%= lucide_icon "x", class: "text-gray-500 w-5 h-5" %>
|
||||
<% end %>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow px-8 pt-12 pb-32 overflow-y-auto">
|
||||
<%= yield %>
|
||||
</main>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render template: "layouts/application" %>
|
||||
6
app/views/shared/_icon.html.erb
Normal file
6
app/views/shared/_icon.html.erb
Normal file
@@ -0,0 +1,6 @@
|
||||
<%# locals: (key:, size: "md", color: "current") %>
|
||||
|
||||
<% size_class = case size when "sm" then "w-4 h-4" when "md" then "w-5 h-5" when "lg" then "w-6 h-6" end %>
|
||||
<% color_class = case color when "current" then "text-current" when "gray" then "text-gray-500" end %>
|
||||
|
||||
<%= lucide_icon key, class: class_names(size_class, color_class, "shrink-0") %>
|
||||
@@ -52,7 +52,11 @@
|
||||
<div class="text-gray-500 text-xs font-normal">
|
||||
<div class="flex items-center gap-1">
|
||||
<%= link_to transfer.from_account.name, transfer.from_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||
<%= lucide_icon "arrow-left-right", class: "w-4 h-4" %>
|
||||
<% if transfer.payment? %>
|
||||
<%= lucide_icon "arrow-right", class: "w-4 h-4" %>
|
||||
<% else %>
|
||||
<%= lucide_icon "arrow-left-right", class: "w-4 h-4" %>
|
||||
<% end %>
|
||||
<%= link_to transfer.to_account.name, transfer.to_account, class: "hover:underline", data: { turbo_frame: "_top" } %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -63,7 +67,11 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 col-span-2">
|
||||
<%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %>
|
||||
<% if transfer.categorizable? %>
|
||||
<%= render "account/transactions/transaction_category", entry: transfer.outflow_transaction.entry %>
|
||||
<% else %>
|
||||
<%= render "categories/badge", category: transfer.payment? ? payment_category : transfer_category %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="col-span-2 ml-auto">
|
||||
|
||||
@@ -70,7 +70,11 @@
|
||||
<!-- Details Section -->
|
||||
<%= disclosure t(".details") do %>
|
||||
<%= styled_form_with model: @transfer,
|
||||
data: { controller: "auto-submit-form" } do |f| %>
|
||||
data: { controller: "auto-submit-form" }, class: "space-y-2" do |f| %>
|
||||
<% if @transfer.categorizable? %>
|
||||
<%= f.collection_select :category_id, @categories.alphabetically, :id, :name, { label: "Category", include_blank: "Uncategorized", selected: @transfer.outflow_transaction.category&.id }, "data-auto-submit-form-target": "auto" %>
|
||||
<% end %>
|
||||
|
||||
<%= f.text_area :notes,
|
||||
label: t(".note_label"),
|
||||
placeholder: t(".note_placeholder"),
|
||||
|
||||
Reference in New Issue
Block a user