Compare commits
10 Commits
1bdfea8d02
...
e078770557
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e078770557 | ||
|
|
264adbfb98 | ||
|
|
963c9a3770 | ||
|
|
5a1775e836 | ||
|
|
6a1122c3be | ||
|
|
b6e137632a | ||
|
|
8848986953 | ||
|
|
68b0c0714e | ||
|
|
76b0a97d31 | ||
|
|
fa9bf74fd9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
|
||||
11
README.md
11
README.md
@@ -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,11 +270,19 @@ 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
|
||||
- Automatic database creation
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
locations =
|
||||
StorageLocation
|
||||
|> order_by([sl], asc: sl.name)
|
||||
|> 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
|
||||
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{}
|
||||
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
|
||||
result =
|
||||
storage_location
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.update()
|
||||
|
||||
case result do
|
||||
{:ok, updated_location} ->
|
||||
{:ok, updated_location}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
@@ -182,8 +122,10 @@ defmodule ComponentsElixir.Inventory do
|
||||
case get_storage_location_by_apriltag_id(apriltag_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
location ->
|
||||
{:ok, %{
|
||||
{:ok,
|
||||
%{
|
||||
type: :storage_location,
|
||||
location: location,
|
||||
apriltag_id: apriltag_id
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
_, 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
252
lib/components_elixir/inventory/hierarchical.ex
Normal file
252
lib/components_elixir/inventory/hierarchical.ex
Normal 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
|
||||
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
40
lib/components_elixir_web/controllers/file_controller.ex
Normal file
40
lib/components_elixir_web/controllers/file_controller.ex
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -23,18 +23,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|> assign(:storage_locations, storage_locations)
|
||||
|> assign(:stats, stats)
|
||||
|> assign(:search, "")
|
||||
|> assign(:sort_criteria, "all_not_id")
|
||||
|> assign(:sort_criteria, "name_asc")
|
||||
|> assign(:selected_category, nil)
|
||||
|> assign(:offset, 0)
|
||||
|> assign(:components, [])
|
||||
|> assign(:has_more, false)
|
||||
|> assign(:loading, false)
|
||||
|> assign(:sort_freeze_until, nil)
|
||||
|> assign(:interacting_with, nil)
|
||||
|> assign(:sort_freeze_timer, nil)
|
||||
|> assign(:sort_frozen, false)
|
||||
|> assign(:show_add_form, false)
|
||||
|> assign(:show_edit_form, false)
|
||||
|> assign(:editing_component, nil)
|
||||
|> assign(:form, nil)
|
||||
|> assign(:show_image_modal, false)
|
||||
|> assign(:modal_image_url, nil)
|
||||
|> assign(:focused_component_id, nil)
|
||||
|> allow_upload(:image,
|
||||
accept: ~w(.jpg .jpeg .png .gif),
|
||||
max_entries: 1,
|
||||
@@ -47,7 +52,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
@impl true
|
||||
def handle_params(params, _uri, socket) do
|
||||
search = Map.get(params, "search", "")
|
||||
criteria = Map.get(params, "criteria", "all_not_id")
|
||||
criteria = Map.get(params, "criteria", "name_asc")
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -92,6 +97,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
def handle_event("category_filter", %{"category_id" => category_id}, socket) do
|
||||
category_id = String.to_integer(category_id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_category, category_id)
|
||||
@@ -113,10 +119,42 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
case Inventory.increment_component_count(component) do
|
||||
{:ok, _updated_component} ->
|
||||
# Only apply sort freeze for dynamic sorting criteria
|
||||
should_freeze =
|
||||
socket.assigns.sort_criteria in [
|
||||
"count_asc",
|
||||
"count_desc",
|
||||
"updated_at_asc",
|
||||
"updated_at_desc"
|
||||
]
|
||||
|
||||
if should_freeze do
|
||||
# Cancel any existing timer
|
||||
if socket.assigns.sort_freeze_timer do
|
||||
Process.cancel_timer(socket.assigns.sort_freeze_timer)
|
||||
end
|
||||
|
||||
# Set sort freeze for 3 seconds and mark component as interacting
|
||||
freeze_until = DateTime.add(DateTime.utc_now(), 3, :second)
|
||||
|
||||
# Set new timer to clear interaction state
|
||||
timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Count updated")
|
||||
|> assign(:sort_freeze_until, freeze_until)
|
||||
|> assign(:interacting_with, id)
|
||||
|> assign(:sort_freeze_timer, timer_ref)
|
||||
|> assign(:sort_frozen, true)
|
||||
|> load_components()}
|
||||
else
|
||||
# Normal behavior for stable sorts
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Count updated")
|
||||
|> load_components()}
|
||||
end
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to update count")}
|
||||
@@ -128,10 +166,42 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
case Inventory.decrement_component_count(component) do
|
||||
{:ok, _updated_component} ->
|
||||
# Only apply sort freeze for dynamic sorting criteria
|
||||
should_freeze =
|
||||
socket.assigns.sort_criteria in [
|
||||
"count_asc",
|
||||
"count_desc",
|
||||
"updated_at_asc",
|
||||
"updated_at_desc"
|
||||
]
|
||||
|
||||
if should_freeze do
|
||||
# Cancel any existing timer
|
||||
if socket.assigns.sort_freeze_timer do
|
||||
Process.cancel_timer(socket.assigns.sort_freeze_timer)
|
||||
end
|
||||
|
||||
# Set sort freeze for 3 seconds and mark component as interacting
|
||||
freeze_until = DateTime.add(DateTime.utc_now(), 3, :second)
|
||||
|
||||
# Set new timer to clear interaction state
|
||||
timer_ref = Process.send_after(self(), {:clear_interaction, id}, 3000)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Count updated")
|
||||
|> assign(:sort_freeze_until, freeze_until)
|
||||
|> assign(:interacting_with, id)
|
||||
|> assign(:sort_freeze_timer, timer_ref)
|
||||
|> assign(:sort_frozen, true)
|
||||
|> load_components()}
|
||||
else
|
||||
# Normal behavior for stable sorts
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Count updated")
|
||||
|> load_components()}
|
||||
end
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Failed to update count")}
|
||||
@@ -207,6 +277,21 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|> assign(:modal_image_url, nil)}
|
||||
end
|
||||
|
||||
def handle_event("toggle_focus", %{"id" => id}, socket) do
|
||||
component_id = String.to_integer(id)
|
||||
|
||||
new_focused_id =
|
||||
if socket.assigns.focused_component_id == component_id do
|
||||
# Unfocus if clicking on the same component
|
||||
nil
|
||||
else
|
||||
# Focus on the new component
|
||||
component_id
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :focused_component_id, new_focused_id)}
|
||||
end
|
||||
|
||||
def handle_event("prevent_close", _params, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
@@ -256,10 +341,38 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:clear_interaction, component_id}, socket) do
|
||||
# Only clear if this timer is for the currently interacting component
|
||||
if socket.assigns.interacting_with == component_id do
|
||||
# Clear interaction state and allow sorting to resume
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:sort_freeze_until, nil)
|
||||
|> assign(:interacting_with, nil)
|
||||
|> assign(:sort_freeze_timer, nil)
|
||||
|> assign(:sort_frozen, false)
|
||||
|> load_components()}
|
||||
else
|
||||
# Ignore stale timer messages
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp load_components(socket, opts \\ []) do
|
||||
append = Keyword.get(opts, :append, false)
|
||||
|
||||
filters = [
|
||||
# Check if sorting should be frozen
|
||||
now = DateTime.utc_now()
|
||||
|
||||
should_reload =
|
||||
is_nil(socket.assigns.sort_freeze_until) ||
|
||||
DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt
|
||||
|
||||
if should_reload do
|
||||
# Normal loading - query database with current sort criteria
|
||||
filters =
|
||||
[
|
||||
search: socket.assigns.search,
|
||||
sort_criteria: socket.assigns.sort_criteria,
|
||||
category_id: socket.assigns.selected_category,
|
||||
@@ -285,6 +398,24 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
socket
|
||||
|> assign(:components, components)
|
||||
|> assign(:has_more, has_more)
|
||||
else
|
||||
# Frozen - just update the specific component in place without reordering
|
||||
if socket.assigns.interacting_with do
|
||||
updated_components =
|
||||
Enum.map(socket.assigns.components, fn component ->
|
||||
if to_string(component.id) == socket.assigns.interacting_with do
|
||||
# Reload this specific component to get updated count
|
||||
Inventory.get_component!(component.id)
|
||||
else
|
||||
component
|
||||
end
|
||||
end)
|
||||
|
||||
assign(socket, :components, updated_components)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp build_query_params(socket, overrides) do
|
||||
@@ -299,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
|
||||
@@ -394,37 +509,53 @@ 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>
|
||||
</form>
|
||||
</div>
|
||||
<div>
|
||||
<form phx-change="sort_change">
|
||||
<form phx-change="sort_change" class="relative">
|
||||
<select
|
||||
name="sort_criteria"
|
||||
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="all_not_id" selected={@sort_criteria == "all_not_id"}>
|
||||
All (without IDs)
|
||||
<option value="name_asc" selected={@sort_criteria == "name_asc"}>
|
||||
Name A-Z
|
||||
</option>
|
||||
<option value="all" selected={@sort_criteria == "all"}>All</option>
|
||||
<option value="name" selected={@sort_criteria == "name"}>Name</option>
|
||||
<option value="description" selected={@sort_criteria == "description"}>
|
||||
Description
|
||||
<option value="name_desc" selected={@sort_criteria == "name_desc"}>
|
||||
Name Z-A
|
||||
</option>
|
||||
<option value="id" selected={@sort_criteria == "id"}>ID</option>
|
||||
<option value="category_id" selected={@sort_criteria == "category_id"}>
|
||||
Category ID
|
||||
<option value="inserted_at_desc" selected={@sort_criteria == "inserted_at_desc"}>
|
||||
Entry Date (Newest)
|
||||
</option>
|
||||
<option value="inserted_at_asc" selected={@sort_criteria == "inserted_at_asc"}>
|
||||
Entry Date (Oldest)
|
||||
</option>
|
||||
<option value="updated_at_desc" selected={@sort_criteria == "updated_at_desc"}>
|
||||
Update Date (Newest)
|
||||
</option>
|
||||
<option value="updated_at_asc" selected={@sort_criteria == "updated_at_asc"}>
|
||||
Update Date (Oldest)
|
||||
</option>
|
||||
<option value="count_desc" selected={@sort_criteria == "count_desc"}>
|
||||
Count (Highest)
|
||||
</option>
|
||||
<option value="count_asc" selected={@sort_criteria == "count_asc"}>
|
||||
Count (Lowest)
|
||||
</option>
|
||||
</select>
|
||||
<%= if @sort_frozen do %>
|
||||
<div class="absolute -bottom-5 left-0 text-xs text-yellow-600 flex items-center transition-opacity duration-200 pointer-events-none">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10 2L3 7v11h14V7l-7-5z" />
|
||||
</svg>
|
||||
Sort temporarily frozen
|
||||
</div>
|
||||
<% end %>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,7 +576,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<.form for={@form} phx-submit="save_component" phx-change="validate" multipart={true} class="space-y-4">
|
||||
<.form
|
||||
for={@form}
|
||||
phx-submit="save_component"
|
||||
phx-change="validate"
|
||||
multipart={true}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">Name</label>
|
||||
<.input field={@form[:name]} type="text" required />
|
||||
@@ -472,7 +609,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<.input field={@form[:keywords]} type="text" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">Storage Location</label>
|
||||
<label class="block text-sm font-medium text-base-content">
|
||||
Storage Location
|
||||
</label>
|
||||
<.input
|
||||
field={@form[:storage_location_id]}
|
||||
type="select"
|
||||
@@ -495,14 +634,17 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">Component Image</label>
|
||||
<div class="mt-1">
|
||||
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />
|
||||
<.live_file_input
|
||||
upload={@uploads.image}
|
||||
class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
JPG, PNG, GIF up to 5MB
|
||||
</p>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.image) do %>
|
||||
<p class="text-error text-sm mt-1"><%= Phoenix.Naming.humanize(err) %></p>
|
||||
<p class="text-error text-sm mt-1">{Phoenix.Naming.humanize(err)}</p>
|
||||
<% end %>
|
||||
|
||||
<%= for entry <- @uploads.image.entries do %>
|
||||
@@ -512,17 +654,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-base-content"><%= entry.client_name %></p>
|
||||
<p class="text-sm text-base-content/60"><%= entry.progress %>%</p>
|
||||
<p class="text-sm font-medium text-base-content">{entry.client_name}</p>
|
||||
<p class="text-sm text-base-content/60">{entry.progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel" class="text-error hover:text-error/80">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel-upload"
|
||||
phx-value-ref={entry.ref}
|
||||
aria-label="cancel"
|
||||
class="text-error hover:text-error/80"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= for err <- upload_errors(@uploads.image) do %>
|
||||
<p class="mt-1 text-sm text-error"><%= upload_error_to_string(err) %></p>
|
||||
<p class="mt-1 text-sm text-error">{upload_error_to_string(err)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -562,7 +710,13 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<.form for={@form} phx-submit="save_edit" phx-change="validate" multipart={true} class="space-y-4">
|
||||
<.form
|
||||
for={@form}
|
||||
phx-submit="save_edit"
|
||||
phx-change="validate"
|
||||
multipart={true}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">Name</label>
|
||||
<.input field={@form[:name]} type="text" required />
|
||||
@@ -589,7 +743,9 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<.input field={@form[:keywords]} type="text" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">Storage Location</label>
|
||||
<label class="block text-sm font-medium text-base-content">
|
||||
Storage Location
|
||||
</label>
|
||||
<.input
|
||||
field={@form[:storage_location_id]}
|
||||
type="select"
|
||||
@@ -614,18 +770,25 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<%= if @editing_component && @editing_component.image_filename do %>
|
||||
<div class="mt-1 mb-2">
|
||||
<p class="text-sm text-base-content/70">Current image:</p>
|
||||
<img src={"/user_generated/uploads/images/#{@editing_component.image_filename}"} alt="Current component" class="h-20 w-20 object-cover rounded-lg" />
|
||||
<img
|
||||
src={"/uploads/images/#{@editing_component.image_filename}"}
|
||||
alt="Current component"
|
||||
class="h-20 w-20 object-cover rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="mt-1">
|
||||
<.live_file_input upload={@uploads.image} class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20" />
|
||||
<.live_file_input
|
||||
upload={@uploads.image}
|
||||
class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary/10 file:text-primary hover:file:bg-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
JPG, PNG, GIF up to 5MB (leave empty to keep current image)
|
||||
</p>
|
||||
|
||||
<%= for err <- upload_errors(@uploads.image) do %>
|
||||
<p class="text-error text-sm mt-1"><%= Phoenix.Naming.humanize(err) %></p>
|
||||
<p class="text-error text-sm mt-1">{Phoenix.Naming.humanize(err)}</p>
|
||||
<% end %>
|
||||
|
||||
<%= for entry <- @uploads.image.entries do %>
|
||||
@@ -635,17 +798,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<.live_img_preview entry={entry} class="h-10 w-10 rounded-lg object-cover" />
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium text-base-content"><%= entry.client_name %></p>
|
||||
<p class="text-sm text-base-content/60"><%= entry.progress %>%</p>
|
||||
<p class="text-sm font-medium text-base-content">{entry.client_name}</p>
|
||||
<p class="text-sm text-base-content/60">{entry.progress}%</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" phx-click="cancel-upload" phx-value-ref={entry.ref} aria-label="cancel" class="text-error hover:text-error/80">
|
||||
<button
|
||||
type="button"
|
||||
phx-click="cancel-upload"
|
||||
phx-value-ref={entry.ref}
|
||||
aria-label="cancel"
|
||||
class="text-error hover:text-error/80"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= for err <- upload_errors(@uploads.image) do %>
|
||||
<p class="mt-1 text-sm text-error"><%= upload_error_to_string(err) %></p>
|
||||
<p class="mt-1 text-sm text-error">{upload_error_to_string(err)}</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -675,17 +844,209 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
<ul class="divide-y divide-base-300" id="components-list" phx-update="replace">
|
||||
<%= for component <- @components do %>
|
||||
<li id={"component-#{component.id}"} class="px-6 py-4 hover:bg-base-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Component Image -->
|
||||
<div class="flex-shrink-0 mr-4">
|
||||
<li
|
||||
id={"component-#{component.id}"}
|
||||
class={[
|
||||
"px-6 py-6 hover:bg-base-200 transition-all duration-200",
|
||||
if(@focused_component_id == component.id,
|
||||
do: "bg-base-50 border-l-4 border-primary",
|
||||
else: "cursor-pointer"
|
||||
),
|
||||
if(@interacting_with == to_string(component.id),
|
||||
do: "ring-2 ring-yellow-400 ring-opacity-50 bg-yellow-50",
|
||||
else: ""
|
||||
)
|
||||
]}
|
||||
phx-click={if @focused_component_id != component.id, do: "toggle_focus", else: nil}
|
||||
phx-value-id={component.id}
|
||||
>
|
||||
<%= if @focused_component_id == component.id do %>
|
||||
<!-- Expanded/Focused View -->
|
||||
<div class="space-y-6">
|
||||
<!-- Header with name, category, and close button -->
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex items-center min-w-0 flex-1">
|
||||
<h3 class="text-lg font-semibold text-primary select-text">
|
||||
<%= if component.datasheet_url do %>
|
||||
<a
|
||||
href={component.datasheet_url}
|
||||
target="_blank"
|
||||
class="hover:text-primary/80"
|
||||
>
|
||||
{component.name}
|
||||
</a>
|
||||
<% else %>
|
||||
{component.name}
|
||||
<% end %>
|
||||
</h3>
|
||||
<%= if component.datasheet_url do %>
|
||||
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="ml-4 flex items-center space-x-3">
|
||||
<span class="px-3 py-1 text-sm font-medium rounded-full bg-green-100 text-green-800">
|
||||
{component.category.name}
|
||||
</span>
|
||||
<!-- Close/Collapse button -->
|
||||
<button
|
||||
phx-click="toggle_focus"
|
||||
phx-value-id={component.id}
|
||||
class="p-1 rounded-full hover:bg-base-200 text-base-content/60 hover:text-base-content transition-colors"
|
||||
title="Collapse"
|
||||
>
|
||||
<.icon name="hero-x-mark" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content area with image and details -->
|
||||
<div class="flex gap-6">
|
||||
<!-- Large Image -->
|
||||
<div class="flex-shrink-0">
|
||||
<%= if component.image_filename do %>
|
||||
<button phx-click="show_image" phx-value-url={"/user_generated/uploads/images/#{component.image_filename}"} class="hover:opacity-75 transition-opacity">
|
||||
<img src={"/user_generated/uploads/images/#{component.image_filename}"} alt={component.name} class="h-12 w-12 rounded-lg object-cover cursor-pointer" />
|
||||
<button
|
||||
phx-click="show_image"
|
||||
phx-value-url={"/user_generated/uploads/images/#{component.image_filename}"}
|
||||
class="hover:opacity-75 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={"/user_generated/uploads/images/#{component.image_filename}"}
|
||||
alt={component.name}
|
||||
class="h-48 w-48 rounded-lg object-contain cursor-pointer border border-base-300"
|
||||
/>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="h-12 w-12 rounded-lg bg-base-200 flex items-center justify-center">
|
||||
<.icon name="hero-cube-transparent" class="h-6 w-6 text-base-content/50" />
|
||||
<div class="h-48 w-48 rounded-lg bg-base-200 flex items-center justify-center border border-base-300">
|
||||
<.icon
|
||||
name="hero-cube-transparent"
|
||||
class="h-20 w-20 text-base-content/50"
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 space-y-4 select-text">
|
||||
<!-- Full Description -->
|
||||
<%= if component.description do %>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-base-content mb-2">Description</h4>
|
||||
<p class="text-sm text-base-content/70 leading-relaxed">
|
||||
{component.description}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<%= if component.storage_location do %>
|
||||
<div class="flex items-start">
|
||||
<.icon
|
||||
name="hero-map-pin"
|
||||
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<span class="font-medium text-base-content">Location:</span>
|
||||
<div class="text-base-content/70">
|
||||
{storage_location_display_name(component.storage_location)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="flex items-center">
|
||||
<.icon name="hero-cube" class="w-4 h-4 mr-2 text-base-content/50" />
|
||||
<span class="font-medium text-base-content">Count:</span>
|
||||
<span class="ml-1 text-base-content/70">{component.count}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start">
|
||||
<.icon
|
||||
name="hero-calendar"
|
||||
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<span class="font-medium text-base-content">Entry Date:</span>
|
||||
<div class="text-base-content/70">
|
||||
{Calendar.strftime(component.inserted_at, "%B %d, %Y")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if @sort_criteria == "all" or @sort_criteria == "id" do %>
|
||||
<div class="flex items-center">
|
||||
<.icon name="hero-hashtag" class="w-4 h-4 mr-2 text-base-content/50" />
|
||||
<span class="font-medium text-base-content">ID:</span>
|
||||
<span class="ml-1 text-base-content/70">{component.id}</span>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if component.keywords do %>
|
||||
<div class="flex items-start">
|
||||
<.icon name="hero-tag" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5" />
|
||||
<div>
|
||||
<span class="font-medium text-base-content">Keywords:</span>
|
||||
<div class="text-base-content/70">{component.keywords}</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
||||
<button
|
||||
phx-click="increment_count"
|
||||
phx-value-id={component.id}
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-1" /> Add
|
||||
</button>
|
||||
<button
|
||||
phx-click="decrement_count"
|
||||
phx-value-id={component.id}
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-yellow-600 hover:bg-yellow-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-yellow-500"
|
||||
>
|
||||
<.icon name="hero-minus" class="w-4 h-4 mr-1" /> Remove
|
||||
</button>
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={component.id}
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-4 h-4 mr-1" /> Edit
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_component"
|
||||
phx-value-id={component.id}
|
||||
data-confirm="Are you sure you want to delete this component?"
|
||||
class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-4 h-4 mr-1" /> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Compact/Normal View -->
|
||||
<div class="flex items-start justify-between min-h-[5rem]">
|
||||
<!-- Component Image -->
|
||||
<div class="flex-shrink-0 mr-6 h-20 w-20 grid place-items-center">
|
||||
<%= if component.image_filename do %>
|
||||
<button
|
||||
phx-click="show_image"
|
||||
phx-value-url={"/user_generated/uploads/images/#{component.image_filename}"}
|
||||
class="hover:opacity-75 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={"/user_generated/uploads/images/#{component.image_filename}"}
|
||||
alt={component.name}
|
||||
class="max-h-20 max-w-20 rounded-md object-contain cursor-pointer block"
|
||||
/>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="h-20 w-20 rounded-md bg-base-200 flex items-center justify-center">
|
||||
<.icon name="hero-cube-transparent" class="h-10 w-10 text-base-content/50" />
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -731,9 +1092,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
<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 %>
|
||||
<div class="flex items-center min-w-0">
|
||||
<.icon name="hero-map-pin" class="w-4 h-4 mr-1 text-base-content/50 flex-shrink-0" />
|
||||
<.icon
|
||||
name="hero-map-pin"
|
||||
class="w-4 h-4 mr-1 text-base-content/50 flex-shrink-0"
|
||||
/>
|
||||
<span class="font-medium">Location:</span>
|
||||
<span class="ml-1 truncate">{storage_location_display_name(component.storage_location)}</span>
|
||||
<span class="ml-1 truncate">
|
||||
{storage_location_display_name(component.storage_location)}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex items-center">
|
||||
@@ -791,6 +1157,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
@@ -825,12 +1192,18 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
<!-- Image Modal -->
|
||||
<%= if @show_image_modal do %>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center p-4" phx-click="close_image_modal">
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
phx-click="close_image_modal"
|
||||
>
|
||||
<!-- 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" phx-click="prevent_close">
|
||||
<div
|
||||
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
|
||||
phx-click="prevent_close"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center p-4 border-b border-base-300 bg-base-100 rounded-t-lg">
|
||||
<h3 class="text-lg font-semibold text-base-content">Component Image</h3>
|
||||
@@ -867,33 +1240,65 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
# Helper functions for image upload handling
|
||||
defp save_uploaded_image(socket, component_params) do
|
||||
IO.puts("=== DEBUG: Starting save_uploaded_image ===")
|
||||
IO.inspect(socket.assigns.uploads.image.entries, label: "Upload entries")
|
||||
|
||||
uploaded_files =
|
||||
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
|
||||
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
|
||||
dest = Path.join(["priv", "static", "user_generated", "uploads", "images", filename])
|
||||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||
upload_dir = Path.join([uploads_dir, "images"])
|
||||
dest = Path.join(upload_dir, filename)
|
||||
|
||||
IO.puts("=== DEBUG: Processing upload ===")
|
||||
IO.puts("Filename: #{filename}")
|
||||
IO.puts("Upload dir: #{upload_dir}")
|
||||
IO.puts("Destination: #{dest}")
|
||||
|
||||
# Ensure the upload directory exists
|
||||
File.mkdir_p!(Path.dirname(dest))
|
||||
File.mkdir_p!(upload_dir)
|
||||
|
||||
# Copy the file
|
||||
case File.cp(path, dest) do
|
||||
:ok -> filename
|
||||
{:error, _reason} -> nil
|
||||
:ok ->
|
||||
IO.puts("=== DEBUG: File copy successful ===")
|
||||
{:ok, filename}
|
||||
|
||||
{:error, reason} ->
|
||||
IO.puts("=== DEBUG: File copy failed: #{inspect(reason)} ===")
|
||||
{:postpone, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
IO.inspect(uploaded_files, label: "Uploaded files result")
|
||||
|
||||
result =
|
||||
case uploaded_files do
|
||||
[filename] when is_binary(filename) -> Map.put(component_params, "image_filename", filename)
|
||||
[] -> component_params
|
||||
_error -> component_params
|
||||
[filename] when is_binary(filename) ->
|
||||
IO.puts("=== DEBUG: Adding filename to params: #{filename} ===")
|
||||
Map.put(component_params, "image_filename", filename)
|
||||
|
||||
[] ->
|
||||
IO.puts("=== DEBUG: No files uploaded ===")
|
||||
component_params
|
||||
|
||||
_error ->
|
||||
IO.puts("=== DEBUG: Upload error ===")
|
||||
IO.inspect(uploaded_files, label: "Unexpected upload result")
|
||||
component_params
|
||||
end
|
||||
|
||||
IO.inspect(result, label: "Final component_params")
|
||||
IO.puts("=== DEBUG: End save_uploaded_image ===")
|
||||
result
|
||||
end
|
||||
|
||||
defp delete_image_file(nil), do: :ok
|
||||
defp delete_image_file(""), do: :ok
|
||||
|
||||
defp delete_image_file(filename) do
|
||||
path = Path.join(["priv", "static", "user_generated", "uploads", "images", filename])
|
||||
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
|
||||
path = Path.join([uploads_dir, "images", filename])
|
||||
File.rm(path)
|
||||
end
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
1
mix.exs
1
mix.exs
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
@@ -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!")
|
||||
|
||||
Reference in New Issue
Block a user