fix(elixir): improve storage location UI
- modify UI and UX to match categories system
This commit is contained in:
@@ -62,6 +62,6 @@ defmodule ComponentsElixir.Auth do
|
||||
|
||||
defp put_session_value(%Phoenix.LiveView.Socket{} = socket, key, value) do
|
||||
session = Map.put(socket.assigns[:session] || %{}, key, value)
|
||||
Phoenix.LiveView.assign(socket, session: session)
|
||||
%{socket | assigns: Map.put(socket.assigns, :session, session)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -348,6 +348,15 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Counts components in a specific storage location.
|
||||
"""
|
||||
def count_components_in_storage_location(storage_location_id) do
|
||||
Component
|
||||
|> where([c], c.storage_location_id == ^storage_location_id)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Increment component stock count.
|
||||
"""
|
||||
|
||||
@@ -4,114 +4,120 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
"""
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.Inventory
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.StorageLocation
|
||||
alias ComponentsElixir.QRCode
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:storage_locations, list_storage_locations())
|
||||
|> assign(:form, to_form(%{}))
|
||||
|> assign(:show_form, false)
|
||||
|> assign(:edit_location, nil)
|
||||
|> assign(:qr_scanner_open, false)
|
||||
|> assign(:scanned_codes, [])
|
||||
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}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(params, _url, socket) do
|
||||
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
||||
end
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Storage Locations")
|
||||
|> assign(:storage_location, %StorageLocation{})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :new, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "New Storage Location")
|
||||
|> assign(:storage_location, %StorageLocation{})
|
||||
|> assign(:show_form, true)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
location = Inventory.get_storage_location!(id)
|
||||
|
||||
socket
|
||||
|> assign(:page_title, "Edit Storage Location")
|
||||
|> assign(:storage_location, location)
|
||||
|> assign(:edit_location, location)
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:form, to_form(Inventory.change_storage_location(location)))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("new", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:storage_location, %StorageLocation{})
|
||||
|> assign(:edit_location, nil)
|
||||
|> assign(:form, to_form(Inventory.change_storage_location(%StorageLocation{})))}
|
||||
end
|
||||
|
||||
def handle_event("cancel", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, false)
|
||||
|> assign(:edit_location, nil)
|
||||
|> push_patch(to: ~p"/storage_locations")}
|
||||
end
|
||||
|
||||
def handle_event("validate", %{"storage_location" => params}, socket) do
|
||||
# Normalize parent_id for validation too
|
||||
normalized_params =
|
||||
case Map.get(params, "parent_id") do
|
||||
"" -> Map.put(params, "parent_id", nil)
|
||||
value -> Map.put(params, "parent_id", value)
|
||||
end
|
||||
|
||||
changeset =
|
||||
case socket.assigns.edit_location do
|
||||
nil -> Inventory.change_storage_location(%StorageLocation{}, normalized_params)
|
||||
location -> Inventory.change_storage_location(location, normalized_params)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"storage_location" => params}, socket) do
|
||||
# Normalize parent_id for consistency
|
||||
normalized_params =
|
||||
case Map.get(params, "parent_id") do
|
||||
"" -> Map.put(params, "parent_id", nil)
|
||||
value -> Map.put(params, "parent_id", value)
|
||||
end
|
||||
|
||||
case socket.assigns.edit_location do
|
||||
nil -> create_storage_location(socket, normalized_params)
|
||||
location -> update_storage_location(socket, location, normalized_params)
|
||||
{: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(:qr_scanner_open, false)
|
||||
|> assign(:scanned_codes, [])
|
||||
|> assign(:page_title, "Storage Location Management")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
@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)}
|
||||
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)}
|
||||
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
|
||||
case Inventory.create_storage_location(location_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, _} ->
|
||||
{:ok, _deleted_location} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Storage location deleted successfully")
|
||||
|> assign(:storage_locations, list_storage_locations())}
|
||||
|> reload_storage_locations()}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Unable to delete storage location")}
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -148,61 +154,416 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
{:noreply, assign(socket, :scanned_codes, [])}
|
||||
end
|
||||
|
||||
defp create_storage_location(socket, params) do
|
||||
case Inventory.create_storage_location(params) do
|
||||
{:ok, _location} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Storage location created successfully")
|
||||
|> assign(:show_form, false)
|
||||
|> assign(:storage_locations, list_storage_locations())}
|
||||
defp reload_storage_locations(socket) do
|
||||
storage_locations = list_storage_locations()
|
||||
assign(socket, :storage_locations, storage_locations)
|
||||
end
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
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 update_storage_location(socket, location, params) do
|
||||
case Inventory.update_storage_location(location, params) do
|
||||
{:ok, _location} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Storage location updated successfully")
|
||||
|> assign(:show_form, false)
|
||||
|> assign(:edit_location, nil)
|
||||
|> assign(:storage_locations, list_storage_locations())
|
||||
|> push_patch(to: ~p"/storage_locations")}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :form, to_form(changeset))}
|
||||
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
|
||||
|
||||
# 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-gray-200 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"""
|
||||
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-blue-500", else: "text-gray-400"} mr-3"} />
|
||||
<div>
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-gray-900", else: "text-gray-700"}"}>{@location.name}</h3>
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-gray-900", else: "text-gray-700"}"}>{@location.name}</h4>
|
||||
<% end %>
|
||||
<%= if @location.description do %>
|
||||
<p class="text-sm text-gray-500 mt-1">{@location.description}</p>
|
||||
<% end %>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<p class="text-xs text-gray-400">
|
||||
{count_components_in_location(@location.id)} components
|
||||
</p>
|
||||
<%= if @location.qr_code do %>
|
||||
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">
|
||||
QR: {@location.qr_code}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@location.id}
|
||||
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
|
||||
>
|
||||
<.icon name="hero-pencil" class={@icon_size} />
|
||||
</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 #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
|
||||
>
|
||||
<.icon name="hero-trash" class={@icon_size} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Render children recursively -->
|
||||
<%= for child <- @children do %>
|
||||
<.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp list_storage_locations do
|
||||
Inventory.list_storage_locations()
|
||||
end
|
||||
|
||||
defp format_level(level) do
|
||||
case level do
|
||||
0 -> "Shelf"
|
||||
1 -> "Drawer"
|
||||
2 -> "Box"
|
||||
n -> "Level #{n}"
|
||||
end
|
||||
end
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- Header -->
|
||||
<div class="bg-white 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-gray-500 hover:text-gray-700"
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-5 h-5" />
|
||||
</.link>
|
||||
<h1 class="text-3xl font-bold text-gray-900">
|
||||
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-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
>
|
||||
<.icon name="hero-cube-transparent" class="w-4 h-4 mr-2" /> Components
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
# Function to get parent options for select dropdown
|
||||
defp parent_options(current_location) do
|
||||
locations = Inventory.list_storage_locations()
|
||||
<!-- Add Location Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Add New Storage Location</h3>
|
||||
<button
|
||||
phx-click="hide_add_form"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
# Filter out the current location if provided (to prevent self-parent)
|
||||
filtered_locations = case current_location do
|
||||
nil -> locations
|
||||
%{id: current_id} -> Enum.filter(locations, fn loc -> loc.id != current_id end)
|
||||
_ -> locations
|
||||
end
|
||||
<.form for={@form} phx-submit="save_location" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<.input field={@form[:name]} type="text" required />
|
||||
</div>
|
||||
|
||||
filtered_locations
|
||||
|> Enum.map(fn location -> {"#{location.name} (#{location.level})", location.id} end)
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">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-gray-700">Description</label>
|
||||
<.input field={@form[:description]} type="textarea" />
|
||||
</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-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Save Location
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Edit Location Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">Edit Storage Location</h3>
|
||||
<button
|
||||
phx-click="hide_edit_form"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<.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-gray-700">Name</label>
|
||||
<.input field={@form[:name]} type="text" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">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-gray-700">Description</label>
|
||||
<.input field={@form[:description]} type="textarea" />
|
||||
</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-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
Update Location
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- QR Scanner Modal -->
|
||||
<%= if @qr_scanner_open do %>
|
||||
<div class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-10 mx-auto p-5 border w-11/12 md:w-1/2 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900">QR Code Scanner</h3>
|
||||
<button
|
||||
phx-click="close_qr_scanner"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Interface -->
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<p class="mt-2 text-sm text-gray-600">Camera QR scanner would go here</p>
|
||||
<p class="text-xs text-gray-500 mt-1">In a real implementation, this would use JavaScript QR scanning</p>
|
||||
|
||||
<!-- Test buttons for demo -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700">Test with sample codes:</p>
|
||||
<button
|
||||
phx-click="qr_scanned"
|
||||
phx-value-code="SL:0:1MTKDM:ROOT"
|
||||
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Scan "Shelf A"
|
||||
</button>
|
||||
<button
|
||||
phx-click="qr_scanned"
|
||||
phx-value-code="SL:1:VDI701:1MTKDM"
|
||||
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Scan "Drawer 1"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Scanned Codes Display -->
|
||||
<%= if length(@scanned_codes) > 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_codes} class="flex items-center justify-between bg-white p-2 rounded border">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900">{location_display_name(scan.location)}</span>
|
||||
<span class="text-sm text-gray-600 ml-2">({scan.code})</span>
|
||||
</div>
|
||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
||||
Level {scan.location.level}
|
||||
</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-white shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h2 class="text-lg font-medium text-gray-900">Storage Location Hierarchy</h2>
|
||||
<p class="text-sm text-gray-500 mt-1">Manage your physical storage locations and QR codes</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-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900">No storage locations</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">
|
||||
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-gray-200">
|
||||
<!-- 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} depth={0} />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900">Storage Locations</h1>
|
||||
<p class="text-gray-600">Manage your physical storage locations and QR codes</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<.link
|
||||
navigate={~p"/"}
|
||||
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
Components
|
||||
</.link>
|
||||
<button
|
||||
phx-click="new"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
New Location
|
||||
</button>
|
||||
|
||||
<button
|
||||
phx-click="open_qr_scanner"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path>
|
||||
</svg>
|
||||
Scan QR Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form Modal -->
|
||||
<div :if={@show_form} class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">
|
||||
<%= if @edit_location, do: "Edit Storage Location", else: "New Storage Location" %>
|
||||
</h3>
|
||||
|
||||
<.form for={@form} phx-submit="save" phx-change="validate" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name</label>
|
||||
<.input field={@form[:name]} type="text" placeholder="Enter location name" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Description</label>
|
||||
<.input field={@form[:description]} type="textarea" placeholder="Optional description" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Parent Location</label>
|
||||
<.input
|
||||
field={@form[:parent_id]}
|
||||
type="select"
|
||||
options={[{"None (Root Level)", ""} | parent_options(@edit_location)]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel"
|
||||
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
|
||||
>
|
||||
<%= if @edit_location, do: "Update", else: "Create" %>
|
||||
</button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Modal -->
|
||||
<div :if={@qr_scanner_open} class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-10 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div class="mt-3">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-900">QR Code Scanner</h3>
|
||||
<button
|
||||
phx-click="close_qr_scanner"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Scanner Interface -->
|
||||
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600">Camera QR scanner would go here</p>
|
||||
<p class="text-xs text-gray-500 mt-1">In a real implementation, this would use JavaScript QR scanning</p>
|
||||
|
||||
<!-- Test buttons for demo -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<p class="text-sm font-medium text-gray-700">Test with sample codes:</p>
|
||||
<button
|
||||
phx-click="qr_scanned"
|
||||
phx-value-code="SL:0:1MTKDM:ROOT"
|
||||
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Scan "Shelf A"
|
||||
</button>
|
||||
<button
|
||||
phx-click="qr_scanned"
|
||||
phx-value-code="SL:1:VDI701:1MTKDM"
|
||||
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Scan "Drawer 1"
|
||||
</button>
|
||||
<button
|
||||
phx-click="qr_scanned"
|
||||
phx-value-code="SL:2:GPG9S8:VDI701"
|
||||
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
|
||||
>
|
||||
Scan "Box 1"
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scanned Codes Display -->
|
||||
<div :if={length(@scanned_codes) > 0} 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_codes} class="flex items-center justify-between bg-white p-2 rounded border">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900"><%= scan.location.path %></span>
|
||||
<span class="text-sm text-gray-600 ml-2">(<%= scan.code %>)</span>
|
||||
</div>
|
||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
||||
Level <%= scan.location.level %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Locations Table -->
|
||||
<div class="bg-white shadow rounded-lg">
|
||||
<div class="px-6 py-4 border-b border-gray-200">
|
||||
<h3 class="text-lg font-medium text-gray-900">Storage Locations</h3>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Location
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Level
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
QR Code
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Description
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr :for={location <- @storage_locations} class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div class="text-sm font-medium text-gray-900">
|
||||
<%= location.path %>
|
||||
<!-- DEBUG: Show actual database values -->
|
||||
<div class="text-xs text-red-600 mt-1">
|
||||
DEBUG - ID: <%= location.id %>, Parent: <%= inspect(location.parent_id) %>, Level: <%= location.level %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
<%= format_level(location.level) %>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<code class="text-sm bg-gray-100 px-2 py-1 rounded">
|
||||
<%= location.qr_code %>
|
||||
</code>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-900 max-w-xs truncate">
|
||||
<%= location.description %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div class="flex space-x-2">
|
||||
<.link
|
||||
patch={~p"/storage_locations/#{location.id}/edit"}
|
||||
class="text-indigo-600 hover:text-indigo-900"
|
||||
>
|
||||
Edit
|
||||
</.link>
|
||||
<button
|
||||
phx-click="delete"
|
||||
phx-value-id={location.id}
|
||||
data-confirm="Are you sure?"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div :if={length(@storage_locations) == 0} class="text-center py-8">
|
||||
<p class="text-gray-500">No storage locations yet. Create one to get started!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Examples -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<h3 class="text-lg font-medium text-blue-800 mb-2">QR Code Examples</h3>
|
||||
<p class="text-sm text-blue-700 mb-3">
|
||||
Here are some sample QR codes generated for your existing storage locations:
|
||||
</p>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div :for={location <- Enum.take(@storage_locations, 4)} class="bg-white p-3 rounded border">
|
||||
<div class="text-sm font-medium text-gray-900"><%= location.path %></div>
|
||||
<code class="text-xs text-gray-600 bg-gray-100 px-2 py-1 rounded mt-1 inline-block">
|
||||
<%= ComponentsElixir.QRCode.generate_qr_data(location) %>
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user