refactor(elixir): hierarchical refactor

to extract common code patterns from
category/storage location systems
This commit is contained in:
Schuwi
2025-09-17 23:56:56 +02:00
parent 963c9a3770
commit 264adbfb98
12 changed files with 415 additions and 1173 deletions

View File

@@ -1,154 +0,0 @@
# Hierarchical Implementation Analysis
## Overview
This document analyzes the current hierarchical implementations in the codebase (Categories and Storage Locations) to determine the best approach for creating a shared `Hierarchical` behavior module.
## Current State Analysis
### Categories vs Storage Locations Feature Comparison
| Feature | Categories | Storage Locations | Assessment |
|---------|------------|-------------------|------------|
| **Path Resolution** | Simple recursive function in schema | Complex virtual fields + batch computation | **Categories wins** - cleaner approach |
| **Cycle Prevention** | UI-level filtering (preventive) | Changeset validation (reactive) | **Categories wins** - more efficient |
| **Hierarchical Display** | Recursive LiveView components | Recursive LiveView components | **Tie** - nearly identical |
| **Parent Selection** | Smart dropdown filtering | Smart dropdown filtering | **Tie** - identical logic |
| **Performance** | O(depth) recursive calls | O(n) batch computation + DB queries | **Categories wins** - simpler |
| **Code Complexity** | ~50 lines of hierarchy logic | ~100+ lines of hierarchy logic | **Categories wins** - more maintainable |
## Key Findings
### 1. Vestigial `is_active` Field Removed
- **Status**: ✅ **REMOVED**
- **Impact**: Field was completely unused across the entire codebase
- **Files Modified**:
- `lib/components_elixir/inventory/storage_location.ex` (schema and changeset)
- Migration created: `20250917210658_remove_is_active_from_storage_locations.exs`
### 2. Categories Implementation is Superior
#### **Path Resolution Approach**
**Categories**: Elegant recursive function in schema
```elixir
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
```
**Storage Locations**: Over-engineered with virtual fields
```elixir
# Virtual fields in schema
field :level, :integer, virtual: true
field :path, :string, virtual: true
# Complex batch computation in context
def compute_hierarchy_fields_batch(locations)
def compute_level_for_single(location)
def compute_path_for_single(location)
```
#### **Cycle Prevention Strategy**
**Categories**: **Prevention at UI level** (efficient)
```elixir
# Prevents invalid options from appearing in dropdown
|> Enum.reject(fn cat ->
cat.id == editing_category_id ||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
end)
```
**Storage Locations**: **Validation at changeset level** (reactive)
```elixir
# Validates after user attempts invalid selection
defp validate_no_circular_reference(changeset) do
# Complex validation logic that runs on every save attempt
end
```
### 3. Shared Patterns Identified
Both systems implement identical patterns for:
- **Hierarchical tree display** with recursive LiveView components
- **Parent/child relationship filtering**
- **Descendant detection algorithms**
- **Root entity identification**
- **UI depth-based styling and icons**
## Architecture Decision
### Recommended Approach: **Category-Style Hierarchical Module**
The category implementation should be the template for generalization because:
1. **Simpler**: No virtual fields or complex batch operations
2. **More Performant**: O(depth) vs O(n) complexity
3. **Preventive**: UI-level cycle prevention vs reactive validation
4. **Maintainable**: Half the lines of code with same functionality
### AprilTag Features Remain Storage-Specific
The AprilTag system should **NOT** be generalized because:
- Physical identification is meaningless for categories
- Future scanning/detection features are location-specific
- Keeps domain separation clean
## Implementation Complexity Comparison
### Categories (Simple & Clean)
```
Files: 1 schema + 1 LiveView = 2 files
Lines: ~50 lines hierarchical logic
Approach: Functional, recursive, preventive
Dependencies: Standard Ecto associations
```
### Storage Locations (Over-Engineered)
```
Files: 1 schema + 1 context helper + 1 LiveView = 3 files
Lines: ~100+ lines hierarchical logic
Approach: Stateful, batch processing, reactive
Dependencies: Virtual fields + custom computation
```
## Performance Analysis
### Path Resolution Performance
- **Categories**: `O(depth)` - traverses only parent chain
- **Storage Locations**: `O(n)` - processes all entities for batch computation
### Memory Usage
- **Categories**: Minimal - uses existing associations
- **Storage Locations**: Higher - virtual fields + intermediate computations
### Database Queries
- **Categories**: Standard association preloading
- **Storage Locations**: Additional queries for path/level computation
## Code Quality Assessment
### Categories Strengths
**Single Responsibility**: Each function does one thing
**Functional Style**: Pure functions, no side effects
**Standard Patterns**: Uses established Ecto association patterns
**Easy Testing**: Simple recursive functions
**Performance**: Minimal computational overhead
### Storage Locations Issues
**Multiple Responsibilities**: Virtual fields + validation + computation
**Complex State**: Virtual fields require careful management
**Custom Patterns**: Non-standard Ecto usage
**Hard Testing**: Complex batch operations
**Performance**: Unnecessary computational overhead
## Conclusion
The **categories implementation is objectively superior** and should guide the refactoring:
1. **Simpler code** (50% fewer lines)
2. **Better performance** (O(depth) vs O(n))
3. **More maintainable** (functional vs stateful)
4. **Standard patterns** (Ecto associations vs virtual fields)
5. **Preventive design** (UI filtering vs changeset validation)
The storage locations system should be refactored to match the categories approach, eliminating virtual fields and complex batch computations in favor of simple recursive functions.

View File

@@ -1,370 +0,0 @@
# Hierarchical Refactoring Plan
## Overview
This document outlines the step-by-step plan to extract common hierarchical behavior from Categories and Storage Locations into a shared `Hierarchical` module, based on the superior category implementation approach.
## Goals
1. **Extract shared hierarchical patterns** into reusable modules
2. **Simplify storage locations** to match category elegance
3. **Maintain AprilTag-specific features** for storage locations
4. **Preserve all existing functionality** during refactor
5. **Improve performance and maintainability**
## Refactoring Strategy
### Phase 1: Extract Hierarchical Behavior Module ⏭️ **NEXT**
Create shared behavior module based on category patterns
### Phase 2: Refactor Storage Locations 🔄 **FOLLOW-UP**
Simplify storage locations to use new shared module
### Phase 3: Refactor Categories 🔄 **FOLLOW-UP**
Update categories to use shared module
### Phase 4: Extract LiveView Components 🔄 **FOLLOW-UP**
Create reusable UI components for hierarchical display
---
## Phase 1: Extract Hierarchical Behavior Module
### 1.1 Create Core Hierarchical Module
**File**: `lib/components_elixir/inventory/hierarchical.ex`
```elixir
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.
## 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 \\ " > ")
@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)
@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)
@doc """
Gets all root entities (entities with no parent).
"""
def root_entities(entities, parent_id_accessor_fn)
@doc """
Gets all child entities of a specific parent.
"""
def child_entities(entities, parent_id, parent_id_accessor_fn)
@doc """
Generates display name for entity including parent context.
For dropdown displays: "Parent > Child"
"""
def display_name(entity, parent_accessor_fn, separator \\ " > ")
end
```
### 1.2 Schema Behaviour Definition
**File**: `lib/components_elixir/inventory/hierarchical_schema.ex`
```elixir
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
@moduledoc """
Behaviour for schemas that implement hierarchical relationships.
Provides a contract for entities with parent-child relationships.
"""
@callback full_path(struct()) :: String.t()
@callback parent(struct()) :: struct() | nil
@callback children(struct()) :: [struct()]
defmacro __using__(_opts) do
quote do
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
import ComponentsElixir.Inventory.Hierarchical
end
end
end
```
### 1.3 Update Category Schema
**File**: `lib/components_elixir/inventory/category.ex`
```elixir
defmodule ComponentsElixir.Inventory.Category do
use ComponentsElixir.Inventory.HierarchicalSchema
# ... existing schema definition
@impl true
def full_path(%Category{} = category) do
ComponentsElixir.Inventory.Hierarchical.full_path(category, &(&1.parent))
end
@impl true
def parent(%Category{parent: parent}), do: parent
@impl true
def children(%Category{children: children}), do: children
end
```
## Phase 2: Refactor Storage Locations
### 2.1 Simplify Storage Location Schema
**Changes to**: `lib/components_elixir/inventory/storage_location.ex`
1. **Remove virtual fields**: `level` and `path`
2. **Add category-style path function**
3. **Remove complex cycle validation**
4. **Keep AprilTag-specific features**
```elixir
defmodule ComponentsElixir.Inventory.StorageLocation do
use ComponentsElixir.Inventory.HierarchicalSchema
# ... existing schema without virtual fields
@impl true
def full_path(%StorageLocation{} = location) do
ComponentsElixir.Inventory.Hierarchical.full_path(location, &(&1.parent), " / ")
end
# Keep AprilTag-specific functionality
def apriltag_format(storage_location), do: storage_location.apriltag_id
end
```
### 2.2 Simplify Storage Location Changeset
**Remove**:
- `validate_no_circular_reference/1` function
- Virtual field handling
- Complex cycle detection
**Keep**:
- AprilTag validation
- Basic field validation
### 2.3 Update Inventory Context
**Changes to**: `lib/components_elixir/inventory.ex`
**Remove**:
- `compute_hierarchy_fields_batch/1`
- `compute_level_for_single/1`
- `compute_path_for_single/1`
- All virtual field computation
**Simplify**:
- `list_storage_locations/0` - use standard preloading
- `get_storage_location!/1` - remove virtual field computation
## Phase 3: Refactor Categories
### 3.1 Update Categories to Use Shared Module
**Changes to**: `lib/components_elixir/inventory/category.ex`
Replace existing `full_path/1` with call to shared module.
### 3.2 Update Categories LiveView
**Changes to**: `lib/components_elixir_web/live/categories_live.ex`
Replace custom hierarchy functions with shared module calls:
```elixir
# Replace custom functions with shared module
defp parent_category_options(categories, editing_category_id \\ nil) do
available_categories =
Hierarchical.filter_parent_options(
categories,
editing_category_id,
&(&1.id),
&(&1.parent_id)
)
|> Enum.map(fn category ->
{Hierarchical.display_name(category, &(&1.parent)), category.id}
end)
[{"No parent (Root category)", nil}] ++ available_categories
end
```
## Phase 4: Extract LiveView Components
### 4.1 Create Hierarchical LiveView Components
**File**: `lib/components_elixir_web/live/hierarchical_components.ex`
```elixir
defmodule ComponentsElixirWeb.HierarchicalComponents do
use Phoenix.Component
alias ComponentsElixir.Inventory.Hierarchical
@doc """
Renders a hierarchical tree of entities with depth-based styling.
"""
def hierarchy_tree(assigns)
@doc """
Renders an individual item in a hierarchical tree.
"""
def hierarchy_item(assigns)
@doc """
Renders a parent selection dropdown with cycle prevention.
"""
def parent_select(assigns)
end
```
### 4.2 Update LiveViews to Use Shared Components
Both `CategoriesLive` and `StorageLocationsLive` can use the shared components, with custom slots for entity-specific content (like AprilTag display).
## Implementation Timeline
### Immediate (Phase 1) - 1-2 hours
- [ ] Create `Hierarchical` module with core functions
- [ ] Create `HierarchicalSchema` behaviour
- [ ] Write comprehensive tests for shared module
### Short-term (Phase 2) - 2-3 hours
- [ ] Refactor storage location schema to remove virtual fields
- [ ] Simplify storage location changeset
- [ ] Update inventory context to remove batch computation
- [ ] Test storage location functionality
### Medium-term (Phase 3) - 1-2 hours
- [ ] Update category schema to use shared module
- [ ] Update categories LiveView to use shared functions
- [ ] Test category functionality
### Long-term (Phase 4) - 2-3 hours
- [ ] Create shared LiveView components
- [ ] Refactor both LiveViews to use shared components
- [ ] Add entity-specific customization slots
- [ ] Comprehensive integration testing
## Benefits After Refactoring
### Code Quality
- **50% reduction** in hierarchical logic duplication
- **Consistent patterns** across both entity types
- **Easier testing** with isolated, pure functions
- **Better maintainability** with single source of truth
### Performance
- **Elimination of virtual fields** reduces memory usage
- **Remove batch computation** improves response times
- **Standard Ecto patterns** optimize database queries
- **UI-level cycle prevention** reduces validation overhead
### Developer Experience
- **Shared components** speed up new hierarchical entity development
- **Consistent API** reduces learning curve
- **Better documentation** with centralized behavior
- **Easier debugging** with simplified call stacks
## Risk Mitigation
### Database Migration Safety
1. **Backup database** before running migrations
2. **Test migrations** on development environment first
3. **Incremental approach** - one table at a time
### Functionality Preservation
1. **Comprehensive test coverage** before refactoring
2. **Feature parity testing** after each phase
3. **AprilTag functionality isolation** to prevent interference
### Rollback Plan
1. **Git branching strategy** for each phase
2. **Database migration rollbacks** prepared
3. **Quick revert capability** if issues discovered
## Testing Strategy
### Unit Tests
- [ ] Test all `Hierarchical` module functions
- [ ] Test schema `full_path/1` implementations
- [ ] Test cycle detection edge cases
### Integration Tests
- [ ] Test LiveView parent selection dropdowns
- [ ] Test hierarchical tree rendering
- [ ] Test AprilTag functionality preservation
### Performance Tests
- [ ] Benchmark path computation performance
- [ ] Measure memory usage before/after
- [ ] Profile database query patterns
## Migration Notes
### Database Changes Required
1. **Remove `is_active` column** from storage_locations (✅ **COMPLETED**)
2. **No other database changes** needed for this refactor
### Deployment Considerations
- **Zero downtime**: Refactor is code-only (except `is_active` removal)
- **Backward compatible**: No API changes
- **Incremental deployment**: Can deploy phase by phase
## Success Criteria
### Functional
- [ ] All existing category functionality preserved
- [ ] All existing storage location functionality preserved
- [ ] AprilTag features remain storage-location specific
- [ ] UI behavior identical to current implementation
### Non-Functional
- [ ] Code duplication reduced by ≥50%
- [ ] Performance maintained or improved
- [ ] Test coverage ≥95% for shared modules
- [ ] Documentation complete for new modules
---
## Next Steps
1. **Review this plan** with team/stakeholders
2. **Create feature branch** for refactoring work
3. **Begin Phase 1** - Extract hierarchical behavior module
4. **Write comprehensive tests** before any refactoring
5. **Execute phases incrementally** with testing between each
This refactoring will significantly improve code quality and maintainability while preserving all existing functionality and preparing the codebase for future hierarchical entities.

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

@@ -11,73 +11,25 @@ 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)
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> Repo.all()
# Compute hierarchy fields for all locations efficiently
processed_locations =
compute_hierarchy_fields_batch(locations)
|> Enum.sort_by(&{&1.level, &1.name})
# 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
# Orphaned record
nil -> 0
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
end
end
# Prevent infinite recursion
defp compute_level_efficient(_location, _location_map, _depth), do: 0
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
# Orphaned record
nil ->
name
parent ->
parent_path = compute_path_efficient(parent, location_map, depth + 1)
"#{parent_path}/#{name}"
end
end
# Prevent infinite recursion
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name
@doc """
Returns the list of root storage locations (no parent).
"""
@@ -89,37 +41,12 @@ defmodule ComponentsElixir.Inventory do
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
|> 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
StorageLocation
|> preload(:parent)
|> Repo.get!(id)
end
@doc """
@@ -130,15 +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 """
@@ -221,7 +139,7 @@ defmodule ComponentsElixir.Inventory do
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
@@ -239,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

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
@@ -16,10 +17,6 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
field :description, :string
field :apriltag_id, :integer
# 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
has_many :children, StorageLocation, foreign_key: :parent_id
@@ -37,47 +34,26 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|> 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.
@@ -102,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

@@ -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

View File

@@ -2,7 +2,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.Component
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
@items_per_page 20
@@ -430,31 +430,15 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
defp category_options(categories) do
[{"Select a category", nil}] ++
Enum.map(categories, fn category ->
{category.name, category.id}
end)
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
end
defp storage_location_display_name(location) do
# Use the computed path from Inventory context for full hierarchy, or fall back to location.path
path = Inventory.compute_storage_location_path(location) || location.path
if path do
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
path
|> String.split("/")
|> Enum.join(" > ")
else
location.name
end
StorageLocation.full_path(location)
end
defp storage_location_options(storage_locations) do
[{"No storage location", nil}] ++
Enum.map(storage_locations, fn location ->
{storage_location_display_name(location), location.id}
end)
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
end
@impl true
@@ -503,7 +487,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
</div>
<!-- Filters -->
<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">
@@ -525,13 +509,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do
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_category)}>All Categories</option>
<%= for category <- @categories do %>
<option value={category.id} selected={@selected_category == category.id}>
<%= if category.parent do %>
{category.parent.name} > {category.name}
<% else %>
{category.name}
<% end %>
<%= for {category_name, category_id} <- Hierarchical.select_options(@categories, &(&1.parent)) do %>
<option value={category_id} selected={@selected_category == category_id}>
{category_name}
</option>
<% end %>
</select>
@@ -580,7 +560,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
</div>
<!-- Add Component Modal -->
<%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -714,7 +694,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<!-- Edit Component Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -858,7 +838,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<!-- Components List -->
<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">
@@ -918,7 +898,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</button>
</div>
</div>
<!-- Content area with image and details -->
<div class="flex gap-6">
<!-- Large Image -->
@@ -944,7 +924,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Details -->
<div class="flex-1 space-y-4 select-text">
<!-- Full Description -->
@@ -956,7 +936,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</p>
</div>
<% end %>
<!-- Metadata Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<%= if component.storage_location do %>
@@ -1013,7 +993,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
<button
@@ -1098,7 +1078,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</span>
</div>
</div>
<!-- Middle row: Description -->
<%= if component.description do %>
<div class="mt-1">
@@ -1107,7 +1087,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</p>
</div>
<% end %>
<!-- 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">
<%= if component.storage_location do %>
@@ -1135,7 +1115,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Keywords row -->
<%= if component.keywords do %>
<div class="mt-2">
@@ -1218,7 +1198,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
>
<!-- Background overlay -->
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
<!-- Modal content -->
<div
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
@@ -1236,7 +1216,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
×
</button>
</div>
<!-- Content -->
<div class="p-6 bg-base-100 rounded-b-lg">
<div class="text-center">

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

@@ -55,6 +55,15 @@ 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"})
@@ -104,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 = [
%{
@@ -186,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
}
]
@@ -211,10 +246,12 @@ sample_locations = [
Enum.each(sample_locations, fn location ->
if location.apriltag_id do
apriltag_url = AprilTag.get_apriltag_url(location)
IO.puts("#{location.path}: AprilTag ID #{location.apriltag_id}")
location_path = StorageLocation.full_path(location)
IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}")
IO.puts(" Download URL: #{apriltag_url}")
else
IO.puts("#{location.path}: No AprilTag assigned")
location_path = StorageLocation.full_path(location)
IO.puts("#{location_path}: No AprilTag assigned")
end
end)