feat: filter by category/location on click
- add filtering by storage location
This commit is contained in:
@@ -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}%"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user