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: []
|
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
|
## Components
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
@@ -245,7 +266,9 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
where(query, [c], c.category_id in ^category_ids)
|
where(query, [c], c.category_id in ^category_ids)
|
||||||
|
|
||||||
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
|
{: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, search_term}, query when is_binary(search_term) and search_term != "" ->
|
||||||
search_pattern = "%#{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 justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<%= if @title_tag == "h3" do %>
|
<%= 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
<span class="text-xs text-base-content/50">
|
<span class="text-xs text-base-content/50">
|
||||||
({@count_display})
|
({@count_display})
|
||||||
@@ -260,9 +274,23 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<%= if @title_tag == "h3" do %>
|
<%= 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
<%= if @category.description do %>
|
<%= if @category.description do %>
|
||||||
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
|
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
|> assign(:search, "")
|
|> assign(:search, "")
|
||||||
|> assign(:sort_criteria, "name_asc")
|
|> assign(:sort_criteria, "name_asc")
|
||||||
|> assign(:selected_category, nil)
|
|> assign(:selected_category, nil)
|
||||||
|
|> assign(:selected_storage_location, nil)
|
||||||
|
|> assign(:show_advanced_filters, false)
|
||||||
|> assign(:offset, 0)
|
|> assign(:offset, 0)
|
||||||
|> assign(:components, [])
|
|> assign(:components, [])
|
||||||
|> assign(:has_more, false)
|
|> assign(:has_more, false)
|
||||||
@@ -53,11 +55,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
def handle_params(params, _uri, socket) do
|
def handle_params(params, _uri, socket) do
|
||||||
search = Map.get(params, "search", "")
|
search = Map.get(params, "search", "")
|
||||||
criteria = Map.get(params, "criteria", "name_asc")
|
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:search, search)
|
|> assign(:search, search)
|
||||||
|> assign(:sort_criteria, criteria)
|
|> 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)
|
|> assign(:offset, 0)
|
||||||
|> load_components()}
|
|> load_components()}
|
||||||
end
|
end
|
||||||
@@ -88,21 +98,57 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("category_filter", %{"category_id" => ""}, socket) do
|
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:selected_category, nil)
|
|> assign(:selected_category, nil)
|
||||||
|> assign(:offset, 0)
|
|> assign(:offset, 0)
|
||||||
|> load_components()}
|
|> load_components()
|
||||||
|
|> push_patch(to: path)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("category_filter", %{"category_id" => category_id}, socket) do
|
def handle_event("category_filter", %{"category_id" => category_id}, socket) do
|
||||||
category_id = String.to_integer(category_id)
|
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:selected_category, category_id)
|
|> assign(:selected_category, category_id)
|
||||||
|> assign(:offset, 0)
|
|> 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
|
end
|
||||||
|
|
||||||
def handle_event("load_more", _params, socket) do
|
def handle_event("load_more", _params, socket) do
|
||||||
@@ -376,6 +422,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
search: socket.assigns.search,
|
search: socket.assigns.search,
|
||||||
sort_criteria: socket.assigns.sort_criteria,
|
sort_criteria: socket.assigns.sort_criteria,
|
||||||
category_id: socket.assigns.selected_category,
|
category_id: socket.assigns.selected_category,
|
||||||
|
storage_location_id: socket.assigns.selected_storage_location,
|
||||||
limit: @items_per_page,
|
limit: @items_per_page,
|
||||||
offset: socket.assigns.offset
|
offset: socket.assigns.offset
|
||||||
]
|
]
|
||||||
@@ -421,7 +468,57 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
defp build_query_params(socket, overrides) do
|
defp build_query_params(socket, overrides) do
|
||||||
params = %{
|
params = %{
|
||||||
search: Map.get(overrides, :search, socket.assigns.search),
|
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
|
params
|
||||||
@@ -558,7 +655,40 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
<% end %>
|
<% end %>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Add Component Modal -->
|
<!-- Add Component Modal -->
|
||||||
|
|||||||
@@ -362,9 +362,23 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<%= if @title_tag == "h3" do %>
|
<%= 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
<span class="text-xs text-base-content/50">
|
<span class="text-xs text-base-content/50">
|
||||||
({@count_display})
|
({@count_display})
|
||||||
@@ -400,9 +414,23 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
|||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<%= if @title_tag == "h3" do %>
|
<%= 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 %>
|
<% 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 %>
|
<% end %>
|
||||||
<%= if @location.description do %>
|
<%= if @location.description do %>
|
||||||
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
|
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user