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 %> -
- {"AprilTag -
+
+
+ + <%= if @has_children do %> + -
+ + <% else %> +
<% end %> -
-
- - + + <.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 %> +
+
+ + +
+
+ <% 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 %> +
+ {"AprilTag +
+ <% else %> +
+ <.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" /> +
+ <% end %> + + <% end %> +
+ + +
+
+
+ <% 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 %>