style: format codebase
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} ->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user