diff --git a/README.md b/README.md index 6a18636..6caffc1 100644 --- a/README.md +++ b/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 diff --git a/design_docs/qr_storage_system.md b/design_docs/qr_storage_system.md new file mode 100644 index 0000000..22bf8d3 --- /dev/null +++ b/design_docs/qr_storage_system.md @@ -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. \ No newline at end of file diff --git a/lib/components_elixir/inventory.ex b/lib/components_elixir/inventory.ex index 30d7a2a..e389e1b 100644 --- a/lib/components_elixir/inventory.ex +++ b/lib/components_elixir/inventory.ex @@ -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 diff --git a/lib/components_elixir/inventory/component.ex b/lib/components_elixir/inventory/component.ex index 8c96cba..52dd70e 100644 --- a/lib/components_elixir/inventory/component.ex +++ b/lib/components_elixir/inventory/component.ex @@ -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) diff --git a/lib/components_elixir/inventory/storage_location.ex b/lib/components_elixir/inventory/storage_location.ex new file mode 100644 index 0000000..bd811f4 --- /dev/null +++ b/lib/components_elixir/inventory/storage_location.ex @@ -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 diff --git a/lib/components_elixir/qr_code.ex b/lib/components_elixir/qr_code.ex new file mode 100644 index 0000000..3485920 --- /dev/null +++ b/lib/components_elixir/qr_code.ex @@ -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 diff --git a/lib/components_elixir_web/live/components_live.ex b/lib/components_elixir_web/live/components_live.ex index f728889..3fb33fd 100644 --- a/lib/components_elixir_web/live/components_live.ex +++ b/lib/components_elixir_web/live/components_live.ex @@ -322,6 +322,12 @@ defmodule ComponentsElixirWeb.ComponentsLive do > <.icon name="hero-folder" class="w-4 h-4 mr-2" /> Categories + <.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 href="/logout" method="post" diff --git a/lib/components_elixir_web/live/storage_locations_live.ex b/lib/components_elixir_web/live/storage_locations_live.ex new file mode 100644 index 0000000..ef8941e --- /dev/null +++ b/lib/components_elixir_web/live/storage_locations_live.ex @@ -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 diff --git a/lib/components_elixir_web/live/storage_locations_live.html.heex b/lib/components_elixir_web/live/storage_locations_live.html.heex new file mode 100644 index 0000000..c1436dc --- /dev/null +++ b/lib/components_elixir_web/live/storage_locations_live.html.heex @@ -0,0 +1,265 @@ +
Manage your physical storage locations and QR codes
+Camera QR scanner would go here
+In a real implementation, this would use JavaScript QR scanning
+ + +Test with sample codes:
+ + + +| + Location + | ++ Level + | ++ QR Code + | ++ Description + | ++ Actions + | +
|---|---|---|---|---|
|
+
+
+
+ <%= location.path %>
+
+
+
+ DEBUG - ID: <%= location.id %>, Parent: <%= inspect(location.parent_id) %>, Level: <%= location.level %>
+
+ |
+ + + <%= format_level(location.level) %> + + | +
+
+ <%= location.qr_code %>
+
+ |
+
+
+ <%= location.description %>
+
+ |
+
+
+ <.link
+ patch={~p"/storage_locations/#{location.id}/edit"}
+ class="text-indigo-600 hover:text-indigo-900"
+ >
+ Edit
+
+
+
+ |
+
No storage locations yet. Create one to get started!
++ Here are some sample QR codes generated for your existing storage locations: +
+
+ <%= ComponentsElixir.QRCode.generate_qr_data(location) %>
+
+