Compare commits

...

10 Commits

Author SHA1 Message Date
Schuwi
e078770557 refactor(elixir): remove unused qr_code 2025-09-18 00:08:32 +02:00
Schuwi
264adbfb98 refactor(elixir): hierarchical refactor
to extract common code patterns from
category/storage location systems
2025-09-17 23:56:56 +02:00
Schuwi
963c9a3770 docs(elixir): prepare hierarchical refactor 2025-09-17 23:32:46 +02:00
Schuwi
5a1775e836 refactor(elixir): remove unused is_active field
from storage location
2025-09-17 23:13:45 +02:00
Schuwi
6a1122c3be feat(elixir): robust sort in component list 2025-09-17 19:10:04 +02:00
Schuwi
b6e137632a feat(elixir): add component list focus mode 2025-09-17 18:03:26 +02:00
Schuwi
8848986953 fix(elixir): component list image size 2025-09-17 18:03:26 +02:00
Schuwi
68b0c0714e build(elixir): update database seed 2025-09-17 18:00:37 +02:00
Schuwi
76b0a97d31 fix(elixir): file upload in production
- move uploads to new directory (`./uploads` per default)
2025-09-16 23:16:02 +02:00
Schuwi
fa9bf74fd9 docs: secret key generation 2025-09-16 21:22:55 +02:00
22 changed files with 1156 additions and 1124 deletions

1
.gitignore vendored
View File

@@ -37,6 +37,7 @@ npm-debug.log
# Ignore all user-generated content (uploads, QR codes, etc.)
/priv/static/user_generated/
/uploads/
# Ignore customized Docker Compose file.
docker-compose.yml

View File

@@ -1,79 +0,0 @@
# AprilTag Migration Summary
## Completed Changes
### 1. Database Migration ✅
- Migrated from `qr_code` string field to `apriltag_id` integer field
- Added constraint to ensure valid AprilTag IDs (0-586)
- Created unique index for apriltag_id
- Preserved old qr_code data as qr_code_old for rollback safety
### 2. Schema Updates ✅
- Updated `StorageLocation` schema to use `apriltag_id` instead of `qr_code`
- Added validation for AprilTag ID range (0-586)
- Implemented auto-assignment of next available ID
- Added unique constraint validation
### 3. Business Logic Refactoring ✅
- Replaced `ComponentsElixir.QRCode` module with `ComponentsElixir.AprilTag` module
- Updated inventory functions to use AprilTag IDs instead of QR code strings
- Implemented AprilTag ID availability checking
- Added bulk SVG generation functionality
### 4. UI/UX Improvements ✅
- Replaced dropdown with 587 options with better UX:
- Radio buttons for "Auto-assign" vs "Manual selection"
- Number input for specific ID selection when manual mode selected
- Shows available ID count and examples
- Different interface for add vs edit forms
- Updated templates to show AprilTag information instead of QR codes
- Added download functionality for AprilTag SVGs
### 5. AprilTag Generation ✅
- Created `ComponentsElixir.AprilTag` module for managing tag36h11 family
- Generated all 587 placeholder SVG files with human-readable IDs
- Added Mix task `mix apriltag.generate_all` for batch generation
- SVG files served statically at `/apriltags/tag36h11_id_XXX.svg`
### 6. Event Handling ✅
- Updated LiveView event handlers for AprilTag scanning/assignment
- Added mode switching for manual vs automatic assignment
- Implemented proper form state management for different modes
## Benefits Achieved
1. **Better UX**: No more 587-option dropdown menu
2. **Future-Ready**: AprilTags designed for multi-tag detection scenarios
3. **Robust**: 587 unique IDs provide ample space without conflicts
4. **Maintainable**: Simpler integer ID system vs complex string encoding
5. **Industry Standard**: AprilTags widely used in robotics/AR applications
## Current State
- ✅ Database schema updated
- ✅ All 587 placeholder SVG files generated
- ✅ UI forms updated with better UX
- ✅ Business logic migrated to AprilTag system
-**Next**: Real AprilTag pattern generation (future enhancement)
-**Next**: Camera detection integration (future enhancement)
## Usage
### Generate AprilTag SVGs
```bash
mix apriltag.generate_all # Generate missing files
mix apriltag.generate_all --force # Regenerate all files
```
### Available AprilTag IDs
- Range: 0-586 (tag36h11 family)
- Auto-assignment picks next available ID
- Manual assignment allows specific ID selection
- Unique constraint prevents conflicts
### File Locations
- SVG files: `priv/static/apriltags/tag36h11_id_XXX.svg`
- URL pattern: `/apriltags/tag36h11_id_XXX.svg`
- Placeholder pattern includes human-readable ID label
The system is now ready for use with AprilTags instead of QR codes! The placeholder SVGs will work perfectly for testing and development until we implement actual AprilTag pattern generation.

View File

@@ -88,6 +88,10 @@ ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# Create data directory for uploads
RUN mkdir -p /data/uploads/images && \
chown -R nobody:root /data/uploads
# set runner ENV
ENV MIX_ENV="prod"

View File

@@ -51,6 +51,7 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
2. **Set up the database:**
```bash
docker run --name components-postgres -p 5432:5432 -e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres # password: config/dev.exs
mix ecto.create
mix ecto.migrate
mix run priv/repo/seeds.exs
@@ -269,10 +270,18 @@ The project includes these Docker files:
```
2. **Generate a secure secret key:**
**With Elixir/Phoenix installed:**
```bash
# Run this locally to generate a new secret
mix phx.gen.secret
```
**Without Elixir/Phoenix (Linux/Unix):**
```bash
dd if=/dev/random bs=1 count=64 status=none | base64 -w0 | cut -c1-64
```
> **Note**: The SECRET_KEY_BASE must be a cryptographically random string that's at least 64 characters long. Phoenix uses it to sign session cookies, CSRF tokens, and other security-sensitive data.
3. **Database Configuration**: The default setup includes:
- PostgreSQL 15 container

View File

@@ -1,6 +1,6 @@
import Config
# Configure your database
# Configure the database
config :components_elixir, ComponentsElixir.Repo,
username: "postgres",
password: "fCnPB8VQdPkhJAD29hq6sZEY",
@@ -10,8 +10,12 @@ config :components_elixir, ComponentsElixir.Repo,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
# For development work, log all queries
# config :components_elixir, ComponentsElixir.Repo, log: false
# For development, use a local uploads directory
config :components_elixir,
uploads_dir: "./uploads"
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it

View File

@@ -1,5 +1,9 @@
import Config
# Runtime configuration for uploads directory
config :components_elixir,
uploads_dir: System.get_env("UPLOADS_DIR", "./uploads")
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration

View File

@@ -1,378 +0,0 @@
# QR Code Storage Location System Design
## Overview
Implement a hierarchical storage location system with QR code generation and scanning capabilities to enable quick component location entry and filtering.
## Database Schema
### 1. Storage Locations Table
```sql
CREATE TABLE storage_locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
qr_code VARCHAR(100) UNIQUE NOT NULL,
parent_id INTEGER REFERENCES storage_locations(id),
level INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL, -- Materialized path: "shelf1/drawer2/box3"
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_storage_locations_parent_id ON storage_locations(parent_id);
CREATE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
CREATE INDEX idx_storage_locations_path ON storage_locations USING gin(path gin_trgm_ops);
CREATE UNIQUE INDEX idx_storage_locations_name_parent ON storage_locations(name, parent_id);
```
### 2. Modified Components Table
```sql
-- Migration to add storage_location_id to components
ALTER TABLE components
ADD COLUMN storage_location_id INTEGER REFERENCES storage_locations(id),
ADD COLUMN legacy_position VARCHAR(255); -- Keep old position data for migration
-- Move existing position data to legacy_position
UPDATE components SET legacy_position = position;
```
## QR Code Format Design
### Hierarchical QR Code Strategy
To avoid confusion with multiple QR codes in the same image, use a hierarchical encoding strategy:
```
Format: SL:{level}:{unique_id}:{parent_path_hash}
Examples:
- Shelf: "SL:1:ABC123:ROOT"
- Drawer: "SL:2:DEF456:ABC123"
- Box: "SL:3:GHI789:DEF456"
```
### QR Code Components:
- **SL**: Storage Location prefix
- **Level**: Hierarchy level (1=shelf, 2=drawer, 3=box, etc.)
- **Unique ID**: Short alphanumeric code (6-8 chars)
- **Parent Hash**: Reference to parent location
## Multi-QR Code Detection Strategy
### 1. Spatial Filtering
```
When multiple QR codes detected:
1. Calculate distance between codes
2. If distance < threshold:
- Prefer higher hierarchy level (lower number)
- Present disambiguation UI
3. If distance > threshold:
- Allow user to tap/select desired code
```
### 2. Context-Aware Selection
```
Selection Priority:
1. Exact level match (if user scanning for specific level)
2. Deepest level in hierarchy (most specific location)
3. Recently used locations (user preference learning)
4. Manual disambiguation prompt
```
### 3. Visual Feedback
```
Camera Overlay:
- Draw bounding boxes around each detected QR code
- Color-code by hierarchy level
- Show location path preview on hover/tap
- Highlight "best match" with different color
```
## Implementation Components
### 1. Elixir Modules
#### Storage Location Schema
```elixir
defmodule ComponentsElixir.Inventory.StorageLocation do
use Ecto.Schema
import Ecto.Changeset
schema "storage_locations" do
field :name, :string
field :description, :string
field :qr_code, :string
field :level, :integer, default: 0
field :path, :string
field :is_active, :boolean, default: true
belongs_to :parent, __MODULE__
has_many :children, __MODULE__, foreign_key: :parent_id
has_many :components, Component
timestamps()
end
end
```
#### QR Code Generation
```elixir
defmodule ComponentsElixir.QRCode do
def generate_storage_qr(location) do
qr_data = "SL:#{location.level}:#{location.qr_code}:#{parent_hash(location)}"
# Use :qr_code library to generate QR image
:qr_code.encode(qr_data)
|> :qr_code.png()
end
def parse_storage_qr(qr_string) do
case String.split(qr_string, ":") do
["SL", level, code, parent] ->
{:ok, %{level: level, code: code, parent: parent}}
_ ->
{:error, :invalid_format}
end
end
end
```
### 2. Phoenix LiveView Components
#### QR Scanner Component
```elixir
defmodule ComponentsElixirWeb.QRScannerLive do
use ComponentsElixirWeb, :live_view
def mount(_params, _session, socket) do
socket =
socket
|> assign(:scanning, false)
|> assign(:detected_codes, [])
|> assign(:selected_location, nil)
|> allow_upload(:qr_scan,
accept: ~w(.jpg .jpeg .png),
max_entries: 1,
auto_upload: true)
{:ok, socket}
end
def handle_event("start_scan", _, socket) do
{:noreply, assign(socket, :scanning, true)}
end
def handle_event("qr_detected", %{"codes" => codes}, socket) do
parsed_codes = Enum.map(codes, &parse_and_resolve_location/1)
socket =
socket
|> assign(:detected_codes, parsed_codes)
|> maybe_auto_select_location(parsed_codes)
{:noreply, socket}
end
defp maybe_auto_select_location(socket, [single_code]) do
assign(socket, :selected_location, single_code)
end
defp maybe_auto_select_location(socket, multiple_codes) do
# Show disambiguation UI
assign(socket, :selected_location, nil)
end
end
```
### 3. JavaScript QR Detection
#### Camera Integration
```javascript
// assets/js/qr_scanner.js
import jsQR from "jsqr";
export const QRScanner = {
mounted() {
this.video = this.el.querySelector('video');
this.canvas = this.el.querySelector('canvas');
this.context = this.canvas.getContext('2d');
this.startCamera();
this.scanLoop();
},
async startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
this.video.srcObject = stream;
} catch (err) {
console.error('Camera access denied:', err);
}
},
scanLoop() {
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
this.context.drawImage(this.video, 0, 0);
const imageData = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height);
// Detect multiple QR codes
const codes = this.detectMultipleQRCodes(imageData);
if (codes.length > 0) {
this.pushEvent("qr_detected", { codes: codes });
}
}
requestAnimationFrame(() => this.scanLoop());
},
detectMultipleQRCodes(imageData) {
// Implementation for detecting multiple QR codes
// This is a simplified version - you'd need a more robust library
const detected = [];
// Scan in grid pattern to find multiple codes
const gridSize = 4;
const width = imageData.width / gridSize;
const height = imageData.height / gridSize;
for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) {
const subImageData = this.getSubImageData(
imageData,
x * width,
y * height,
width,
height
);
const code = jsQR(subImageData.data, subImageData.width, subImageData.height);
if (code && this.isStorageLocationQR(code.data)) {
detected.push({
data: code.data,
location: { x: x * width, y: y * height },
corners: code.location
});
}
}
}
return this.filterDuplicates(detected);
},
isStorageLocationQR(data) {
return data.startsWith('SL:');
}
};
```
## User Experience Flow
### 1. Adding Components with QR Scan
```
1. User clicks "Add Component"
2. Position field shows camera icon
3. Click camera → QR scanner opens
4. Scan storage location QR code
5. If multiple codes detected:
- Show overlay with detected locations
- User taps to select specific location
6. Location path auto-filled: "Shelf A → Drawer 2 → Box 5"
7. Component saved with storage_location_id
```
### 2. Filtering by Storage Location
```
1. Component list shows location filter dropdown
2. Filter options show hierarchical tree:
├── Shelf A
│ ├── Drawer 1
│ │ ├── Box 1
│ │ └── Box 2
│ └── Drawer 2
└── Shelf B
3. Select any level to filter components
4. Breadcrumb shows: "Shelf A → Drawer 2" (23 components)
```
### 3. Location Management
```
1. New "Storage Locations" section in admin
2. Add/edit locations with auto QR generation
3. Print QR labels with location hierarchy
4. Bulk QR code generation for initial setup
```
## Handling Multiple QR Codes in Same Image
### Strategy 1: Spatial Separation
- Calculate euclidean distance between QR code centers
- If distance < 100px → show disambiguation
- If distance > 100px → allow selection by tap
### Strategy 2: Hierarchy Preference
- Always prefer deepest level (most specific)
- If same level → show all options
- Color-code by hierarchy level in UI
### Strategy 3: Machine Learning (Future)
- Learn user selection patterns
- Predict most likely intended QR code
- Still allow manual override
## Migration Strategy
### Phase 1: Add Storage Locations
1. Create migration for storage_locations table
2. Add storage_location_id to components
3. Create admin interface for location management
### Phase 2: QR Code Generation
1. Add QR code generation to location creation
2. Implement QR code printing/export functionality
3. Generate codes for existing locations
### Phase 3: QR Code Scanning
1. Add camera permissions and JavaScript QR scanner
2. Implement single QR code detection first
3. Add multi-QR detection and disambiguation
### Phase 4: Advanced Features
1. Location-based filtering and search
2. Bulk operations by location
3. Location analytics and optimization
## Technical Dependencies
### Elixir Dependencies
```elixir
# mix.exs
{:qr_code, "~> 3.1"}, # QR code generation
{:image, "~> 0.37"}, # Image processing
{:ex_image_info, "~> 0.2.4"} # Image metadata
```
### JavaScript Dependencies
???
## Database Indexes for Performance
```sql
-- Fast location lookups
CREATE INDEX idx_components_storage_location_id ON components(storage_location_id);
-- Hierarchical queries
CREATE INDEX idx_storage_locations_path_gin ON storage_locations USING gin(path gin_trgm_ops);
-- QR code uniqueness and fast lookup
CREATE UNIQUE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
```
This design provides a robust foundation for QR code-based storage management while handling the complexity of multiple codes in the same image through spatial analysis and user interaction patterns.

View File

@@ -25,9 +25,12 @@ services:
PHX_HOST: "localhost"
PHX_SERVER: "true"
PORT: "4000"
UPLOADS_DIR: "/data/uploads"
depends_on:
db:
condition: service_healthy
volumes:
- uploaded_files:/data/uploads
command:
[
"/bin/sh",
@@ -37,3 +40,4 @@ services:
volumes:
postgres_data:
uploaded_files:

View File

@@ -11,99 +11,42 @@ defmodule ComponentsElixir.Inventory do
## Storage Locations
@doc """
Returns the list of storage locations with computed hierarchy fields.
Returns the list of storage locations with optimized parent preloading.
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
"""
def list_storage_locations do
# Get all locations with preloaded parents in a single query
locations = StorageLocation
|> order_by([sl], [asc: sl.name])
|> preload(:parent)
|> Repo.all()
# Compute hierarchy fields for all locations efficiently
processed_locations = compute_hierarchy_fields_batch(locations)
|> Enum.sort_by(&{&1.level, &1.name})
locations =
StorageLocation
|> order_by([sl], asc: sl.name)
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> Repo.all()
# Ensure AprilTag SVGs exist for all locations
spawn(fn ->
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
end)
processed_locations
locations
end
# Efficient batch computation of hierarchy fields
defp compute_hierarchy_fields_batch(locations) do
# Create a map for quick parent lookup to avoid N+1 queries
location_map = Map.new(locations, fn loc -> {loc.id, loc} end)
Enum.map(locations, fn location ->
level = compute_level_efficient(location, location_map, 0)
path = compute_path_efficient(location, location_map, 0)
%{location | level: level, path: path}
end)
end
defp compute_level_efficient(%{parent_id: nil}, _location_map, _depth), do: 0
defp compute_level_efficient(%{parent_id: parent_id}, location_map, depth) when depth < 10 do
case Map.get(location_map, parent_id) do
nil -> 0 # Orphaned record
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
end
end
defp compute_level_efficient(_location, _location_map, _depth), do: 0 # Prevent infinite recursion
defp compute_path_efficient(%{parent_id: nil, name: name}, _location_map, _depth), do: name
defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth) when depth < 10 do
case Map.get(location_map, parent_id) do
nil -> name # Orphaned record
parent ->
parent_path = compute_path_efficient(parent, location_map, depth + 1)
"#{parent_path}/#{name}"
end
end
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name # Prevent infinite recursion
@doc """
Returns the list of root storage locations (no parent).
"""
def list_root_storage_locations do
StorageLocation
|> where([sl], is_nil(sl.parent_id))
|> order_by([sl], [asc: sl.name])
|> order_by([sl], asc: sl.name)
|> Repo.all()
end
@doc """
Gets a single storage location with computed hierarchy fields.
Gets a single storage location with preloaded associations.
"""
def get_storage_location!(id) do
location = StorageLocation
StorageLocation
|> preload(:parent)
|> Repo.get!(id)
# Compute hierarchy fields
level = compute_level_for_single(location)
path = compute_path_for_single(location)
%{location | level: level, path: path}
end
# Simple computation for single location (allows DB queries)
defp compute_level_for_single(%{parent_id: nil}), do: 0
defp compute_level_for_single(%{parent_id: parent_id}) do
case Repo.get(StorageLocation, parent_id) do
nil -> 0
parent -> 1 + compute_level_for_single(parent)
end
end
defp compute_path_for_single(%{parent_id: nil, name: name}), do: name
defp compute_path_for_single(%{parent_id: parent_id, name: name}) do
case Repo.get(StorageLocation, parent_id) do
nil -> name
parent -> "#{compute_path_for_single(parent)}/#{name}"
end
end
@doc """
@@ -114,13 +57,6 @@ defmodule ComponentsElixir.Inventory do
|> where([sl], sl.apriltag_id == ^apriltag_id)
|> preload(:parent)
|> Repo.one()
|> case do
nil -> nil
location ->
level = compute_level_for_single(location)
path = compute_path_for_single(location)
%{location | level: level, path: path}
end
end
@doc """
@@ -130,13 +66,15 @@ defmodule ComponentsElixir.Inventory do
# Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs)
result = %StorageLocation{}
|> StorageLocation.changeset(attrs)
|> Repo.insert()
result =
%StorageLocation{}
|> StorageLocation.changeset(attrs)
|> Repo.insert()
case result do
{:ok, location} ->
{:ok, location}
error ->
error
end
@@ -149,13 +87,15 @@ defmodule ComponentsElixir.Inventory do
# Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs)
result = storage_location
|> StorageLocation.changeset(attrs)
|> Repo.update()
result =
storage_location
|> StorageLocation.changeset(attrs)
|> Repo.update()
case result do
{:ok, updated_location} ->
{:ok, updated_location}
error ->
error
end
@@ -182,12 +122,14 @@ defmodule ComponentsElixir.Inventory do
case get_storage_location_by_apriltag_id(apriltag_id) do
nil ->
{:error, :not_found}
location ->
{:ok, %{
type: :storage_location,
location: location,
apriltag_id: apriltag_id
}}
{:ok,
%{
type: :storage_location,
location: location,
apriltag_id: apriltag_id
}}
end
end
@@ -195,8 +137,9 @@ defmodule ComponentsElixir.Inventory do
Computes the path for a storage location (for display purposes).
"""
def compute_storage_location_path(nil), do: nil
def compute_storage_location_path(%StorageLocation{} = location) do
compute_path_for_single(location)
StorageLocation.full_path(location)
end
# Convert string keys to atoms for consistency
@@ -205,6 +148,7 @@ defmodule ComponentsElixir.Inventory do
{key, value}, acc when is_binary(key) ->
atom_key = String.to_atom(key)
Map.put(acc, atom_key, value)
{key, value}, acc ->
Map.put(acc, key, value)
end)
@@ -213,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
## Categories
@doc """
Returns the list of categories.
Returns the list of categories with optimized parent preloading.
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
"""
def list_categories do
Category
|> preload(:parent)
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> Repo.all()
end
@@ -266,7 +211,7 @@ defmodule ComponentsElixir.Inventory do
def list_components(opts \\ []) do
Component
|> apply_component_filters(opts)
|> order_by([c], [asc: c.name])
|> apply_component_sorting(opts)
|> preload([:category, :storage_location])
|> Repo.all()
end
@@ -281,17 +226,36 @@ defmodule ComponentsElixir.Inventory do
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
search_pattern = "%#{search_term}%"
where(query, [c],
where(
query,
[c],
ilike(c.name, ^search_pattern) or
ilike(c.description, ^search_pattern) or
ilike(c.keywords, ^search_pattern) or
ilike(c.position, ^search_pattern)
ilike(c.description, ^search_pattern) or
ilike(c.keywords, ^search_pattern) or
ilike(c.position, ^search_pattern)
)
_, query -> query
_, query ->
query
end)
end
defp apply_component_sorting(query, opts) do
case Keyword.get(opts, :sort_criteria, "name_asc") do
"name_asc" -> order_by(query, [c], asc: c.name)
"name_desc" -> order_by(query, [c], desc: c.name)
"inserted_at_asc" -> order_by(query, [c], asc: c.inserted_at)
"inserted_at_desc" -> order_by(query, [c], desc: c.inserted_at)
"updated_at_asc" -> order_by(query, [c], asc: c.updated_at)
"updated_at_desc" -> order_by(query, [c], desc: c.updated_at)
"count_asc" -> order_by(query, [c], asc: c.count)
"count_desc" -> order_by(query, [c], desc: c.count)
# Default fallback
_ -> order_by(query, [c], asc: c.name)
end
end
@doc """
Gets a single component.
"""
@@ -339,10 +303,12 @@ defmodule ComponentsElixir.Inventory do
def get_inventory_stats do
total_components = Repo.aggregate(Component, :count, :id)
total_stock = Component
total_stock =
Component
|> Repo.aggregate(:sum, :count)
categories_with_components = Component
categories_with_components =
Component
|> distinct([c], c.category_id)
|> Repo.aggregate(:count, :category_id)
@@ -392,6 +358,7 @@ defmodule ComponentsElixir.Inventory do
"""
def decrement_component_count(%Component{} = component) do
new_count = max(0, component.count - 1)
component
|> Component.changeset(%{count: new_count})
|> Repo.update()

View File

@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
Categories can be hierarchical with parent-child relationships.
"""
use Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset
alias ComponentsElixir.Inventory.{Category, Component}
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
@doc """
Returns the full path of the category including parent names.
"""
def full_path(%Category{parent: nil} = category), do: category.name
def full_path(%Category{parent: %Category{} = parent} = category) do
"#{full_path(parent)} > #{category.name}"
end
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
category.name
@impl true
def full_path(%Category{} = category) do
Hierarchical.full_path(category, &(&1.parent), path_separator())
end
@impl true
def parent(%Category{parent: parent}), do: parent
@impl true
def children(%Category{children: children}), do: children
@impl true
def path_separator(), do: " > "
@impl true
def entity_type(), do: :category
end

View File

@@ -0,0 +1,252 @@
defmodule ComponentsElixir.Inventory.Hierarchical do
@moduledoc """
Shared hierarchical behavior for entities with parent-child relationships.
This module provides common functionality for:
- Path computation (e.g., "Parent > Child > Grandchild")
- Cycle detection and prevention
- Parent/child filtering for UI dropdowns
- Tree traversal utilities
Based on the elegant category implementation approach.
"""
@doc """
Computes full hierarchical path for an entity.
Uses recursive traversal of parent chain, loading parents from database if needed.
Optimized to minimize database queries by trying preloaded associations first.
## Examples
iex> category = %Category{name: "Resistors", parent: %Category{name: "Electronics", parent: nil}}
iex> Hierarchical.full_path(category, &(&1.parent))
"Electronics > Resistors"
"""
def full_path(entity, parent_accessor_fn, separator \\ " > ")
def full_path(nil, _parent_accessor_fn, _separator), do: ""
def full_path(entity, parent_accessor_fn, separator) do
case parent_accessor_fn.(entity) do
nil ->
entity.name
%Ecto.Association.NotLoaded{} ->
# Parent not loaded - fall back to database lookup
# This is a fallback and should be rare if preloading is done correctly
build_path_with_db_lookup(entity, separator)
parent ->
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
end
end
# Helper function to build path when parent associations are not loaded
# This is optimized to minimize database queries
defp build_path_with_db_lookup(entity, separator) do
# Build path by walking up the parent chain via database queries
# Collect parent names from root to leaf
path_parts = collect_path_from_root(entity, [])
Enum.join(path_parts, separator)
end
defp collect_path_from_root(entity, path_so_far) do
case entity.parent_id do
nil ->
# This is a root entity, add its name and return the complete path
[entity.name | path_so_far]
parent_id ->
# Load parent from database
case load_parent_entity(entity, parent_id) do
nil ->
# Parent not found (orphaned record), treat this as root
[entity.name | path_so_far]
parent ->
# Recursively get the path from the parent, then add current entity
collect_path_from_root(parent, [entity.name | path_so_far])
end
end
end
defp load_parent_entity(%{__struct__: module} = _entity, parent_id) do
# Note: This function makes individual database queries
# For better performance, consider preloading parent associations properly
# or implementing batch loading if this becomes a bottleneck
ComponentsElixir.Repo.get(module, parent_id)
end
@doc """
Filters entities to remove circular reference options for parent selection.
Prevents an entity from being its own ancestor.
## Examples
iex> categories = [%{id: 1, parent_id: nil}, %{id: 2, parent_id: 1}]
iex> Hierarchical.filter_parent_options(categories, 1, &(&1.id), &(&1.parent_id))
[%{id: 2, parent_id: 1}] # ID 1 filtered out (self-reference)
"""
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn)
def filter_parent_options(entities, nil, _id_accessor_fn, _parent_id_accessor_fn) do
entities
end
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn) do
entities
|> Enum.reject(fn entity ->
entity_id = id_accessor_fn.(entity)
# Remove self-reference
entity_id == editing_entity_id ||
# Remove descendants (they would create a cycle)
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
end)
end
@doc """
Checks if an entity is a descendant of an ancestor entity.
Used for cycle detection in parent selection.
"""
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
case descendant do
nil -> false
entity -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
end
end
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
case parent_id_accessor_fn.(entity) do
nil -> false
^ancestor_id -> true
parent_id ->
parent = Enum.find(entities, fn e -> e.id == parent_id end)
case parent do
nil -> false
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end
end
end
@doc """
Gets all root entities (entities with no parent).
"""
def root_entities(entities, parent_id_accessor_fn) do
Enum.filter(entities, fn entity ->
is_nil(parent_id_accessor_fn.(entity))
end)
end
@doc """
Gets all child entities of a specific parent.
"""
def child_entities(entities, parent_id, parent_id_accessor_fn) do
Enum.filter(entities, fn entity ->
parent_id_accessor_fn.(entity) == parent_id
end)
end
@doc """
Generates display name for entity including parent context.
For dropdown displays: "Parent > Child"
"""
def display_name(entity, parent_accessor_fn, separator \\ " > ") do
full_path(entity, parent_accessor_fn, separator)
end
@doc """
Generates options for a parent selection dropdown.
Includes proper filtering to prevent cycles and formatted display names.
Results are sorted hierarchically for intuitive navigation.
"""
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do
available_entities =
filter_parent_options(
entities,
editing_entity_id,
&(&1.id),
&(&1.parent_id)
)
|> sort_hierarchically(&(&1.parent_id))
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
[{nil_option_text, nil}] ++ available_entities
end
@doc """
Generates options for a general selection dropdown (like filters).
Results are sorted hierarchically for intuitive navigation.
"""
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities =
entities
|> sort_hierarchically(&(&1.parent_id))
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
if nil_option_text do
[{nil_option_text, nil}] ++ sorted_entities
else
sorted_entities
end
end
@doc """
Computes the depth/level of an entity in the hierarchy.
Root entities have level 0.
"""
def compute_level(entity, parent_accessor_fn) do
case parent_accessor_fn.(entity) do
nil -> 0
%Ecto.Association.NotLoaded{} -> 0
parent -> 1 + compute_level(parent, parent_accessor_fn)
end
end
@doc """
Returns the separator string used for a specific entity type.
Categories use " > " while storage locations use " / ".
"""
def separator_for(:category), do: " > "
def separator_for(:storage_location), do: " / "
def separator_for(_), do: " > "
@doc """
Sorts entities hierarchically in depth-first order.
Each parent is followed immediately by all its children (recursively).
Within each level, entities are sorted alphabetically by name.
## Examples
iex> entities = [
...> %{id: 1, name: "Resistors", parent_id: nil},
...> %{id: 2, name: "Wire", parent_id: 1},
...> %{id: 3, name: "Capacitors", parent_id: nil},
...> %{id: 4, name: "Ceramic", parent_id: 3}
...> ]
iex> Hierarchical.sort_hierarchically(entities, &(&1.parent_id))
# Returns: [Capacitors, Capacitors>Ceramic, Resistors, Resistors>Wire]
"""
def sort_hierarchically(entities, parent_id_accessor_fn) do
# First, get all root entities sorted alphabetically
root_entities =
entities
|> root_entities(parent_id_accessor_fn)
|> Enum.sort_by(& &1.name)
# Then recursively add children after each parent
Enum.flat_map(root_entities, fn root ->
[root | sort_children_recursively(entities, root.id, parent_id_accessor_fn)]
end)
end
defp sort_children_recursively(entities, parent_id, parent_id_accessor_fn) do
children =
entities
|> child_entities(parent_id, parent_id_accessor_fn)
|> Enum.sort_by(& &1.name)
Enum.flat_map(children, fn child ->
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
end)
end
end

View File

@@ -0,0 +1,41 @@
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
@moduledoc """
Behaviour for schemas that implement hierarchical relationships.
Provides a contract for entities with parent-child relationships,
ensuring consistent interface across different hierarchical entities.
"""
@doc """
Returns the full hierarchical path as a string.
Example: "Electronics > Components > Resistors"
"""
@callback full_path(struct()) :: String.t()
@doc """
Returns the parent entity or nil if this is a root entity.
"""
@callback parent(struct()) :: struct() | nil
@doc """
Returns the children entities as a list.
"""
@callback children(struct()) :: [struct()]
@doc """
Returns the separator used for path display.
"""
@callback path_separator() :: String.t()
@doc """
Returns the entity type for use with the Hierarchical module.
"""
@callback entity_type() :: atom()
defmacro __using__(_opts) do
quote do
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
alias ComponentsElixir.Inventory.Hierarchical
end
end
end

View File

@@ -3,9 +3,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
Schema for storage locations with hierarchical organization.
Storage locations can be nested (shelf -> drawer -> box) and each
has a unique QR code for quick scanning and identification.
has a unique AprilTag for quick scanning and identification.
"""
use Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset
import Ecto.Query
@@ -15,11 +16,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
field :name, :string
field :description, :string
field :apriltag_id, :integer
field :is_active, :boolean, default: true
# Computed/virtual fields - not stored in database
field :level, :integer, virtual: true
field :path, :string, virtual: true
# Only parent relationship is stored
belongs_to :parent, StorageLocation
@@ -32,53 +28,32 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
@doc false
def changeset(storage_location, attrs) do
storage_location
|> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id])
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:description, max: 500)
|> validate_apriltag_id()
|> foreign_key_constraint(:parent_id)
|> validate_no_circular_reference()
|> put_apriltag_id()
end
# Prevent circular references (location being its own ancestor)
defp validate_no_circular_reference(changeset) do
case get_change(changeset, :parent_id) do
nil -> changeset
parent_id ->
location_id = changeset.data.id
if location_id && would_create_cycle?(location_id, parent_id) do
add_error(changeset, :parent_id, "cannot be a descendant of this location")
else
changeset
end
end
# HierarchicalSchema implementations
@impl true
def full_path(%StorageLocation{} = storage_location) do
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
end
defp would_create_cycle?(location_id, parent_id) do
# Check if parent_id is the same as location_id or any of its descendants
location_id == parent_id or
(parent_id && is_descendant_of?(parent_id, location_id))
end
@impl true
def parent(%StorageLocation{parent: parent}), do: parent
defp is_descendant_of?(potential_descendant, ancestor_id) do
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
nil -> false
%{parent_id: nil} -> false
%{parent_id: ^ancestor_id} -> true
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
end
end
@impl true
def children(%StorageLocation{children: children}), do: children
@doc """
Returns the full hierarchical path as a human-readable string.
"""
def full_path(storage_location) do
storage_location.path
|> String.split("/")
|> Enum.join("")
end
@impl true
def path_separator(), do: " / "
@impl true
def entity_type(), do: :storage_location
@doc """
Returns the AprilTag format for this storage location.
@@ -103,28 +78,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
end
end
# Compute the hierarchy level based on parent chain
def compute_level(%StorageLocation{parent_id: nil}), do: 0
def compute_level(%StorageLocation{parent: %StorageLocation{} = parent}) do
compute_level(parent) + 1
end
def compute_level(%StorageLocation{parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
compute_level(parent) + 1
end
# Compute the full path based on parent chain
def compute_path(%StorageLocation{name: name, parent_id: nil}), do: name
def compute_path(%StorageLocation{name: name, parent: %StorageLocation{} = parent}) do
"#{compute_path(parent)}/#{name}"
end
def compute_path(%StorageLocation{name: name, parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
"#{compute_path(parent)}/#{name}"
end
defp get_next_available_apriltag_id do
# Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all(

View File

@@ -1,233 +0,0 @@
defmodule ComponentsElixir.QRCode do
@moduledoc """
QR Code generation and parsing for storage locations.
Provides functionality to generate QR codes for storage locations
and parse them back to retrieve location information.
"""
@doc """
Generates a QR code data string for a storage location.
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123", parent: nil}
iex> ComponentsElixir.QRCode.generate_qr_data(location)
"SL:1:ABC123:ROOT"
iex> parent = %StorageLocation{qr_code: "SHELF1"}
iex> drawer = %StorageLocation{level: 2, qr_code: "DRAW01", parent: parent}
iex> ComponentsElixir.QRCode.generate_qr_data(drawer)
"SL:2:DRAW01:SHELF1"
"""
def generate_qr_data(storage_location) do
parent_code =
case storage_location.parent do
nil -> "ROOT"
parent -> parent.qr_code
end
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
end
@doc """
Parses a QR code string and extracts components.
## Examples
iex> ComponentsElixir.QRCode.parse_qr_data("SL:1:ABC123:ROOT")
{:ok, %{level: 1, code: "ABC123", parent: "ROOT"}}
iex> ComponentsElixir.QRCode.parse_qr_data("invalid")
{:error, :invalid_format}
"""
def parse_qr_data(qr_string) do
case String.split(qr_string, ":") do
["SL", level_str, code, parent] ->
case Integer.parse(level_str) do
{level, ""} ->
{:ok, %{level: level, code: code, parent: parent}}
_ ->
{:error, :invalid_level}
end
_ ->
{:error, :invalid_format}
end
end
@doc """
Validates if a string looks like a storage location QR code.
## Examples
iex> ComponentsElixir.QRCode.valid_storage_qr?("SL:1:ABC123:ROOT")
true
iex> ComponentsElixir.QRCode.valid_storage_qr?("COMP:12345")
false
"""
def valid_storage_qr?(qr_string) do
case parse_qr_data(qr_string) do
{:ok, _} -> true
_ -> false
end
end
@doc """
Generates a printable label data structure for a storage location.
This could be used to generate PDF labels or send to a label printer.
"""
def generate_label_data(storage_location) do
qr_data = generate_qr_data(storage_location)
%{
qr_code: qr_data,
name: storage_location.name,
path: storage_location.path,
level: storage_location.level,
description: storage_location.description
}
end
@doc """
Generates multiple QR codes for disambiguation testing.
This is useful for testing multi-QR detection scenarios.
"""
def generate_test_codes(storage_locations) when is_list(storage_locations) do
Enum.map(storage_locations, &generate_qr_data/1)
end
@doc """
Generates a QR code image (PNG) for a storage location.
Returns the binary PNG data that can be saved to disk or served directly.
## Options
- `:size` - The size of the QR code image in pixels (default: 200)
- `:background` - Background color as `{r, g, b}` tuple (default: white)
- `:foreground` - Foreground color as `{r, g, b}` tuple (default: black)
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123"}
iex> {:ok, png_data} = ComponentsElixir.QRCode.generate_qr_image(location)
iex> File.write!("/tmp/qr_code.png", png_data)
"""
def generate_qr_image(storage_location, _opts \\ []) do
qr_data = generate_qr_data(storage_location)
qr_data
|> QRCode.create()
|> QRCode.render(:png)
end
@doc """
Generates and saves a QR code image to the specified file path.
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123"}
iex> ComponentsElixir.QRCode.save_qr_image(location, "/tmp/qr_code.png")
:ok
"""
def save_qr_image(storage_location, file_path, opts \\ []) do
case generate_qr_image(storage_location, opts) do
{:ok, png_data} ->
# Ensure directory exists
file_path
|> Path.dirname()
|> File.mkdir_p!()
File.write!(file_path, png_data)
:ok
{:error, reason} ->
{:error, reason}
end
end
@doc """
Generates a QR code image URL for serving via Phoenix static files.
This function generates the QR code image and saves it to the static directory,
returning a URL that can be used in templates.
## Examples
iex> location = %StorageLocation{id: 123, qr_code: "ABC123"}
iex> ComponentsElixir.QRCode.get_qr_image_url(location)
"/qr_codes/storage_location_123.png"
"""
def get_qr_image_url(storage_location, opts \\ []) do
filename = "storage_location_#{storage_location.id}.png"
file_path = Path.join([Application.app_dir(:components_elixir, "priv/static/user_generated/qr_codes"), filename])
# Generate and save the image if it doesn't exist or if regeneration is forced
force_regenerate = Keyword.get(opts, :force_regenerate, false)
if force_regenerate || !File.exists?(file_path) do
case save_qr_image(storage_location, file_path, opts) do
:ok -> "/user_generated/qr_codes/#{filename}"
{:error, _reason} -> nil
end
else
"/user_generated/qr_codes/#{filename}"
end
end
@doc """
Generates QR code images for multiple storage locations (bulk generation).
Returns a list of results indicating success or failure for each location.
## Examples
iex> locations = [location1, location2, location3]
iex> ComponentsElixir.QRCode.bulk_generate_images(locations)
[
{:ok, "/qr_codes/storage_location_1.png"},
{:ok, "/qr_codes/storage_location_2.png"},
{:error, "Failed to generate for location 3"}
]
"""
def bulk_generate_images(storage_locations, opts \\ []) do
# Use Task.async_stream for concurrent generation with back-pressure
storage_locations
|> Task.async_stream(
fn location ->
case get_qr_image_url(location, Keyword.put(opts, :force_regenerate, true)) do
nil -> {:error, "Failed to generate QR code for location #{location.id}"}
url -> {:ok, url}
end
end,
timeout: :infinity,
max_concurrency: System.schedulers_online() * 2
)
|> Enum.map(fn {:ok, result} -> result end)
end
@doc """
Cleans up QR code images for deleted storage locations.
Should be called when storage locations are deleted to prevent orphaned files.
"""
def cleanup_qr_image(storage_location_id) do
filename = "storage_location_#{storage_location_id}.png"
file_path = Path.join([Application.app_dir(:components_elixir, "priv/static/user_generated/qr_codes"), filename])
if File.exists?(file_path) do
File.rm(file_path)
else
:ok
end
end
end

View File

@@ -0,0 +1,40 @@
defmodule ComponentsElixirWeb.FileController do
use ComponentsElixirWeb, :controller
def show(conn, %{"filename" => filename}) do
# Security: only allow alphanumeric, dashes, underscores, and dots
if String.match?(filename, ~r/^[a-zA-Z0-9_\-\.]+$/) do
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
file_path = Path.join([uploads_dir, "images", filename])
if File.exists?(file_path) do
# Get the file's MIME type
mime_type = get_mime_type(filename)
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
|> send_file(200, file_path)
else
conn
|> put_status(:not_found)
|> text("File not found")
end
else
conn
|> put_status(:bad_request)
|> text("Invalid filename")
end
end
defp get_mime_type(filename) do
case Path.extname(filename) |> String.downcase() do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".png" -> "image/png"
".gif" -> "image/gif"
".webp" -> "image/webp"
_ -> "application/octet-stream"
end
end
end

View File

@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.Category
alias ComponentsElixir.Inventory.{Category, Hierarchical}
@impl true
def mount(_params, session, socket) do
@@ -121,45 +121,20 @@ defmodule ComponentsElixirWeb.CategoriesLive do
end
defp parent_category_options(categories, editing_category_id \\ nil) do
available_categories =
categories
|> Enum.reject(fn cat ->
cat.id == editing_category_id ||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
end)
|> Enum.map(fn category ->
{category_display_name(category), category.id}
end)
[{"No parent (Root category)", nil}] ++ available_categories
end
defp is_descendant?(categories, descendant_id, ancestor_id) do
# Check if descendant_id is a descendant of ancestor_id
descendant = Enum.find(categories, fn cat -> cat.id == descendant_id end)
case descendant do
nil -> false
%{parent_id: nil} -> false
%{parent_id: parent_id} when parent_id == ancestor_id -> true
%{parent_id: parent_id} -> is_descendant?(categories, parent_id, ancestor_id)
end
end
defp category_display_name(category) do
if category.parent do
"#{category.parent.name} > #{category.name}"
else
category.name
end
Hierarchical.parent_select_options(
categories,
editing_category_id,
&(&1.parent),
"No parent (Root category)"
)
end
defp root_categories(categories) do
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end)
Hierarchical.root_entities(categories, &(&1.parent_id))
end
defp child_categories(categories, parent_id) do
Enum.filter(categories, fn cat -> cat.parent_id == parent_id end)
Hierarchical.child_entities(categories, parent_id, &(&1.parent_id))
end
defp count_components_in_category(category_id) do

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.StorageLocation
alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
alias ComponentsElixir.AprilTag
@impl true
@@ -240,48 +240,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
available_locations =
storage_locations
|> Enum.reject(fn loc ->
loc.id == editing_location_id ||
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id))
end)
|> Enum.map(fn location ->
{location_display_name(location), location.id}
end)
[{"No parent (Root location)", nil}] ++ available_locations
end
defp is_descendant?(storage_locations, descendant_id, ancestor_id) do
# Check if descendant_id is a descendant of ancestor_id
descendant = Enum.find(storage_locations, fn loc -> loc.id == descendant_id end)
case descendant do
nil -> false
%{parent_id: nil} -> false
%{parent_id: parent_id} when parent_id == ancestor_id -> true
%{parent_id: parent_id} -> is_descendant?(storage_locations, parent_id, ancestor_id)
end
Hierarchical.parent_select_options(
storage_locations,
editing_location_id,
&(&1.parent),
"No parent (Root location)"
)
end
defp location_display_name(location) do
if location.path do
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
location.path
|> String.split("/")
|> Enum.join(" > ")
else
location.name
end
StorageLocation.full_path(location)
end
defp root_storage_locations(storage_locations) do
Enum.filter(storage_locations, fn loc -> is_nil(loc.parent_id) end)
Hierarchical.root_entities(storage_locations, &(&1.parent_id))
end
defp child_storage_locations(storage_locations, parent_id) do
Enum.filter(storage_locations, fn loc -> loc.parent_id == parent_id end)
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id))
end
defp count_components_in_location(location_id) do
@@ -766,7 +742,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
</div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
Level {scan.location.level}
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
</span>
</div>
</div>

View File

@@ -24,6 +24,9 @@ defmodule ComponentsElixirWeb.Router do
live "/login", LoginLive, :index
get "/login/authenticate", AuthController, :authenticate
post "/logout", AuthController, :logout
# File serving endpoint
get "/uploads/images/:filename", FileController, :show
end
scope "/", ComponentsElixirWeb do

View File

@@ -60,7 +60,6 @@ defmodule ComponentsElixir.MixProject do
depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:qr_code, "~> 3.1"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},

View File

@@ -0,0 +1,9 @@
defmodule ComponentsElixir.Repo.Migrations.RemoveIsActiveFromStorageLocations do
use Ecto.Migration
def change do
alter table(:storage_locations) do
remove :is_active, :boolean
end
end
end

View File

@@ -1,7 +1,13 @@
# Script for populating the database. You can run it as:
# Script for populating the database with sample data. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# This seeds file creates:
# - Sample categories (with hierarchical subcategories)
# - Storage locations (with auto-assigned AprilTag IDs)
# - Sample electronic components with proper storage assignments
# - Generates all AprilTag SVG files for immediate use
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
@@ -10,7 +16,7 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias ComponentsElixir.{Repo, Inventory}
alias ComponentsElixir.{Repo, Inventory, AprilTag}
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
# Clear existing data
@@ -49,9 +55,18 @@ Repo.delete_all(StorageLocation)
parent_id: capacitors.id
})
# 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_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
{:ok, shelf_a} = 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"})
{:ok, _shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
# Create drawers on Shelf A
{:ok, drawer_a1} = Inventory.create_storage_location(%{
@@ -73,7 +88,7 @@ Repo.delete_all(StorageLocation)
parent_id: drawer_a1.id
})
{:ok, box_a1_2} = Inventory.create_storage_location(%{
{:ok, _box_a1_2} = Inventory.create_storage_location(%{
name: "Box 2",
description: "SMD resistors",
parent_id: drawer_a1.id
@@ -98,6 +113,15 @@ Repo.delete_all(StorageLocation)
parent_id: drawer_a2.id
})
# 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_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
sample_components = [
%{
@@ -180,6 +204,23 @@ sample_components = [
storage_location_id: box_a1_1.id,
count: 100,
category_id: resistors.id
},
# Test components for deep hierarchies to ensure fallback path is exercised
%{
name: "Deep Category Test Component",
description: "Component in 7-level deep category hierarchy",
keywords: "test deep hierarchy category fallback",
storage_location_id: box_a1_1.id,
count: 1,
category_id: deep_cat_7.id
},
%{
name: "Deep Storage Test Component",
description: "Component in 7-level deep storage location hierarchy",
keywords: "test deep hierarchy storage fallback",
storage_location_id: deep_loc_7.id,
count: 1,
category_id: resistors.id
}
]
@@ -192,19 +233,44 @@ IO.puts("Categories: #{length(Inventory.list_categories())}")
IO.puts("Storage Locations: #{length(Inventory.list_storage_locations())}")
IO.puts("Components: #{length(Inventory.list_components())}")
IO.puts("")
IO.puts("Sample QR codes for testing:")
IO.puts("Sample AprilTag information:")
# Print some sample QR codes for testing
# Print AprilTag information for sample storage locations
sample_locations = [
Inventory.get_storage_location!(shelf_a.id),
Inventory.get_storage_location!(drawer_a1.id),
Inventory.get_storage_location!(box_a1_1.id),
Inventory.get_storage_location!(box_a2_1.id)
]
Enum.each(sample_locations, fn location ->
qr_data = ComponentsElixir.QRCode.generate_qr_data(location)
IO.puts("#{location.path}: #{qr_data}")
if location.apriltag_id do
apriltag_url = AprilTag.get_apriltag_url(location)
location_path = StorageLocation.full_path(location)
IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}")
IO.puts(" Download URL: #{apriltag_url}")
else
location_path = StorageLocation.full_path(location)
IO.puts("#{location_path}: No AprilTag assigned")
end
end)
# Generate all AprilTag SVGs for immediate use
IO.puts("Generating AprilTag SVG files...")
result = AprilTag.generate_all_apriltag_svgs()
IO.puts("Generated #{result.success}/#{result.total} AprilTag SVG files")
IO.puts("")
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
IO.puts("🎉 Database seeded successfully!")
IO.puts("📊 Summary:")
IO.puts(" Categories: #{length(Inventory.list_categories())}")
IO.puts(" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)")
IO.puts(" Components: #{length(Inventory.list_components())}")
IO.puts("")
IO.puts("🏷️ AprilTag System:")
IO.puts(" - Each storage location has an auto-assigned AprilTag ID (0-586)")
IO.puts(" - SVG files available at /apriltags/tag36h11_id_XXX.svg")
IO.puts(" - Download AprilTags from storage location management page")
IO.puts("")
IO.puts("🔐 Login with password: changeme (or set AUTH_PASSWORD environment variable)")
IO.puts("🌐 Visit http://localhost:4000 to start using the system!")