style: format codebase

This commit is contained in:
Schuwi
2025-09-20 11:52:43 +02:00
parent aaf278f7f9
commit c6c218970c
20 changed files with 722 additions and 385 deletions

View File

@@ -16,6 +16,7 @@ config :components_elixir, ComponentsElixir.Repo,
# For development, use a local uploads directory # For development, use a local uploads directory
config :components_elixir, config :components_elixir,
uploads_dir: "./uploads" uploads_dir: "./uploads"
# #
# The watchers configuration can be used to run external # The watchers configuration can be used to run external
# watchers to your application. For example, we can use it # watchers to your application. For example, we can use it

View File

@@ -31,6 +31,7 @@ defmodule ComponentsElixir.AprilTag do
def valid_apriltag_id?(id) when is_integer(id) do def valid_apriltag_id?(id) when is_integer(id) do
id >= 0 and id < @tag36h11_count id >= 0 and id < @tag36h11_count
end end
def valid_apriltag_id?(_), do: false def valid_apriltag_id?(_), do: false
@doc """ @doc """
@@ -78,8 +79,8 @@ defmodule ComponentsElixir.AprilTag do
def used_apriltag_ids do def used_apriltag_ids do
ComponentsElixir.Repo.all( ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation, from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id), where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id select: sl.apriltag_id
) )
end end
@@ -130,10 +131,11 @@ defmodule ComponentsElixir.AprilTag do
This should be run once during setup to pre-generate all AprilTag images. This should be run once during setup to pre-generate all AprilTag images.
""" """
def generate_all_apriltag_svgs(opts \\ []) do def generate_all_apriltag_svgs(opts \\ []) do
static_dir = Path.join([ static_dir =
Application.app_dir(:components_elixir, "priv/static"), Path.join([
"apriltags" Application.app_dir(:components_elixir, "priv/static"),
]) "apriltags"
])
# Ensure directory exists # Ensure directory exists
File.mkdir_p!(static_dir) File.mkdir_p!(static_dir)
@@ -187,10 +189,12 @@ defmodule ComponentsElixir.AprilTag do
""" """
def cleanup_apriltag_svg(apriltag_id) do def cleanup_apriltag_svg(apriltag_id) do
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg" filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
file_path = Path.join([
Application.app_dir(:components_elixir, "priv/static/apriltags"), file_path =
filename Path.join([
]) Application.app_dir(:components_elixir, "priv/static/apriltags"),
filename
])
if File.exists?(file_path) do if File.exists?(file_path) do
File.rm(file_path) File.rm(file_path)

View File

@@ -16,6 +16,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
[_, id_str, hex_pattern] -> [_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)} {String.to_integer(id_str), String.downcase(hex_pattern)}
_ -> _ ->
nil nil
end end
@@ -23,26 +24,30 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
# Extract patterns from PostScript file at compile time # Extract patterns from PostScript file at compile time
@all_patterns ( @all_patterns (
path = Path.join([File.cwd!(), "apriltags.ps"]) path = Path.join([File.cwd!(), "apriltags.ps"])
if File.exists?(path) do if File.exists?(path) do
File.read!(path) File.read!(path)
|> String.split("\n") |> String.split("\n")
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11")) |> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|> Enum.map(fn line -> |> Enum.map(fn line ->
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do case Regex.run(
[_, id_str, hex_pattern] -> ~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/,
{String.to_integer(id_str), String.downcase(hex_pattern)} line
_ -> ) do
nil [_, id_str, hex_pattern] ->
end {String.to_integer(id_str), String.downcase(hex_pattern)}
end)
|> Enum.reject(&is_nil/1) _ ->
|> Map.new() nil
else end
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation." end)
end |> Enum.reject(&is_nil/1)
) |> Map.new()
else
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
end
)
# Sample of real tag36h11 hex patterns from AprilRobotics repository # Sample of real tag36h11 hex patterns from AprilRobotics repository
# This will be populated with patterns extracted from the PostScript file # This will be populated with patterns extracted from the PostScript file
@@ -64,7 +69,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
|> Map.new() |> Map.new()
else else
%{} # Return empty map if file not found, will fall back to hardcoded patterns # Return empty map if file not found, will fall back to hardcoded patterns
%{}
end end
end end
@@ -76,6 +82,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do
Map.get(@tag36h11_patterns, id) Map.get(@tag36h11_patterns, id)
end end
def get_hex_pattern(_), do: nil def get_hex_pattern(_), do: nil
@doc """ @doc """
@@ -97,6 +104,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
# Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp) # Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp)
row_bytes = 3 row_bytes = 3
rows = rows =
for row <- 0..9 do for row <- 0..9 do
<<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes <<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes
@@ -104,12 +112,23 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
samples = samples =
[ [
b0 >>> 6 &&& 0x3, b0 >>> 4 &&& 0x3, b0 >>> 2 &&& 0x3, b0 &&& 0x3, b0 >>> 6 &&& 0x3,
b1 >>> 6 &&& 0x3, b1 >>> 4 &&& 0x3, b1 >>> 2 &&& 0x3, b1 &&& 0x3, b0 >>> 4 &&& 0x3,
b2 >>> 6 &&& 0x3, b2 >>> 4 &&& 0x3, b2 >>> 2 &&& 0x3, b2 &&& 0x3 b0 >>> 2 &&& 0x3,
b0 &&& 0x3,
b1 >>> 6 &&& 0x3,
b1 >>> 4 &&& 0x3,
b1 >>> 2 &&& 0x3,
b1 &&& 0x3,
b2 >>> 6 &&& 0x3,
b2 >>> 4 &&& 0x3,
b2 >>> 2 &&& 0x3,
b2 &&& 0x3
] ]
|> Enum.take(10) # drop the 2 padding samples at end of row # drop the 2 padding samples at end of row
|> Enum.map(&(&1 == 0)) # 0 = black, 3 = white → boolean |> Enum.take(10)
# 0 = black, 3 = white → boolean
|> Enum.map(&(&1 == 0))
samples samples
end end
@@ -133,7 +152,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
Only black modules are drawn over a white background. Only black modules are drawn over a white background.
""" """
def binary_matrix_to_svg(binary_matrix, opts \\ []) do def binary_matrix_to_svg(binary_matrix, opts \\ []) do
size = Keyword.get(opts, :size, 200) # final CSS size in px # final CSS size in px
size = Keyword.get(opts, :size, 200)
id_text = Keyword.get(opts, :id_text, "") id_text = Keyword.get(opts, :id_text, "")
# binary_matrix is 10x10 of booleans: true=black, false=white # binary_matrix is 10x10 of booleans: true=black, false=white
@@ -172,9 +192,11 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
<!-- caption --> <!-- caption -->
#{if id_text != "" do #{if id_text != "" do
~s(<text x="#{modules_w/2}" y="#{modules_h + 1.4}" text-anchor="middle" ~s(<text x="#{modules_w / 2}" y="#{modules_h + 1.4}" text-anchor="middle"
font-family="Arial" font-size="0.9">#{id_text}</text>) font-family="Arial" font-size="0.9">#{id_text}</text>)
else "" end} else
""
end}
</svg> </svg>
""" """
end end
@@ -197,11 +219,13 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
opts_with_id = Keyword.put(opts, :id_text, id_text) opts_with_id = Keyword.put(opts, :id_text, id_text)
binary_matrix_to_svg(binary_matrix, opts_with_id) binary_matrix_to_svg(binary_matrix, opts_with_id)
end end
end # Generate a placeholder pattern for IDs we don't have real data for yet end
# Generate a placeholder pattern for IDs we don't have real data for yet
defp generate_placeholder_svg(id, opts) do defp generate_placeholder_svg(id, opts) do
size = Keyword.get(opts, :size, 200) size = Keyword.get(opts, :size, 200)
margin = Keyword.get(opts, :margin, div(size, 10)) margin = Keyword.get(opts, :margin, div(size, 10))
square_size = size - (2 * margin) square_size = size - 2 * margin
""" """
<svg width="#{size}" height="#{size + 30}" xmlns="http://www.w3.org/2000/svg"> <svg width="#{size}" height="#{size + 30}" xmlns="http://www.w3.org/2000/svg">

View File

@@ -28,6 +28,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
case URI.parse(url) do case URI.parse(url) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> %URI{scheme: scheme} when scheme in ["http", "https"] ->
{:ok, URI.parse(url)} {:ok, URI.parse(url)}
_ -> _ ->
{:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."} {:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."}
end end
@@ -36,9 +37,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp generate_filename(url) do defp generate_filename(url) do
# Try to extract a meaningful filename from the URL # Try to extract a meaningful filename from the URL
uri = URI.parse(url) uri = URI.parse(url)
original_filename = original_filename =
case Path.basename(uri.path || "") do case Path.basename(uri.path || "") do
"" -> "datasheet" "" ->
"datasheet"
basename -> basename ->
# Remove extension and sanitize # Remove extension and sanitize
basename basename
@@ -54,10 +58,14 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp sanitize_filename(filename) do defp sanitize_filename(filename) do
filename filename
|> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores # Replace non-word chars with underscores
|> String.replace(~r/_+/, "_") # Replace multiple underscores with single |> String.replace(~r/[^\w\-_]/, "_")
|> String.trim("_") # Remove leading/trailing underscores # Replace multiple underscores with single
|> String.slice(0, 50) # Limit length |> String.replace(~r/_+/, "_")
# Remove leading/trailing underscores
|> String.trim("_")
# Limit length
|> String.slice(0, 50)
|> case do |> case do
"" -> "datasheet" "" -> "datasheet"
name -> name name -> name
@@ -66,17 +74,19 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp fetch_pdf(url) do defp fetch_pdf(url) do
case Req.get(url, case Req.get(url,
redirect: true, redirect: true,
max_redirects: 5, max_redirects: 5,
receive_timeout: 30_000, receive_timeout: 30_000,
headers: [ headers: [
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"} {"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
] ]
) do ) do
{:ok, %Req.Response{status: 200} = response} -> {:ok, %Req.Response{status: 200} = response} ->
{:ok, response} {:ok, response}
{:ok, %Req.Response{status: status}} -> {:ok, %Req.Response{status: status}} ->
{:error, "HTTP error: #{status}"} {:error, "HTTP error: #{status}"}
{:error, reason} -> {:error, reason} ->
Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}") Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}")
{:error, "Download failed: #{inspect(reason)}"} {:error, "Download failed: #{inspect(reason)}"}
@@ -88,6 +98,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
case body do case body do
<<"%PDF", _rest::binary>> -> <<"%PDF", _rest::binary>> ->
:ok :ok
_ -> _ ->
{:error, "Downloaded content is not a valid PDF file"} {:error, "Downloaded content is not a valid PDF file"}
end end
@@ -105,10 +116,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
:ok -> :ok ->
Logger.info("Successfully saved datasheet: #{filename}") Logger.info("Successfully saved datasheet: #{filename}")
:ok :ok
{:error, reason} -> {:error, reason} ->
Logger.error("Failed to save datasheet file: #{inspect(reason)}") Logger.error("Failed to save datasheet file: #{inspect(reason)}")
{:error, "Failed to save file: #{inspect(reason)}"} {:error, "Failed to save file: #{inspect(reason)}"}
end end
{:error, reason} -> {:error, reason} ->
Logger.error("Failed to create datasheets directory: #{inspect(reason)}") Logger.error("Failed to create datasheets directory: #{inspect(reason)}")
{:error, "Failed to create directory: #{inspect(reason)}"} {:error, "Failed to create directory: #{inspect(reason)}"}
@@ -129,6 +142,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
:ok -> :ok ->
Logger.info("Deleted datasheet file: #{filename}") Logger.info("Deleted datasheet file: #{filename}")
:ok :ok
{:error, reason} -> {:error, reason} ->
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}") Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
{:error, reason} {:error, reason}

View File

@@ -19,7 +19,7 @@ defmodule ComponentsElixir.Inventory do
locations = locations =
StorageLocation StorageLocation
|> order_by([sl], asc: sl.name) |> order_by([sl], asc: sl.name)
|> preload([parent: [parent: [parent: [parent: :parent]]]]) |> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all() |> Repo.all()
# Ensure AprilTag SVGs exist for all locations # Ensure AprilTag SVGs exist for all locations
@@ -162,7 +162,7 @@ defmodule ComponentsElixir.Inventory do
""" """
def list_categories do def list_categories do
Category Category
|> preload([parent: [parent: [parent: [parent: :parent]]]]) |> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all() |> Repo.all()
end end
@@ -217,8 +217,15 @@ defmodule ComponentsElixir.Inventory do
# Verify the category exists before getting descendants # Verify the category exists before getting descendants
case Enum.find(categories, &(&1.id == category_id)) do case Enum.find(categories, &(&1.id == category_id)) do
nil -> [] nil ->
_category -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(categories, category_id, &(&1.parent_id)) []
_category ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
categories,
category_id,
& &1.parent_id
)
end end
end end
@@ -233,13 +240,21 @@ defmodule ComponentsElixir.Inventory do
for typical storage location tree sizes (hundreds of locations). For very large storage location trees, for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
a recursive CTE query could be used instead. a recursive CTE query could be used instead.
""" """
def get_storage_location_and_descendant_ids(storage_location_id) when is_integer(storage_location_id) do def get_storage_location_and_descendant_ids(storage_location_id)
when is_integer(storage_location_id) do
storage_locations = list_storage_locations() storage_locations = list_storage_locations()
# Verify the storage location exists before getting descendants # Verify the storage location exists before getting descendants
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
nil -> [] nil ->
_storage_location -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(storage_locations, storage_location_id, &(&1.parent_id)) []
_storage_location ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
storage_locations,
storage_location_id,
& &1.parent_id
)
end end
end end
@@ -306,7 +321,7 @@ defmodule ComponentsElixir.Inventory do
} }
defp get_sort_order(criteria) do defp get_sort_order(criteria) do
Map.get(@sort_orders, criteria, [asc: :name, asc: :id]) Map.get(@sort_orders, criteria, asc: :name, asc: :id)
end end
@doc """ @doc """
@@ -338,10 +353,12 @@ defmodule ComponentsElixir.Inventory do
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
{:ok, filename} -> {:ok, filename} ->
Map.put(attrs, "datasheet_filename", filename) Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} -> {:error, _reason} ->
# Continue without datasheet file if download fails # Continue without datasheet file if download fails
attrs attrs
end end
_ -> _ ->
attrs attrs
end end
@@ -372,13 +389,18 @@ defmodule ComponentsElixir.Inventory do
{:ok, filename} -> {:ok, filename} ->
# Delete old datasheet file if it exists # Delete old datasheet file if it exists
if component.datasheet_filename do if component.datasheet_filename do
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename) ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
component.datasheet_filename
)
end end
Map.put(attrs, "datasheet_filename", filename) Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} -> {:error, _reason} ->
# Keep existing filename if download fails # Keep existing filename if download fails
attrs attrs
end end
_ -> _ ->
attrs attrs
end end

View File

@@ -37,7 +37,7 @@ defmodule ComponentsElixir.Inventory.Category do
""" """
@impl true @impl true
def full_path(%Category{} = category) do def full_path(%Category{} = category) do
Hierarchical.full_path(category, &(&1.parent), path_separator()) Hierarchical.full_path(category, & &1.parent, path_separator())
end end
@impl true @impl true

View File

@@ -30,7 +30,18 @@ defmodule ComponentsElixir.Inventory.Component do
@doc false @doc false
def changeset(component, attrs) do def changeset(component, attrs) do
component component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :datasheet_filename, :image_filename, :category_id, :storage_location_id]) |> cast(attrs, [
:name,
:description,
:keywords,
:position,
:count,
:datasheet_url,
:datasheet_filename,
:image_filename,
:category_id,
:storage_location_id
])
|> validate_required([:name, :category_id]) |> validate_required([:name, :category_id])
|> validate_length(:name, min: 1, max: 255) |> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 2000) |> validate_length(:description, max: 2000)

View File

@@ -29,10 +29,12 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
case parent_accessor_fn.(entity) do case parent_accessor_fn.(entity) do
nil -> nil ->
entity.name entity.name
%Ecto.Association.NotLoaded{} -> %Ecto.Association.NotLoaded{} ->
# Parent not loaded - fall back to database lookup # Parent not loaded - fall back to database lookup
# This is a fallback and should be rare if preloading is done correctly # This is a fallback and should be rare if preloading is done correctly
build_path_with_db_lookup(entity, separator) build_path_with_db_lookup(entity, separator)
parent -> parent ->
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}" "#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
end end
@@ -52,12 +54,14 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
nil -> nil ->
# This is a root entity, add its name and return the complete path # This is a root entity, add its name and return the complete path
[entity.name | path_so_far] [entity.name | path_so_far]
parent_id -> parent_id ->
# Load parent from database # Load parent from database
case load_parent_entity(entity, parent_id) do case load_parent_entity(entity, parent_id) do
nil -> nil ->
# Parent not found (orphaned record), treat this as root # Parent not found (orphaned record), treat this as root
[entity.name | path_so_far] [entity.name | path_so_far]
parent -> parent ->
# Recursively get the path from the parent, then add current entity # Recursively get the path from the parent, then add current entity
collect_path_from_root(parent, [entity.name | path_so_far]) collect_path_from_root(parent, [entity.name | path_so_far])
@@ -93,9 +97,9 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
entity_id = id_accessor_fn.(entity) entity_id = id_accessor_fn.(entity)
# Remove self-reference # Remove self-reference
entity_id == editing_entity_id ||
# Remove descendants (they would create a cycle) # Remove descendants (they would create a cycle)
descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn) entity_id == editing_entity_id ||
descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
end) end)
end end
@@ -114,13 +118,21 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
defp descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn) do defp descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn) do
case parent_id_accessor_fn.(entity) do case parent_id_accessor_fn.(entity) do
nil -> false nil ->
^ancestor_id -> true false
^ancestor_id ->
true
parent_id -> parent_id ->
parent = Enum.find(entities, fn e -> e.id == parent_id end) parent = Enum.find(entities, fn e -> e.id == parent_id end)
case parent do case parent do
nil -> false nil ->
parent_entity -> descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn) false
parent_entity ->
descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end end
end end
end end
@@ -182,15 +194,20 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
Includes proper filtering to prevent cycles and formatted display names. Includes proper filtering to prevent cycles and formatted display names.
Results are sorted hierarchically for intuitive navigation. Results are sorted hierarchically for intuitive navigation.
""" """
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do def parent_select_options(
entities,
editing_entity_id,
parent_accessor_fn,
nil_option_text \\ "No parent"
) do
available_entities = available_entities =
filter_parent_options( filter_parent_options(
entities, entities,
editing_entity_id, editing_entity_id,
&(&1.id), & &1.id,
&(&1.parent_id) & &1.parent_id
) )
|> sort_hierarchically(&(&1.parent_id)) |> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity -> |> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id} {display_name(entity, parent_accessor_fn), entity.id}
end) end)
@@ -205,7 +222,7 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities = sorted_entities =
entities entities
|> sort_hierarchically(&(&1.parent_id)) |> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity -> |> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id} {display_name(entity, parent_accessor_fn), entity.id}
end) end)
@@ -300,9 +317,10 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
descendant_ids_only = List.delete(all_descendant_ids, entity_id) descendant_ids_only = List.delete(all_descendant_ids, entity_id)
# Sum counts for all descendants # Sum counts for all descendants
children_count = Enum.reduce(descendant_ids_only, 0, fn id, acc -> children_count =
acc + count_fn.(id) Enum.reduce(descendant_ids_only, 0, fn id, acc ->
end) acc + count_fn.(id)
end)
{self_count, children_count, self_count + children_count} {self_count, children_count, self_count + children_count}
end end
@@ -320,7 +338,13 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
- singular_noun: What to call a single item (default: "component") - singular_noun: What to call a single item (default: "component")
- plural_noun: What to call multiple items (default: "components") - plural_noun: What to call multiple items (default: "components")
""" """
def format_count_display(self_count, children_count, is_expanded, singular_noun \\ "component", plural_noun \\ "components") do def format_count_display(
self_count,
children_count,
is_expanded,
singular_noun \\ "component",
plural_noun \\ "components"
) do
total_count = self_count + children_count total_count = self_count + children_count
count_noun = if total_count == 1, do: singular_noun, else: plural_noun count_noun = if total_count == 1, do: singular_noun, else: plural_noun

View File

@@ -25,7 +25,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
timestamps(type: :naive_datetime_usec) timestamps(type: :naive_datetime_usec)
end end
@doc false @doc false
def changeset(storage_location, attrs) do def changeset(storage_location, attrs) do
storage_location storage_location
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id]) |> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
@@ -40,7 +40,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
# HierarchicalSchema implementations # HierarchicalSchema implementations
@impl true @impl true
def full_path(%StorageLocation{} = storage_location) do def full_path(%StorageLocation{} = storage_location) do
Hierarchical.full_path(storage_location, &(&1.parent), path_separator()) Hierarchical.full_path(storage_location, & &1.parent, path_separator())
end end
@impl true @impl true
@@ -80,11 +80,12 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
defp get_next_available_apriltag_id do defp get_next_available_apriltag_id do
# Get all used AprilTag IDs # Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all( used_ids =
from sl in ComponentsElixir.Inventory.StorageLocation, ComponentsElixir.Repo.all(
where: not is_nil(sl.apriltag_id), from sl in ComponentsElixir.Inventory.StorageLocation,
select: sl.apriltag_id where: not is_nil(sl.apriltag_id),
) select: sl.apriltag_id
)
# Find the first available ID (0-586) # Find the first available ID (0-586)
0..586 0..586
@@ -93,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
nil -> nil ->
# All IDs are used - this should be handled at the application level # All IDs are used - this should be handled at the application level
raise "All AprilTag IDs are in use" raise "All AprilTag IDs are in use"
id -> id
id ->
id
end end
end end
end end

View File

@@ -195,7 +195,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
name={@name} name={@name}
value="true" value="true"
checked={@checked} checked={@checked}
class={@class || "checkbox checkbox-sm border-base-300 checked:bg-primary checked:border-primary"} class={
@class ||
"checkbox checkbox-sm border-base-300 checked:bg-primary checked:border-primary"
}
{@rest} {@rest}
/>{@label} />{@label}
</span> </span>
@@ -213,7 +216,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
<select <select
id={@id} id={@id}
name={@name} name={@name}
class={[@class || "w-full select bg-base-100 border-base-300 text-base-content", @errors != [] && (@error_class || "select-error border-error")]} class={[
@class || "w-full select bg-base-100 border-base-300 text-base-content",
@errors != [] && (@error_class || "select-error border-error")
]}
multiple={@multiple} multiple={@multiple}
{@rest} {@rest}
> >
@@ -235,7 +241,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
id={@id} id={@id}
name={@name} name={@name}
class={[ class={[
@class || "w-full textarea bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50", @class ||
"w-full textarea bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
@errors != [] && (@error_class || "textarea-error border-error") @errors != [] && (@error_class || "textarea-error border-error")
]} ]}
{@rest} {@rest}
@@ -258,7 +265,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
id={@id} id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)} value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[ class={[
@class || "w-full input bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50", @class ||
"w-full input bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
@errors != [] && (@error_class || "input-error border-error") @errors != [] && (@error_class || "input-error border-error")
]} ]}
{@rest} {@rest}

View File

@@ -13,7 +13,8 @@ defmodule ComponentsElixirWeb.FileController do
conn conn
|> put_resp_content_type(mime_type) |> put_resp_content_type(mime_type)
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day # Cache for 1 day
|> put_resp_header("cache-control", "public, max-age=86400")
|> send_file(200, file_path) |> send_file(200, file_path)
else else
conn conn
@@ -40,7 +41,8 @@ defmodule ComponentsElixirWeb.FileController do
conn conn
|> put_resp_content_type(mime_type) |> put_resp_content_type(mime_type)
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day # Cache for 1 day
|> put_resp_header("cache-control", "public, max-age=86400")
|> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"") |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
|> send_file(200, file_path) |> send_file(200, file_path)
else else
@@ -64,9 +66,9 @@ defmodule ComponentsElixirWeb.FileController do
# Security validation: prevent directory traversal and only allow safe characters # Security validation: prevent directory traversal and only allow safe characters
# Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation # Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation
if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and
not String.contains?(decoded_filename, "..") and not String.contains?(decoded_filename, "..") and
not String.starts_with?(decoded_filename, "/") and not String.starts_with?(decoded_filename, "/") and
not String.contains?(decoded_filename, "\\") do not String.contains?(decoded_filename, "\\") do
{:ok, decoded_filename} {:ok, decoded_filename}
else else
{:error, "Invalid filename: contains unsafe characters"} {:error, "Invalid filename: contains unsafe characters"}

View File

@@ -46,12 +46,13 @@ defmodule ComponentsElixirWeb.CategoriesLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do def handle_event("show_edit_form", %{"id" => id}, socket) do
category = Inventory.get_category!(id) category = Inventory.get_category!(id)
# Create a changeset with current values forced into changes for proper form display # Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_category(category, %{ changeset =
name: category.name, Inventory.change_category(category, %{
description: category.description, name: category.name,
parent_id: category.parent_id description: category.description,
}) parent_id: category.parent_id
|> Ecto.Changeset.force_change(:parent_id, category.parent_id) })
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
form = to_form(changeset) form = to_form(changeset)
@@ -112,7 +113,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> reload_categories()} |> reload_categories()}
{:error, _changeset} -> {:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Cannot delete category - it may have components assigned or child categories")} {:noreply,
put_flash(
socket,
:error,
"Cannot delete category - it may have components assigned or child categories"
)}
end end
end end
@@ -120,11 +126,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
category_id = String.to_integer(id) category_id = String.to_integer(id)
expanded_categories = socket.assigns.expanded_categories expanded_categories = socket.assigns.expanded_categories
new_expanded = if MapSet.member?(expanded_categories, category_id) do new_expanded =
MapSet.delete(expanded_categories, category_id) if MapSet.member?(expanded_categories, category_id) do
else MapSet.delete(expanded_categories, category_id)
MapSet.put(expanded_categories, category_id) else
end MapSet.put(expanded_categories, category_id)
end
{:noreply, assign(socket, :expanded_categories, new_expanded)} {:noreply, assign(socket, :expanded_categories, new_expanded)}
end end
@@ -138,17 +145,17 @@ defmodule ComponentsElixirWeb.CategoriesLive do
Hierarchical.parent_select_options( Hierarchical.parent_select_options(
categories, categories,
editing_category_id, editing_category_id,
&(&1.parent), & &1.parent,
"No parent (Root category)" "No parent (Root category)"
) )
end end
defp root_categories(categories) do defp root_categories(categories) do
Hierarchical.root_entities(categories, &(&1.parent_id)) Hierarchical.root_entities(categories, & &1.parent_id)
end end
defp child_categories(categories, parent_id) do defp child_categories(categories, parent_id) do
Hierarchical.child_entities(categories, parent_id, &(&1.parent_id)) Hierarchical.child_entities(categories, parent_id, & &1.parent_id)
end end
defp count_components_in_category(category_id) do defp count_components_in_category(category_id) do
@@ -164,38 +171,41 @@ defmodule ComponentsElixirWeb.CategoriesLive do
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: "" 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 and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do {icon_size, button_size, text_size, title_tag} =
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} case assigns.depth do
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
end _ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
children = child_categories(assigns.categories, assigns.category.id) children = child_categories(assigns.categories, assigns.category.id)
has_children = !Enum.empty?(children) has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id) is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
# Calculate component counts including descendants # Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants( {self_count, children_count, _total_count} =
assigns.category.id, Hierarchical.count_with_descendants(
assigns.categories, assigns.category.id,
&(&1.parent_id), assigns.categories,
&count_components_in_category/1 & &1.parent_id,
) &count_components_in_category/1
)
# Format count display # Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded) count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns assigns =
|> assign(:margin_left, margin_left) assigns
|> assign(:border_class, border_class) |> assign(:margin_left, margin_left)
|> assign(:icon_size, icon_size) |> assign(:border_class, border_class)
|> assign(:button_size, button_size) |> assign(:icon_size, icon_size)
|> assign(:text_size, text_size) |> assign(:button_size, button_size)
|> assign(:title_tag, title_tag) |> assign(:text_size, text_size)
|> assign(:children, children) |> assign(:title_tag, title_tag)
|> assign(:has_children, has_children) |> assign(:children, children)
|> assign(:is_expanded, is_expanded) |> assign(:has_children, has_children)
|> assign(:count_display, count_display) |> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H""" ~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}> <div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
@@ -215,12 +225,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<% end %> <% end %>
</button> </button>
<% else %> <% else %>
<div class="w-6"></div> <!-- Spacer for alignment --> <div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %> <% end %>
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} /> <.icon
name="hero-folder"
<!-- Content area - always starts at same vertical position --> 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"> <div class="flex-1">
<!-- Minimized view (default) --> <!-- Minimized view (default) -->
<%= unless @is_expanded do %> <%= unless @is_expanded do %>
@@ -268,8 +282,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Expanded view --> <!-- Expanded view -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
@@ -321,11 +335,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Render children recursively (only when expanded) --> <!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<%= for child <- @children do %> <%= for child <- @children do %>
<.category_item category={child} categories={@categories} expanded_categories={@expanded_categories} depth={@depth + 1} /> <.category_item
category={child}
categories={@categories}
expanded_categories={@expanded_categories}
depth={@depth + 1}
/>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
@@ -368,8 +387,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Add Category Modal --> <!-- Add Category Modal -->
<%= if @show_add_form do %> <%= 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="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="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">
@@ -424,8 +443,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Edit Category Modal --> <!-- Edit Category Modal -->
<%= if @show_edit_form do %> <%= 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="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="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">
@@ -480,13 +499,15 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Categories List --> <!-- Categories List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300"> <div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2> <h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2>
<p class="text-sm text-base-content/60 mt-1">Manage your component categories and subcategories</p> <p class="text-sm text-base-content/60 mt-1">
Manage your component categories and subcategories
</p>
</div> </div>
<%= if Enum.empty?(@categories) do %> <%= if Enum.empty?(@categories) do %>
@@ -501,8 +522,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
phx-click="show_add_form" 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" 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" /> <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
Add Category
</button> </button>
</div> </div>
</div> </div>
@@ -511,7 +531,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<!-- Recursive Category Tree --> <!-- Recursive Category Tree -->
<%= for category <- root_categories(@categories) do %> <%= for category <- root_categories(@categories) do %>
<div class="px-6 py-4"> <div class="px-6 py-4">
<.category_item category={category} categories={@categories} expanded_categories={@expanded_categories} depth={0} /> <.category_item
category={category}
categories={@categories}
expanded_categories={@expanded_categories}
depth={0}
/>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -139,7 +139,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> push_patch(to: path)} |> push_patch(to: path)}
end end
def handle_event("storage_location_filter", %{"storage_location_id" => storage_location_id}, socket) do def handle_event(
"storage_location_filter",
%{"storage_location_id" => storage_location_id},
socket
) do
storage_location_id = String.to_integer(storage_location_id) storage_location_id = String.to_integer(storage_location_id)
query_string = build_query_params_with_storage_location(socket, storage_location_id) query_string = build_query_params_with_storage_location(socket, storage_location_id)
path = if query_string == "", do: "/", else: "/?" <> query_string path = if query_string == "", do: "/", else: "/?" <> query_string
@@ -387,7 +391,10 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> save_uploaded_image(component_params) |> save_uploaded_image(component_params)
|> save_uploaded_datasheet(socket) |> save_uploaded_datasheet(socket)
case Inventory.update_component_with_datasheet(socket.assigns.editing_component, updated_params) do case Inventory.update_component_with_datasheet(
socket.assigns.editing_component,
updated_params
) do
{:ok, _component} -> {:ok, _component} ->
{:noreply, {:noreply,
socket socket
@@ -496,7 +503,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
search: Map.get(overrides, :search, socket.assigns.search), search: Map.get(overrides, :search, socket.assigns.search),
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria), criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria),
category_id: Map.get(overrides, :category_id, socket.assigns.selected_category), category_id: Map.get(overrides, :category_id, socket.assigns.selected_category),
storage_location_id: Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location) storage_location_id:
Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
} }
params params
@@ -506,12 +514,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
defp parse_filter_id(nil), do: nil defp parse_filter_id(nil), do: nil
defp parse_filter_id(""), do: nil defp parse_filter_id(""), do: nil
defp parse_filter_id(id) when is_binary(id) do defp parse_filter_id(id) when is_binary(id) do
case Integer.parse(id) do case Integer.parse(id) do
{int_id, ""} -> int_id {int_id, ""} -> int_id
_ -> nil _ -> nil
end end
end end
defp parse_filter_id(id) when is_integer(id), do: id defp parse_filter_id(id) when is_integer(id), do: id
defp build_query_params_with_category(socket, category_id) do defp build_query_params_with_category(socket, category_id) do
@@ -553,7 +563,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end end
defp category_options(categories) do defp category_options(categories) do
Hierarchical.select_options(categories, &(&1.parent), "Select a category") Hierarchical.select_options(categories, & &1.parent, "Select a category")
end end
defp storage_location_display_name(location) do defp storage_location_display_name(location) do
@@ -561,7 +571,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end end
defp storage_location_options(storage_locations) do defp storage_location_options(storage_locations) do
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location") Hierarchical.select_options(storage_locations, & &1.parent, "No storage location")
end end
@impl true @impl true
@@ -610,7 +620,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Filters --> <!-- Filters -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col sm:flex-row gap-4">
@@ -687,23 +697,27 @@ defmodule ComponentsElixirWeb.ComponentsLive do
class="inline-flex items-center px-3 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" class="inline-flex items-center px-3 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-adjustments-horizontal" class="w-4 h-4 mr-2" /> <.icon name="hero-adjustments-horizontal" class="w-4 h-4 mr-2" />
<%= if @show_advanced_filters, do: "Hide", else: "More" %> Filters {if @show_advanced_filters, do: "Hide", else: "More"} Filters
</button> </button>
</div> </div>
</div> </div>
<!-- Advanced Filters (Collapsible) --> <!-- Advanced Filters (Collapsible) -->
<%= if @show_advanced_filters do %> <%= if @show_advanced_filters do %>
<div class="mt-4 p-4 bg-base-100 border border-base-300 rounded-md"> <div class="mt-4 p-4 bg-base-100 border border-base-300 rounded-md">
<div class="flex flex-col sm:flex-row gap-4"> <div class="flex flex-col sm:flex-row gap-4">
<div> <div>
<label class="block text-sm font-medium text-base-content mb-2">Storage Location</label> <label class="block text-sm font-medium text-base-content mb-2">
Storage Location
</label>
<form phx-change="storage_location_filter"> <form phx-change="storage_location_filter">
<select <select
name="storage_location_id" name="storage_location_id"
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm" class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
> >
<option value="" selected={is_nil(@selected_storage_location)}>All Storage Locations</option> <option value="" selected={is_nil(@selected_storage_location)}>
All Storage Locations
</option>
<%= for {location_name, location_id} <- Hierarchical.select_options(@storage_locations, &(&1.parent)) do %> <%= for {location_name, location_id} <- Hierarchical.select_options(@storage_locations, &(&1.parent)) do %>
<option value={location_id} selected={@selected_storage_location == location_id}> <option value={location_id} selected={@selected_storage_location == location_id}>
{location_name} {location_name}
@@ -716,7 +730,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
<% end %> <% end %>
</div> </div>
<!-- Add Component Modal --> <!-- Add Component Modal -->
<%= if @show_add_form do %> <%= 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="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -895,7 +909,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Edit Component Modal --> <!-- Edit Component Modal -->
<%= if @show_edit_form do %> <%= 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="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -1029,8 +1043,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
target="_blank" target="_blank"
class="inline-flex items-center text-primary hover:text-primary/80" class="inline-flex items-center text-primary hover:text-primary/80"
> >
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> <.icon name="hero-document-text" class="w-4 h-4 mr-1" /> View PDF
View PDF
</a> </a>
</div> </div>
<% end %> <% end %>
@@ -1097,7 +1110,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Components List --> <!-- Components List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md"> <div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
@@ -1166,7 +1179,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</button> </button>
</div> </div>
</div> </div>
<!-- Content area with image and details --> <!-- Content area with image and details -->
<div class="flex gap-6"> <div class="flex gap-6">
<!-- Large Image --> <!-- Large Image -->
@@ -1192,7 +1205,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
<% end %> <% end %>
</div> </div>
<!-- Details --> <!-- Details -->
<div class="flex-1 space-y-4 select-text"> <div class="flex-1 space-y-4 select-text">
<!-- Full Description --> <!-- Full Description -->
@@ -1201,10 +1214,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<h4 class="text-sm font-medium text-base-content mb-2">Description</h4> <h4 class="text-sm font-medium text-base-content mb-2">Description</h4>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation. <%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
Use phx-no-format so the formatter won't break the layout. --%> Use phx-no-format so the formatter won't break the layout. --%>
<p phx-no-format class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap">{component.description}</p> <p
phx-no-format
class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap"
>{component.description}</p>
</div> </div>
<% end %> <% end %>
<!-- Metadata Grid --> <!-- Metadata Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm"> <div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<%= if component.storage_location do %> <%= if component.storage_location do %>
@@ -1261,7 +1277,10 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<%= if component.datasheet_filename || component.datasheet_url do %> <%= if component.datasheet_filename || component.datasheet_url do %>
<div class="flex items-start"> <div class="flex items-start">
<.icon name="hero-document-text" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" /> <.icon
name="hero-document-text"
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
/>
<div> <div>
<span class="font-medium text-base-content">Datasheet:</span> <span class="font-medium text-base-content">Datasheet:</span>
<div class="space-y-1 mt-1"> <div class="space-y-1 mt-1">
@@ -1284,8 +1303,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
target="_blank" target="_blank"
class="inline-flex items-center text-base-content/70 hover:text-primary text-sm" class="inline-flex items-center text-base-content/70 hover:text-primary text-sm"
> >
<.icon name="hero-link" class="w-4 h-4 mr-1" /> <.icon name="hero-link" class="w-4 h-4 mr-1" /> Original URL
Original URL
</a> </a>
</div> </div>
<% end %> <% end %>
@@ -1296,7 +1314,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Action Buttons --> <!-- Action Buttons -->
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300"> <div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
<button <button
@@ -1390,16 +1408,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</span> </span>
</div> </div>
</div> </div>
<!-- Middle row: Description --> <!-- Middle row: Description -->
<%= if component.description do %> <%= if component.description do %>
<div class="mt-1"> <div class="mt-1">
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation. <%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
Use phx-no-format so the formatter won't break the layout. --%> Use phx-no-format so the formatter won't break the layout. --%>
<p phx-no-format class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap">{component.description}</p> <p
phx-no-format
class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap"
>{component.description}</p>
</div> </div>
<% end %> <% end %>
<!-- Bottom row: Metadata --> <!-- Bottom row: Metadata -->
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60"> <div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
<%= if component.storage_location do %> <%= if component.storage_location do %>
@@ -1427,7 +1448,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div> </div>
<% end %> <% end %>
</div> </div>
<!-- Keywords row --> <!-- Keywords row -->
<%= if component.keywords do %> <%= if component.keywords do %>
<div class="mt-2"> <div class="mt-2">
@@ -1510,7 +1531,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
> >
<!-- Background overlay --> <!-- Background overlay -->
<div class="absolute inset-0 bg-black bg-opacity-75"></div> <div class="absolute inset-0 bg-black bg-opacity-75"></div>
<!-- Modal content --> <!-- Modal content -->
<div <div
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto" class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
@@ -1528,7 +1549,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
× ×
</button> </button>
</div> </div>
<!-- Content --> <!-- Content -->
<div class="p-6 bg-base-100 rounded-b-lg"> <div class="p-6 bg-base-100 rounded-b-lg">
<div class="text-center"> <div class="text-center">

View File

@@ -66,7 +66,7 @@ defmodule ComponentsElixirWeb.LoginLive do
<%= if @error_message do %> <%= if @error_message do %>
<div class="text-red-600 text-sm text-center"> <div class="text-red-600 text-sm text-center">
<%= @error_message %> {@error_message}
</div> </div>
<% end %> <% end %>

View File

@@ -54,12 +54,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do def handle_event("show_edit_form", %{"id" => id}, socket) do
location = Inventory.get_storage_location!(id) location = Inventory.get_storage_location!(id)
# Create a changeset with current values forced into changes for proper form display # Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_storage_location(location, %{ changeset =
name: location.name, Inventory.change_storage_location(location, %{
description: location.description, name: location.name,
parent_id: location.parent_id description: location.description,
}) parent_id: location.parent_id
|> Ecto.Changeset.force_change(:parent_id, location.parent_id) })
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
form = to_form(changeset) form = to_form(changeset)
@@ -82,29 +83,31 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("save_location", %{"storage_location" => location_params}, socket) do def handle_event("save_location", %{"storage_location" => location_params}, socket) do
# Process AprilTag assignment based on mode # Process AprilTag assignment based on mode
processed_params = case socket.assigns.apriltag_mode do processed_params =
"none" -> case socket.assigns.apriltag_mode do
# Remove any apriltag_id from params to ensure it's nil "none" ->
Map.delete(location_params, "apriltag_id") # Remove any apriltag_id from params to ensure it's nil
Map.delete(location_params, "apriltag_id")
"auto" -> "auto" ->
# Auto-assign next available AprilTag ID # Auto-assign next available AprilTag ID
case AprilTag.next_available_apriltag_id() do case AprilTag.next_available_apriltag_id() do
nil -> nil ->
# No available IDs, proceed without AprilTag # No available IDs, proceed without AprilTag
Map.delete(location_params, "apriltag_id") Map.delete(location_params, "apriltag_id")
apriltag_id ->
Map.put(location_params, "apriltag_id", apriltag_id)
end
"manual" -> apriltag_id ->
# Use the manually entered apriltag_id (validation will be handled by changeset) Map.put(location_params, "apriltag_id", apriltag_id)
location_params end
_ -> "manual" ->
# Fallback: remove apriltag_id # Use the manually entered apriltag_id (validation will be handled by changeset)
Map.delete(location_params, "apriltag_id") location_params
end
_ ->
# Fallback: remove apriltag_id
Map.delete(location_params, "apriltag_id")
end
case Inventory.create_storage_location(processed_params) do case Inventory.create_storage_location(processed_params) do
{:ok, _location} -> {:ok, _location} ->
@@ -147,7 +150,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> reload_storage_locations()} |> reload_storage_locations()}
{:error, _changeset} -> {:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")} {:noreply,
put_flash(
socket,
:error,
"Cannot delete storage location - it may have components assigned or child locations"
)}
end end
end end
@@ -164,10 +172,17 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 -> {apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
nil -> nil ->
{:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")} {:noreply,
put_flash(
socket,
:error,
"Storage location not found for AprilTag ID: #{apriltag_id}"
)}
location -> location ->
scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags] scanned_tags = [
%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags
]
{:noreply, {:noreply,
socket socket
@@ -188,11 +203,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
location_id = String.to_integer(id) location_id = String.to_integer(id)
expanded_locations = socket.assigns.expanded_locations expanded_locations = socket.assigns.expanded_locations
new_expanded = if MapSet.member?(expanded_locations, location_id) do new_expanded =
MapSet.delete(expanded_locations, location_id) if MapSet.member?(expanded_locations, location_id) do
else MapSet.delete(expanded_locations, location_id)
MapSet.put(expanded_locations, location_id) else
end MapSet.put(expanded_locations, location_id)
end
{:noreply, assign(socket, :expanded_locations, new_expanded)} {:noreply, assign(socket, :expanded_locations, new_expanded)}
end end
@@ -203,19 +219,26 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
# Clear the apriltag_id field when switching modes # Clear the apriltag_id field when switching modes
form = case mode do form =
"remove" -> case mode do
socket.assigns.form "remove" ->
|> Phoenix.Component.to_form() socket.assigns.form
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil)) |> Phoenix.Component.to_form()
"keep" -> |> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
current_id = socket.assigns.editing_location.apriltag_id
socket.assigns.form "keep" ->
|> Phoenix.Component.to_form() current_id = socket.assigns.editing_location.apriltag_id
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
_ -> socket.assigns.form
socket.assigns.form |> Phoenix.Component.to_form()
end |> Map.put(
:params,
Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)
)
_ ->
socket.assigns.form
end
{:noreply, {:noreply,
socket socket
@@ -234,7 +257,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")} {:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
apriltag_url -> apriltag_url ->
filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg" filename =
"#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
# Send file download to browser # Send file download to browser
{:noreply, {:noreply,
@@ -257,7 +281,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
Hierarchical.parent_select_options( Hierarchical.parent_select_options(
storage_locations, storage_locations,
editing_location_id, editing_location_id,
&(&1.parent), & &1.parent,
"No parent (Root location)" "No parent (Root location)"
) )
end end
@@ -267,11 +291,11 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end end
defp root_storage_locations(storage_locations) do defp root_storage_locations(storage_locations) do
Hierarchical.root_entities(storage_locations, &(&1.parent_id)) Hierarchical.root_entities(storage_locations, & &1.parent_id)
end end
defp child_storage_locations(storage_locations, parent_id) do defp child_storage_locations(storage_locations, parent_id) do
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id)) Hierarchical.child_entities(storage_locations, parent_id, & &1.parent_id)
end end
defp count_components_in_location(location_id) do defp count_components_in_location(location_id) do
@@ -291,46 +315,53 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: "" 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 and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do {icon_size, button_size, text_size, title_tag} =
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} case assigns.depth do
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
end _ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
# Different icons based on level - QR code is always present for storage locations # Different icons based on level - QR code is always present for storage locations
icon_name = case assigns.depth do icon_name =
0 -> "hero-building-office" # Shelf/Room case assigns.depth do
1 -> "hero-archive-box" # Drawer/Cabinet # Shelf/Room
_ -> "hero-cube" # Box/Container 0 -> "hero-building-office"
end # Drawer/Cabinet
1 -> "hero-archive-box"
# Box/Container
_ -> "hero-cube"
end
children = child_storage_locations(assigns.storage_locations, assigns.location.id) children = child_storage_locations(assigns.storage_locations, assigns.location.id)
has_children = !Enum.empty?(children) has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id) is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
# Calculate component counts including descendants # Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants( {self_count, children_count, _total_count} =
assigns.location.id, Hierarchical.count_with_descendants(
assigns.storage_locations, assigns.location.id,
&(&1.parent_id), assigns.storage_locations,
&count_components_in_location/1 & &1.parent_id,
) &count_components_in_location/1
)
# Format count display # Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded) count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns assigns =
|> assign(:margin_left, margin_left) assigns
|> assign(:border_class, border_class) |> assign(:margin_left, margin_left)
|> assign(:icon_size, icon_size) |> assign(:border_class, border_class)
|> assign(:button_size, button_size) |> assign(:icon_size, icon_size)
|> assign(:text_size, text_size) |> assign(:button_size, button_size)
|> assign(:title_tag, title_tag) |> assign(:text_size, text_size)
|> assign(:icon_name, icon_name) |> assign(:title_tag, title_tag)
|> assign(:children, children) |> assign(:icon_name, icon_name)
|> assign(:has_children, has_children) |> assign(:children, children)
|> assign(:is_expanded, is_expanded) |> assign(:has_children, has_children)
|> assign(:count_display, count_display) |> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H""" ~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}> <div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
@@ -350,12 +381,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<% end %> <% end %>
</button> </button>
<% else %> <% else %>
<div class="w-6"></div> <!-- Spacer for alignment --> <div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %> <% end %>
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} /> <.icon
name={@icon_name}
<!-- Content area - always starts at same vertical position --> 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"> <div class="flex-1">
<!-- Minimized view (default) --> <!-- Minimized view (default) -->
<%= unless @is_expanded do %> <%= unless @is_expanded do %>
@@ -408,8 +443,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Expanded view --> <!-- Expanded view -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
@@ -468,8 +503,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
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" 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" title="Download AprilTag"
> >
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> <.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> Download
Download
</button> </button>
<% end %> <% end %>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -495,11 +529,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Render children recursively (only when expanded) --> <!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<%= for child <- @children do %> <%= for child <- @children do %>
<.location_item location={child} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={@depth + 1} /> <.location_item
location={child}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={@depth + 1}
/>
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
@@ -552,8 +591,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Add Location Modal --> <!-- Add Location Modal -->
<%= if @show_add_form do %> <%= 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="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="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">
@@ -589,7 +628,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-base-content">AprilTag ID (Optional)</label> <label class="block text-sm font-medium text-base-content">
AprilTag ID (Optional)
</label>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input <input
@@ -647,10 +688,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32" class="w-32"
/> />
<div class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60">
Available IDs: <%= length(@available_apriltag_ids) %> of 587 Available IDs: {length(@available_apriltag_ids)} of 587
<%= if length(@available_apriltag_ids) < 20 do %> <%= if length(@available_apriltag_ids) < 20 do %>
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %> <br />Next available: {@available_apriltag_ids
<%= if length(@available_apriltag_ids) > 10, do: "..." %> |> Enum.take(10)
|> Enum.join(", ")}
{if length(@available_apriltag_ids) > 10, do: "..."}
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -678,8 +721,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Edit Location Modal --> <!-- Edit Location Modal -->
<%= if @show_edit_form do %> <%= 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="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="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">
@@ -773,12 +816,14 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32" class="w-32"
/> />
<div class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60">
Available IDs: <%= length(@available_apriltag_ids) %> of 587 Available IDs: {length(@available_apriltag_ids)} of 587
</div> </div>
</div> </div>
<% end %> <% end %>
<p class="text-xs text-base-content/60 mt-1"> <p class="text-xs text-base-content/60 mt-1">
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %> Current: {if @editing_location.apriltag_id,
do: "ID #{@editing_location.apriltag_id}",
else: "None"}
</p> </p>
</div> </div>
</div> </div>
@@ -803,8 +848,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- AprilTag Scanner Modal --> <!-- AprilTag Scanner Modal -->
<%= if @apriltag_scanner_open do %> <%= 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="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="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">
@@ -818,16 +863,20 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<.icon name="hero-x-mark" class="w-6 h-6" /> <.icon name="hero-x-mark" class="w-6 h-6" />
</button> </button>
</div> </div>
<!-- AprilTag Scanner Interface --> <!-- AprilTag Scanner Interface -->
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center"> <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" /> <.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="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> <p class="text-xs text-base-content/60 mt-1">
In a real implementation, this would use JavaScript AprilTag detection
<!-- Test buttons for demo --> </p>
<!-- Test buttons for demo -->
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<p class="text-sm font-medium text-base-content/80">Test with sample AprilTag IDs:</p> <p class="text-sm font-medium text-base-content/80">
Test with sample AprilTag IDs:
</p>
<button <button
phx-click="apriltag_scanned" phx-click="apriltag_scanned"
phx-value-apriltag_id="0" phx-value-apriltag_id="0"
@@ -848,8 +897,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Scanned Tags Display --> <!-- Scanned Tags Display -->
<%= if length(@scanned_tags) > 0 do %> <%= 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="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="bg-green-50 border border-green-200 rounded-lg p-4">
@@ -863,26 +912,35 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</button> </button>
</div> </div>
<div class="space-y-2"> <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
:for={scan <- @scanned_tags}
class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300"
>
<div> <div>
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span> <span class="font-medium text-base-content">
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span> {location_display_name(scan.location)}
</span>
<span class="text-sm text-base-content/70 ml-2">
(AprilTag ID {scan.apriltag_id})
</span>
</div> </div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded"> <span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %> Level {Hierarchical.compute_level(scan.location, & &1.parent)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Storage Locations List --> <!-- Storage Locations List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300"> <div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2> <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> <p class="text-sm text-base-content/60 mt-1">
Manage your physical storage locations and AprilTags
</p>
</div> </div>
<%= if Enum.empty?(@storage_locations) do %> <%= if Enum.empty?(@storage_locations) do %>
@@ -897,8 +955,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
phx-click="show_add_form" 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" 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" /> <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
Add Location
</button> </button>
</div> </div>
</div> </div>
@@ -907,7 +964,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<!-- Recursive Storage Location Tree --> <!-- Recursive Storage Location Tree -->
<%= for location <- root_storage_locations(@storage_locations) do %> <%= for location <- root_storage_locations(@storage_locations) do %>
<div class="px-6 py-4"> <div class="px-6 py-4">
<.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} /> <.location_item
location={location}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={0}
/>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

@@ -25,9 +25,8 @@ defmodule Mix.Tasks.Apriltag.GenerateAll do
start_time = System.monotonic_time(:millisecond) start_time = System.monotonic_time(:millisecond)
result = ComponentsElixir.AprilTag.generate_all_apriltag_svgs( result =
force_regenerate: force_regenerate ComponentsElixir.AprilTag.generate_all_apriltag_svgs(force_regenerate: force_regenerate)
)
end_time = System.monotonic_time(:millisecond) end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time duration = end_time - start_time
@@ -39,6 +38,7 @@ defmodule Mix.Tasks.Apriltag.GenerateAll do
if result.errors > 0 do if result.errors > 0 do
IO.puts("\nErrors encountered:") IO.puts("\nErrors encountered:")
result.results result.results
|> Enum.filter(&match?({:error, _, _}, &1)) |> Enum.filter(&match?({:error, _, _}, &1))
|> Enum.each(fn {:error, id, reason} -> |> Enum.each(fn {:error, id, reason} ->

View File

@@ -14,7 +14,9 @@ defmodule ComponentsElixir.Repo.Migrations.MigrateQrToApriltag do
create unique_index(:storage_locations, [:apriltag_id]) create unique_index(:storage_locations, [:apriltag_id])
# Add constraint to ensure apriltag_id is in valid range (0-586 for tag36h11) # Add constraint to ensure apriltag_id is in valid range (0-586 for tag36h11)
create constraint(:storage_locations, :apriltag_id_range, check: "apriltag_id >= 0 AND apriltag_id <= 586") create constraint(:storage_locations, :apriltag_id_range,
check: "apriltag_id >= 0 AND apriltag_id <= 586"
)
# Note: We keep qr_code_old for now in case we need to rollback # Note: We keep qr_code_old for now in case we need to rollback
# It can be removed in a future migration after confirming everything works # It can be removed in a future migration after confirming everything works

View File

@@ -25,102 +25,208 @@ Repo.delete_all(Category)
Repo.delete_all(StorageLocation) Repo.delete_all(StorageLocation)
# Create categories # Create categories
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"}) {:ok, resistors} =
{:ok, capacitors} = Inventory.create_category(%{name: "Capacitors", description: "Electrolytic, ceramic, and film capacitors"}) Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
{:ok, semiconductors} = Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} = Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"}) {:ok, capacitors} =
Inventory.create_category(%{
name: "Capacitors",
description: "Electrolytic, ceramic, and film capacitors"
})
{:ok, semiconductors} =
Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} =
Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"})
# Create subcategories # Create subcategories
{:ok, _through_hole_resistors} = Inventory.create_category(%{ {:ok, _through_hole_resistors} =
name: "Through-hole", Inventory.create_category(%{
description: "Traditional leaded resistors", name: "Through-hole",
parent_id: resistors.id description: "Traditional leaded resistors",
}) parent_id: resistors.id
})
{:ok, _smd_resistors} = Inventory.create_category(%{ {:ok, _smd_resistors} =
name: "SMD/SMT", Inventory.create_category(%{
description: "Surface mount resistors", name: "SMD/SMT",
parent_id: resistors.id description: "Surface mount resistors",
}) parent_id: resistors.id
})
{:ok, _ceramic_caps} = Inventory.create_category(%{ {:ok, _ceramic_caps} =
name: "Ceramic", Inventory.create_category(%{
description: "Ceramic disc and multilayer capacitors", name: "Ceramic",
parent_id: capacitors.id description: "Ceramic disc and multilayer capacitors",
}) parent_id: capacitors.id
})
{:ok, _electrolytic_caps} = Inventory.create_category(%{ {:ok, _electrolytic_caps} =
name: "Electrolytic", Inventory.create_category(%{
description: "Polarized electrolytic capacitors", name: "Electrolytic",
parent_id: capacitors.id description: "Polarized electrolytic capacitors",
}) parent_id: capacitors.id
})
# Create a DEEP category hierarchy to test fallback path (7+ levels) # Create a DEEP category hierarchy to test fallback path (7+ levels)
{:ok, deep_cat_1} = Inventory.create_category(%{name: "Level 1", description: "Deep hierarchy test", parent_id: resistors.id}) {:ok, deep_cat_1} =
{:ok, deep_cat_2} = Inventory.create_category(%{name: "Level 2", description: "Deep hierarchy test", parent_id: deep_cat_1.id}) Inventory.create_category(%{
{:ok, deep_cat_3} = Inventory.create_category(%{name: "Level 3", description: "Deep hierarchy test", parent_id: deep_cat_2.id}) name: "Level 1",
{:ok, deep_cat_4} = Inventory.create_category(%{name: "Level 4", description: "Deep hierarchy test", parent_id: deep_cat_3.id}) description: "Deep hierarchy test",
{:ok, deep_cat_5} = Inventory.create_category(%{name: "Level 5", description: "Deep hierarchy test", parent_id: deep_cat_4.id}) parent_id: resistors.id
{:ok, deep_cat_6} = Inventory.create_category(%{name: "Level 6", description: "Deep hierarchy test", parent_id: deep_cat_5.id}) })
{:ok, deep_cat_7} = Inventory.create_category(%{name: "Level 7", description: "Deep hierarchy test - triggers fallback", parent_id: deep_cat_6.id})
{:ok, deep_cat_2} =
Inventory.create_category(%{
name: "Level 2",
description: "Deep hierarchy test",
parent_id: deep_cat_1.id
})
{:ok, deep_cat_3} =
Inventory.create_category(%{
name: "Level 3",
description: "Deep hierarchy test",
parent_id: deep_cat_2.id
})
{:ok, deep_cat_4} =
Inventory.create_category(%{
name: "Level 4",
description: "Deep hierarchy test",
parent_id: deep_cat_3.id
})
{:ok, deep_cat_5} =
Inventory.create_category(%{
name: "Level 5",
description: "Deep hierarchy test",
parent_id: deep_cat_4.id
})
{:ok, deep_cat_6} =
Inventory.create_category(%{
name: "Level 6",
description: "Deep hierarchy test",
parent_id: deep_cat_5.id
})
{:ok, deep_cat_7} =
Inventory.create_category(%{
name: "Level 7",
description: "Deep hierarchy test - triggers fallback",
parent_id: deep_cat_6.id
})
# Create storage locations # Create storage locations
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"}) {:ok, shelf_a} =
{:ok, _shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"}) Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
{:ok, _shelf_b} =
Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
# Create drawers on Shelf A # Create drawers on Shelf A
{:ok, drawer_a1} = Inventory.create_storage_location(%{ {:ok, drawer_a1} =
name: "Drawer 1", Inventory.create_storage_location(%{
description: "Resistors and capacitors", name: "Drawer 1",
parent_id: shelf_a.id description: "Resistors and capacitors",
}) parent_id: shelf_a.id
})
{:ok, drawer_a2} = Inventory.create_storage_location(%{ {:ok, drawer_a2} =
name: "Drawer 2", Inventory.create_storage_location(%{
description: "Semiconductors and ICs", name: "Drawer 2",
parent_id: shelf_a.id description: "Semiconductors and ICs",
}) parent_id: shelf_a.id
})
# Create boxes in Drawer A1 # Create boxes in Drawer A1
{:ok, box_a1_1} = Inventory.create_storage_location(%{ {:ok, box_a1_1} =
name: "Box 1", Inventory.create_storage_location(%{
description: "Through-hole resistors", name: "Box 1",
parent_id: drawer_a1.id description: "Through-hole resistors",
}) parent_id: drawer_a1.id
})
{:ok, _box_a1_2} = Inventory.create_storage_location(%{ {:ok, _box_a1_2} =
name: "Box 2", Inventory.create_storage_location(%{
description: "SMD resistors", name: "Box 2",
parent_id: drawer_a1.id description: "SMD resistors",
}) parent_id: drawer_a1.id
})
{:ok, box_a1_3} = Inventory.create_storage_location(%{ {:ok, box_a1_3} =
name: "Box 3", Inventory.create_storage_location(%{
description: "Ceramic capacitors", name: "Box 3",
parent_id: drawer_a1.id description: "Ceramic capacitors",
}) parent_id: drawer_a1.id
})
# Create boxes in Drawer A2 # Create boxes in Drawer A2
{:ok, box_a2_1} = Inventory.create_storage_location(%{ {:ok, box_a2_1} =
name: "Box 1", Inventory.create_storage_location(%{
description: "Microcontrollers", name: "Box 1",
parent_id: drawer_a2.id description: "Microcontrollers",
}) parent_id: drawer_a2.id
})
{:ok, _box_a2_2} = Inventory.create_storage_location(%{ {:ok, _box_a2_2} =
name: "Box 2", Inventory.create_storage_location(%{
description: "Transistors and diodes", name: "Box 2",
parent_id: drawer_a2.id description: "Transistors and diodes",
}) parent_id: drawer_a2.id
})
# Create a DEEP storage location hierarchy to test fallback path (7+ levels) # Create a DEEP storage location hierarchy to test fallback path (7+ levels)
{:ok, deep_loc_1} = Inventory.create_storage_location(%{name: "Deep Level 1", description: "Deep hierarchy test", parent_id: box_a1_3.id}) {:ok, deep_loc_1} =
{:ok, deep_loc_2} = Inventory.create_storage_location(%{name: "Deep Level 2", description: "Deep hierarchy test", parent_id: deep_loc_1.id}) Inventory.create_storage_location(%{
{:ok, deep_loc_3} = Inventory.create_storage_location(%{name: "Deep Level 3", description: "Deep hierarchy test", parent_id: deep_loc_2.id}) name: "Deep Level 1",
{:ok, deep_loc_4} = Inventory.create_storage_location(%{name: "Deep Level 4", description: "Deep hierarchy test", parent_id: deep_loc_3.id}) description: "Deep hierarchy test",
{:ok, deep_loc_5} = Inventory.create_storage_location(%{name: "Deep Level 5", description: "Deep hierarchy test", parent_id: deep_loc_4.id}) parent_id: box_a1_3.id
{:ok, deep_loc_6} = Inventory.create_storage_location(%{name: "Deep Level 6", description: "Deep hierarchy test", parent_id: deep_loc_5.id}) })
{:ok, deep_loc_7} = Inventory.create_storage_location(%{name: "Deep Level 7", description: "Deep hierarchy test - triggers fallback", parent_id: deep_loc_6.id})
{:ok, deep_loc_2} =
Inventory.create_storage_location(%{
name: "Deep Level 2",
description: "Deep hierarchy test",
parent_id: deep_loc_1.id
})
{:ok, deep_loc_3} =
Inventory.create_storage_location(%{
name: "Deep Level 3",
description: "Deep hierarchy test",
parent_id: deep_loc_2.id
})
{:ok, deep_loc_4} =
Inventory.create_storage_location(%{
name: "Deep Level 4",
description: "Deep hierarchy test",
parent_id: deep_loc_3.id
})
{:ok, deep_loc_5} =
Inventory.create_storage_location(%{
name: "Deep Level 5",
description: "Deep hierarchy test",
parent_id: deep_loc_4.id
})
{:ok, deep_loc_6} =
Inventory.create_storage_location(%{
name: "Deep Level 6",
description: "Deep hierarchy test",
parent_id: deep_loc_5.id
})
{:ok, deep_loc_7} =
Inventory.create_storage_location(%{
name: "Deep Level 7",
description: "Deep hierarchy test - triggers fallback",
parent_id: deep_loc_6.id
})
# Create sample components # Create sample components
sample_components = [ sample_components = [
@@ -162,7 +268,8 @@ sample_components = [
keywords: "microcontroller avr atmega328 arduino", keywords: "microcontroller avr atmega328 arduino",
storage_location_id: box_a2_1.id, storage_location_id: box_a2_1.id,
count: 10, count: 10,
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf", datasheet_url:
"https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
category_id: semiconductors.id category_id: semiconductors.id
}, },
%{ %{
@@ -264,7 +371,11 @@ IO.puts("")
IO.puts("🎉 Database seeded successfully!") IO.puts("🎉 Database seeded successfully!")
IO.puts("📊 Summary:") IO.puts("📊 Summary:")
IO.puts(" Categories: #{length(Inventory.list_categories())}") IO.puts(" Categories: #{length(Inventory.list_categories())}")
IO.puts(" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)")
IO.puts(
" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)"
)
IO.puts(" Components: #{length(Inventory.list_components())}") IO.puts(" Components: #{length(Inventory.list_components())}")
IO.puts("") IO.puts("")
IO.puts("🏷️ AprilTag System:") IO.puts("🏷️ AprilTag System:")

View File

@@ -9,6 +9,7 @@ defmodule ComponentsElixirWeb.ErrorHTMLTest do
end end
test "renders 500.html" do test "renders 500.html" do
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) == "Internal Server Error" assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) ==
"Internal Server Error"
end end
end end

View File

@@ -2,7 +2,9 @@ defmodule ComponentsElixirWeb.ErrorJSONTest do
use ComponentsElixirWeb.ConnCase, async: true use ComponentsElixirWeb.ConnCase, async: true
test "renders 404" do test "renders 404" do
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}} assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{
errors: %{detail: "Not Found"}
}
end end
test "renders 500" do test "renders 500" do