feat(elixir): storage location system
This commit is contained in:
43
README.md
43
README.md
@@ -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 |
|
| `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 |
|
| Session management | Phoenix sessions + LiveView | Built-in CSRF protection |
|
||||||
|
|
||||||
## Future Enhancements
|
## 🚀 Future Enhancements
|
||||||
|
|
||||||
1. ~~**Image Upload**: Implement Phoenix file uploads for component images~~ ✅ **COMPLETED**
|
### Priority 1: Complete QR Code System (see [qr_storage_system](qr_storage_system.md))
|
||||||
2. **Bulk Operations**: Import/export components via CSV
|
- **QR Code Image Generation** - Add Elixir library (e.g., `qr_code` hex package) to generate actual QR code images
|
||||||
3. **API Endpoints**: REST API for external integrations
|
- **QR Code Display in Interface** - Show generated QR code images in the storage locations interface
|
||||||
4. **User Management**: Multi-user support with roles and permissions
|
- **Camera Integration** - JavaScript-based QR scanning with camera access for mobile/desktop
|
||||||
5. **Advanced Search**: Filters by category, stock level, etc.
|
- **Multi-QR Code Detection** - Spatial analysis and disambiguation for multiple codes in same image
|
||||||
6. **Barcode/QR Codes**: Generate and scan codes for quick inventory updates
|
|
||||||
|
### 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
|
## ✅ 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
|
- **Phoenix LiveView file uploads** with `.live_file_input` component
|
||||||
- **Image preview** during upload with progress indication
|
- **Image preview** during upload with progress indication
|
||||||
- **File validation** (JPG, PNG, GIF up to 5MB)
|
- **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
|
- **Responsive image display** in component listings with fallback placeholders
|
||||||
- **Upload error handling** with user-friendly messages
|
- **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
|
- **Datasheet emoji** (📄) displayed next to component names when datasheet URL is present
|
||||||
- **Clickable datasheet links** with clear visual indication
|
- **Clickable datasheet links** with clear visual indication
|
||||||
- **Improved component listing** with image thumbnails and datasheet indicators
|
- **Improved component listing** with image thumbnails and datasheet indicators
|
||||||
|
|||||||
384
design_docs/qr_storage_system.md
Normal file
384
design_docs/qr_storage_system.md
Normal 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.
|
||||||
@@ -1,12 +1,185 @@
|
|||||||
defmodule ComponentsElixir.Inventory do
|
defmodule ComponentsElixir.Inventory do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
The Inventory context for managing components and categories.
|
The Inventory context: managing components and categories.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
alias ComponentsElixir.Repo
|
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
|
## Categories
|
||||||
|
|
||||||
@@ -15,39 +188,14 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
"""
|
"""
|
||||||
def list_categories do
|
def list_categories do
|
||||||
Category
|
Category
|
||||||
|> order_by([c], [asc: c.name])
|
|
||||||
|> preload(:parent)
|
|> preload(:parent)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Gets a single category.
|
Gets a single category.
|
||||||
"""
|
"""
|
||||||
def get_category!(id) do
|
def get_category!(id), do: Repo.get!(Category, id)
|
||||||
Category
|
|
||||||
|> preload(:parent)
|
|
||||||
|> Repo.get!(id)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a category.
|
Creates a category.
|
||||||
@@ -81,90 +229,37 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
Category.changeset(category, attrs)
|
Category.changeset(category, attrs)
|
||||||
end
|
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
|
## Components
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of components with optional filtering and pagination.
|
Returns the list of components.
|
||||||
"""
|
"""
|
||||||
def list_components(opts \\ []) do
|
def list_components(opts \\ []) do
|
||||||
Component
|
Component
|
||||||
|> apply_component_filters(opts)
|
|> apply_component_filters(opts)
|
||||||
|> preload(:category)
|
|> order_by([c], [asc: c.name])
|
||||||
|> order_by([c], [asc: c.category_id, asc: c.name])
|
|> preload([:category, :storage_location])
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
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
|
defp apply_component_filters(query, opts) do
|
||||||
Enum.reduce(opts, query, fn
|
Enum.reduce(opts, query, fn
|
||||||
{:search, search}, query when is_binary(search) and search != "" ->
|
{:category_id, category_id}, query when not is_nil(category_id) ->
|
||||||
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) ->
|
|
||||||
where(query, [c], c.category_id == ^category_id)
|
where(query, [c], c.category_id == ^category_id)
|
||||||
|
|
||||||
{:sort_criteria, "name"}, query ->
|
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
|
||||||
order_by(query, [c], [asc: c.name])
|
where(query, [c], c.storage_location_id == ^storage_location_id)
|
||||||
|
|
||||||
{:sort_criteria, "description"}, query ->
|
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
|
||||||
order_by(query, [c], [asc: c.description])
|
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 ->
|
_, query -> 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
|
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -173,7 +268,7 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
"""
|
"""
|
||||||
def get_component!(id) do
|
def get_component!(id) do
|
||||||
Component
|
Component
|
||||||
|> preload(:category)
|
|> preload([:category, :storage_location])
|
||||||
|> Repo.get!(id)
|
|> Repo.get!(id)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -195,39 +290,6 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
|> Repo.update()
|
|> Repo.update()
|
||||||
end
|
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 """
|
@doc """
|
||||||
Deletes a component.
|
Deletes a component.
|
||||||
"""
|
"""
|
||||||
@@ -243,15 +305,16 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns component statistics.
|
Returns inventory statistics.
|
||||||
"""
|
"""
|
||||||
def component_stats do
|
def get_inventory_stats do
|
||||||
total_components = Repo.aggregate(Component, :count, :id)
|
total_components = Repo.aggregate(Component, :count, :id)
|
||||||
total_stock = Repo.aggregate(Component, :sum, :count) || 0
|
|
||||||
categories_with_components =
|
total_stock = Component
|
||||||
Component
|
|> Repo.aggregate(:sum, :count)
|
||||||
|> select([c], c.category_id)
|
|
||||||
|> distinct(true)
|
categories_with_components = Component
|
||||||
|
|> distinct([c], c.category_id)
|
||||||
|> Repo.aggregate(:count, :category_id)
|
|> Repo.aggregate(:count, :category_id)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
@@ -260,4 +323,48 @@ defmodule ComponentsElixir.Inventory do
|
|||||||
categories_with_components: categories_with_components
|
categories_with_components: categories_with_components
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -8,18 +8,20 @@ defmodule ComponentsElixir.Inventory.Component do
|
|||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias ComponentsElixir.Inventory.Category
|
alias ComponentsElixir.Inventory.{Category, StorageLocation}
|
||||||
|
|
||||||
schema "components" do
|
schema "components" do
|
||||||
field :name, :string
|
field :name, :string
|
||||||
field :description, :string
|
field :description, :string
|
||||||
field :keywords, :string
|
field :keywords, :string
|
||||||
field :position, :string
|
field :position, :string
|
||||||
|
field :legacy_position, :string
|
||||||
field :count, :integer, default: 0
|
field :count, :integer, default: 0
|
||||||
field :datasheet_url, :string
|
field :datasheet_url, :string
|
||||||
field :image_filename, :string
|
field :image_filename, :string
|
||||||
|
|
||||||
belongs_to :category, Category
|
belongs_to :category, Category
|
||||||
|
belongs_to :storage_location, StorageLocation
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
end
|
||||||
@@ -27,7 +29,7 @@ defmodule ComponentsElixir.Inventory.Component do
|
|||||||
@doc false
|
@doc false
|
||||||
def changeset(component, attrs) do
|
def changeset(component, attrs) do
|
||||||
component
|
component
|
||||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :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_required([:name, :category_id])
|
||||||
|> validate_length(:name, min: 1, max: 255)
|
|> validate_length(:name, min: 1, max: 255)
|
||||||
|> validate_length(:description, max: 2000)
|
|> validate_length(:description, max: 2000)
|
||||||
|
|||||||
133
lib/components_elixir/inventory/storage_location.ex
Normal file
133
lib/components_elixir/inventory/storage_location.ex
Normal 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
|
||||||
103
lib/components_elixir/qr_code.ex
Normal file
103
lib/components_elixir/qr_code.ex
Normal 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
|
||||||
@@ -322,6 +322,12 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
|||||||
>
|
>
|
||||||
<.icon name="hero-folder" class="w-4 h-4 mr-2" /> Categories
|
<.icon name="hero-folder" class="w-4 h-4 mr-2" /> Categories
|
||||||
</.link>
|
</.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
|
<.link
|
||||||
href="/logout"
|
href="/logout"
|
||||||
method="post"
|
method="post"
|
||||||
|
|||||||
208
lib/components_elixir_web/live/storage_locations_live.ex
Normal file
208
lib/components_elixir_web/live/storage_locations_live.ex
Normal 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
|
||||||
265
lib/components_elixir_web/live/storage_locations_live.html.heex
Normal file
265
lib/components_elixir_web/live/storage_locations_live.html.heex
Normal 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>
|
||||||
@@ -31,6 +31,9 @@ defmodule ComponentsElixirWeb.Router do
|
|||||||
|
|
||||||
live "/", ComponentsLive, :index
|
live "/", ComponentsLive, :index
|
||||||
live "/categories", CategoriesLive, :index
|
live "/categories", CategoriesLive, :index
|
||||||
|
live "/storage_locations", StorageLocationsLive, :index
|
||||||
|
live "/storage_locations/new", StorageLocationsLive, :new
|
||||||
|
live "/storage_locations/:id/edit", StorageLocationsLive, :edit
|
||||||
end
|
end
|
||||||
|
|
||||||
# Other scopes may use custom stacks.
|
# Other scopes may use custom stacks.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -11,11 +11,12 @@
|
|||||||
# and so on) as they will fail if something goes wrong.
|
# and so on) as they will fail if something goes wrong.
|
||||||
|
|
||||||
alias ComponentsElixir.{Repo, Inventory}
|
alias ComponentsElixir.{Repo, Inventory}
|
||||||
alias ComponentsElixir.Inventory.{Category, Component}
|
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
|
||||||
|
|
||||||
# Clear existing data
|
# Clear existing data
|
||||||
Repo.delete_all(Component)
|
Repo.delete_all(Component)
|
||||||
Repo.delete_all(Category)
|
Repo.delete_all(Category)
|
||||||
|
Repo.delete_all(StorageLocation)
|
||||||
|
|
||||||
# Create categories
|
# Create categories
|
||||||
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
|
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
|
||||||
@@ -48,13 +49,62 @@ Repo.delete_all(Category)
|
|||||||
parent_id: capacitors.id
|
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
|
# Create sample components
|
||||||
sample_components = [
|
sample_components = [
|
||||||
%{
|
%{
|
||||||
name: "1kΩ Resistor (1/4W)",
|
name: "1kΩ Resistor (1/4W)",
|
||||||
description: "Carbon film resistor, 5% tolerance",
|
description: "Carbon film resistor, 5% tolerance",
|
||||||
keywords: "resistor carbon film 1k ohm",
|
keywords: "resistor carbon film 1k ohm",
|
||||||
position: "A1-1",
|
storage_location_id: box_a1_1.id,
|
||||||
count: 150,
|
count: 150,
|
||||||
category_id: resistors.id
|
category_id: resistors.id
|
||||||
},
|
},
|
||||||
@@ -62,7 +112,7 @@ sample_components = [
|
|||||||
name: "10kΩ Resistor (1/4W)",
|
name: "10kΩ Resistor (1/4W)",
|
||||||
description: "Carbon film resistor, 5% tolerance",
|
description: "Carbon film resistor, 5% tolerance",
|
||||||
keywords: "resistor carbon film 10k ohm",
|
keywords: "resistor carbon film 10k ohm",
|
||||||
position: "A1-2",
|
storage_location_id: box_a1_1.id,
|
||||||
count: 200,
|
count: 200,
|
||||||
category_id: resistors.id
|
category_id: resistors.id
|
||||||
},
|
},
|
||||||
@@ -70,7 +120,7 @@ sample_components = [
|
|||||||
name: "100μF Electrolytic Capacitor",
|
name: "100μF Electrolytic Capacitor",
|
||||||
description: "25V electrolytic capacitor, radial leads",
|
description: "25V electrolytic capacitor, radial leads",
|
||||||
keywords: "capacitor electrolytic 100uf microfarad",
|
keywords: "capacitor electrolytic 100uf microfarad",
|
||||||
position: "B2-1",
|
storage_location_id: box_a1_3.id,
|
||||||
count: 50,
|
count: 50,
|
||||||
category_id: capacitors.id
|
category_id: capacitors.id
|
||||||
},
|
},
|
||||||
@@ -78,7 +128,7 @@ sample_components = [
|
|||||||
name: "0.1μF Ceramic Capacitor",
|
name: "0.1μF Ceramic Capacitor",
|
||||||
description: "50V ceramic disc capacitor",
|
description: "50V ceramic disc capacitor",
|
||||||
keywords: "capacitor ceramic 100nf nanofarad disc",
|
keywords: "capacitor ceramic 100nf nanofarad disc",
|
||||||
position: "B2-2",
|
storage_location_id: box_a1_3.id,
|
||||||
count: 300,
|
count: 300,
|
||||||
category_id: capacitors.id
|
category_id: capacitors.id
|
||||||
},
|
},
|
||||||
@@ -86,7 +136,7 @@ sample_components = [
|
|||||||
name: "ATmega328P-PU",
|
name: "ATmega328P-PU",
|
||||||
description: "8-bit AVR microcontroller, DIP-28 package",
|
description: "8-bit AVR microcontroller, DIP-28 package",
|
||||||
keywords: "microcontroller avr atmega328 arduino",
|
keywords: "microcontroller avr atmega328 arduino",
|
||||||
position: "C3-1",
|
storage_location_id: box_a2_1.id,
|
||||||
count: 10,
|
count: 10,
|
||||||
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
|
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
|
||||||
category_id: semiconductors.id
|
category_id: semiconductors.id
|
||||||
@@ -95,7 +145,7 @@ sample_components = [
|
|||||||
name: "LM358 Op-Amp",
|
name: "LM358 Op-Amp",
|
||||||
description: "Dual operational amplifier, DIP-8 package",
|
description: "Dual operational amplifier, DIP-8 package",
|
||||||
keywords: "opamp operational amplifier lm358 dual",
|
keywords: "opamp operational amplifier lm358 dual",
|
||||||
position: "C3-2",
|
storage_location_id: box_a2_1.id,
|
||||||
count: 25,
|
count: 25,
|
||||||
category_id: semiconductors.id
|
category_id: semiconductors.id
|
||||||
},
|
},
|
||||||
@@ -103,7 +153,7 @@ sample_components = [
|
|||||||
name: "2N2222 NPN Transistor",
|
name: "2N2222 NPN Transistor",
|
||||||
description: "General purpose NPN transistor, TO-92 package",
|
description: "General purpose NPN transistor, TO-92 package",
|
||||||
keywords: "transistor npn 2n2222 to92",
|
keywords: "transistor npn 2n2222 to92",
|
||||||
position: "C3-3",
|
storage_location_id: box_a2_1.id,
|
||||||
count: 40,
|
count: 40,
|
||||||
category_id: semiconductors.id
|
category_id: semiconductors.id
|
||||||
},
|
},
|
||||||
@@ -111,7 +161,7 @@ sample_components = [
|
|||||||
name: "2.54mm Pin Headers",
|
name: "2.54mm Pin Headers",
|
||||||
description: "Male pin headers, 40 pins, break-away",
|
description: "Male pin headers, 40 pins, break-away",
|
||||||
keywords: "header pins male 2.54mm breakaway",
|
keywords: "header pins male 2.54mm breakaway",
|
||||||
position: "D4-1",
|
storage_location_id: drawer_a2.id,
|
||||||
count: 20,
|
count: 20,
|
||||||
category_id: connectors.id
|
category_id: connectors.id
|
||||||
},
|
},
|
||||||
@@ -119,7 +169,7 @@ sample_components = [
|
|||||||
name: "JST-XH 2-pin Connector",
|
name: "JST-XH 2-pin Connector",
|
||||||
description: "2-pin JST-XH connector with housing",
|
description: "2-pin JST-XH connector with housing",
|
||||||
keywords: "jst xh connector 2pin housing",
|
keywords: "jst xh connector 2pin housing",
|
||||||
position: "D4-2",
|
storage_location_id: drawer_a2.id,
|
||||||
count: 30,
|
count: 30,
|
||||||
category_id: connectors.id
|
category_id: connectors.id
|
||||||
},
|
},
|
||||||
@@ -127,7 +177,7 @@ sample_components = [
|
|||||||
name: "470Ω Resistor (1/4W)",
|
name: "470Ω Resistor (1/4W)",
|
||||||
description: "Carbon film resistor, 5% tolerance, commonly used for LEDs",
|
description: "Carbon film resistor, 5% tolerance, commonly used for LEDs",
|
||||||
keywords: "resistor carbon film 470 ohm led current limiting",
|
keywords: "resistor carbon film 470 ohm led current limiting",
|
||||||
position: "A1-3",
|
storage_location_id: box_a1_1.id,
|
||||||
count: 100,
|
count: 100,
|
||||||
category_id: resistors.id
|
category_id: resistors.id
|
||||||
}
|
}
|
||||||
@@ -137,8 +187,24 @@ Enum.each(sample_components, fn component_attrs ->
|
|||||||
{:ok, _component} = Inventory.create_component(component_attrs)
|
{:ok, _component} = Inventory.create_component(component_attrs)
|
||||||
end)
|
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("Categories: #{length(Inventory.list_categories())}")
|
||||||
|
IO.puts("Storage Locations: #{length(Inventory.list_storage_locations())}")
|
||||||
IO.puts("Components: #{length(Inventory.list_components())}")
|
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("")
|
||||||
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
||||||
|
|||||||
Reference in New Issue
Block a user