921 lines
38 KiB
Elixir
921 lines
38 KiB
Elixir
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"""
|
|
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
|
|
<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 %>
|
|
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
|
|
<% end %>
|
|
</button>
|
|
<% else %>
|
|
<div class="w-6"></div> <!-- Spacer for alignment -->
|
|
<% end %>
|
|
|
|
<.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 (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>
|
|
"""
|
|
end
|
|
|
|
defp list_storage_locations do
|
|
Inventory.list_storage_locations()
|
|
end
|
|
|
|
@impl true
|
|
def render(assigns) do
|
|
~H"""
|
|
<div class="min-h-screen bg-base-200">
|
|
<!-- Header -->
|
|
<div class="bg-base-100 shadow">
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
<div class="flex justify-between items-center py-6">
|
|
<div class="flex items-center space-x-4">
|
|
<.link
|
|
navigate={~p"/"}
|
|
class="text-base-content/60 hover:text-base-content"
|
|
>
|
|
<.icon name="hero-arrow-left" class="w-5 h-5" />
|
|
</.link>
|
|
<h1 class="text-3xl font-bold text-base-content">
|
|
Storage Location Management
|
|
</h1>
|
|
</div>
|
|
<div class="flex items-center space-x-4">
|
|
<button
|
|
phx-click="open_qr_scanner"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
|
>
|
|
<.icon name="hero-qr-code" class="w-4 h-4 mr-2" /> Scan QR
|
|
</button>
|
|
<button
|
|
phx-click="show_add_form"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
>
|
|
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
|
|
</button>
|
|
<.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
|
|
</.link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Add Location Modal -->
|
|
<%= if @show_add_form do %>
|
|
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
|
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
|
<div class="mt-3">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-medium text-base-content">Add New Storage Location</h3>
|
|
<button
|
|
phx-click="hide_add_form"
|
|
class="text-base-content/60 hover:text-base-content"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<.form for={@form} phx-submit="save_location" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">Name</label>
|
|
<.input field={@form[:name]} type="text" required />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">Parent Location</label>
|
|
<.input
|
|
field={@form[:parent_id]}
|
|
type="select"
|
|
options={parent_location_options(@storage_locations)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">Description</label>
|
|
<.input field={@form[:description]} type="textarea" />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">AprilTag ID (Optional)</label>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="apriltag_assignment"
|
|
value="none"
|
|
id="apriltag_none"
|
|
checked={@apriltag_mode == "none"}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-base-300"
|
|
phx-click="set_apriltag_mode"
|
|
phx-value-mode="none"
|
|
/>
|
|
<label for="apriltag_none" class="text-sm text-base-content">
|
|
No AprilTag
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="apriltag_assignment"
|
|
value="auto"
|
|
id="apriltag_auto"
|
|
checked={@apriltag_mode == "auto"}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-base-300"
|
|
phx-click="set_apriltag_mode"
|
|
phx-value-mode="auto"
|
|
/>
|
|
<label for="apriltag_auto" class="text-sm text-base-content">
|
|
Auto-assign next available ID
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="apriltag_assignment"
|
|
value="manual"
|
|
id="apriltag_manual"
|
|
checked={@apriltag_mode == "manual"}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-base-300"
|
|
phx-click="set_apriltag_mode"
|
|
phx-value-mode="manual"
|
|
/>
|
|
<label for="apriltag_manual" class="text-sm text-base-content">
|
|
Choose specific ID
|
|
</label>
|
|
</div>
|
|
<%= if @apriltag_mode == "manual" do %>
|
|
<div class="ml-6 space-y-2">
|
|
<.input
|
|
field={@form[:apriltag_id]}
|
|
type="number"
|
|
min="0"
|
|
max="586"
|
|
placeholder="Enter ID (0-586)"
|
|
class="w-32"
|
|
/>
|
|
<div class="text-xs text-base-content/60">
|
|
Available IDs: <%= length(@available_apriltag_ids) %> of 587
|
|
<%= if length(@available_apriltag_ids) < 20 do %>
|
|
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %>
|
|
<%= if length(@available_apriltag_ids) > 10, do: "..." %>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button
|
|
type="button"
|
|
phx-click="hide_add_form"
|
|
class="px-4 py-2 border border-base-300 rounded-md text-sm font-medium text-base-content hover:bg-base-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-content bg-primary hover:bg-primary/90"
|
|
>
|
|
Save Location
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Edit Location Modal -->
|
|
<%= if @show_edit_form do %>
|
|
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
|
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
|
<div class="mt-3">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-medium text-base-content">Edit Storage Location</h3>
|
|
<button
|
|
phx-click="hide_edit_form"
|
|
class="text-base-content/60 hover:text-base-content"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<.form for={@form} phx-submit="save_edit" class="space-y-4">
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">Name</label>
|
|
<.input field={@form[:name]} type="text" required />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">Parent Location</label>
|
|
<.input
|
|
field={@form[:parent_id]}
|
|
type="select"
|
|
options={parent_location_options(@storage_locations, @editing_location.id)}
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">Description</label>
|
|
<.input field={@form[:description]} type="textarea" />
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-medium text-base-content">AprilTag ID</label>
|
|
<div class="space-y-2">
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="edit_apriltag_assignment"
|
|
value="keep"
|
|
id="edit_apriltag_keep"
|
|
checked={@edit_apriltag_mode == "keep"}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-base-300"
|
|
phx-click="set_edit_apriltag_mode"
|
|
phx-value-mode="keep"
|
|
/>
|
|
<label for="edit_apriltag_keep" class="text-sm text-base-content">
|
|
Keep current assignment
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="edit_apriltag_assignment"
|
|
value="change"
|
|
id="edit_apriltag_change"
|
|
checked={@edit_apriltag_mode == "change"}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-base-300"
|
|
phx-click="set_edit_apriltag_mode"
|
|
phx-value-mode="change"
|
|
/>
|
|
<label for="edit_apriltag_change" class="text-sm text-base-content">
|
|
Change to different ID
|
|
</label>
|
|
</div>
|
|
<div class="flex items-center space-x-2">
|
|
<input
|
|
type="radio"
|
|
name="edit_apriltag_assignment"
|
|
value="remove"
|
|
id="edit_apriltag_remove"
|
|
checked={@edit_apriltag_mode == "remove"}
|
|
class="h-4 w-4 text-primary focus:ring-primary border-base-300"
|
|
phx-click="set_edit_apriltag_mode"
|
|
phx-value-mode="remove"
|
|
/>
|
|
<label for="edit_apriltag_remove" class="text-sm text-base-content">
|
|
Remove AprilTag assignment
|
|
</label>
|
|
</div>
|
|
<%= if @edit_apriltag_mode == "change" do %>
|
|
<div class="ml-6 space-y-2">
|
|
<.input
|
|
field={@form[:apriltag_id]}
|
|
type="number"
|
|
min="0"
|
|
max="586"
|
|
placeholder="Enter new ID (0-586)"
|
|
class="w-32"
|
|
/>
|
|
<div class="text-xs text-base-content/60">
|
|
Available IDs: <%= length(@available_apriltag_ids) %> of 587
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
<p class="text-xs text-base-content/60 mt-1">
|
|
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex justify-end space-x-3 pt-4">
|
|
<button
|
|
type="button"
|
|
phx-click="hide_edit_form"
|
|
class="px-4 py-2 border border-base-300 rounded-md text-sm font-medium text-base-content hover:bg-base-200"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-primary-content bg-primary hover:bg-primary/90"
|
|
>
|
|
Update Location
|
|
</button>
|
|
</div>
|
|
</.form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- AprilTag Scanner Modal -->
|
|
<%= if @apriltag_scanner_open do %>
|
|
<div class="fixed inset-0 bg-base-content/30 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
<div class="relative top-10 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
|
<div class="mt-3">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="text-lg font-medium text-base-content">AprilTag Scanner</h3>
|
|
<button
|
|
phx-click="close_apriltag_scanner"
|
|
class="text-base-content/50 hover:text-base-content"
|
|
>
|
|
<.icon name="hero-x-mark" class="w-6 h-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<!-- AprilTag Scanner Interface -->
|
|
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center">
|
|
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" />
|
|
<p class="mt-2 text-sm text-base-content/70">Camera AprilTag scanner would go here</p>
|
|
<p class="text-xs text-base-content/60 mt-1">In a real implementation, this would use JavaScript AprilTag detection</p>
|
|
|
|
<!-- Test buttons for demo -->
|
|
<div class="mt-4 space-y-2">
|
|
<p class="text-sm font-medium text-base-content/80">Test with sample AprilTag IDs:</p>
|
|
<button
|
|
phx-click="apriltag_scanned"
|
|
phx-value-apriltag_id="0"
|
|
class="block w-full px-3 py-2 text-sm bg-base-200 hover:bg-base-300 rounded"
|
|
>
|
|
Scan AprilTag ID 0
|
|
</button>
|
|
<button
|
|
phx-click="apriltag_scanned"
|
|
phx-value-apriltag_id="1"
|
|
class="block w-full px-3 py-2 text-sm bg-base-200 hover:bg-base-300 rounded"
|
|
>
|
|
Scan AprilTag ID 1
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Scanned Tags Display -->
|
|
<%= if length(@scanned_tags) > 0 do %>
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<h3 class="text-lg font-medium text-green-800">Recently Scanned</h3>
|
|
<button
|
|
phx-click="clear_scanned"
|
|
class="text-sm text-green-600 hover:text-green-800"
|
|
>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
<div class="space-y-2">
|
|
<div :for={scan <- @scanned_tags} class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300">
|
|
<div>
|
|
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span>
|
|
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
|
|
</div>
|
|
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
|
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<% end %>
|
|
|
|
<!-- Storage Locations List -->
|
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
|
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
|
<div class="px-6 py-4 border-b border-base-300">
|
|
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2>
|
|
<p class="text-sm text-base-content/60 mt-1">Manage your physical storage locations and AprilTags</p>
|
|
</div>
|
|
|
|
<%= if Enum.empty?(@storage_locations) do %>
|
|
<div class="text-center py-12">
|
|
<.icon name="hero-building-office" class="mx-auto h-12 w-12 text-base-content/50" />
|
|
<h3 class="mt-2 text-sm font-medium text-base-content">No storage locations</h3>
|
|
<p class="mt-1 text-sm text-base-content/60">
|
|
Get started by creating your first storage location.
|
|
</p>
|
|
<div class="mt-6">
|
|
<button
|
|
phx-click="show_add_form"
|
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
|
>
|
|
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
|
Add Location
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<% else %>
|
|
<div class="divide-y divide-base-300">
|
|
<!-- 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} expanded_locations={@expanded_locations} depth={0} />
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
<% end %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
"""
|
|
end
|
|
end
|