defmodule ComponentsElixirWeb.StorageLocationsLive do @moduledoc """ LiveView for managing storage locations and AprilTags. """ use ComponentsElixirWeb, :live_view alias ComponentsElixir.{Inventory, Auth} alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical} alias ComponentsElixir.AprilTag @impl true def mount(_params, session, socket) do # Check authentication if Auth.authenticated?(session) do storage_locations = list_storage_locations() {:ok, socket |> assign(:session, session) |> assign(:storage_locations, storage_locations) |> assign(:show_add_form, false) |> assign(:show_edit_form, false) |> assign(:editing_location, nil) |> assign(:form, nil) |> assign(:apriltag_scanner_open, false) |> assign(:scanned_tags, []) |> assign(:expanded_locations, MapSet.new()) |> assign(:page_title, "Storage Location Management")} else {:ok, socket |> push_navigate(to: ~p"/login")} end end @impl true def handle_event("show_add_form", _params, socket) do changeset = Inventory.change_storage_location(%StorageLocation{}) form = to_form(changeset) {:noreply, socket |> assign(:show_add_form, true) |> assign(:form, form) |> assign(:available_apriltag_ids, AprilTag.available_apriltag_ids()) |> assign(:apriltag_mode, "auto")} end def handle_event("hide_add_form", _params, socket) do {:noreply, socket |> assign(:show_add_form, false) |> assign(:form, nil)} end def handle_event("show_edit_form", %{"id" => id}, socket) do location = Inventory.get_storage_location!(id) # Create a changeset with current values forced into changes for proper form display changeset = Inventory.change_storage_location(location, %{ name: location.name, description: location.description, parent_id: location.parent_id }) |> Ecto.Changeset.force_change(:parent_id, location.parent_id) form = to_form(changeset) {:noreply, socket |> assign(:show_edit_form, true) |> assign(:editing_location, location) |> assign(:form, form) |> assign(:available_apriltag_ids, AprilTag.available_apriltag_ids()) |> assign(:edit_apriltag_mode, "keep")} end def handle_event("hide_edit_form", _params, socket) do {:noreply, socket |> assign(:show_edit_form, false) |> assign(:editing_location, nil) |> assign(:form, nil)} end def handle_event("save_location", %{"storage_location" => location_params}, socket) do # Process AprilTag assignment based on mode processed_params = case socket.assigns.apriltag_mode do "none" -> # Remove any apriltag_id from params to ensure it's nil Map.delete(location_params, "apriltag_id") "auto" -> # Auto-assign next available AprilTag ID case AprilTag.next_available_apriltag_id() do nil -> # No available IDs, proceed without AprilTag Map.delete(location_params, "apriltag_id") apriltag_id -> Map.put(location_params, "apriltag_id", apriltag_id) end "manual" -> # Use the manually entered apriltag_id (validation will be handled by changeset) location_params _ -> # Fallback: remove apriltag_id Map.delete(location_params, "apriltag_id") end case Inventory.create_storage_location(processed_params) do {:ok, _location} -> {:noreply, socket |> put_flash(:info, "Storage location created successfully") |> assign(:show_add_form, false) |> assign(:form, nil) |> reload_storage_locations()} {:error, changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end def handle_event("save_edit", %{"storage_location" => location_params}, socket) do case Inventory.update_storage_location(socket.assigns.editing_location, location_params) do {:ok, _location} -> {:noreply, socket |> put_flash(:info, "Storage location updated successfully") |> assign(:show_edit_form, false) |> assign(:editing_location, nil) |> assign(:form, nil) |> reload_storage_locations()} {:error, changeset} -> {:noreply, assign(socket, :form, to_form(changeset))} end end def handle_event("delete_location", %{"id" => id}, socket) do location = Inventory.get_storage_location!(id) case Inventory.delete_storage_location(location) do {:ok, _deleted_location} -> {:noreply, socket |> put_flash(:info, "Storage location deleted successfully") |> reload_storage_locations()} {:error, _changeset} -> {:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")} end end def handle_event("open_apriltag_scanner", _params, socket) do {:noreply, assign(socket, :apriltag_scanner_open, true)} end def handle_event("close_apriltag_scanner", _params, socket) do {:noreply, assign(socket, :apriltag_scanner_open, false)} end def handle_event("apriltag_scanned", %{"apriltag_id" => apriltag_id_str}, socket) do case Integer.parse(apriltag_id_str) do {apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 -> case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do nil -> {:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")} location -> scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags] {:noreply, socket |> assign(:scanned_tags, scanned_tags) |> put_flash(:info, "Scanned: #{location.path}")} end _ -> {:noreply, put_flash(socket, :error, "Invalid AprilTag ID: #{apriltag_id_str}")} end end def handle_event("clear_scanned", _params, socket) 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 def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do # Clear the apriltag_id field when switching modes form = case mode do "remove" -> socket.assigns.form |> Phoenix.Component.to_form() |> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil)) "keep" -> current_id = socket.assigns.editing_location.apriltag_id socket.assigns.form |> Phoenix.Component.to_form() |> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)) _ -> socket.assigns.form end {:noreply, socket |> assign(:edit_apriltag_mode, mode) |> assign(:form, form)} end def handle_event("download_apriltag", %{"id" => id}, socket) do case Inventory.get_storage_location!(id) do %{apriltag_id: nil} -> {:noreply, put_flash(socket, :error, "No AprilTag assigned to this location")} location -> case AprilTag.get_apriltag_url(location) do nil -> {:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")} apriltag_url -> filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg" # Send file download to browser {:noreply, socket |> push_event("download_apriltag", %{ filename: filename, url: apriltag_url, apriltag_id: location.apriltag_id })} end end end defp reload_storage_locations(socket) do storage_locations = list_storage_locations() assign(socket, :storage_locations, storage_locations) end defp parent_location_options(storage_locations, editing_location_id \\ nil) do Hierarchical.parent_select_options( storage_locations, editing_location_id, &(&1.parent), "No parent (Root location)" ) end defp location_display_name(location) do StorageLocation.full_path(location) end defp root_storage_locations(storage_locations) do Hierarchical.root_entities(storage_locations, &(&1.parent_id)) end defp child_storage_locations(storage_locations, parent_id) do Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id)) end defp count_components_in_location(location_id) do Inventory.count_components_in_storage_location(location_id) end defp get_apriltag_url(location) do AprilTag.get_apriltag_url(location) end # Component for rendering individual storage location items with QR code support defp location_item(assigns) do # Calculate margin based on depth (0 = no margin, 1+ = incremental margin) margin_left = if assigns.depth == 0, do: 0, else: 32 + assigns.depth * 16 # Determine border style based on depth border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: "" # Icon size and button size based on depth {icon_size, button_size, text_size, title_tag} = case assigns.depth do 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} _ -> {"w-3 h-3", "p-1", "text-sm", "h5"} end # Different icons based on level - QR code is always present for storage locations icon_name = case assigns.depth do 0 -> "hero-building-office" # Shelf/Room 1 -> "hero-archive-box" # Drawer/Cabinet _ -> "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) |> assign(:icon_size, icon_size) |> assign(:button_size, button_size) |> assign(:text_size, text_size) |> assign(:title_tag, title_tag) |> assign(:icon_name, icon_name) |> 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"}>
<%= 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 %>

<.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}

<% else %>

<.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}

<% end %> ({@count_display}) <%= if @location.apriltag_id do %> AprilTag: {@location.apriltag_id} <% end %>
<% end %> <%= if @is_expanded do %>
<%= if @title_tag == "h3" do %>

<.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}

<% else %>

<.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}

<% 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 %>
<%= 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 %>
""" end defp list_storage_locations do Inventory.list_storage_locations() end @impl true def render(assigns) do ~H"""
<.link navigate={~p"/"} class="text-base-content/60 hover:text-base-content" > <.icon name="hero-arrow-left" class="w-5 h-5" />

Storage Location Management

<.link navigate={~p"/"} class="inline-flex items-center px-4 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-cube-transparent" class="w-4 h-4 mr-2" /> Components
<%= if @show_add_form do %>

Add New Storage Location

<.form for={@form} phx-submit="save_location" class="space-y-4">
<.input field={@form[:name]} type="text" required />
<.input field={@form[:parent_id]} type="select" options={parent_location_options(@storage_locations)} />
<.input field={@form[:description]} type="textarea" />
<%= if @apriltag_mode == "manual" do %>
<.input field={@form[:apriltag_id]} type="number" min="0" max="586" placeholder="Enter ID (0-586)" class="w-32" />
Available IDs: <%= length(@available_apriltag_ids) %> of 587 <%= if length(@available_apriltag_ids) < 20 do %>
Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %> <%= if length(@available_apriltag_ids) > 10, do: "..." %> <% end %>
<% end %>
<% end %> <%= if @show_edit_form do %>

Edit Storage Location

<.form for={@form} phx-submit="save_edit" class="space-y-4">
<.input field={@form[:name]} type="text" required />
<.input field={@form[:parent_id]} type="select" options={parent_location_options(@storage_locations, @editing_location.id)} />
<.input field={@form[:description]} type="textarea" />
<%= if @edit_apriltag_mode == "change" do %>
<.input field={@form[:apriltag_id]} type="number" min="0" max="586" placeholder="Enter new ID (0-586)" class="w-32" />
Available IDs: <%= length(@available_apriltag_ids) %> of 587
<% end %>

Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>

<% end %> <%= if @apriltag_scanner_open do %>

AprilTag Scanner

<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" />

Camera AprilTag scanner would go here

In a real implementation, this would use JavaScript AprilTag detection

Test with sample AprilTag IDs:

<% end %> <%= if length(@scanned_tags) > 0 do %>

Recently Scanned

{location_display_name(scan.location)} (AprilTag ID {scan.apriltag_id})
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
<% end %>

Storage Location Hierarchy

Manage your physical storage locations and AprilTags

<%= if Enum.empty?(@storage_locations) do %>
<.icon name="hero-building-office" class="mx-auto h-12 w-12 text-base-content/50" />

No storage locations

Get started by creating your first storage location.

<% else %>
<%= for location <- root_storage_locations(@storage_locations) do %>
<.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} />
<% end %>
<% end %>
""" end end