From ece9850713e962df919eaa00de4afb031c98d88a Mon Sep 17 00:00:00 2001 From: Schuwi Date: Sun, 14 Sep 2025 12:32:46 +0200 Subject: [PATCH] feat(elixir): category managing w/ filtering &CRUD --- README.md | 14 +- lib/components_elixir/inventory.ex | 9 + .../live/categories_live.ex | 420 ++++++++++++++++++ .../live/components_live.ex | 46 +- lib/components_elixir_web/router.ex | 1 + 5 files changed, 483 insertions(+), 7 deletions(-) create mode 100644 lib/components_elixir_web/live/categories_live.ex diff --git a/README.md b/README.md index 4bc444c..9f9f1ed 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve - **Inventory Tracking**: Monitor component quantities with increment/decrement buttons - **Search & Filter**: Fast search across component names, descriptions, and keywords - **Category Organization**: Hierarchical category system for better organization +- **Category Management**: Add, edit, delete categories through the web interface with hierarchical support - **Datasheet Links**: Direct links to component datasheets - **Position Tracking**: Track component storage locations - **Real-time Updates**: All changes are immediately reflected in the interface @@ -97,6 +98,7 @@ The application uses a simple password-based authentication system: ### Live Views - **`ComponentsElixirWeb.LoginLive`**: Authentication interface - **`ComponentsElixirWeb.ComponentsLive`**: Main component management interface +- **`ComponentsElixirWeb.CategoriesLive`**: Category management interface ### Key Features - **Real-time updates**: Changes are immediately reflected without page refresh @@ -113,18 +115,18 @@ The application uses a simple password-based authentication system: | `addItem.php` | `Inventory.create_component/1` | Built-in validation, changesets | | Manual editing | `Inventory.update_component/2` | **NEW**: Full edit functionality with validation | | `changeAmount.php` | `Inventory.update_component_count/2` | Atomic operations, constraints | +| Manual category management | `CategoriesLive` + `Inventory.create_category/1` | **NEW**: Full category CRUD with web interface | | `imageUpload.php` | (Future: Phoenix file uploads) | Planned improvement | | Session management | Phoenix sessions + LiveView | Built-in CSRF protection | ## Future Enhancements 1. **Image Upload**: Implement Phoenix file uploads for component images -2. **Category Management UI**: Add/edit/delete categories through the web interface -3. **Bulk Operations**: Import/export components via CSV -4. **API Endpoints**: REST API for external integrations -5. **User Management**: Multi-user support with roles and permissions -6. **Advanced Search**: Filters by category, stock level, etc. -7. **Barcode/QR Codes**: Generate and scan codes for quick inventory updates +2. **Bulk Operations**: Import/export components via CSV +3. **API Endpoints**: REST API for external integrations +4. **User Management**: Multi-user support with roles and permissions +5. **Advanced Search**: Filters by category, stock level, etc. +6. **Barcode/QR Codes**: Generate and scan codes for quick inventory updates ## Development diff --git a/lib/components_elixir/inventory.ex b/lib/components_elixir/inventory.ex index 2163aef..30d7a2a 100644 --- a/lib/components_elixir/inventory.ex +++ b/lib/components_elixir/inventory.ex @@ -81,6 +81,15 @@ defmodule ComponentsElixir.Inventory do Category.changeset(category, attrs) end + @doc """ + Returns the count of components in a specific category. + """ + def count_components_in_category(category_id) do + Component + |> where([c], c.category_id == ^category_id) + |> Repo.aggregate(:count, :id) + end + ## Components @doc """ diff --git a/lib/components_elixir_web/live/categories_live.ex b/lib/components_elixir_web/live/categories_live.ex new file mode 100644 index 0000000..fa402d5 --- /dev/null +++ b/lib/components_elixir_web/live/categories_live.ex @@ -0,0 +1,420 @@ +defmodule ComponentsElixirWeb.CategoriesLive do + use ComponentsElixirWeb, :live_view + + alias ComponentsElixir.{Inventory, Auth} + alias ComponentsElixir.Inventory.Category + + @impl true + def mount(_params, session, socket) do + # Check authentication + unless Auth.authenticated?(session) do + {:ok, socket |> push_navigate(to: ~p"/login")} + else + categories = Inventory.list_categories() + + {:ok, + socket + |> assign(:session, session) + |> assign(:categories, categories) + |> assign(:show_add_form, false) + |> assign(:show_edit_form, false) + |> assign(:editing_category, nil) + |> assign(:form, nil) + |> assign(:page_title, "Category Management")} + end + end + + @impl true + def handle_event("show_add_form", _params, socket) do + changeset = Inventory.change_category(%Category{}) + form = to_form(changeset) + + {:noreply, + socket + |> assign(:show_add_form, true) + |> assign(:form, form)} + end + + def handle_event("hide_add_form", _params, socket) do + {:noreply, + socket + |> assign(:show_add_form, false) + |> assign(:form, nil)} + end + + def handle_event("show_edit_form", %{"id" => id}, socket) do + category = Inventory.get_category!(id) + changeset = Inventory.change_category(category) + form = to_form(changeset) + + {:noreply, + socket + |> assign(:show_edit_form, true) + |> assign(:editing_category, category) + |> assign(:form, form)} + end + + def handle_event("hide_edit_form", _params, socket) do + {:noreply, + socket + |> assign(:show_edit_form, false) + |> assign(:editing_category, nil) + |> assign(:form, nil)} + end + + def handle_event("save_category", %{"category" => category_params}, socket) do + case Inventory.create_category(category_params) do + {:ok, _category} -> + {:noreply, + socket + |> put_flash(:info, "Category created successfully") + |> assign(:show_add_form, false) + |> assign(:form, nil) + |> reload_categories()} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + def handle_event("save_edit", %{"category" => category_params}, socket) do + case Inventory.update_category(socket.assigns.editing_category, category_params) do + {:ok, _category} -> + {:noreply, + socket + |> put_flash(:info, "Category updated successfully") + |> assign(:show_edit_form, false) + |> assign(:editing_category, nil) + |> assign(:form, nil) + |> reload_categories()} + + {:error, changeset} -> + {:noreply, assign(socket, :form, to_form(changeset))} + end + end + + def handle_event("delete_category", %{"id" => id}, socket) do + category = Inventory.get_category!(id) + + case Inventory.delete_category(category) do + {:ok, _deleted_category} -> + {:noreply, + socket + |> put_flash(:info, "Category deleted successfully") + |> reload_categories()} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Cannot delete category - it may have components assigned or child categories")} + end + end + + defp reload_categories(socket) do + categories = Inventory.list_categories() + assign(socket, :categories, categories) + end + + defp parent_category_options(categories, editing_category_id \\ nil) do + available_categories = + categories + |> Enum.reject(fn cat -> + cat.id == editing_category_id || + (editing_category_id && is_descendant?(categories, cat.id, editing_category_id)) + end) + |> Enum.map(fn category -> + {category_display_name(category), category.id} + end) + + [{"No parent (Root category)", nil}] ++ available_categories + end + + defp is_descendant?(categories, potential_ancestor_id, category_id) do + # Prevent circular references by checking if the potential parent is a descendant + category = Enum.find(categories, fn cat -> cat.id == category_id end) + + case category do + nil -> false + %{parent_id: nil} -> false + %{parent_id: parent_id} when parent_id == potential_ancestor_id -> true + %{parent_id: parent_id} -> is_descendant?(categories, potential_ancestor_id, parent_id) + end + end + + defp category_display_name(category) do + if category.parent do + "#{category.parent.name} > #{category.name}" + else + category.name + end + end + + defp root_categories(categories) do + Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end) + end + + defp child_categories(categories, parent_id) do + Enum.filter(categories, fn cat -> cat.parent_id == parent_id end) + end + + defp count_components_in_category(category_id) do + Inventory.count_components_in_category(category_id) + end + + @impl true + def render(assigns) do + ~H""" +
+ +
+
+
+
+ <.link + navigate={~p"/"} + class="text-gray-500 hover:text-gray-700" + > + <.icon name="hero-arrow-left" class="w-5 h-5" /> + +

+ Category Management +

+
+
+ + <.link + navigate={~p"/"} + class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > + <.icon name="hero-cube-transparent" class="w-4 h-4 mr-2" /> Components + +
+
+
+
+ + + <%= if @show_add_form do %> +
+
+
+
+

Add New Category

+ +
+ + <.form for={@form} phx-submit="save_category" class="space-y-4"> +
+ + <.input field={@form[:name]} type="text" required /> +
+ +
+ + <.input + field={@form[:parent_id]} + type="select" + options={parent_category_options(@categories)} + /> +
+ +
+ + <.input field={@form[:description]} type="textarea" /> +
+ +
+ + +
+ +
+
+
+ <% end %> + + + <%= if @show_edit_form do %> +
+
+
+
+

Edit Category

+ +
+ + <.form for={@form} phx-submit="save_edit" class="space-y-4"> +
+ + <.input field={@form[:name]} type="text" required /> +
+ +
+ + <.input + field={@form[:parent_id]} + type="select" + options={parent_category_options(@categories, @editing_category.id)} + /> +
+ +
+ + <.input field={@form[:description]} type="textarea" /> +
+ +
+ + +
+ +
+
+
+ <% end %> + + +
+
+
+

Category Hierarchy

+

Manage your component categories and subcategories

+
+ + <%= if Enum.empty?(@categories) do %> +
+ <.icon name="hero-folder-open" class="mx-auto h-12 w-12 text-gray-400" /> +

No categories

+

+ Get started by creating your first category. +

+
+ +
+
+ <% else %> +
    + + <%= for category <- root_categories(@categories) do %> +
  • +
    +
    +
    + <.icon name="hero-folder" class="w-5 h-5 text-indigo-500 mr-3" /> +
    +

    {category.name}

    + <%= if category.description do %> +

    {category.description}

    + <% end %> +

    + {count_components_in_category(category.id)} components +

    +
    +
    +
    +
    + + +
    +
    + + + <%= for child <- child_categories(@categories, category.id) do %> +
    +
    +
    + <.icon name="hero-folder" class="w-4 h-4 text-gray-400 mr-3" /> +
    +

    {child.name}

    + <%= if child.description do %> +

    {child.description}

    + <% end %> +

    + {count_components_in_category(child.id)} components +

    +
    +
    +
    +
    + + +
    +
    + <% end %> +
  • + <% end %> +
+ <% end %> +
+
+
+ """ + end +end diff --git a/lib/components_elixir_web/live/components_live.ex b/lib/components_elixir_web/live/components_live.ex index 3b20ece..b9456b4 100644 --- a/lib/components_elixir_web/live/components_live.ex +++ b/lib/components_elixir_web/live/components_live.ex @@ -67,6 +67,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do |> push_patch(to: ~p"/?#{build_query_params(socket, %{criteria: criteria})}")} end + def handle_event("category_filter", %{"category_id" => ""}, socket) do + {:noreply, + socket + |> assign(:selected_category, nil) + |> assign(:offset, 0) + |> load_components()} + end + + def handle_event("category_filter", %{"category_id" => category_id}, socket) do + category_id = String.to_integer(category_id) + {:noreply, + socket + |> assign(:selected_category, category_id) + |> assign(:offset, 0) + |> load_components()} + end + def handle_event("load_more", _params, socket) do new_offset = socket.assigns.offset + @items_per_page @@ -195,9 +212,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do filters = [ search: socket.assigns.search, sort_criteria: socket.assigns.sort_criteria, + category_id: socket.assigns.selected_category, limit: @items_per_page, offset: socket.assigns.offset ] + |> Enum.reject(fn {_k, v} -> is_nil(v) end) %{components: new_components, has_more: has_more} = Inventory.paginate_components(filters) @@ -255,6 +274,12 @@ defmodule ComponentsElixirWeb.ComponentsLive do > <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Component + <.link + navigate={~p"/categories"} + class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" + > + <.icon name="hero-folder" class="w-4 h-4 mr-2" /> Categories + <.link href="/logout" method="post" @@ -267,7 +292,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do - +
@@ -281,6 +306,25 @@ defmodule ComponentsElixirWeb.ComponentsLive do />
+
+
+ +
+