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 |
|
||||
| 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
|
||||
|
||||
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
|
||||
@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
|
||||
|
||||
@@ -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)
|
||||
|
||||
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
|
||||
</.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"
|
||||
|
||||
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 "/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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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)")
|
||||
|
||||
Reference in New Issue
Block a user