Files
component-system/lib/components_elixir_web/live/storage_locations_live.ex

621 lines
24 KiB
Elixir

defmodule ComponentsElixirWeb.StorageLocationsLive do
@moduledoc """
LiveView for managing storage locations and QR codes.
"""
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.StorageLocation
alias ComponentsElixir.QRCode
@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(:qr_scanner_open, false)
|> assign(:scanned_codes, [])
|> 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)}
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, _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_qr_scanner", _params, socket) do
{:noreply, assign(socket, :qr_scanner_open, true)}
end
def handle_event("close_qr_scanner", _params, socket) do
{:noreply, assign(socket, :qr_scanner_open, false)}
end
def handle_event("qr_scanned", %{"code" => code}, socket) do
case QRCode.parse_qr_data(code) do
{:ok, parsed} ->
case Inventory.get_storage_location_by_qr_code(parsed.code) do
nil ->
{:noreply, put_flash(socket, :error, "Storage location not found for QR code: #{code}")}
location ->
scanned_codes = [%{code: code, location: location} | socket.assigns.scanned_codes]
{:noreply,
socket
|> assign(:scanned_codes, scanned_codes)
|> put_flash(:info, "Scanned: #{location.path}")}
end
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Invalid QR code: #{reason}")}
end
end
def handle_event("clear_scanned", _params, socket) do
{:noreply, assign(socket, :scanned_codes, [])}
end
def handle_event("download_qr", %{"id" => id}, socket) do
case Inventory.get_storage_location!(id) do
location ->
case QRCode.generate_qr_image(location) do
{:ok, png_data} ->
filename = "#{location.name |> String.replace(" ", "_")}_QR.png"
# Send file download to browser
{:noreply,
socket
|> push_event("download_file", %{
filename: filename,
data: Base.encode64(png_data),
mime_type: "image/png"
})}
{:error, _reason} ->
{:noreply, put_flash(socket, :error, "Failed to generate QR code")}
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_qr_image_url(location) do
QRCode.get_qr_image_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-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 items-center flex-1 space-x-4">
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-blue-500", else: "text-gray-400"}"} />
<div class="flex-1">
<%= 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>
<%= if @location.qr_code do %>
<div class="flex items-center space-x-3">
<%= if get_qr_image_url(@location) do %>
<div class="qr-code-container flex-shrink-0">
<img
src={get_qr_image_url(@location)}
alt={"QR Code for #{@location.name}"}
class="w-16 h-16 border border-gray-200 rounded bg-white"
onerror="this.style.display='none'"
/>
</div>
<% else %>
<div class="w-16 h-16 border border-gray-200 rounded bg-gray-50 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-gray-400" />
</div>
<% end %>
<button
phx-click="download_qr"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 flex-shrink-0"
title="Download QR Code"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
</div>
<% 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-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>
<!-- 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
@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>
<!-- 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>
<.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>
<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