feat: filter by category/location on click

- add filtering by storage location
This commit is contained in:
Schuwi
2025-09-19 22:12:58 +02:00
parent 288d84614a
commit c4a0b41e7d
4 changed files with 221 additions and 12 deletions

View File

@@ -224,6 +224,27 @@ defmodule ComponentsElixir.Inventory do
def get_category_and_descendant_ids(_), do: []
@doc """
Gets all storage location IDs that are descendants of the given storage location ID, including the location itself.
This is used for filtering components by storage location and all its sub-locations.
Returns an empty list if the storage location doesn't exist.
Note: This implementation loads all storage locations into memory for traversal, which is efficient
for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
a recursive CTE query could be used instead.
"""
def get_storage_location_and_descendant_ids(storage_location_id) when is_integer(storage_location_id) do
storage_locations = list_storage_locations()
# Verify the storage location exists before getting descendants
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
nil -> []
_storage_location -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(storage_locations, storage_location_id, &(&1.parent_id))
end
end
def get_storage_location_and_descendant_ids(_), do: []
## Components
@doc """
@@ -245,7 +266,9 @@ defmodule ComponentsElixir.Inventory do
where(query, [c], c.category_id in ^category_ids)
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
where(query, [c], c.storage_location_id == ^storage_location_id)
# Get the storage location and all its descendant storage location IDs
storage_location_ids = get_storage_location_and_descendant_ids(storage_location_id)
where(query, [c], c.storage_location_id in ^storage_location_ids)
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
search_pattern = "%#{search_term}%"

View File

@@ -227,9 +227,23 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h3>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h4>
<% end %>
<span class="text-xs text-base-content/50">
({@count_display})
@@ -260,9 +274,23 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h3>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h4>
<% end %>
<%= if @category.description do %>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>

View File

@@ -25,6 +25,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> assign(:search, "")
|> assign(:sort_criteria, "name_asc")
|> assign(:selected_category, nil)
|> assign(:selected_storage_location, nil)
|> assign(:show_advanced_filters, false)
|> assign(:offset, 0)
|> assign(:components, [])
|> assign(:has_more, false)
@@ -53,11 +55,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
def handle_params(params, _uri, socket) do
search = Map.get(params, "search", "")
criteria = Map.get(params, "criteria", "name_asc")
category_id = parse_filter_id(Map.get(params, "category_id"))
storage_location_id = parse_filter_id(Map.get(params, "storage_location_id"))
# Show advanced filters if storage location is being used
show_advanced = not is_nil(storage_location_id)
{:noreply,
socket
|> assign(:search, search)
|> assign(:sort_criteria, criteria)
|> assign(:selected_category, category_id)
|> assign(:selected_storage_location, storage_location_id)
|> assign(:show_advanced_filters, show_advanced)
|> assign(:offset, 0)
|> load_components()}
end
@@ -88,21 +98,57 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
def handle_event("category_filter", %{"category_id" => ""}, socket) do
query_string = build_query_params_without_category(socket)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_category, nil)
|> assign(:offset, 0)
|> load_components()}
|> load_components()
|> push_patch(to: path)}
end
def handle_event("category_filter", %{"category_id" => category_id}, socket) do
category_id = String.to_integer(category_id)
query_string = build_query_params_with_category(socket, category_id)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_category, category_id)
|> assign(:offset, 0)
|> load_components()}
|> load_components()
|> push_patch(to: path)}
end
def handle_event("storage_location_filter", %{"storage_location_id" => ""}, socket) do
query_string = build_query_params_with_storage_location(socket, nil)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_storage_location, nil)
|> assign(:offset, 0)
|> load_components()
|> push_patch(to: path)}
end
def handle_event("storage_location_filter", %{"storage_location_id" => storage_location_id}, socket) do
storage_location_id = String.to_integer(storage_location_id)
query_string = build_query_params_with_storage_location(socket, storage_location_id)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_storage_location, storage_location_id)
|> assign(:offset, 0)
|> load_components()
|> push_patch(to: path)}
end
def handle_event("toggle_advanced_filters", _params, socket) do
{:noreply, assign(socket, :show_advanced_filters, !socket.assigns.show_advanced_filters)}
end
def handle_event("load_more", _params, socket) do
@@ -376,6 +422,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
storage_location_id: socket.assigns.selected_storage_location,
limit: @items_per_page,
offset: socket.assigns.offset
]
@@ -421,7 +468,57 @@ defmodule ComponentsElixirWeb.ComponentsLive do
defp build_query_params(socket, overrides) do
params = %{
search: Map.get(overrides, :search, socket.assigns.search),
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria)
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria),
category_id: Map.get(overrides, :category_id, socket.assigns.selected_category),
storage_location_id: Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp parse_filter_id(nil), do: nil
defp parse_filter_id(""), do: nil
defp parse_filter_id(id) when is_binary(id) do
case Integer.parse(id) do
{int_id, ""} -> int_id
_ -> nil
end
end
defp parse_filter_id(id) when is_integer(id), do: id
defp build_query_params_with_category(socket, category_id) do
params = %{
search: socket.assigns.search,
criteria: socket.assigns.sort_criteria,
category_id: category_id,
storage_location_id: socket.assigns.selected_storage_location
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp build_query_params_with_storage_location(socket, storage_location_id) do
params = %{
search: socket.assigns.search,
criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
storage_location_id: storage_location_id
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp build_query_params_without_category(socket) do
params = %{
search: socket.assigns.search,
criteria: socket.assigns.sort_criteria,
storage_location_id: socket.assigns.selected_storage_location
}
params
@@ -558,7 +655,40 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %>
</form>
</div>
<div class="flex items-end">
<button
phx-click="toggle_advanced_filters"
class="inline-flex items-center px-3 py-2 border border-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<.icon name="hero-adjustments-horizontal" class="w-4 h-4 mr-2" />
<%= if @show_advanced_filters, do: "Hide", else: "More" %> Filters
</button>
</div>
</div>
<!-- Advanced Filters (Collapsible) -->
<%= if @show_advanced_filters do %>
<div class="mt-4 p-4 bg-base-100 border border-base-300 rounded-md">
<div class="flex flex-col sm:flex-row gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">Storage Location</label>
<form phx-change="storage_location_filter">
<select
name="storage_location_id"
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
>
<option value="" selected={is_nil(@selected_storage_location)}>All Storage Locations</option>
<%= for {location_name, location_id} <- Hierarchical.select_options(@storage_locations, &(&1.parent)) do %>
<option value={location_id} selected={@selected_storage_location == location_id}>
{location_name}
</option>
<% end %>
</select>
</form>
</div>
</div>
</div>
<% end %>
</div>
<!-- Add Component Modal -->

View File

@@ -362,9 +362,23 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h4>
<% end %>
<span class="text-xs text-base-content/50">
({@count_display})
@@ -400,9 +414,23 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h4>
<% end %>
<%= if @location.description do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>