diff --git a/lib/components_elixir/inventory/hierarchical.ex b/lib/components_elixir/inventory/hierarchical.ex
index 02153cb..e46a8c6 100644
--- a/lib/components_elixir/inventory/hierarchical.ex
+++ b/lib/components_elixir/inventory/hierarchical.ex
@@ -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
diff --git a/lib/components_elixir_web/live/categories_live.ex b/lib/components_elixir_web/live/categories_live.ex
index aace009..dd7f546 100644
--- a/lib/components_elixir_web/live/categories_live.ex
+++ b/lib/components_elixir_web/live/categories_live.ex
@@ -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,113 @@ 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"""
0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
-
-
-
- <.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mr-3"} />
-
- <%= if @title_tag == "h3" do %>
-
{@category.name}
+
+
+
+ <%= if @has_children do %>
+
+
+ <% else %>
+
+ <% end %>
+
+ <.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
+
+
+
+
+ <%= unless @is_expanded do %>
+
+
+ <%= if @title_tag == "h3" do %>
+
{@category.name}
+ <% else %>
+ {@category.name}
+ <% end %>
+
+ ({@count_display})
+
+
+
+
+
+
+
+ <% end %>
+
+
+ <%= if @is_expanded do %>
+
+
+ <%= if @title_tag == "h3" do %>
+
{@category.name}
+ <% else %>
+
{@category.name}
+ <% end %>
+ <%= if @category.description do %>
+
{@category.description}
+ <% end %>
+
+ {@count_display}
+
+
+
+
+
+
+
+ <% end %>
-
-
-
-
-
- <%= for child <- @children do %>
- <.category_item category={child} categories={@categories} depth={@depth + 1} />
+
+
+ <%= if @is_expanded do %>
+ <%= for child <- @children do %>
+ <.category_item category={child} categories={@categories} expanded_categories={@expanded_categories} depth={@depth + 1} />
+ <% end %>
<% end %>
"""
@@ -391,7 +483,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<%= for category <- root_categories(@categories) do %>
- <.category_item category={category} categories={@categories} depth={0} />
+ <.category_item category={category} categories={@categories} expanded_categories={@expanded_categories} depth={0} />
<% end %>
diff --git a/lib/components_elixir_web/live/storage_locations_live.ex b/lib/components_elixir_web/live/storage_locations_live.ex
index 7cde1ca..1ab3f82 100644
--- a/lib/components_elixir_web/live/storage_locations_live.ex
+++ b/lib/components_elixir_web/live/storage_locations_live.ex
@@ -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,152 @@ 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"""
0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
-
-
- <.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"}"} />
-
- <%= if @title_tag == "h3" do %>
-
{@location.name}
- <% else %>
-
{@location.name}
- <% end %>
- <%= if @location.description do %>
-
{@location.description}
- <% end %>
-
-
- {count_components_in_location(@location.id)} components
-
- <%= if @location.apriltag_id do %>
-
- AprilTag: {@location.apriltag_id}
-
- <% end %>
-
-
- <%= if @location.apriltag_id do %>
-
- <%= if get_apriltag_url(@location) do %>
-
-
})
-
+
+
+
+ <%= if @has_children do %>
+
+
+ <% else %>
+
<% end %>
-
-
-
- <.icon name="hero-pencil" class="w-4 h-4" />
-
-
- <.icon name="hero-trash" class="w-4 h-4" />
-
+
+ <.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
+
+
+
+
+ <%= unless @is_expanded do %>
+
+
+ <%= if @title_tag == "h3" do %>
+
{@location.name}
+ <% else %>
+ {@location.name}
+ <% end %>
+
+ ({@count_display})
+
+ <%= if @location.apriltag_id do %>
+
+ AprilTag: {@location.apriltag_id}
+
+ <% end %>
+
+
+
+ <.icon name="hero-pencil" class="w-3 h-3" />
+
+
+ <.icon name="hero-trash" class="w-3 h-3" />
+
+
+
+ <% end %>
+
+
+ <%= if @is_expanded do %>
+
+
+ <%= if @title_tag == "h3" do %>
+
{@location.name}
+ <% else %>
+
{@location.name}
+ <% end %>
+ <%= if @location.description do %>
+
{@location.description}
+ <% end %>
+
+
+ {@count_display}
+
+ <%= if @location.apriltag_id do %>
+
+ AprilTag: {@location.apriltag_id}
+
+ <% end %>
+
+
+
+ <%= if @location.apriltag_id do %>
+ <%= if get_apriltag_url(@location) do %>
+
+
})
+
+ <% else %>
+
+ <.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
+
+ <% end %>
+
+ <.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
+ Download
+
+ <% end %>
+
+
+ <.icon name="hero-pencil" class="w-4 h-4" />
+
+
+ <.icon name="hero-trash" class="w-4 h-4" />
+
+
+
+
+ <% end %>
+
-
- <%= for child <- @children do %>
- <.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} />
+
+
+ <%= 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 %>
"""
@@ -780,7 +879,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<%= for location <- root_storage_locations(@storage_locations) do %>
- <.location_item location={location} storage_locations={@storage_locations} depth={0} />
+ <.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} />
<% end %>