Compare commits

...

3 Commits

Author SHA1 Message Date
Schuwi
086bc65ac1 fix: inconsistent sorting
on components that were inserted in quick succession
2025-09-19 22:43:25 +02:00
Schuwi
c4a0b41e7d feat: filter by category/location on click
- add filtering by storage location
2025-09-19 22:12:58 +02:00
Schuwi
288d84614a feat: collapsable hierarchical view 2025-09-19 21:54:34 +02:00
8 changed files with 578 additions and 122 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}%"
@@ -266,16 +289,16 @@ defmodule ComponentsElixir.Inventory do
defp apply_component_sorting(query, opts) do
case Keyword.get(opts, :sort_criteria, "name_asc") do
"name_asc" -> order_by(query, [c], asc: c.name)
"name_desc" -> order_by(query, [c], desc: c.name)
"inserted_at_asc" -> order_by(query, [c], asc: c.inserted_at)
"inserted_at_desc" -> order_by(query, [c], desc: c.inserted_at)
"updated_at_asc" -> order_by(query, [c], asc: c.updated_at)
"updated_at_desc" -> order_by(query, [c], desc: c.updated_at)
"count_asc" -> order_by(query, [c], asc: c.count)
"count_desc" -> order_by(query, [c], desc: c.count)
"name_asc" -> order_by(query, [c], [asc: c.name, asc: c.id])
"name_desc" -> order_by(query, [c], [desc: c.name, asc: c.id])
"inserted_at_asc" -> order_by(query, [c], [asc: c.inserted_at, asc: c.id])
"inserted_at_desc" -> order_by(query, [c], [desc: c.inserted_at, asc: c.id])
"updated_at_asc" -> order_by(query, [c], [asc: c.updated_at, asc: c.id])
"updated_at_desc" -> order_by(query, [c], [desc: c.updated_at, asc: c.id])
"count_asc" -> order_by(query, [c], [asc: c.count, asc: c.id])
"count_desc" -> order_by(query, [c], [desc: c.count, asc: c.id])
# Default fallback
_ -> order_by(query, [c], asc: c.name)
_ -> order_by(query, [c], [asc: c.name, asc: c.id])
end
end

View File

@@ -18,7 +18,7 @@ defmodule ComponentsElixir.Inventory.Category do
has_many :children, Category, foreign_key: :parent_id
has_many :components, Component
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false

View File

@@ -23,7 +23,7 @@ defmodule ComponentsElixir.Inventory.Component do
belongs_to :category, Category
belongs_to :storage_location, StorageLocation
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false

View File

@@ -275,4 +275,60 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
end)
end
@doc """
Calculates component counts for an entity and all its descendants.
Returns a tuple of {self_count, children_count, total_count}.
## Parameters
- entity_id: The ID of the entity to count for
- all_entities: List of all entities in the hierarchy
- parent_id_accessor_fn: Function to get parent_id from an entity
- count_fn: Function that takes an entity_id and returns the direct count for that entity
## Examples
iex> count_fn = fn id -> MyRepo.count_components_for(id) end
iex> Hierarchical.count_with_descendants(1, entities, &(&1.parent_id), count_fn)
{3, 7, 10} # 3 in self, 7 in children, 10 total
"""
def count_with_descendants(entity_id, all_entities, parent_id_accessor_fn, count_fn) do
# Get direct count for this entity
self_count = count_fn.(entity_id)
# Get all descendant entity IDs (excluding self)
all_descendant_ids = descendant_ids(all_entities, entity_id, parent_id_accessor_fn)
descendant_ids_only = List.delete(all_descendant_ids, entity_id)
# Sum counts for all descendants
children_count = Enum.reduce(descendant_ids_only, 0, fn id, acc ->
acc + count_fn.(id)
end)
{self_count, children_count, self_count + children_count}
end
@doc """
Formats component count display based on expansion state.
When collapsed: Shows total count only: "10 components"
When expanded: Shows breakdown: "10 components (3 self, 7 children)"
## Parameters
- self_count: Number of components directly in this entity
- children_count: Number of components in all descendant entities
- is_expanded: Whether the entity is currently expanded
- singular_noun: What to call a single item (default: "component")
- plural_noun: What to call multiple items (default: "components")
"""
def format_count_display(self_count, children_count, is_expanded, singular_noun \\ "component", plural_noun \\ "components") do
total_count = self_count + children_count
count_noun = if total_count == 1, do: singular_noun, else: plural_noun
if is_expanded and children_count > 0 do
"#{total_count} #{count_noun} (#{self_count} self, #{children_count} children)"
else
"#{total_count} #{count_noun}"
end
end
end

View File

@@ -22,7 +22,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
has_many :children, StorageLocation, foreign_key: :parent_id
has_many :components, Component
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false

View File

@@ -20,6 +20,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:show_edit_form, false)
|> assign(:editing_category, nil)
|> assign(:form, nil)
|> assign(:expanded_categories, MapSet.new())
|> assign(:page_title, "Category Management")}
end
end
@@ -115,6 +116,19 @@ defmodule ComponentsElixirWeb.CategoriesLive do
end
end
def handle_event("toggle_expand", %{"id" => id}, socket) do
category_id = String.to_integer(id)
expanded_categories = socket.assigns.expanded_categories
new_expanded = if MapSet.member?(expanded_categories, category_id) do
MapSet.delete(expanded_categories, category_id)
else
MapSet.put(expanded_categories, category_id)
end
{:noreply, assign(socket, :expanded_categories, new_expanded)}
end
defp reload_categories(socket) do
categories = Inventory.list_categories()
assign(socket, :categories, categories)
@@ -156,6 +170,21 @@ defmodule ComponentsElixirWeb.CategoriesLive do
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
children = child_categories(assigns.categories, assigns.category.id)
has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
# Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants(
assigns.category.id,
assigns.categories,
&(&1.parent_id),
&count_components_in_category/1
)
# Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
@@ -163,50 +192,141 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:button_size, button_size)
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:children, child_categories(assigns.categories, assigns.category.id))
|> assign(:children, children)
|> assign(:has_children, has_children)
|> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center">
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mr-3"} />
<div>
<%= 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>
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex items-start flex-1 space-x-2">
<!-- Expand/Collapse button - always aligned to top -->
<%= if @has_children do %>
<button
phx-click="toggle_expand"
phx-value-id={@category.id}
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
>
<%= if @is_expanded do %>
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
<% end %>
<%= if @category.description do %>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
<% end %>
<p class="text-xs text-base-content/50 mt-1">
{count_components_in_category(@category.id)} components
</p>
</div>
</button>
<% else %>
<div class="w-6"></div> <!-- Spacer for alignment -->
<% end %>
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
<!-- Content area - always starts at same vertical position -->
<div class="flex-1">
<!-- Minimized view (default) -->
<%= unless @is_expanded 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"}>
<.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"}>
<.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})
</span>
</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-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={@category.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 %>
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<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"}>
<.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>
<% end %>
<p class="text-xs text-base-content/50 mt-1">
{@count_display}
</p>
</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 #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
>
<.icon name="hero-pencil" class={@icon_size} />
</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 #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
>
<.icon name="hero-trash" class={@icon_size} />
</button>
</div>
</div>
<% end %>
</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 #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
>
<.icon name="hero-pencil" class={@icon_size} />
</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 #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
>
<.icon name="hero-trash" class={@icon_size} />
</button>
</div>
</div>
<!-- Render children recursively -->
<%= for child <- @children do %>
<.category_item category={child} categories={@categories} depth={@depth + 1} />
<!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %>
<%= for child <- @children do %>
<.category_item category={child} categories={@categories} expanded_categories={@expanded_categories} depth={@depth + 1} />
<% end %>
<% end %>
</div>
"""
@@ -391,7 +511,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<!-- Recursive Category Tree -->
<%= for category <- root_categories(@categories) do %>
<div class="px-6 py-4">
<.category_item category={category} categories={@categories} depth={0} />
<.category_item category={category} categories={@categories} expanded_categories={@expanded_categories} depth={0} />
</div>
<% end %>
</div>

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

@@ -26,6 +26,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:form, nil)
|> assign(:apriltag_scanner_open, false)
|> assign(:scanned_tags, [])
|> assign(:expanded_locations, MapSet.new())
|> assign(:page_title, "Storage Location Management")}
end
end
@@ -183,6 +184,19 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, assign(socket, :scanned_tags, [])}
end
def handle_event("toggle_expand", %{"id" => id}, socket) do
location_id = String.to_integer(id)
expanded_locations = socket.assigns.expanded_locations
new_expanded = if MapSet.member?(expanded_locations, location_id) do
MapSet.delete(expanded_locations, location_id)
else
MapSet.put(expanded_locations, location_id)
end
{:noreply, assign(socket, :expanded_locations, new_expanded)}
end
def handle_event("set_apriltag_mode", %{"mode" => mode}, socket) do
{:noreply, assign(socket, :apriltag_mode, mode)}
end
@@ -290,6 +304,21 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
_ -> "hero-cube" # Box/Container
end
children = child_storage_locations(assigns.storage_locations, assigns.location.id)
has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
# Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants(
assigns.location.id,
assigns.storage_locations,
&(&1.parent_id),
&count_components_in_location/1
)
# Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
@@ -298,82 +327,180 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:icon_name, icon_name)
|> assign(:children, child_storage_locations(assigns.storage_locations, assigns.location.id))
|> assign(:children, children)
|> assign(:has_children, has_children)
|> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
<div class="flex items-center justify-between">
<div class="flex items-center flex-1 space-x-4">
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"}"} />
<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>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<% end %>
<%= if @location.description do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
<% end %>
<div class="flex items-center space-x-2 mt-1">
<p class="text-xs text-base-content/50">
{count_components_in_location(@location.id)} components
</p>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<%= if @location.apriltag_id do %>
<div class="flex items-center space-x-3">
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-base-300 rounded bg-base-100"
onerror="this.style.display='none'"
/>
</div>
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex items-start flex-1 space-x-2">
<!-- Expand/Collapse button - always aligned to top -->
<%= if @has_children do %>
<button
phx-click="toggle_expand"
phx-value-id={@location.id}
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
>
<%= if @is_expanded do %>
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
<% else %>
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
<% end %>
<button
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
</div>
</button>
<% else %>
<div class="w-6"></div> <!-- Spacer for alignment -->
<% end %>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
phx-click="show_edit_form"
phx-value-id={@location.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_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? 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>
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
<!-- Content area - always starts at same vertical position -->
<div class="flex-1">
<!-- Minimized view (default) -->
<%= unless @is_expanded 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"}>
<.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"}>
<.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})
</span>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
phx-click="show_edit_form"
phx-value-id={@location.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_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? 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 %>
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<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"}>
<.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>
<% end %>
<div class="flex items-center space-x-2 mt-1">
<p class="text-xs text-base-content/50">
{@count_display}
</p>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<div class="flex items-start space-x-3 ml-4">
<%= if @location.apriltag_id do %>
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-base-300 rounded bg-base-100"
onerror="this.style.display='none'"
/>
</div>
<% else %>
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<% end %>
<button
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
<% end %>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@location.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_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? 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>
</div>
<% end %>
</div>
</div>
</div>
<!-- Render children recursively -->
<%= for child <- @children do %>
<.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} />
<!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %>
<%= for child <- @children do %>
<.location_item location={child} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={@depth + 1} />
<% end %>
<% end %>
</div>
"""
@@ -780,7 +907,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<!-- Recursive Storage Location Tree -->
<%= for location <- root_storage_locations(@storage_locations) do %>
<div class="px-6 py-4">
<.location_item location={location} storage_locations={@storage_locations} depth={0} />
<.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} />
</div>
<% end %>
</div>