mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 05:09:38 +02:00
Basic transaction categories CRUD actions (inline) (#601)
* Fix dropdown issues and add dummy transaction category modal * Minor namings tweaks * Add search type * Use new menu controller * Complete basic transaction category inline CRUD actions * Fix lint error --------- Co-authored-by: Jakub Kottnauer <jk@jakubkottnauer.com>
This commit is contained in:
parent
315c4bf1ec
commit
d29d465a3c
23 changed files with 254 additions and 101 deletions
34
app/controllers/transactions/categories_controller.rb
Normal file
34
app/controllers/transactions/categories_controller.rb
Normal file
|
@ -0,0 +1,34 @@
|
|||
class Transactions::CategoriesController < ApplicationController
|
||||
before_action :set_category, only: [ :update, :destroy ]
|
||||
|
||||
def create
|
||||
if Current.family.transaction_categories.create(category_params)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
render transactions_path, status: :unprocessable_entity, notice: t(".error")
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @category.update(category_params)
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
else
|
||||
render transactions_path, status: :unprocessable_entity, notice: t(".error")
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@category.destroy!
|
||||
redirect_to transactions_path, notice: t(".success")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_category
|
||||
@category = Current.family.transaction_categories.find(params[:id])
|
||||
end
|
||||
|
||||
def category_params
|
||||
params.require(:transaction_category).permit(:name, :name, :color)
|
||||
end
|
||||
end
|
2
app/helpers/transactions/categories_helper.rb
Normal file
2
app/helpers/transactions/categories_helper.rb
Normal file
|
@ -0,0 +1,2 @@
|
|||
module Transactions::CategoriesHelper
|
||||
end
|
|
@ -6,11 +6,25 @@ import { Controller } from "@hotwired/stimulus";
|
|||
* - If you need a form-enabled "select" element, use the "listbox" controller instead.
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ["button", "content"];
|
||||
static targets = [
|
||||
"button",
|
||||
"content",
|
||||
"submenu",
|
||||
"submenuButton",
|
||||
"submenuContent",
|
||||
];
|
||||
|
||||
static values = {
|
||||
show: { type: Boolean, default: false },
|
||||
showSubmenu: { type: Boolean, default: false },
|
||||
};
|
||||
|
||||
initialize() {
|
||||
this.show = this.showValue;
|
||||
this.showSubmenu = this.showSubmenuValue;
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.show = false;
|
||||
this.contentTarget.classList.add("hidden"); // Initially hide the popover
|
||||
this.buttonTarget.addEventListener("click", this.toggle);
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
|
@ -38,11 +52,6 @@ export default class extends Controller {
|
|||
|
||||
handleKeydown = (event) => {
|
||||
switch (event.key) {
|
||||
case " ":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
if (document.activeElement === this.buttonTarget) {
|
||||
this.toggle();
|
||||
}
|
||||
case "Escape":
|
||||
this.close();
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
|
|
|
@ -8,12 +8,25 @@ import { Controller } from "@hotwired/stimulus";
|
|||
export default class extends Controller {
|
||||
static classes = ["active"];
|
||||
static targets = ["option", "button", "list", "input", "buttonText"];
|
||||
static values = { selected: String };
|
||||
|
||||
initialize() {
|
||||
this.show = false;
|
||||
|
||||
const selectedElement = this.optionTargets.find(
|
||||
(option) => option.dataset.value === this.selectedValue
|
||||
);
|
||||
if (selectedElement) {
|
||||
this.updateAriaAttributesAndClasses(selectedElement);
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.show = false;
|
||||
this.syncButtonTextWithInput();
|
||||
this.listTarget.classList.add("hidden");
|
||||
this.buttonTarget.addEventListener("click", this.toggleList);
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.addEventListener("click", this.toggleList);
|
||||
}
|
||||
this.element.addEventListener("keydown", this.handleKeydown);
|
||||
document.addEventListener("click", this.handleOutsideClick);
|
||||
this.element.addEventListener("turbo:load", this.handleTurboLoad);
|
||||
|
@ -22,8 +35,15 @@ export default class extends Controller {
|
|||
disconnect() {
|
||||
this.element.removeEventListener("keydown", this.handleKeydown);
|
||||
document.removeEventListener("click", this.handleOutsideClick);
|
||||
this.buttonTarget.removeEventListener("click", this.toggleList);
|
||||
this.element.removeEventListener("turbo:load", this.handleTurboLoad);
|
||||
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.removeEventListener("click", this.toggleList);
|
||||
}
|
||||
}
|
||||
|
||||
selectedValueChanged() {
|
||||
this.syncButtonTextWithInput();
|
||||
}
|
||||
|
||||
handleOutsideClick = (event) => {
|
||||
|
@ -42,7 +62,10 @@ export default class extends Controller {
|
|||
case " ":
|
||||
case "Enter":
|
||||
event.preventDefault(); // Prevent the default action to avoid scrolling
|
||||
if (document.activeElement === this.buttonTarget) {
|
||||
if (
|
||||
this.hasButtonTarget &&
|
||||
document.activeElement === this.buttonTarget
|
||||
) {
|
||||
this.toggleList();
|
||||
} else {
|
||||
this.selectOption(event);
|
||||
|
@ -58,7 +81,9 @@ export default class extends Controller {
|
|||
break;
|
||||
case "Escape":
|
||||
this.close();
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
if (this.hasButtonTarget) {
|
||||
this.buttonTarget.focus(); // Bring focus back to the button
|
||||
}
|
||||
break;
|
||||
case "Tab":
|
||||
this.close();
|
||||
|
@ -85,6 +110,8 @@ export default class extends Controller {
|
|||
}
|
||||
|
||||
toggleList = () => {
|
||||
if (!this.hasButtonTarget) return; // Ensure button target is present before toggling
|
||||
|
||||
this.show = !this.show;
|
||||
this.listTarget.classList.toggle("hidden", !this.show);
|
||||
this.buttonTarget.setAttribute("aria-expanded", this.show.toString());
|
||||
|
@ -99,14 +126,24 @@ export default class extends Controller {
|
|||
};
|
||||
|
||||
close() {
|
||||
this.show = false;
|
||||
this.listTarget.classList.add("hidden");
|
||||
this.buttonTarget.setAttribute("aria-expanded", "false");
|
||||
if (this.hasButtonTarget) {
|
||||
this.show = false;
|
||||
this.listTarget.classList.add("hidden");
|
||||
this.buttonTarget.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
|
||||
selectOption(event) {
|
||||
const selectedOption =
|
||||
event.type === "keydown" ? document.activeElement : event.currentTarget;
|
||||
this.updateAriaAttributesAndClasses(selectedOption);
|
||||
if (this.inputTarget.value !== selectedOption.getAttribute("data-value")) {
|
||||
this.updateInputValueAndEmitEvent(selectedOption);
|
||||
}
|
||||
this.close(); // Close the list after selection
|
||||
}
|
||||
|
||||
updateAriaAttributesAndClasses(selectedOption) {
|
||||
this.optionTargets.forEach((option) => {
|
||||
option.setAttribute("aria-selected", "false");
|
||||
option.setAttribute("tabindex", "-1");
|
||||
|
@ -115,14 +152,15 @@ export default class extends Controller {
|
|||
selectedOption.classList.add(...this.activeClasses);
|
||||
selectedOption.setAttribute("aria-selected", "true");
|
||||
selectedOption.focus();
|
||||
this.close(); // Close the list after selection
|
||||
}
|
||||
|
||||
updateInputValueAndEmitEvent(selectedOption) {
|
||||
// Update the hidden input's value
|
||||
const selectedValue = selectedOption.getAttribute("data-value");
|
||||
this.inputTarget.value = selectedValue;
|
||||
this.syncButtonTextWithInput();
|
||||
|
||||
// Auto-submit controller listens for this even to auto-submit
|
||||
// Emit an input event for auto-submit functionality
|
||||
const inputEvent = new Event("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
|
@ -134,7 +172,7 @@ export default class extends Controller {
|
|||
const matchingOption = this.optionTargets.find(
|
||||
(option) => option.getAttribute("data-value") === this.inputTarget.value
|
||||
);
|
||||
if (matchingOption) {
|
||||
if (matchingOption && this.hasButtonTextTarget) {
|
||||
this.buttonTextTarget.textContent = matchingOption.textContent.trim();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,21 +3,6 @@
|
|||
* Stimulus controllers to reference our color palette. Mostly used for D3 charts.
|
||||
*/
|
||||
|
||||
export const categoryColors = [
|
||||
"#e99537",
|
||||
"#4da568",
|
||||
"#6471eb",
|
||||
"#db5a54",
|
||||
"#df4e92",
|
||||
"#c44fe9",
|
||||
"#eb5429",
|
||||
"#61c9ea",
|
||||
"#805dee",
|
||||
"#6ad28a"
|
||||
]
|
||||
|
||||
export const categoryDefaultColor = "#737373"
|
||||
|
||||
export default {
|
||||
transparent: "transparent",
|
||||
current: "currentColor",
|
||||
|
|
|
@ -1,20 +1,24 @@
|
|||
class Transaction::Category < ApplicationRecord
|
||||
has_many :transactions
|
||||
has_many :transactions, dependent: :nullify
|
||||
belongs_to :family
|
||||
|
||||
validates :name, :color, :family, presence: true
|
||||
|
||||
before_update :clear_internal_category, if: :name_changed?
|
||||
|
||||
COLORS = %w[#e99537 #4da568 #6471eb #db5a54 #df4e92 #c44fe9 #eb5429 #61c9ea #805dee #6ad28a]
|
||||
|
||||
UNCATEGORIZED_COLOR = "#737373"
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
{ internal_category: "income", color: "#e99537" },
|
||||
{ internal_category: "food_and_drink", color: "#4da568" },
|
||||
{ internal_category: "entertainment", color: "#6471eb" },
|
||||
{ internal_category: "personal_care", color: "#db5a54" },
|
||||
{ internal_category: "general_services", color: "#df4e92" },
|
||||
{ internal_category: "auto_and_transport", color: "#c44fe9" },
|
||||
{ internal_category: "rent_and_utilities", color: "#eb5429" },
|
||||
{ internal_category: "home_improvement", color: "#61c9ea" }
|
||||
{ internal_category: "income", color: COLORS[0] },
|
||||
{ internal_category: "food_and_drink", color: COLORS[1] },
|
||||
{ internal_category: "entertainment", color: COLORS[2] },
|
||||
{ internal_category: "personal_care", color: COLORS[3] },
|
||||
{ internal_category: "general_services", color: COLORS[4] },
|
||||
{ internal_category: "auto_and_transport", color: COLORS[5] },
|
||||
{ internal_category: "rent_and_utilities", color: COLORS[6] },
|
||||
{ internal_category: "home_improvement", color: COLORS[7] }
|
||||
]
|
||||
|
||||
def self.ransackable_attributes(auth_object = nil)
|
||||
|
|
|
@ -37,7 +37,7 @@
|
|||
</button>
|
||||
<div
|
||||
data-menu-target="content"
|
||||
class="absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit"
|
||||
class="hidden absolute min-w-[200px] z-10 top-10 right-0 bg-white p-1 rounded-sm shadow-xs border border-alpha-black-25 w-fit"
|
||||
>
|
||||
<%= link_to edit_settings_path, class: "flex gap-1 items-center hover:bg-gray-50 rounded-md p-2" do %>
|
||||
<%= lucide_icon("pencil-line", class: "w-5 h-5 text-gray-500 shrink-0") %>
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<%# locals: (value: 'all') -%>
|
||||
<%# locals: (value: 'last_30_days') -%>
|
||||
<% options = [['7D', 'last_7_days'], ['1M', 'last_30_days'], ["1Y", "last_365_days"], ['All', 'all']] %>
|
||||
<div data-controller="select" data-select-active-class="bg-alpha-black-50" class="relative">
|
||||
<div data-controller="select" data-select-active-class="bg-alpha-black-50" class="relative" data-select-selected-value="<%= value %>">
|
||||
<button type="button" data-select-target="button" class="flex items-center gap-1 w-full border border-alpha-black-100 shadow-xs rounded-lg text-sm p-2 cursor-pointer">
|
||||
<span data-select-target="buttonText" class="text-gray-900 text-sm"><%= options.find { |option| option[1] == value }[0] %></span>
|
||||
<%= lucide_icon("chevron-down", class: "w-5 h-5 text-gray-500") %>
|
||||
</button>
|
||||
<input type="hidden" name="period" value="<%= value %>" data-select-target="input" data-auto-submit-form-target="auto">
|
||||
<input type="hidden" name="period" data-select-target="input" data-auto-submit-form-target="auto">
|
||||
<ul data-select-target="list" class="hidden absolute z-10 top-10 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs">
|
||||
<% options.each do |label, value| %>
|
||||
<li tabindex="0" data-select-target="option" data-action="click->select#selectOption" data-value="<%= value %>" class="text-sm text-gray-900 rounded-lg cursor-pointer hover:bg-alpha-black-50 px-5 py-1">
|
||||
|
|
|
@ -1,45 +0,0 @@
|
|||
<%# locals: (transaction:) %>
|
||||
<div class="relative" data-controller="menu">
|
||||
<button data-menu-target="button" class="flex">
|
||||
<%= render partial: "shared/category_badge", locals: transaction.category.nil? ? {} : { name: transaction.category.name, color: transaction.category.color } %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<div class="flex flex-col" data-controller="list-filter">
|
||||
<div class="grow p-1.5">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<input placeholder="Search" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter" />
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
</div>
|
||||
<%= form_with model: transaction, namespace: dom_id(transaction), html: { data: { controller: "auto-submit-form", list_filter_target: "list" }, class: "flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar" } do |form| %>
|
||||
<div class="py-8 pl-4 mt-0.5 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
No categories found
|
||||
</div>
|
||||
<% Current.family.transaction_categories.each do |category| %>
|
||||
<% is_selected = category.id == transaction.category.try(:id) %>
|
||||
<%= content_tag :div, class: ["filterable-item flex items-center hover:bg-gray-25 border-none rounded-lg px-2 py-1 group", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||
<%= form.radio_button :category_id, category.id, class: "hidden", data: { auto_submit_form_target: "auto" } %>
|
||||
<%= label dom_id(transaction), :transaction_category_id, value: category.id, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
<%= render partial: "shared/category_badge", locals: { name: category.name, color: category.color } %>
|
||||
<% end %>
|
||||
<button class="ml-auto flex items-center justify-center hover:bg-gray-50 w-8 h-8 rounded-lg invisible group-hover:visible cursor-not-allowed" type="button" disabled>
|
||||
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<hr/>
|
||||
<div class="p-1.5 w-full">
|
||||
<button class="cursor-not-allowed flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100" disabled>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
Add new
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
|
@ -11,7 +11,7 @@
|
|||
<%= lucide_icon("list-filter", class: "w-5 h-5 text-gray-500") %>
|
||||
<p class="text-sm font-medium text-gray-900">Filter</p>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-10 top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs min-w-[450px]">
|
||||
<div data-menu-target="content" class="hidden absolute z-10 top-12 right-0 border border-alpha-black-25 bg-white rounded-lg shadow-xs min-w-[450px]">
|
||||
<div data-controller="tabs" data-tabs-active-class="border-b-2 border-b-black text-gray-900" data-tabs-default-tab-value="txn-account-filter">
|
||||
<div class="flex items-center px-3 text-sm font-medium text-gray-500 gap-4 border-b border-b-alpha-black-50">
|
||||
<button class="py-2 border-b-2" type="button" data-id="txn-account-filter" data-tabs-target="btn" data-action="tabs#select">Account</button>
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
</div>
|
||||
<% end %>
|
||||
<div class="w-48">
|
||||
<%= render partial: "transactions/category_dropdown", locals: { transaction: } %>
|
||||
<%= render partial: "transactions/categories/menu", locals: { transaction: } %>
|
||||
</div>
|
||||
<div>
|
||||
<p><%= transaction.account.name %></p>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<%# locals: (name: "Uncategorized", color: "#737373") %>
|
||||
<%# locals: (name: "Uncategorized", color: Transaction::Category::UNCATEGORIZED_COLOR) %>
|
||||
<% background_color = "color-mix(in srgb, #{color} 5%, white)" %>
|
||||
<% border_color = "color-mix(in srgb, #{color} 10%, white)" %>
|
||||
<span class="border text-sm font-medium px-2.5 py-1 rounded-full cursor-pointer" style="background-color: <%= background_color %>; border-color: <%= border_color %>; color: <%= color %>"><%= name %></span>
|
39
app/views/transactions/categories/_menu.html.erb
Normal file
39
app/views/transactions/categories/_menu.html.erb
Normal file
|
@ -0,0 +1,39 @@
|
|||
<%# locals: (transaction:) %>
|
||||
<div class="relative" data-controller="menu">
|
||||
<button data-menu-target="button" class="flex">
|
||||
<%= render partial: "transactions/categories/badge", locals: transaction.category.nil? ? {} : { name: transaction.category.name, color: transaction.category.color } %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-10 hidden w-screen mt-2 max-w-min cursor-default">
|
||||
<div class="w-64 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<div class="flex flex-col relative" data-controller="list-filter">
|
||||
<div class="grow p-1.5">
|
||||
<div class="relative flex items-center bg-white border border-gray-200 rounded-lg">
|
||||
<input placeholder="Search" type="search" class="placeholder:text-sm placeholder:text-gray-500 font-normal h-10 relative pl-10 w-full border-none rounded-lg" data-list-filter-target="input" data-action="list-filter#filter" />
|
||||
<%= lucide_icon("search", class: "w-5 h-5 text-gray-500 ml-2 absolute inset-0 transform top-1/2 -translate-y-1/2") %>
|
||||
</div>
|
||||
</div>
|
||||
<div data-list-filter-target="list" class="flex flex-col gap-0.5 p-1.5 mt-0.5 mr-2 max-h-64 overflow-y-scroll scrollbar">
|
||||
<div class="pb-2 pl-4 mr-2 text-gray-500 hidden" data-list-filter-target="emptyMessage">
|
||||
No categories found
|
||||
</div>
|
||||
<% sorted_categories = Current.family.transaction_categories.sort_by { |category| category.id == transaction.category_id ? 0 : 1 } %>
|
||||
<% sorted_categories.each do |category| %>
|
||||
<%= render partial: "transactions/categories/dropdown/row", locals: { category:, transaction: } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<hr/>
|
||||
<div data-controller="menu" class="relative p-1.5 w-full">
|
||||
<button data-menu-target="button" class="flex text-sm font-medium items-center gap-2 text-gray-500 w-full rounded-lg p-2 hover:bg-gray-100">
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %>
|
||||
Add new
|
||||
</button>
|
||||
<div data-menu-target="content" class="hidden absolute bottom-14 right-0">
|
||||
<div class="w-96 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<%= render partial: "transactions/categories/dropdown/form" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
15
app/views/transactions/categories/dropdown/_edit.html.erb
Normal file
15
app/views/transactions/categories/dropdown/_edit.html.erb
Normal file
|
@ -0,0 +1,15 @@
|
|||
<%# locals: (category:) %>
|
||||
<div class="w-96 text-sm font-semibold leading-6 text-gray-900 bg-white shadow-lg shrink rounded-xl ring-1 ring-gray-900/5">
|
||||
<div class="flex flex-col">
|
||||
<%= render partial: "transactions/categories/dropdown/form", locals: { category: } %>
|
||||
<hr/>
|
||||
<div class="p-1.5 w-full">
|
||||
<%= button_to transactions_category_path(category),
|
||||
method: :delete,
|
||||
class: "flex text-sm font-medium items-center gap-2 text-red-600 w-full rounded-lg p-2 hover:bg-gray-100",
|
||||
data: { turbo: false } do %>
|
||||
<%= lucide_icon("trash-2", class: "w-5 h-5") %> Delete category
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
28
app/views/transactions/categories/dropdown/_form.html.erb
Normal file
28
app/views/transactions/categories/dropdown/_form.html.erb
Normal file
|
@ -0,0 +1,28 @@
|
|||
<%# locals: (category: nil) %>
|
||||
<%= form_with url: category ? transactions_category_path(category) : transactions_categories_path, method: category ? :patch : :post, scope: :transaction_category, html: { class: "text-sm font-semibold leading-6 text-gray-900" }, data: { turbo: false } do |form| %>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-col p-1.5 gap-1.5">
|
||||
<div class="relative flex items-center border border-gray-200 rounded-lg">
|
||||
<%= form.text_field :name, value: category&.name, placeholder: "Enter Category name", class: "text-sm font-normal placeholder:text-gray-500 h-10 relative pl-6 w-full border-none rounded-lg" %>
|
||||
</div>
|
||||
<div class="p-2 overflow-x-auto">
|
||||
<div data-controller="select" data-select-active-class="bg-gray-200" data-select-selected-value="<%= category&.color || Transaction::Category::COLORS[0] %>">
|
||||
<%= form.hidden_field :color, data: { select_target: "input" } %>
|
||||
<ul data-select-target="list" class="flex gap-2 items-center">
|
||||
<% Transaction::Category::COLORS.each do |color| %>
|
||||
<li tabindex="0" data-select-target="option" data-action="click->select#selectOption" data-value="<%= color %>" class="flex shrink-0 justify-center items-center w-6 h-6 cursor-pointer hover:bg-gray-200 rounded-full">
|
||||
<div style="background-color: <%= color %>" class="shrink-0 w-4 h-4 rounded-full"></div>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="p-1.5 w-full">
|
||||
<%= form.button "Create category", class: "flex text-sm font-medium items-center gap-2 text-gray-900 w-full rounded-lg p-2 hover:bg-gray-100" do %>
|
||||
<%= lucide_icon("plus", class: "w-5 h-5") %> <%= category.nil? ? "Create" : "Update" %> category
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
18
app/views/transactions/categories/dropdown/_row.html.erb
Normal file
18
app/views/transactions/categories/dropdown/_row.html.erb
Normal file
|
@ -0,0 +1,18 @@
|
|||
<%# locals: (category:, transaction:) %>
|
||||
<% is_selected = transaction.category_id == category.id %>
|
||||
<%= content_tag :div, class: ["filterable-item flex justify-between items-center border-none rounded-lg px-2 py-1 group w-full", { "bg-gray-25": is_selected }], data: { filter_name: category.name } do %>
|
||||
<%= button_to transaction_path(transaction, transaction: { category_id: category.id }), method: :patch, class: "flex w-full items-center gap-1.5 cursor-pointer" do %>
|
||||
<span class="w-5 h-5">
|
||||
<%= lucide_icon("check", class: "w-5 h-5 text-gray-500") if is_selected %>
|
||||
</span>
|
||||
<%= render partial: "transactions/categories/badge", locals: { name: category.name, color: category.color } %>
|
||||
<% end %>
|
||||
<div data-controller="menu">
|
||||
<button data-menu-target="button" type="button" class="flex items-center justify-center hover:bg-gray-50 w-8 h-8 rounded-lg">
|
||||
<%= lucide_icon("more-horizontal", class: "w-5 h-5 text-gray-500") %>
|
||||
</button>
|
||||
<div data-menu-target="content" class="absolute z-30 hidden w-screen mt-2 max-w-min">
|
||||
<%= render partial: "transactions/categories/dropdown/edit", locals: { category: } %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
|
@ -9,7 +9,7 @@
|
|||
<div class="filterable-item flex items-center gap-2 p-2" data-filter-name="<%= transaction_category.name %>">
|
||||
<%= form.check_box :category_id_in, { "data-auto-submit-form-target": "auto", multiple: true, class: "rounded-sm border-gray-300 text-indigo-600 shadow-xs focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" }, transaction_category.id, nil %>
|
||||
<%= form.label :category_id_in, transaction_category.name, value: transaction_category.id, class: "text-sm text-gray-900" do %>
|
||||
<%= render partial: "shared/category_badge", locals: { name: transaction_category.name, color: transaction_category.color } %>
|
||||
<%= render partial: "transactions/categories/badge", locals: { name: transaction_category.name, color: transaction_category.color } %>
|
||||
<%end%>
|
||||
</div>
|
||||
<% end %>
|
||||
|
|
|
@ -1,6 +1,15 @@
|
|||
---
|
||||
en:
|
||||
transactions:
|
||||
categories:
|
||||
create:
|
||||
error: Error creating transaction category
|
||||
success: New transaction category created successfully
|
||||
destroy:
|
||||
success: Transaction category deleted successfully
|
||||
update:
|
||||
error: Error updating transaction category
|
||||
success: Transaction category updated successfully
|
||||
create:
|
||||
success: New transaction created successfully
|
||||
destroy:
|
||||
|
|
|
@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
|||
match "search" => "transactions#search", on: :collection, via: [ :get, :post ], as: :search
|
||||
end
|
||||
|
||||
namespace :transactions do
|
||||
resources :categories
|
||||
end
|
||||
|
||||
resources :accounts, shallow: true do
|
||||
post :sync, on: :member
|
||||
resources :valuations
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
class ChangeTransactionCategoryDeleteBehavior < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
remove_foreign_key :transactions, :transaction_categories, column: :category_id
|
||||
add_foreign_key :transactions, :transaction_categories, column: :category_id, on_delete: :nullify
|
||||
end
|
||||
end
|
4
db/schema.rb
generated
4
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_04_03_192649) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2024_04_04_112829) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
|
@ -253,7 +253,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_04_03_192649) do
|
|||
add_foreign_key "accounts", "families"
|
||||
add_foreign_key "transaction_categories", "families"
|
||||
add_foreign_key "transactions", "accounts", on_delete: :cascade
|
||||
add_foreign_key "transactions", "transaction_categories", column: "category_id"
|
||||
add_foreign_key "transactions", "transaction_categories", column: "category_id", on_delete: :nullify
|
||||
add_foreign_key "users", "families"
|
||||
add_foreign_key "valuations", "accounts", on_delete: :cascade
|
||||
end
|
||||
|
|
|
@ -5,6 +5,8 @@ namespace :demo_data do
|
|||
|
||||
family.accounts.delete_all
|
||||
ExchangeRate.delete_all
|
||||
family.transaction_categories.delete_all
|
||||
Transaction::Category.create_default_categories(family)
|
||||
|
||||
user = User.find_or_create_by(email: "user@maybe.local") do |u|
|
||||
u.password = "password"
|
||||
|
@ -46,8 +48,6 @@ namespace :demo_data do
|
|||
|
||||
puts "Loaded mock exchange rates for last 60 days"
|
||||
|
||||
Transaction::Category.create_default_categories(family) if family.transaction_categories.empty?
|
||||
|
||||
# ========== Accounts ================
|
||||
empty_account = Account.create(name: "Demo Empty Account", family: family, accountable: Account::Depository.new, balance: 500, currency: "USD")
|
||||
multi_currency_checking = Account.create(name: "Demo Multi-Currency Checking", family: family, accountable: Account::Depository.new, balance: 4000, currency: "EUR")
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class Transactions::CategoriesControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue