feat(elixir): storage location system

This commit is contained in:
Schuwi
2025-09-14 15:20:25 +02:00
parent b9126c286f
commit 9d090859e8
14 changed files with 1517 additions and 160 deletions

View File

@@ -119,18 +119,43 @@ The application uses a simple password-based authentication system:
| `imageUpload.php` | Phoenix LiveView file uploads with `.live_file_input` | **IMPLEMENTED**: Full image upload with preview, validation, and automatic cleanup |
| Session management | Phoenix sessions + LiveView | Built-in CSRF protection |
## Future Enhancements
## 🚀 Future Enhancements
1. ~~**Image Upload**: Implement Phoenix file uploads for component images~~ ✅ **COMPLETED**
2. **Bulk Operations**: Import/export components via CSV
3. **API Endpoints**: REST API for external integrations
4. **User Management**: Multi-user support with roles and permissions
5. **Advanced Search**: Filters by category, stock level, etc.
6. **Barcode/QR Codes**: Generate and scan codes for quick inventory updates
### Priority 1: Complete QR Code System (see [qr_storage_system](qr_storage_system.md))
- **QR Code Image Generation** - Add Elixir library (e.g., `qr_code` hex package) to generate actual QR code images
- **QR Code Display in Interface** - Show generated QR code images in the storage locations interface
- **Camera Integration** - JavaScript-based QR scanning with camera access for mobile/desktop
- **Multi-QR Code Detection** - Spatial analysis and disambiguation for multiple codes in same image
### Component Management
- **Barcode Support** - Generate and scan traditional barcodes in addition to QR codes
- **Bulk Operations** - Import/export components from CSV, batch updates
- **Search and Filtering** - Advanced search by specifications, tags, location
- **Component Templates** - Reusable templates for common component types
- **Version History** - Track changes to component specifications over time
### Storage Organization
- **Physical Layout Mapping** - Visual representation of shelves, drawers, and boxes
- **Bulk QR Code Printing** - Generate printable sheets of QR codes for labeling
## ✅ Recently Implemented Features
### Image Upload System
### Storage Location System Foundation 🚧 **PARTIALLY IMPLEMENTED**
- **Database Schema** ✅ Complete - Hierarchical storage locations with parent-child relationships
- **Storage Location CRUD** ✅ Complete - Full create, read, update, delete operations via web interface
- **QR Code Data Generation** ✅ Complete - Text-based QR codes with format `SL:{level}:{code}:{parent}`
- **Hierarchical Organization** ✅ Complete - Unlimited nesting (shelf → drawer → box)
- **Web Interface** ✅ Complete - Storage locations management page with navigation
- **Component-Storage Integration** ❌ Missing - Linking components to storage locations not yet implemented correctly
### QR Code System - Still Needed 🚧 **NOT IMPLEMENTED**
- **Visual QR Code Generation** ❌ Missing - No actual QR code images are generated
- **QR Code Display** ❌ Missing - QR codes not shown in interface (as seen in screenshot)
- **QR Code Scanning** ❌ Missing - No camera integration or scanning functionality
- **QR Code Processing** ❌ Missing - Backend logic for processing scanned codes
- **Multi-QR Disambiguation** ❌ Missing - No handling of multiple QR codes in same image
### Image Upload System ✅ **COMPLETED**
- **Phoenix LiveView file uploads** with `.live_file_input` component
- **Image preview** during upload with progress indication
- **File validation** (JPG, PNG, GIF up to 5MB)
@@ -138,7 +163,7 @@ The application uses a simple password-based authentication system:
- **Responsive image display** in component listings with fallback placeholders
- **Upload error handling** with user-friendly messages
### Visual Datasheet Indicators
### Visual Datasheet Indicators ✅ **COMPLETED**
- **Datasheet emoji** (📄) displayed next to component names when datasheet URL is present
- **Clickable datasheet links** with clear visual indication
- **Improved component listing** with image thumbnails and datasheet indicators

View File

@@ -0,0 +1,384 @@
# 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
```json
// package.json
{
"jsqr": "^1.4.0",
"qr-scanner": "^1.4.2"
}
```
## Database Indexes for Performance
```sql
-- Fast location lookups
CREATE INDEX idx_components_storage_location_id ON components(storage_location_id);
-- Hierarchical queries
CREATE INDEX idx_storage_locations_path_gin ON storage_locations USING gin(path gin_trgm_ops);
-- QR code uniqueness and fast lookup
CREATE UNIQUE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
```
This design provides a robust foundation for QR code-based storage management while handling the complexity of multiple codes in the same image through spatial analysis and user interaction patterns.

View File

@@ -1,12 +1,185 @@
defmodule ComponentsElixir.Inventory do
@moduledoc """
The Inventory context for managing components and categories.
The Inventory context: managing components and categories.
"""
import Ecto.Query, warn: false
alias ComponentsElixir.Repo
alias ComponentsElixir.Inventory.{Category, Component}
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
## Storage Locations
@doc """
Returns the list of storage locations with computed hierarchy fields.
"""
def list_storage_locations do
# Get all locations with preloaded parents in a single query
locations = StorageLocation
|> order_by([sl], [asc: sl.name])
|> preload(:parent)
|> Repo.all()
# Compute hierarchy fields for all locations efficiently
compute_hierarchy_fields_batch(locations)
|> Enum.sort_by(&{&1.level, &1.name})
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])
|> Repo.all()
end
@doc """
Gets a single storage location with computed hierarchy fields.
"""
def get_storage_location!(id) do
location = StorageLocation
|> preload(:parent)
|> Repo.get!(id)
# Compute hierarchy fields
level = compute_level_for_single(location)
path = compute_path_for_single(location)
%{location | level: level, path: path}
end
# Simple computation for single location (allows DB queries)
defp compute_level_for_single(%{parent_id: nil}), do: 0
defp compute_level_for_single(%{parent_id: parent_id}) do
case Repo.get(StorageLocation, parent_id) do
nil -> 0
parent -> 1 + compute_level_for_single(parent)
end
end
defp compute_path_for_single(%{parent_id: nil, name: name}), do: name
defp compute_path_for_single(%{parent_id: parent_id, name: name}) do
case Repo.get(StorageLocation, parent_id) do
nil -> name
parent -> "#{compute_path_for_single(parent)}/#{name}"
end
end
@doc """
Gets a storage location by QR code.
"""
def get_storage_location_by_qr_code(qr_code) do
StorageLocation
|> where([sl], sl.qr_code == ^qr_code)
|> 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 """
Creates a storage location.
"""
def create_storage_location(attrs \\ %{}) do
# Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs)
%StorageLocation{}
|> StorageLocation.changeset(attrs)
|> Repo.insert()
end
@doc """
Updates a storage location.
"""
def update_storage_location(%StorageLocation{} = storage_location, attrs) do
# Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs)
storage_location
|> StorageLocation.changeset(attrs)
|> Repo.update()
end
@doc """
Deletes a storage location.
"""
def delete_storage_location(%StorageLocation{} = storage_location) do
Repo.delete(storage_location)
end
@doc """
Returns an `%Ecto.Changeset{}` for tracking storage location changes.
"""
def change_storage_location(%StorageLocation{} = storage_location, attrs \\ %{}) do
StorageLocation.changeset(storage_location, attrs)
end
@doc """
Parses a QR code string and returns storage location information.
"""
def parse_qr_code(qr_string) do
case get_storage_location_by_qr_code(qr_string) do
nil ->
{:error, :not_found}
location ->
{:ok, %{
type: :storage_location,
location: location,
qr_code: qr_string
}}
end
end
# Convert string keys to atoms for consistency
defp normalize_string_keys(attrs) when is_map(attrs) do
Enum.reduce(attrs, %{}, fn
{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)
end
## Categories
@@ -15,39 +188,14 @@ defmodule ComponentsElixir.Inventory do
"""
def list_categories do
Category
|> order_by([c], [asc: c.name])
|> preload(:parent)
|> Repo.all()
end
@doc """
Returns the list of root categories (no parent).
"""
def list_root_categories do
Category
|> where([c], is_nil(c.parent_id))
|> order_by([c], [asc: c.name])
|> Repo.all()
end
@doc """
Returns the list of child categories for a given parent.
"""
def list_child_categories(parent_id) do
Category
|> where([c], c.parent_id == ^parent_id)
|> order_by([c], [asc: c.name])
|> Repo.all()
end
@doc """
Gets a single category.
"""
def get_category!(id) do
Category
|> preload(:parent)
|> Repo.get!(id)
end
def get_category!(id), do: Repo.get!(Category, id)
@doc """
Creates a category.
@@ -81,90 +229,37 @@ defmodule ComponentsElixir.Inventory do
Category.changeset(category, attrs)
end
@doc """
Returns the count of components in a specific category.
"""
def count_components_in_category(category_id) do
Component
|> where([c], c.category_id == ^category_id)
|> Repo.aggregate(:count, :id)
end
## Components
@doc """
Returns the list of components with optional filtering and pagination.
Returns the list of components.
"""
def list_components(opts \\ []) do
Component
|> apply_component_filters(opts)
|> preload(:category)
|> order_by([c], [asc: c.category_id, asc: c.name])
|> order_by([c], [asc: c.name])
|> preload([:category, :storage_location])
|> Repo.all()
end
@doc """
Returns paginated components with search and filtering.
"""
def paginate_components(opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
offset = Keyword.get(opts, :offset, 0)
query =
Component
|> apply_component_filters(opts)
|> preload(:category)
|> order_by([c], [asc: c.category_id, asc: c.name])
components =
query
|> limit(^limit)
|> offset(^offset)
|> Repo.all()
total_count = Repo.aggregate(query, :count, :id)
%{
components: components,
total_count: total_count,
has_more: total_count > offset + length(components)
}
end
defp apply_component_filters(query, opts) do
Enum.reduce(opts, query, fn
{:search, search}, query when is_binary(search) and search != "" ->
if String.length(search) > 3 do
# Use full-text search for longer queries
where(query, [c],
fragment("to_tsvector('english', ? || ' ' || coalesce(?, '') || ' ' || coalesce(?, '')) @@ plainto_tsquery(?)",
c.name, c.description, c.keywords, ^search))
else
# Use ILIKE for shorter queries
search_term = "%#{search}%"
where(query, [c],
ilike(c.name, ^search_term) or
ilike(c.description, ^search_term) or
ilike(c.keywords, ^search_term))
end
{:category_id, category_id}, query when is_integer(category_id) ->
{:category_id, category_id}, query when not is_nil(category_id) ->
where(query, [c], c.category_id == ^category_id)
{:sort_criteria, "name"}, query ->
order_by(query, [c], [asc: c.name])
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
where(query, [c], c.storage_location_id == ^storage_location_id)
{:sort_criteria, "description"}, query ->
order_by(query, [c], [asc: c.description])
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
search_pattern = "%#{search_term}%"
where(query, [c],
ilike(c.name, ^search_pattern) or
ilike(c.description, ^search_pattern) or
ilike(c.manufacturer, ^search_pattern) or
ilike(c.part_number, ^search_pattern)
)
{:sort_criteria, "id"}, query ->
order_by(query, [c], [asc: c.id])
{:sort_criteria, "category_id"}, query ->
order_by(query, [c], [asc: c.category_id, asc: c.name])
_, query ->
query
_, query -> query
end)
end
@@ -173,7 +268,7 @@ defmodule ComponentsElixir.Inventory do
"""
def get_component!(id) do
Component
|> preload(:category)
|> preload([:category, :storage_location])
|> Repo.get!(id)
end
@@ -195,39 +290,6 @@ defmodule ComponentsElixir.Inventory do
|> Repo.update()
end
@doc """
Updates a component's count.
"""
def update_component_count(%Component{} = component, count) when is_integer(count) do
component
|> Component.count_changeset(%{count: count})
|> Repo.update()
end
@doc """
Increments a component's count.
"""
def increment_component_count(%Component{} = component) do
update_component_count(component, component.count + 1)
end
@doc """
Decrements a component's count (minimum 0).
"""
def decrement_component_count(%Component{} = component) do
new_count = max(0, component.count - 1)
update_component_count(component, new_count)
end
@doc """
Updates a component's image filename.
"""
def update_component_image(%Component{} = component, image_filename) do
component
|> Component.image_changeset(%{image_filename: image_filename})
|> Repo.update()
end
@doc """
Deletes a component.
"""
@@ -243,15 +305,16 @@ defmodule ComponentsElixir.Inventory do
end
@doc """
Returns component statistics.
Returns inventory statistics.
"""
def component_stats do
def get_inventory_stats do
total_components = Repo.aggregate(Component, :count, :id)
total_stock = Repo.aggregate(Component, :sum, :count) || 0
categories_with_components =
Component
|> select([c], c.category_id)
|> distinct(true)
total_stock = Component
|> Repo.aggregate(:sum, :count)
categories_with_components = Component
|> distinct([c], c.category_id)
|> Repo.aggregate(:count, :category_id)
%{
@@ -260,4 +323,48 @@ defmodule ComponentsElixir.Inventory do
categories_with_components: categories_with_components
}
end
@doc """
Returns component statistics (alias for get_inventory_stats for compatibility).
"""
def component_stats do
get_inventory_stats()
end
@doc """
Counts components in a specific category.
"""
def count_components_in_category(category_id) do
Component
|> where([c], c.category_id == ^category_id)
|> Repo.aggregate(:count, :id)
end
@doc """
Increment component stock count.
"""
def increment_component_count(%Component{} = component) do
component
|> Component.changeset(%{count: component.count + 1})
|> Repo.update()
end
@doc """
Decrement component stock count.
"""
def decrement_component_count(%Component{} = component) do
new_count = max(0, component.count - 1)
component
|> Component.changeset(%{count: new_count})
|> Repo.update()
end
@doc """
Paginate components with filters.
"""
def paginate_components(opts \\ []) do
# For now, just return all components - pagination can be added later
components = list_components(opts)
%{components: components, has_more: false}
end
end

View File

@@ -8,18 +8,20 @@ defmodule ComponentsElixir.Inventory.Component do
use Ecto.Schema
import Ecto.Changeset
alias ComponentsElixir.Inventory.Category
alias ComponentsElixir.Inventory.{Category, StorageLocation}
schema "components" do
field :name, :string
field :description, :string
field :keywords, :string
field :position, :string
field :legacy_position, :string
field :count, :integer, default: 0
field :datasheet_url, :string
field :image_filename, :string
belongs_to :category, Category
belongs_to :storage_location, StorageLocation
timestamps()
end
@@ -27,7 +29,7 @@ defmodule ComponentsElixir.Inventory.Component do
@doc false
def changeset(component, attrs) do
component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id])
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id])
|> validate_required([:name, :category_id])
|> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 2000)

View File

@@ -0,0 +1,133 @@
defmodule ComponentsElixir.Inventory.StorageLocation do
@moduledoc """
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.
"""
use Ecto.Schema
import Ecto.Changeset
alias ComponentsElixir.Inventory.{StorageLocation, Component}
schema "storage_locations" do
field :name, :string
field :description, :string
field :qr_code, :string
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
has_many :children, StorageLocation, foreign_key: :parent_id
has_many :components, Component
timestamps()
end
@doc false
def changeset(storage_location, attrs) do
storage_location
|> cast(attrs, [:name, :description, :parent_id, :is_active])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:description, max: 500)
|> foreign_key_constraint(:parent_id)
|> validate_no_circular_reference()
|> put_qr_code()
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
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
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
@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
@doc """
Returns the QR code format for this storage location.
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
"""
def qr_format(storage_location, parent \\ nil) do
parent_code = if parent, do: parent.qr_code, else: "ROOT"
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
end
# Private functions for changeset processing
defp put_qr_code(changeset) do
case get_field(changeset, :qr_code) do
nil -> put_change(changeset, :qr_code, generate_qr_code())
_ -> changeset
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 generate_qr_code do
# Generate a unique 6-character alphanumeric code
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
1..6
|> Enum.map(fn _ ->
chars
|> String.graphemes()
|> Enum.random()
end)
|> Enum.join()
end
end

View File

@@ -0,0 +1,103 @@
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
end

View File

@@ -322,6 +322,12 @@ defmodule ComponentsElixirWeb.ComponentsLive do
>
<.icon name="hero-folder" class="w-4 h-4 mr-2" /> Categories
</.link>
<.link
navigate={~p"/storage_locations"}
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
<.icon name="hero-archive-box" class="w-4 h-4 mr-2" /> Storage
</.link>
<.link
href="/logout"
method="post"

View File

@@ -0,0 +1,208 @@
defmodule ComponentsElixirWeb.StorageLocationsLive do
@moduledoc """
LiveView for managing storage locations and QR codes.
"""
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.Inventory
alias ComponentsElixir.Inventory.StorageLocation
alias ComponentsElixir.QRCode
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:storage_locations, list_storage_locations())
|> assign(:form, to_form(%{}))
|> assign(:show_form, false)
|> assign(:edit_location, nil)
|> assign(:qr_scanner_open, false)
|> assign(:scanned_codes, [])
{:ok, socket}
end
@impl true
def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
end
defp apply_action(socket, :index, _params) do
socket
|> assign(:page_title, "Storage Locations")
|> assign(:storage_location, %StorageLocation{})
end
defp apply_action(socket, :new, _params) do
socket
|> assign(:page_title, "New Storage Location")
|> assign(:storage_location, %StorageLocation{})
|> assign(:show_form, true)
end
defp apply_action(socket, :edit, %{"id" => id}) do
location = Inventory.get_storage_location!(id)
socket
|> assign(:page_title, "Edit Storage Location")
|> assign(:storage_location, location)
|> assign(:edit_location, location)
|> assign(:show_form, true)
|> assign(:form, to_form(Inventory.change_storage_location(location)))
end
@impl true
def handle_event("new", _params, socket) do
{:noreply,
socket
|> assign(:show_form, true)
|> assign(:storage_location, %StorageLocation{})
|> assign(:edit_location, nil)
|> assign(:form, to_form(Inventory.change_storage_location(%StorageLocation{})))}
end
def handle_event("cancel", _params, socket) do
{:noreply,
socket
|> assign(:show_form, false)
|> assign(:edit_location, nil)
|> push_patch(to: ~p"/storage_locations")}
end
def handle_event("validate", %{"storage_location" => params}, socket) do
# Normalize parent_id for validation too
normalized_params =
case Map.get(params, "parent_id") do
"" -> Map.put(params, "parent_id", nil)
value -> Map.put(params, "parent_id", value)
end
changeset =
case socket.assigns.edit_location do
nil -> Inventory.change_storage_location(%StorageLocation{}, normalized_params)
location -> Inventory.change_storage_location(location, normalized_params)
end
{:noreply, assign(socket, :form, to_form(changeset, action: :validate))}
end
def handle_event("save", %{"storage_location" => params}, socket) do
# Normalize parent_id for consistency
normalized_params =
case Map.get(params, "parent_id") do
"" -> Map.put(params, "parent_id", nil)
value -> Map.put(params, "parent_id", value)
end
case socket.assigns.edit_location do
nil -> create_storage_location(socket, normalized_params)
location -> update_storage_location(socket, location, normalized_params)
end
end
def handle_event("delete", %{"id" => id}, socket) do
location = Inventory.get_storage_location!(id)
case Inventory.delete_storage_location(location) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Storage location deleted successfully")
|> assign(:storage_locations, list_storage_locations())}
{:error, _} ->
{:noreply, put_flash(socket, :error, "Unable to delete storage location")}
end
end
def handle_event("open_qr_scanner", _params, socket) do
{:noreply, assign(socket, :qr_scanner_open, true)}
end
def handle_event("close_qr_scanner", _params, socket) do
{:noreply, assign(socket, :qr_scanner_open, false)}
end
def handle_event("qr_scanned", %{"code" => code}, socket) do
case QRCode.parse_qr_data(code) do
{:ok, parsed} ->
case Inventory.get_storage_location_by_qr_code(parsed.code) do
nil ->
{:noreply, put_flash(socket, :error, "Storage location not found for QR code: #{code}")}
location ->
scanned_codes = [%{code: code, location: location} | socket.assigns.scanned_codes]
{:noreply,
socket
|> assign(:scanned_codes, scanned_codes)
|> put_flash(:info, "Scanned: #{location.path}")}
end
{:error, reason} ->
{:noreply, put_flash(socket, :error, "Invalid QR code: #{reason}")}
end
end
def handle_event("clear_scanned", _params, socket) do
{:noreply, assign(socket, :scanned_codes, [])}
end
defp create_storage_location(socket, params) do
case Inventory.create_storage_location(params) do
{:ok, _location} ->
{:noreply,
socket
|> put_flash(:info, "Storage location created successfully")
|> assign(:show_form, false)
|> assign(:storage_locations, list_storage_locations())}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp update_storage_location(socket, location, params) do
case Inventory.update_storage_location(location, params) do
{:ok, _location} ->
{:noreply,
socket
|> put_flash(:info, "Storage location updated successfully")
|> assign(:show_form, false)
|> assign(:edit_location, nil)
|> assign(:storage_locations, list_storage_locations())
|> push_patch(to: ~p"/storage_locations")}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
end
defp list_storage_locations do
Inventory.list_storage_locations()
end
defp format_level(level) do
case level do
0 -> "Shelf"
1 -> "Drawer"
2 -> "Box"
n -> "Level #{n}"
end
end
# Function to get parent options for select dropdown
defp parent_options(current_location) do
locations = Inventory.list_storage_locations()
# Filter out the current location if provided (to prevent self-parent)
filtered_locations = case current_location do
nil -> locations
%{id: current_id} -> Enum.filter(locations, fn loc -> loc.id != current_id end)
_ -> locations
end
filtered_locations
|> Enum.map(fn location -> {"#{location.name} (#{location.level})", location.id} end)
end
end

View File

@@ -0,0 +1,265 @@
<div class="space-y-6">
<!-- Header -->
<div class="flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900">Storage Locations</h1>
<p class="text-gray-600">Manage your physical storage locations and QR codes</p>
</div>
<div class="flex gap-2">
<.link
navigate={~p"/"}
class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
</svg>
Components
</.link>
<button
phx-click="new"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
New Location
</button>
<button
phx-click="open_qr_scanner"
class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 flex items-center gap-2"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path>
</svg>
Scan QR Code
</button>
</div>
</div>
<!-- Form Modal -->
<div :if={@show_form} class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<h3 class="text-lg font-bold text-gray-900 mb-4">
<%= if @edit_location, do: "Edit Storage Location", else: "New Storage Location" %>
</h3>
<.form for={@form} phx-submit="save" phx-change="validate" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<.input field={@form[:name]} type="text" placeholder="Enter location name" required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<.input field={@form[:description]} type="textarea" placeholder="Optional description" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Parent Location</label>
<.input
field={@form[:parent_id]}
type="select"
options={[{"None (Root Level)", ""} | parent_options(@edit_location)]}
/>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
phx-click="cancel"
class="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-md hover:bg-gray-300"
>
Cancel
</button>
<button
type="submit"
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700"
>
<%= if @edit_location, do: "Update", else: "Create" %>
</button>
</div>
</.form>
</div>
</div>
</div>
<!-- QR Scanner Modal -->
<div :if={@qr_scanner_open} class="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold text-gray-900">QR Code Scanner</h3>
<button
phx-click="close_qr_scanner"
class="text-gray-400 hover:text-gray-600"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<!-- QR Scanner Interface -->
<div class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"></path>
</svg>
<p class="mt-2 text-sm text-gray-600">Camera QR scanner would go here</p>
<p class="text-xs text-gray-500 mt-1">In a real implementation, this would use JavaScript QR scanning</p>
<!-- Test buttons for demo -->
<div class="mt-4 space-y-2">
<p class="text-sm font-medium text-gray-700">Test with sample codes:</p>
<button
phx-click="qr_scanned"
phx-value-code="SL:0:1MTKDM:ROOT"
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
>
Scan "Shelf A"
</button>
<button
phx-click="qr_scanned"
phx-value-code="SL:1:VDI701:1MTKDM"
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
>
Scan "Drawer 1"
</button>
<button
phx-click="qr_scanned"
phx-value-code="SL:2:GPG9S8:VDI701"
class="block w-full px-3 py-2 text-sm bg-gray-100 hover:bg-gray-200 rounded"
>
Scan "Box 1"
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Scanned Codes Display -->
<div :if={length(@scanned_codes) > 0} class="bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex justify-between items-center mb-2">
<h3 class="text-lg font-medium text-green-800">Recently Scanned</h3>
<button
phx-click="clear_scanned"
class="text-sm text-green-600 hover:text-green-800"
>
Clear
</button>
</div>
<div class="space-y-2">
<div :for={scan <- @scanned_codes} class="flex items-center justify-between bg-white p-2 rounded border">
<div>
<span class="font-medium text-gray-900"><%= scan.location.path %></span>
<span class="text-sm text-gray-600 ml-2">(<%= scan.code %>)</span>
</div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
Level <%= scan.location.level %>
</span>
</div>
</div>
</div>
<!-- Storage Locations Table -->
<div class="bg-white shadow rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h3 class="text-lg font-medium text-gray-900">Storage Locations</h3>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Location
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Level
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
QR Code
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Description
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr :for={location <- @storage_locations} class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="text-sm font-medium text-gray-900">
<%= location.path %>
<!-- DEBUG: Show actual database values -->
<div class="text-xs text-red-600 mt-1">
DEBUG - ID: <%= location.id %>, Parent: <%= inspect(location.parent_id) %>, Level: <%= location.level %>
</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
<%= format_level(location.level) %>
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm bg-gray-100 px-2 py-1 rounded">
<%= location.qr_code %>
</code>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-900 max-w-xs truncate">
<%= location.description %>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div class="flex space-x-2">
<.link
patch={~p"/storage_locations/#{location.id}/edit"}
class="text-indigo-600 hover:text-indigo-900"
>
Edit
</.link>
<button
phx-click="delete"
phx-value-id={location.id}
data-confirm="Are you sure?"
class="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div :if={length(@storage_locations) == 0} class="text-center py-8">
<p class="text-gray-500">No storage locations yet. Create one to get started!</p>
</div>
</div>
</div>
<!-- QR Code Examples -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h3 class="text-lg font-medium text-blue-800 mb-2">QR Code Examples</h3>
<p class="text-sm text-blue-700 mb-3">
Here are some sample QR codes generated for your existing storage locations:
</p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div :for={location <- Enum.take(@storage_locations, 4)} class="bg-white p-3 rounded border">
<div class="text-sm font-medium text-gray-900"><%= location.path %></div>
<code class="text-xs text-gray-600 bg-gray-100 px-2 py-1 rounded mt-1 inline-block">
<%= ComponentsElixir.QRCode.generate_qr_data(location) %>
</code>
</div>
</div>
</div>
</div>

View File

@@ -31,6 +31,9 @@ defmodule ComponentsElixirWeb.Router do
live "/", ComponentsLive, :index
live "/categories", CategoriesLive, :index
live "/storage_locations", StorageLocationsLive, :index
live "/storage_locations/new", StorageLocationsLive, :new
live "/storage_locations/:id/edit", StorageLocationsLive, :edit
end
# Other scopes may use custom stacks.

View File

@@ -0,0 +1,29 @@
defmodule ComponentsElixir.Repo.Migrations.CreateStorageLocations do
use Ecto.Migration
def change do
create table(:storage_locations) do
add :name, :string, null: false
add :description, :text
add :qr_code, :string, null: false
add :level, :integer, default: 0
add :path, :text, null: false
add :is_active, :boolean, default: true
add :parent_id, references(:storage_locations, on_delete: :restrict)
timestamps()
end
create unique_index(:storage_locations, [:qr_code])
create index(:storage_locations, [:parent_id])
create index(:storage_locations, [:level])
create unique_index(:storage_locations, [:name, :parent_id])
# Enable trigram extension for path searching
execute "CREATE EXTENSION IF NOT EXISTS pg_trgm", "DROP EXTENSION IF EXISTS pg_trgm"
# GIN index for fast path-based searches
execute "CREATE INDEX storage_locations_path_gin_idx ON storage_locations USING gin(path gin_trgm_ops)",
"DROP INDEX storage_locations_path_gin_idx"
end
end

View File

@@ -0,0 +1,16 @@
defmodule ComponentsElixir.Repo.Migrations.AddStorageLocationToComponents do
use Ecto.Migration
def change do
alter table(:components) do
add :storage_location_id, references(:storage_locations, on_delete: :nilify_all)
add :legacy_position, :string
end
create index(:components, [:storage_location_id])
# Copy existing position data to legacy_position for migration
execute "UPDATE components SET legacy_position = position WHERE position IS NOT NULL",
"UPDATE components SET position = legacy_position WHERE legacy_position IS NOT NULL"
end
end

View File

@@ -0,0 +1,10 @@
defmodule ComponentsElixir.Repo.Migrations.RemoveNotNullConstraintsFromStorageLocations do
use Ecto.Migration
def change do
alter table(:storage_locations) do
modify :level, :integer, null: true
modify :path, :string, null: true
end
end
end

View File

@@ -11,11 +11,12 @@
# and so on) as they will fail if something goes wrong.
alias ComponentsElixir.{Repo, Inventory}
alias ComponentsElixir.Inventory.{Category, Component}
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
# Clear existing data
Repo.delete_all(Component)
Repo.delete_all(Category)
Repo.delete_all(StorageLocation)
# Create categories
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
@@ -48,13 +49,62 @@ Repo.delete_all(Category)
parent_id: capacitors.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"})
# Create drawers on Shelf A
{:ok, drawer_a1} = Inventory.create_storage_location(%{
name: "Drawer 1",
description: "Resistors and capacitors",
parent_id: shelf_a.id
})
{:ok, drawer_a2} = Inventory.create_storage_location(%{
name: "Drawer 2",
description: "Semiconductors and ICs",
parent_id: shelf_a.id
})
# Create boxes in Drawer A1
{:ok, box_a1_1} = Inventory.create_storage_location(%{
name: "Box 1",
description: "Through-hole resistors",
parent_id: drawer_a1.id
})
{:ok, box_a1_2} = Inventory.create_storage_location(%{
name: "Box 2",
description: "SMD resistors",
parent_id: drawer_a1.id
})
{:ok, box_a1_3} = Inventory.create_storage_location(%{
name: "Box 3",
description: "Ceramic capacitors",
parent_id: drawer_a1.id
})
# Create boxes in Drawer A2
{:ok, box_a2_1} = Inventory.create_storage_location(%{
name: "Box 1",
description: "Microcontrollers",
parent_id: drawer_a2.id
})
{:ok, _box_a2_2} = Inventory.create_storage_location(%{
name: "Box 2",
description: "Transistors and diodes",
parent_id: drawer_a2.id
})
# Create sample components
sample_components = [
%{
name: "1kΩ Resistor (1/4W)",
description: "Carbon film resistor, 5% tolerance",
keywords: "resistor carbon film 1k ohm",
position: "A1-1",
storage_location_id: box_a1_1.id,
count: 150,
category_id: resistors.id
},
@@ -62,7 +112,7 @@ sample_components = [
name: "10kΩ Resistor (1/4W)",
description: "Carbon film resistor, 5% tolerance",
keywords: "resistor carbon film 10k ohm",
position: "A1-2",
storage_location_id: box_a1_1.id,
count: 200,
category_id: resistors.id
},
@@ -70,7 +120,7 @@ sample_components = [
name: "100μF Electrolytic Capacitor",
description: "25V electrolytic capacitor, radial leads",
keywords: "capacitor electrolytic 100uf microfarad",
position: "B2-1",
storage_location_id: box_a1_3.id,
count: 50,
category_id: capacitors.id
},
@@ -78,7 +128,7 @@ sample_components = [
name: "0.1μF Ceramic Capacitor",
description: "50V ceramic disc capacitor",
keywords: "capacitor ceramic 100nf nanofarad disc",
position: "B2-2",
storage_location_id: box_a1_3.id,
count: 300,
category_id: capacitors.id
},
@@ -86,7 +136,7 @@ sample_components = [
name: "ATmega328P-PU",
description: "8-bit AVR microcontroller, DIP-28 package",
keywords: "microcontroller avr atmega328 arduino",
position: "C3-1",
storage_location_id: box_a2_1.id,
count: 10,
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
category_id: semiconductors.id
@@ -95,7 +145,7 @@ sample_components = [
name: "LM358 Op-Amp",
description: "Dual operational amplifier, DIP-8 package",
keywords: "opamp operational amplifier lm358 dual",
position: "C3-2",
storage_location_id: box_a2_1.id,
count: 25,
category_id: semiconductors.id
},
@@ -103,7 +153,7 @@ sample_components = [
name: "2N2222 NPN Transistor",
description: "General purpose NPN transistor, TO-92 package",
keywords: "transistor npn 2n2222 to92",
position: "C3-3",
storage_location_id: box_a2_1.id,
count: 40,
category_id: semiconductors.id
},
@@ -111,7 +161,7 @@ sample_components = [
name: "2.54mm Pin Headers",
description: "Male pin headers, 40 pins, break-away",
keywords: "header pins male 2.54mm breakaway",
position: "D4-1",
storage_location_id: drawer_a2.id,
count: 20,
category_id: connectors.id
},
@@ -119,7 +169,7 @@ sample_components = [
name: "JST-XH 2-pin Connector",
description: "2-pin JST-XH connector with housing",
keywords: "jst xh connector 2pin housing",
position: "D4-2",
storage_location_id: drawer_a2.id,
count: 30,
category_id: connectors.id
},
@@ -127,7 +177,7 @@ sample_components = [
name: "470Ω Resistor (1/4W)",
description: "Carbon film resistor, 5% tolerance, commonly used for LEDs",
keywords: "resistor carbon film 470 ohm led current limiting",
position: "A1-3",
storage_location_id: box_a1_1.id,
count: 100,
category_id: resistors.id
}
@@ -137,8 +187,24 @@ Enum.each(sample_components, fn component_attrs ->
{:ok, _component} = Inventory.create_component(component_attrs)
end)
IO.puts("Seeded database with categories and sample components!")
IO.puts("Seeded database with categories, storage locations, and sample components!")
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:")
# Print some sample QR codes for testing
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}")
end)
IO.puts("")
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")