defmodule ComponentsElixirWeb.StorageLocationsLive do @moduledoc """ LiveView for managing storage locations and AprilTags. """ use ComponentsElixirWeb, :live_view alias ComponentsElixir.{Inventory, Auth} alias ComponentsElixir.Inventory.StorageLocation alias ComponentsElixir.AprilTag @impl true def mount(_params, session, socket) do # Check authentication unless Auth.authenticated?(session) do {:ok, socket |> push_navigate(to: ~p"/login")} else 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(:page_title, "Storage Location Management")} 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("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 available_locations = storage_locations |> Enum.reject(fn loc -> loc.id == editing_location_id || (editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id)) end) |> Enum.map(fn location -> {location_display_name(location), location.id} end) [{"No parent (Root location)", nil}] ++ available_locations end defp is_descendant?(storage_locations, descendant_id, ancestor_id) do # Check if descendant_id is a descendant of ancestor_id descendant = Enum.find(storage_locations, fn loc -> loc.id == descendant_id end) case descendant do nil -> false %{parent_id: nil} -> false %{parent_id: parent_id} when parent_id == ancestor_id -> true %{parent_id: parent_id} -> is_descendant?(storage_locations, parent_id, ancestor_id) end end defp location_display_name(location) do if location.path do # Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1" location.path |> String.split("/") |> Enum.join(" > ") else location.name end end defp root_storage_locations(storage_locations) do Enum.filter(storage_locations, fn loc -> is_nil(loc.parent_id) end) end defp child_storage_locations(storage_locations, parent_id) do Enum.filter(storage_locations, fn loc -> loc.parent_id == parent_id end) 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 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, child_storage_locations(assigns.storage_locations, assigns.location.id)) ~H"""
{@location.description}
<% end %>{count_components_in_location(@location.id)} components
<%= if @location.apriltag_id do %> AprilTag: {@location.apriltag_id} <% end %>Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>
Camera AprilTag scanner would go here
In a real implementation, this would use JavaScript AprilTag detection
Test with sample AprilTag IDs:
Manage your physical storage locations and AprilTags
Get started by creating your first storage location.