feat(elixir): category managing w/ filtering &CRUD
This commit is contained in:
@@ -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 """
|
||||
|
||||
420
lib/components_elixir_web/live/categories_live.ex
Normal file
420
lib/components_elixir_web/live/categories_live.ex
Normal file
@@ -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"""
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header -->
|
||||
<div class="bg-white shadow">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-6">
|
||||
<div class="flex items-center space-x-4">
|
||||
<.link
|
||||
navigate={~p"/"}
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-5 h-5" />
|
||||
</.link>
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
Category Management
|
||||
</h1>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<button
|
||||
phx-click="show_add_form"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
|
||||
</button>
|
||||
<.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
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Category Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Add New Category</h3>
|
||||
<button
|
||||
phx-click="hide_add_form"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<.form for={@form} phx-submit="save_category" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<.input field={@form[:name]} type="text" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Parent Category</label>
|
||||
<.input
|
||||
field={@form[:parent_id]}
|
||||
type="select"
|
||||
options={parent_category_options(@categories)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<.input field={@form[:description]} type="textarea" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="hide_add_form"
|
||||
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Save Category
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Edit Category</h3>
|
||||
<button
|
||||
phx-click="hide_edit_form"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<.form for={@form} phx-submit="save_edit" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<.input field={@form[:name]} type="text" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Parent Category</label>
|
||||
<.input
|
||||
field={@form[:parent_id]}
|
||||
type="select"
|
||||
options={parent_category_options(@categories, @editing_category.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<.input field={@form[:description]} type="textarea" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="hide_edit_form"
|
||||
class="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Update Category
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Category Hierarchy</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">Manage your component categories and subcategories</p>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@categories) do %>
|
||||
<div class="text-center py-12">
|
||||
<.icon name="hero-folder-open" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No categories</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first category.
|
||||
</p>
|
||||
<div class="mt-6">
|
||||
<button
|
||||
phx-click="show_add_form"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
||||
Add Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<ul class="divide-y divide-gray-200">
|
||||
<!-- Root Categories -->
|
||||
<%= for category <- root_categories(@categories) do %>
|
||||
<li class="px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<.icon name="hero-folder" class="w-5 h-5 text-indigo-500 mr-3" />
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900">{category.name}</h3>
|
||||
<%= if category.description do %>
|
||||
<p class="text-sm text-gray-500 mt-1">{category.description}</p>
|
||||
<% end %>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{count_components_in_category(category.id)} components
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={category.id}
|
||||
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_category"
|
||||
phx-value-id={category.id}
|
||||
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
|
||||
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Child Categories -->
|
||||
<%= for child <- child_categories(@categories, category.id) do %>
|
||||
<div class="ml-8 mt-4 flex items-center justify-between border-l-2 border-gray-200 pl-6">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<.icon name="hero-folder" class="w-4 h-4 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<h4 class="text-base font-medium text-gray-700">{child.name}</h4>
|
||||
<%= if child.description do %>
|
||||
<p class="text-sm text-gray-500 mt-1">{child.description}</p>
|
||||
<% end %>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
{count_components_in_category(child.id)} components
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={child.id}
|
||||
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_category"
|
||||
phx-value-id={child.id}
|
||||
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
|
||||
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
</button>
|
||||
<.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>
|
||||
<.link
|
||||
href="/logout"
|
||||
method="post"
|
||||
@@ -267,7 +292,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<!-- Filters -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
@@ -281,6 +306,25 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<form phx-change="category_filter">
|
||||
<select
|
||||
name="category_id"
|
||||
class="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
>
|
||||
<option value="" selected={is_nil(@selected_category)}>All Categories</option>
|
||||
<%= for category <- @categories do %>
|
||||
<option value={category.id} selected={@selected_category == category.id}>
|
||||
<%= if category.parent do %>
|
||||
{category.parent.name} > {category.name}
|
||||
<% else %>
|
||||
{category.name}
|
||||
<% end %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<form phx-change="sort_change">
|
||||
<select
|
||||
|
||||
@@ -30,6 +30,7 @@ defmodule ComponentsElixirWeb.Router do
|
||||
pipe_through [:browser, :authenticated]
|
||||
|
||||
live "/", ComponentsLive, :index
|
||||
live "/categories", CategoriesLive, :index
|
||||
end
|
||||
|
||||
# Other scopes may use custom stacks.
|
||||
|
||||
Reference in New Issue
Block a user