Compare commits

...

34 Commits

Author SHA1 Message Date
Schuwi
6548a06b43 ci: fix container tags again
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 1m53s
Docker Build and Publish / docker-build (push) Successful in 1m2s
2025-09-21 11:33:15 +02:00
Schuwi
7ce80b6026 ci: fix container tag policy
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 1m53s
Docker Build and Publish / docker-build (push) Failing after 25s
- only move `latest` on tagged releases
2025-09-21 10:58:39 +02:00
Schuwi
d620a9c620 docs: use pre-built docker image 2025-09-21 10:53:19 +02:00
Schuwi
4c7751f1ea ci: re-enable docker cache
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 3m41s
Docker Build and Publish / docker-build (push) Successful in 4m38s
2025-09-21 10:35:00 +02:00
a714d5a28f ci: disable docker cache for now
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 22m7s
Docker Build and Publish / docker-build (push) Successful in 4m4s
2025-09-20 20:15:19 +02:00
e33f700485 Merge pull request 'ci: fix quality checks pipeline' (#5) from schuwi-patch-1 into main
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 22m8s
Docker Build and Publish / docker-build (push) Failing after 5m9s
Reviewed-on: #5
2025-09-20 18:58:40 +02:00
cff6680f3a ci: change postgres hostname to service
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (pull_request) Successful in 22m9s
2025-09-20 17:01:27 +02:00
49b639e422 ci: allow setting db hostname
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (pull_request) Has been cancelled
2025-09-20 16:58:44 +02:00
Schuwi
3b15318372 ci: remove arm64 support for now
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Failing after 13m29s
Docker Build and Publish / docker-build (push) Failing after 5m12s
2025-09-20 12:47:45 +02:00
Schuwi
04db36c38d ci: fix missing ssl library 2025-09-20 12:30:21 +02:00
Schuwi
537a97cecc ci: add gitea ci/cd pipeline
Some checks failed
Code Quality / Code Quality (Elixir 1.15 OTP 26.0) (push) Failing after 2m16s
Docker Build and Publish / docker-build (push) Failing after 6m37s
2025-09-20 12:24:50 +02:00
Schuwi
a6991b6877 docs: add GitHub Copilot instructions 2025-09-20 12:08:50 +02:00
Schuwi
32dea59c74 test: rudimentary fix 2025-09-20 11:55:59 +02:00
Schuwi
c6c218970c style: format codebase 2025-09-20 11:52:43 +02:00
Schuwi
aaf278f7f9 style: prevent formatter issue 2025-09-20 11:52:05 +02:00
Schuwi
f4ee768c52 refactor: cleanup mix credo issues 2025-09-20 11:36:30 +02:00
Schuwi
72484c0d08 docs: update README 2025-09-20 11:25:58 +02:00
Schuwi
5d2e3f7768 feat: datasheet upload and auto-retrieve
- store datasheet PDFs on the server
- download PDF automatically when given a link
2025-09-19 23:09:29 +02:00
Schuwi
086bc65ac1 fix: inconsistent sorting
on components that were inserted in quick succession
2025-09-19 22:43:25 +02:00
Schuwi
c4a0b41e7d feat: filter by category/location on click
- add filtering by storage location
2025-09-19 22:12:58 +02:00
Schuwi
288d84614a feat: collapsable hierarchical view 2025-09-19 21:54:34 +02:00
Schuwi
8fe199f50c fix: description line break preservation 2025-09-19 20:49:46 +02:00
Schuwi
b68f8d92f7 feat: category filter includes subcategories 2025-09-19 20:28:12 +02:00
Schuwi
a0348c7df9 fix: image path regression
- probably lost during rebase
2025-09-19 20:21:31 +02:00
Schuwi
e078770557 refactor(elixir): remove unused qr_code 2025-09-18 00:08:32 +02:00
Schuwi
264adbfb98 refactor(elixir): hierarchical refactor
to extract common code patterns from
category/storage location systems
2025-09-17 23:56:56 +02:00
Schuwi
963c9a3770 docs(elixir): prepare hierarchical refactor 2025-09-17 23:32:46 +02:00
Schuwi
5a1775e836 refactor(elixir): remove unused is_active field
from storage location
2025-09-17 23:13:45 +02:00
Schuwi
6a1122c3be feat(elixir): robust sort in component list 2025-09-17 19:10:04 +02:00
Schuwi
b6e137632a feat(elixir): add component list focus mode 2025-09-17 18:03:26 +02:00
Schuwi
8848986953 fix(elixir): component list image size 2025-09-17 18:03:26 +02:00
Schuwi
68b0c0714e build(elixir): update database seed 2025-09-17 18:00:37 +02:00
Schuwi
76b0a97d31 fix(elixir): file upload in production
- move uploads to new directory (`./uploads` per default)
2025-09-16 23:16:02 +02:00
Schuwi
fa9bf74fd9 docs: secret key generation 2025-09-16 21:22:55 +02:00
41 changed files with 3460 additions and 1786 deletions

121
.gitea/workflows/README.md Normal file
View File

@@ -0,0 +1,121 @@
# Gitea CI/CD Pipeline
This directory contains Gitea Actions workflows for automated code quality checks and Docker image publishing.
## Workflows
### 1. Code Quality (`code-quality.yml`)
Runs on every push to main and pull requests targeting main. This workflow:
- Sets up Elixir 1.15 with OTP 26
- Installs dependencies and restores caches for faster builds
- Checks for unused dependencies
- Compiles with warnings as errors (enforces clean compilation)
- Validates code formatting (`mix format --check-formatted`)
- Runs the full test suite
- Executes `mix precommit` to ensure all quality checks pass
**Important**: This workflow will fail if `mix precommit` hasn't been run locally, ensuring code quality standards are maintained.
### 2. Docker Build and Publish (`docker-build.yml`)
Publishes Docker images to the Gitea container registry:
- **Snapshot builds**: For every commit to main branch
- Tagged as: `latest`, `main`, `snapshot-{sha}`
- **Release builds**: For every tagged commit (e.g., `v1.0.0`)
- Tagged as: `{tag-name}`, `latest`
Features:
- Multi-platform builds (linux/amd64, linux/arm64)
- Build caching for faster subsequent builds
- Comprehensive metadata and labels
## Setup Requirements
### 1. Gitea Configuration
Update the `REGISTRY` environment variable in `docker-build.yml`:
```yaml
env:
REGISTRY: your-gitea-instance.com # Replace with your Gitea URL
```
### 2. Required Secrets
Create the following secret in your Gitea repository settings:
- `GITEAX_TOKEN`: Personal Access Token with package write permissions
- Go to your Gitea instance → Settings → Applications → Generate New Token
- Select "write:packages" scope
- Add this token as a repository secret named `GITEAX_TOKEN`
> Gitea Actions currently do not support package repository authorization like GitHub Actions, so a PAT is necessary for publishing.
> See https://github.com/go-gitea/gitea/issues/23642#issuecomment-2119876692.
### 3. Container Registry
Enable the container registry in your Gitea instance if not already enabled. The published images will be available at:
```
{your-gitea-instance}/{owner}/components-elixir
```
## Usage
### For Developers
Before pushing code, always run:
```bash
mix precommit
```
This ensures your code will pass the CI quality checks.
### For Releases
To create a release:
1. Tag your commit: `git tag v1.0.0`
2. Push the tag: `git push origin v1.0.0`
3. The pipeline will automatically build and publish a release image
### For Snapshots
Snapshot builds are created automatically for every commit to the main branch.
## Docker Image Usage
Pull and run the latest snapshot:
```bash
docker pull {your-gitea-instance}/{owner}/components-elixir:latest
docker run -p 4000:4000 {your-gitea-instance}/{owner}/components-elixir:latest
```
Pull and run a specific release:
```bash
docker pull {your-gitea-instance}/{owner}/components-elixir:v1.0.0
docker run -p 4000:4000 {your-gitea-instance}/{owner}/components-elixir:v1.0.0
```
## Gitea Actions Limitations
This pipeline is designed with Gitea Actions limitations in mind:
- No `concurrency`, `run-name`, `permissions`, or `timeout-minutes` support
- Limited expression support (only `always()` function)
- Simple `runs-on` syntax only
- No package repository authorization - uses Personal Access Token instead
## Troubleshooting
### Authentication Issues
- Ensure `GITEAX_TOKEN` secret is properly set with package write permissions
- Verify the token hasn't expired
### Build Failures
- Check that `mix precommit` passes locally
- Ensure all tests pass with the test database configuration
- Verify Docker build works locally: `docker build -t test .`
### Registry Issues
- Confirm container registry is enabled in your Gitea instance
- Check that the registry URL in the workflow matches your Gitea instance

View File

@@ -0,0 +1,82 @@
name: Code Quality
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
code-quality:
runs-on: ubuntu-22.04
name: Code Quality (Elixir ${{matrix.elixir}} OTP ${{matrix.otp}})
strategy:
matrix:
otp: ['26.2']
elixir: ['1.15.7']
services:
db:
image: postgres:15
ports: ['5432:5432']
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libssl-dev libncurses5-dev
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
elixir-version: ${{matrix.elixir}}
otp-version: ${{matrix.otp}}
- name: Restore dependencies cache
uses: actions/cache@v4
with:
path: deps
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-mix-
- name: Restore compiled code cache
uses: actions/cache@v4
with:
path: _build
key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
restore-keys: |
${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-
- name: Install dependencies
run: mix deps.get
- name: Check for unused dependencies
run: mix deps.unlock --check-unused
- name: Compile with warnings as errors
run: mix compile --warnings-as-errors
- name: Check code formatting
run: mix format --check-formatted
- name: Run tests
run: mix test
env:
POSTGRES_HOSTNAME: db
POSTGRES_PASSWORD: postgres
- name: Run precommit (should pass if all above passed)
run: mix precommit
env:
POSTGRES_HOSTNAME: db
POSTGRES_PASSWORD: postgres

View File

@@ -0,0 +1,75 @@
name: Docker Build and Publish
on:
push:
branches: ["main"]
tags: ["v*"]
env:
REGISTRY: git.maxboeer.com
IMAGE_NAME: components-elixir
jobs:
docker-build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
# Latest tag will automatically be generated for the latest tagged release
# Version tag on releases (e.g., v1.2.3)
type=ref,event=tag
# Keep a moving branch tag (e.g., main)
type=ref,event=branch
# Snapshot tag for commits on the default branch (e.g., snapshot-<hash>)
type=raw,value=snapshot-{{sha}},enable={{is_default_branch}}
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITEAX_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# GitHub Actions cache needs proper runner configuration
# https://docs.gitea.com/usage/actions/act-runner#configuring-cache-when-starting-a-runner-using-docker-image
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
ELIXIR_VERSION=1.15
OTP_VERSION=26
DEBIAN_VERSION=bookworm-slim
- name: Generate summary
run: |
echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: \`${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Platform**: linux/amd64" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "- **Build Type**: Release build for tag \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "- **Build Type**: Snapshot build for branch \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
fi
echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY

125
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,125 @@
# GitHub Copilot Instructions
This is an electronic component inventory management system built with Phoenix LiveView. Follow these project-specific patterns for effective contributions.
## Project Architecture
**Core Structure**: Phoenix 1.8 with LiveView-first architecture for real-time inventory management
- `lib/components_elixir/` - Business logic contexts (Inventory, AprilTag, Auth)
- `lib/components_elixir_web/live/` - LiveView modules for real-time UI
- Key contexts: `Inventory` (components/categories/locations), `DatasheetDownloader`, `AprilTag`
**Hierarchical Data Pattern**: Both categories and storage locations use unlimited nesting
```elixir
# Use Hierarchical module for tree operations
alias ComponentsElixir.Inventory.Hierarchical
Hierarchical.build_tree(categories) # Converts flat list to nested structure
```
**Authentication**: Simple session-based auth using `ComponentsElixir.Auth`
- All LiveViews must check `Auth.authenticated?(session)` in mount/3
- Default password: "changeme" (configurable via `AUTH_PASSWORD` env var)
## Key Development Workflows
**Development Setup**:
```bash
mix deps.get
mix ecto.setup # Creates DB, runs migrations, seeds data
mix phx.server # Starts dev server with hot reload
```
**Code Quality (Critical)**: Always run before committing
```bash
mix precommit # Compiles with warnings-as-errors, formats, runs tests
```
**Database Operations**:
```bash
mix ecto.reset # Drop/recreate/migrate/seed database
mix ecto.gen.migration # Generate new migration
```
## LiveView Patterns
**Stream-Based Components**: Use LiveView streams for component lists to prevent memory issues:
```elixir
# In LiveView
stream(socket, :components, components)
# In template
<div id="components" phx-update="stream">
<div :for={{id, component} <- @streams.components} id={id}>
<!-- component content -->
</div>
</div>
```
**File Upload Handling**: Uses Phoenix.LiveView.Upload with custom validators
- Datasheets: PDF only, 10MB max, stored in `uploads/datasheets/`
- Images: JPEG/PNG only, 5MB max, stored in `uploads/images/`
## Database Conventions
**Hierarchical Relationships**: Self-referential `parent_id` foreign keys
```elixir
# Schema pattern for categories/locations
field :parent_id, :id
belongs_to :parent, __MODULE__
has_many :children, __MODULE__, foreign_key: :parent_id
```
**Component Search**: Full-text search across name, description, keywords
```elixir
# Use ilike for case-insensitive search
from c in Component, where: ilike(c.name, ^"%#{search}%")
```
## Asset Management
**DaisyUI Components**: Prefer DaisyUI classes for consistent styling:
- `btn btn-primary` for buttons
- `card card-compact` for component cards
- `badge` for category/location tags
## External Dependencies
**AprilTag Generation**: SVG-based tags for physical location labeling
```elixir
ComponentsElixir.AprilTag.generate_svg(tag_id) # Returns SVG string
```
## Testing Guidelines
**Factory Pattern**: Use explicit test data setup, not factories
- Seed realistic test data in `test/support/` modules
- Test both hierarchical and flat data structures
## Production Deployment
**Docker-First**: Primary deployment method via `docker-compose.yml`
- Environment: `SECRET_KEY_BASE`, `AUTH_PASSWORD`, `PHX_HOST`
- File uploads mounted as volumes: `./uploads:/app/uploads`
**Release Commands**: Database migrations via release tasks
```bash
# In production container
./bin/components_elixir eval "ComponentsElixir.Release.migrate"
```
## CI/CD with Gitea Actions
**Gitea Actions Limitations** (important differences from GitHub Actions):
- No `concurrency`, `run-name`, `permissions`, `timeout-minutes`, `continue-on-error` support
- Limited expression support (only `always()` function)
- No package repository authorization - use Personal Access Token for OCI publishing
- Problem matchers and error annotations are ignored
- Simple `runs-on` syntax only (`runs-on: ubuntu-latest`, not complex selectors)
**CI Pipeline Will Include**:
- Code quality checks (`mix precommit`)
- Test execution across Elixir/OTP versions
- Docker image building and publishing (requires PAT for package registry)
- Database migration testing
Ensure all code passes `mix precommit` before pushing, as this will be enforced in CI.

4
.gitignore vendored
View File

@@ -35,8 +35,8 @@ components_elixir-*.tar
npm-debug.log
/assets/node_modules/
# Ignore all user-generated content (uploads, QR codes, etc.)
/priv/static/user_generated/
# Ignore all dynamicly uploaded files.
/uploads/
# Ignore customized Docker Compose file.
docker-compose.yml

View File

@@ -1,79 +0,0 @@
# AprilTag Migration Summary
## Completed Changes
### 1. Database Migration ✅
- Migrated from `qr_code` string field to `apriltag_id` integer field
- Added constraint to ensure valid AprilTag IDs (0-586)
- Created unique index for apriltag_id
- Preserved old qr_code data as qr_code_old for rollback safety
### 2. Schema Updates ✅
- Updated `StorageLocation` schema to use `apriltag_id` instead of `qr_code`
- Added validation for AprilTag ID range (0-586)
- Implemented auto-assignment of next available ID
- Added unique constraint validation
### 3. Business Logic Refactoring ✅
- Replaced `ComponentsElixir.QRCode` module with `ComponentsElixir.AprilTag` module
- Updated inventory functions to use AprilTag IDs instead of QR code strings
- Implemented AprilTag ID availability checking
- Added bulk SVG generation functionality
### 4. UI/UX Improvements ✅
- Replaced dropdown with 587 options with better UX:
- Radio buttons for "Auto-assign" vs "Manual selection"
- Number input for specific ID selection when manual mode selected
- Shows available ID count and examples
- Different interface for add vs edit forms
- Updated templates to show AprilTag information instead of QR codes
- Added download functionality for AprilTag SVGs
### 5. AprilTag Generation ✅
- Created `ComponentsElixir.AprilTag` module for managing tag36h11 family
- Generated all 587 placeholder SVG files with human-readable IDs
- Added Mix task `mix apriltag.generate_all` for batch generation
- SVG files served statically at `/apriltags/tag36h11_id_XXX.svg`
### 6. Event Handling ✅
- Updated LiveView event handlers for AprilTag scanning/assignment
- Added mode switching for manual vs automatic assignment
- Implemented proper form state management for different modes
## Benefits Achieved
1. **Better UX**: No more 587-option dropdown menu
2. **Future-Ready**: AprilTags designed for multi-tag detection scenarios
3. **Robust**: 587 unique IDs provide ample space without conflicts
4. **Maintainable**: Simpler integer ID system vs complex string encoding
5. **Industry Standard**: AprilTags widely used in robotics/AR applications
## Current State
- ✅ Database schema updated
- ✅ All 587 placeholder SVG files generated
- ✅ UI forms updated with better UX
- ✅ Business logic migrated to AprilTag system
-**Next**: Real AprilTag pattern generation (future enhancement)
-**Next**: Camera detection integration (future enhancement)
## Usage
### Generate AprilTag SVGs
```bash
mix apriltag.generate_all # Generate missing files
mix apriltag.generate_all --force # Regenerate all files
```
### Available AprilTag IDs
- Range: 0-586 (tag36h11 family)
- Auto-assignment picks next available ID
- Manual assignment allows specific ID selection
- Unique constraint prevents conflicts
### File Locations
- SVG files: `priv/static/apriltags/tag36h11_id_XXX.svg`
- URL pattern: `/apriltags/tag36h11_id_XXX.svg`
- Placeholder pattern includes human-readable ID label
The system is now ready for use with AprilTags instead of QR codes! The placeholder SVGs will work perfectly for testing and development until we implement actual AprilTag pattern generation.

View File

@@ -88,6 +88,10 @@ ENV LC_ALL en_US.UTF-8
WORKDIR "/app"
RUN chown nobody /app
# Create data directory for uploads
RUN mkdir -p /data/uploads/images && \
chown -R nobody:root /data/uploads
# set runner ENV
ENV MIX_ENV="prod"

440
README.md
View File

@@ -1,48 +1,52 @@
# Components Inventory - Elixir/Phoenix Implementation
# Electronic Components Inventory System
A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inventory management system.
A modern, comprehensive electronic component inventory management system built with Elixir/Phoenix LiveView. Features real-time updates, hierarchical organization, and advanced search capabilities.
## Features
### ✨ Improvements over the original PHP version:
1. **Modern Architecture**
- Phoenix LiveView for real-time, reactive UI without JavaScript
- Ecto for type-safe database operations
- Proper separation of concerns with Phoenix contexts
- Built-in validation and error handling
2. **Enhanced User Experience**
- Real-time search with no page refreshes
- Responsive design with Tailwind CSS
- Loading states and better feedback
- Improved mobile experience
3. **Better Data Management**
- Full-text search with PostgreSQL
- Hierarchical categories with parent-child relationships
- Proper foreign key constraints
- Database migrations for schema management
4. **Security & Reliability**
- CSRF protection built-in
- SQL injection prevention through Ecto
- Session-based authentication
- Input validation and sanitization
### 🔧 Core Functionality
- **Component Management**: Add, edit, delete, and track electronic components
- **Component Management**: Add, edit, delete, and track electronic components with real-time updates
- **Inventory Tracking**: Monitor component quantities with increment/decrement buttons
- **Search & Filter**: Fast search across component names, descriptions, and keywords
- **Category Organization**: Hierarchical category system for better organization
- **Category Management**: Add, edit, delete categories through the web interface with hierarchical support
- **Storage Location System**: Hierarchical storage locations (shelf → drawer → box) with automatic AprilTag generation
- **AprilTag Integration**: Automatic AprilTag generation and display for all storage locations with download capability
- **Datasheet Links**: Direct links to component datasheets
- **Real-time Updates**: All changes are immediately reflected in the interface
- **Advanced Search & Filtering**:
- Fast full-text search across component names, descriptions, and keywords
- Filter by categories and storage locations (including subcategories/sublocations)
- Clickable category and location filters for quick navigation
- **Hierarchical Organization**:
- Unlimited nesting for both categories and storage locations
- Collapsible tree views for easy navigation
- Visual breadcrumb paths (e.g., "Electronics > Resistors > Through-hole")
- **Datasheet Management**:
- Upload PDF datasheets directly or provide URLs for automatic download
- Automatic datasheet retrieval from URLs with validation
- Visual indicators for components with datasheets
- Direct PDF viewing and download
- **Storage Location System**:
- Hierarchical locations (shelf → drawer → box) with AprilTag integration
- Automatic AprilTag generation for physical labeling
- Downloadable SVG AprilTags for printing
- **Image Support**: Upload component images with preview and validation
- **Real-time Interface**: All changes reflected immediately without page refresh
## Setup
### 🎨 User Experience
- **Modern Responsive Design**: Works seamlessly on desktop and mobile
- **Dark/Light Mode**: Automatic theme support with DaisyUI
- **Interactive Components**:
- Collapsible hierarchical views
- Focus mode for detailed component viewing
- Drag-and-drop file uploads with progress indicators
- **Smart Navigation**: Clickable categories and locations for instant filtering
- **Visual Feedback**: Loading states, progress bars, and clear error messages
## Quick Start
### Prerequisites
- Elixir 1.15+
- PostgreSQL 15+
- Docker (optional, for containerized deployment)
### Development Setup
1. **Install dependencies:**
```bash
@@ -51,143 +55,122 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
2. **Set up the database:**
```bash
# Using Docker (recommended)
docker run --name components-postgres -p 5432:5432 \
-e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres
# Initialize database
mix ecto.create
mix ecto.migrate
mix run priv/repo/seeds.exs
```
3. **Start the server:**
3. **Start the development server:**
```bash
mix phx.server
```
4. **Visit the application:**
Open [http://localhost:4000](http://localhost:4000)
4. **Access the application:**
- Open [http://localhost:4000](http://localhost:4000)
- Default password: `changeme`
## Authentication
### Authentication
The application uses a simple password-based authentication system:
- Default password: `changeme`
- Set custom password via environment variable: `AUTH_PASSWORD=your_password`
Simple password-based authentication:
- Default: `changeme`
- Custom: Set `AUTH_PASSWORD=your_password` environment variable
## Database Schema
### Categories
### Categories (Hierarchical)
- `id`: Primary key
- `name`: Category name (required)
- `description`: Optional description
- `parent_id`: Foreign key for hierarchical categories
- Supports unlimited nesting levels
- `parent_id`: Foreign key for hierarchical structure
- **Features**: Unlimited nesting, full-path display, clickable filtering
### Storage Locations (Hierarchical)
- `id`: Primary key
- `name`: Location name (required)
- `description`: Optional description
- `parent_id`: Foreign key for hierarchical structure
- `apriltag_id`: Optional AprilTag identifier for physical labeling
- **Features**: AprilTag generation, SVG download, hierarchical filtering
### Components
- `id`: Primary key
- `name`: Component name (required)
- `description`: Detailed description
- `keywords`: Search keywords
- `position`: Storage location/position
- `count`: Current quantity (default: 0)
- `datasheet_url`: Optional link to datasheet
- `image_filename`: Optional image file name
- `category_id`: Required foreign key to categories
- `storage_location_id`: Optional foreign key to storage locations
- `datasheet_url`: Optional URL to external datasheet
- `datasheet_filename`: Optional uploaded PDF datasheet
- `image_filename`: Optional uploaded component image
## Architecture
## Technical Architecture
### Contexts
- **`ComponentsElixir.Inventory`**: Business logic for components and categories
- **`ComponentsElixir.Auth`**: Simple authentication system
### Phoenix LiveView Application
- **Real-time updates**: No page refreshes needed
- **Phoenix Contexts**: Clean separation of business logic
- **Ecto**: Type-safe database operations with migrations
- **Authentication**: Session-based with CSRF protection
### Live Views
- **`ComponentsElixirWeb.LoginLive`**: Authentication interface
- **`ComponentsElixirWeb.ComponentsLive`**: Main component management interface
- **`ComponentsElixirWeb.CategoriesLive`**: Category management interface
- **`ComponentsElixirWeb.StorageLocationsLive`**: Hierarchical storage location management with AprilTags
### Key Modules
- `ComponentsElixir.Inventory`: Core business logic
- `ComponentsElixir.DatasheetDownloader`: Automatic PDF retrieval
- `ComponentsElixir.AprilTag`: SVG AprilTag generation
- `ComponentsElixir.Inventory.Hierarchical`: Reusable hierarchy management
- `ComponentsElixirWeb.*Live`: LiveView interfaces for real-time UI
## Recent Features & Improvements
### Key Features
- **Real-time updates**: Changes are immediately reflected without page refresh
- **Infinite scroll**: Load more components as needed
- **Search optimization**: Uses PostgreSQL full-text search for long queries, ILIKE for short ones
- **Responsive design**: Works on desktop and mobile devices
### ✅ Datasheet Management System
- **Automatic Download**: Provide a URL and the system downloads the PDF automatically
- **Direct Upload**: Upload PDF datasheets up to 10MB
- **Smart Validation**: Ensures uploaded files are valid PDFs
- **Visual Indicators**: Components with datasheets show clear visual cues
- **Integrated Viewing**: Click component names to view datasheets directly
## API Comparison
### ✅ Advanced Filtering & Navigation
- **Hierarchical Filtering**: Filter by categories/locations including all subcategories/sublocations
- **Click-to-Filter**: Click any category or location name to instantly filter components
- **Collapsible Trees**: Expand/collapse category and storage location hierarchies
- **Smart Search**: Combines full-text search with hierarchical filtering
| Original PHP | New Elixir/Phoenix | Improvement |
|-------------|-------------------|-------------|
| `getItems.php` | `Inventory.list_components/1` | Type-safe, composable queries |
| `getCategories.php` | `Inventory.list_categories/0` | Proper associations, hierarchical support |
| `addItem.php` | `Inventory.create_component/1` | Built-in validation, changesets |
| Manual editing | `Inventory.update_component/2` | **NEW**: Full edit functionality with validation |
| `changeAmount.php` | `Inventory.update_component_count/2` | Atomic operations, constraints |
| Manual category management | `CategoriesLive` + `Inventory.create_category/1` | **NEW**: Full category CRUD with web interface |
| Manual location tracking | `StorageLocationsLive` + `Inventory` context | **NEW**: Hierarchical storage locations with automatic AprilTags |
| `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 |
### ✅ Enhanced User Interface
- **Focus Mode**: Detailed component view with full information display
- **Responsive Design**: Optimized for mobile and desktop usage
- **Consistent Sorting**: Robust sorting even with rapid data changes
- **Visual Feedback**: Loading states, progress indicators, and clear error messages
## 🚀 Future Enhancements
### ✅ Production-Ready Deployment
- **Docker Support**: Complete containerized deployment with database
- **File Upload Optimization**: Improved handling in production environments
- **Performance Tuning**: Optimized queries and caching for better responsiveness
## Future Enhancements
### Component Management
- **Barcode Support** - Generate and scan traditional barcodes in addition to AprilTags
- **Camera Integration** - JavaScript-based AprilTag scanning with camera access for mobile/desktop
- **Multi-AprilTag Detection** - Spatial analysis and disambiguation for multiple tags in same image
- **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
- **Barcode Support**: Generate and scan traditional barcodes alongside AprilTags
- **Camera Integration**: Mobile/desktop AprilTag scanning with camera access
- **Multi-Tag Detection**: Spatial analysis for multiple tags in the same view
- **Bulk Operations**: CSV import/export and batch component updates
- **Component Templates**: Reusable templates for common component types
- **Specifications Tracking**: Detailed technical specifications with custom fields
- **Version History**: Track changes to component data over time
### Storage Organization
- **Physical Layout Mapping** - Visual representation of shelves, drawers, and boxes
- **Bulk AprilTag Printing** - Generate printable sheets of AprilTags for labeling
### Advanced Features
- **API Integration**: Connect with distributor APIs for price and availability
- **Low Stock Alerts**: Automatic notifications for components below threshold
- **Bill of Materials (BOM)**: Project-based component tracking and allocation
- **Purchase History**: Track component purchases and supplier information
- **Location Mapping**: Visual representation of physical storage layout
## ✅ Recently Implemented Features
### Storage Location System Foundation ✅ **COMPLETED**
- **Database Schema** ✅ Complete - Hierarchical storage locations with parent-child relationships
- **Storage Location CRUD** ✅ Complete - Full create, read, update, delete operations via web interface
- **Hierarchical Organization** ✅ Complete - Unlimited nesting (shelf → drawer → box)
- **Web Interface** ✅ Complete - Storage locations management page with navigation
- **Component-Storage Integration** ✅ Complete - Components can now be assigned to storage locations via dropdown interface
### AprilTag System 🚧 **PARTIALLY IMPLEMENTED**
- **Visual AprilTag Generation** ❌ Partially Implemented - Placeholder SVGs generated
- **Flexible Assignment Options** ✅ Complete - Auto-assign, manual selection, or no AprilTag assignment for storage locations
- **AprilTag Download** ✅ Complete - Individual AprilTag SVG files can be downloaded for printing
- **AprilTag Scanning** ❌ Missing - No camera integration or scanning functionality (future enhancement)
- **AprilTag Processing** ❌ Missing - Backend logic for processing scanned tags (future enhancement)
### 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)
- **Automatic cleanup** of old images when updated or deleted
- **Responsive image display** in component listings with fallback placeholders
- **Upload error handling** with user-friendly messages
### 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
### Technical Implementation Details
#### Image Upload Architecture
- **LiveView uploads** configured with `allow_upload/3` in mount
- **File processing** with `consume_uploaded_entries/3` for secure file handling
- **Unique filename generation** to prevent conflicts
- **Static file serving** through Phoenix.Plug.Static with `/uploads` path
- **Database integration** with `image_filename` field in components schema
#### Upload Features
- **File type validation**: Only JPG, PNG, GIF files accepted
- **Size limits**: Maximum 5MB per file
- **Single file uploads**: One image per component
- **Progress indication**: Real-time upload progress display
- **Cancel functionality**: Users can cancel uploads in progress
- **Preview system**: Live image preview before form submission
#### File Management
- **Automatic cleanup**: Old images deleted when new ones uploaded
- **Orphan prevention**: Images deleted when components are removed
- **Error handling**: Graceful fallback for missing or corrupted files
- **Static serving**: Images served directly through Phoenix static file handler
### AprilTag Enhancement
- **Scanner App**: Dedicated mobile app for inventory management
- **Batch Printing**: Generate printable sheets of multiple AprilTags
- **Smart Scanning**: Context-aware actions based on scanned tags
## Development
@@ -207,143 +190,86 @@ mix credo
# Reset database
mix ecto.reset
# Add new migration
# Create new migration
mix ecto.gen.migration add_feature
# Check migration status
mix ecto.migrations
```
### Development Environment
The project includes:
- **Hot code reloading** for rapid development
- **TailwindCSS** with live recompilation
- **DaisyUI** for consistent component styling
- **Database seeding** with sample data
## Deployment
### 🐳 Docker Deployment (Recommended)
#### Prerequisites
- Docker and Docker Compose installed
- Git for cloning the repository
Docker provides the easiest deployment method with a pre-built container image including all dependencies.
#### Quick Start
1. **Clone the repository:**
1. **Download the docker-compose file:**
```bash
git clone <repository-url>
cd components_elixir
```
2. **Copy docker-compose.yml.example to docker-compose.yml**
Follow steps in [Customizing Docker Deployment](#customizing-docker-deployment).
3. **Build and run with Docker Compose:**
```bash
docker compose up --build
```
**⚠️ Build Time Notice**: The initial build can take 5-15 minutes because:
- Tailwind CSS downloads ~500MB from GitHub (can be very slow)
- ESBuild downloads additional build tools
- Elixir compiles all dependencies
- Network conditions may significantly affect download speeds
4. **Access the application:**
- Open [http://localhost:4000](http://localhost:4000)
- Default password: `changeme`
#### Docker Configuration Files
The project includes these Docker files:
- **`Dockerfile`** - Multi-stage build for production
- **`docker-compose.yml`** - Local development/testing setup
- **`.dockerignore`** - Excludes unnecessary files from build context
#### Customizing Docker Deployment
1. **Environment Variables**: Edit `docker-compose.yml` to customize:
```yaml
environment:
DATABASE_URL: "ecto://postgres:postgres@db:5432/components_elixir_prod"
SECRET_KEY_BASE: "your-secret-key-here" # Generate with: mix phx.gen.secret
PHX_HOST: "localhost" # Change to your domain
PHX_SERVER: "true"
PORT: "4000"
curl -O https://git.maxboeer.com/schuwi/component-system/raw/branch/main/docker-compose.yml.example
mv docker-compose.yml.example docker-compose.yml
```
2. **Generate a secure secret key:**
**With Elixir/Phoenix installed:**
```bash
# Run this locally to generate a new secret
mix phx.gen.secret
```
**Without Elixir/Phoenix (Linux/Unix):**
```bash
dd if=/dev/random bs=1 count=64 status=none | base64 -w0 | cut -c1-64
```
> **Note**: The SECRET_KEY_BASE must be a cryptographically random string that's at least 64 characters long. Phoenix uses it to sign session cookies, CSRF tokens, and other security-sensitive data.
3. **Database Configuration**: The default setup includes:
- PostgreSQL 15 container
- Automatic database creation
- Health checks to ensure proper startup order
- Persistent data storage with Docker volumes
3. **Configure environment** (edit `docker-compose.yml`):
```yaml
environment:
SECRET_KEY_BASE: "your-generated-64-character-secret-key"
AUTH_PASSWORD: "your-secure-password" # Login password for the app
PHX_HOST: "localhost" # Change to your domain
```
#### Production Docker Deployment
4. **Deploy:**
```bash
docker compose up -d
```
5. **Access:** [http://localhost:4000](http://localhost:4000)
The container image is automatically built and published from the main branch at https://git.maxboeer.com/schuwi/component-system.
#### Production Configuration
For production environments:
1. **Create a production docker-compose.yml:**
```yaml
services:
db:
image: postgres:15
environment:
POSTGRES_USER: components_user
POSTGRES_PASSWORD: secure_db_password
POSTGRES_DB: components_elixir_prod
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
app:
build: .
ports:
- "80:4000"
environment:
DATABASE_URL: "ecto://components_user:secure_db_password@db:5432/components_elixir_prod"
SECRET_KEY_BASE: "your-64-char-secret-key"
PHX_HOST: "yourdomain.com"
PHX_SERVER: "true"
PORT: "4000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
command: ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"]
volumes:
postgres_data:
```
2. **Deploy to production:**
```bash
docker compose -f docker-compose.prod.yml up -d
```
#### Docker Troubleshooting
**Build Issues:**
- **Slow Tailwind download**: This is normal - GitHub releases can be slow
- **Network timeouts**: Retry the build with `docker compose up --build`
- **AprilTag compilation errors**: Ensure `apriltags.ps` file exists in project root
**Runtime Issues:**
- **Database connection errors**: Wait for PostgreSQL health check to pass
- **Permission errors**: Check file ownership and Docker user permissions
- **Port conflicts**: Change the port mapping in docker-compose.yml
**Performance:**
- **Slow startup**: First-time container startup includes database initialization
- **Memory usage**: Elixir/Phoenix applications typically use 50-200MB RAM
- **Storage**: PostgreSQL data persists in Docker volumes
- **Use specific versions**: Pin to specific tags like `git.maxboeer.com/schuwi/components-elixir:v1.0.0` instead of `:latest`
- **Available tags**:
- `:latest` - Latest stable release from main branch
- `:main` - Latest build from main branch
- `:v*` - Specific version tags
- `:snapshot-<hash>` - Specific commit builds
- **Generate secure keys**: Generate a 64+ character random string for SECRET_KEY_BASE (see Quick Start section for methods)
- **Set strong passwords**: Use AUTH_PASSWORD environment variable
- **Configure domain**: Set PHX_HOST to your actual domain
- **Database security**: Use strong PostgreSQL credentials
- **SSL/HTTPS**: Configure reverse proxy (nginx, Caddy) for SSL termination
### 🚀 Traditional Deployment
For production deployment without Docker:
For deployment without Docker:
1. **Set environment variables:**
1. **Environment setup:**
```bash
export AUTH_PASSWORD=your_secure_password
export SECRET_KEY_BASE=your_secret_key
@@ -355,18 +281,24 @@ For production deployment without Docker:
MIX_ENV=prod mix release
```
3. **Run migrations:**
3. **Database setup:**
```bash
_build/prod/rel/components_elixir/bin/components_elixir eval "ComponentsElixir.Release.migrate"
```
## Contributing
This is a modernized, idiomatic Elixir/Phoenix implementation that maintains feature parity with the original PHP version while providing significant improvements in code quality, security, and user experience.
This project follows Phoenix and Elixir best practices:
The application follows Phoenix and Elixir best practices:
- Contexts for business logic
- LiveView for interactive UI
- Ecto for database operations
- Comprehensive error handling
- Input validation and sanitization
- **Phoenix Contexts** for business logic separation
- **LiveView** for real-time, interactive user interfaces
- **Ecto** for type-safe database operations
- **Comprehensive testing** with ExUnit
- **Input validation** and sanitization throughout
- **CSRF protection** and security best practices
The codebase emphasizes:
- **Maintainability**: Clear module organization and documentation
- **Performance**: Optimized queries and efficient data structures
- **Reliability**: Comprehensive error handling and graceful degradation
- **Extensibility**: Modular design for easy feature additions

View File

@@ -1,6 +1,6 @@
import Config
# Configure your database
# Configure the database
config :components_elixir, ComponentsElixir.Repo,
username: "postgres",
password: "fCnPB8VQdPkhJAD29hq6sZEY",
@@ -10,8 +10,13 @@ config :components_elixir, ComponentsElixir.Repo,
show_sensitive_data_on_connection_error: true,
pool_size: 10
# For development, we disable any cache and enable
# debugging and code reloading.
# For development work, log all queries
# config :components_elixir, ComponentsElixir.Repo, log: false
# For development, use a local uploads directory
config :components_elixir,
uploads_dir: "./uploads"
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it

View File

@@ -1,5 +1,9 @@
import Config
# Runtime configuration for uploads directory
config :components_elixir,
uploads_dir: System.get_env("UPLOADS_DIR", "./uploads")
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration

View File

@@ -7,8 +7,8 @@ import Config
# Run `mix help test` for more information.
config :components_elixir, ComponentsElixir.Repo,
username: "postgres",
password: "postgres",
hostname: "localhost",
password: System.get_env("POSTGRES_PASSWORD") || "fCnPB8VQdPkhJAD29hq6sZEY",
hostname: System.get_env("POSTGRES_HOSTNAME") || "localhost",
database: "components_elixir_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2

View File

@@ -1,378 +0,0 @@
# QR Code Storage Location System Design
## Overview
Implement a hierarchical storage location system with QR code generation and scanning capabilities to enable quick component location entry and filtering.
## Database Schema
### 1. Storage Locations Table
```sql
CREATE TABLE storage_locations (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
qr_code VARCHAR(100) UNIQUE NOT NULL,
parent_id INTEGER REFERENCES storage_locations(id),
level INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL, -- Materialized path: "shelf1/drawer2/box3"
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_storage_locations_parent_id ON storage_locations(parent_id);
CREATE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
CREATE INDEX idx_storage_locations_path ON storage_locations USING gin(path gin_trgm_ops);
CREATE UNIQUE INDEX idx_storage_locations_name_parent ON storage_locations(name, parent_id);
```
### 2. Modified Components Table
```sql
-- Migration to add storage_location_id to components
ALTER TABLE components
ADD COLUMN storage_location_id INTEGER REFERENCES storage_locations(id),
ADD COLUMN legacy_position VARCHAR(255); -- Keep old position data for migration
-- Move existing position data to legacy_position
UPDATE components SET legacy_position = position;
```
## QR Code Format Design
### Hierarchical QR Code Strategy
To avoid confusion with multiple QR codes in the same image, use a hierarchical encoding strategy:
```
Format: SL:{level}:{unique_id}:{parent_path_hash}
Examples:
- Shelf: "SL:1:ABC123:ROOT"
- Drawer: "SL:2:DEF456:ABC123"
- Box: "SL:3:GHI789:DEF456"
```
### QR Code Components:
- **SL**: Storage Location prefix
- **Level**: Hierarchy level (1=shelf, 2=drawer, 3=box, etc.)
- **Unique ID**: Short alphanumeric code (6-8 chars)
- **Parent Hash**: Reference to parent location
## Multi-QR Code Detection Strategy
### 1. Spatial Filtering
```
When multiple QR codes detected:
1. Calculate distance between codes
2. If distance < threshold:
- Prefer higher hierarchy level (lower number)
- Present disambiguation UI
3. If distance > threshold:
- Allow user to tap/select desired code
```
### 2. Context-Aware Selection
```
Selection Priority:
1. Exact level match (if user scanning for specific level)
2. Deepest level in hierarchy (most specific location)
3. Recently used locations (user preference learning)
4. Manual disambiguation prompt
```
### 3. Visual Feedback
```
Camera Overlay:
- Draw bounding boxes around each detected QR code
- Color-code by hierarchy level
- Show location path preview on hover/tap
- Highlight "best match" with different color
```
## Implementation Components
### 1. Elixir Modules
#### Storage Location Schema
```elixir
defmodule ComponentsElixir.Inventory.StorageLocation do
use Ecto.Schema
import Ecto.Changeset
schema "storage_locations" do
field :name, :string
field :description, :string
field :qr_code, :string
field :level, :integer, default: 0
field :path, :string
field :is_active, :boolean, default: true
belongs_to :parent, __MODULE__
has_many :children, __MODULE__, foreign_key: :parent_id
has_many :components, Component
timestamps()
end
end
```
#### QR Code Generation
```elixir
defmodule ComponentsElixir.QRCode do
def generate_storage_qr(location) do
qr_data = "SL:#{location.level}:#{location.qr_code}:#{parent_hash(location)}"
# Use :qr_code library to generate QR image
:qr_code.encode(qr_data)
|> :qr_code.png()
end
def parse_storage_qr(qr_string) do
case String.split(qr_string, ":") do
["SL", level, code, parent] ->
{:ok, %{level: level, code: code, parent: parent}}
_ ->
{:error, :invalid_format}
end
end
end
```
### 2. Phoenix LiveView Components
#### QR Scanner Component
```elixir
defmodule ComponentsElixirWeb.QRScannerLive do
use ComponentsElixirWeb, :live_view
def mount(_params, _session, socket) do
socket =
socket
|> assign(:scanning, false)
|> assign(:detected_codes, [])
|> assign(:selected_location, nil)
|> allow_upload(:qr_scan,
accept: ~w(.jpg .jpeg .png),
max_entries: 1,
auto_upload: true)
{:ok, socket}
end
def handle_event("start_scan", _, socket) do
{:noreply, assign(socket, :scanning, true)}
end
def handle_event("qr_detected", %{"codes" => codes}, socket) do
parsed_codes = Enum.map(codes, &parse_and_resolve_location/1)
socket =
socket
|> assign(:detected_codes, parsed_codes)
|> maybe_auto_select_location(parsed_codes)
{:noreply, socket}
end
defp maybe_auto_select_location(socket, [single_code]) do
assign(socket, :selected_location, single_code)
end
defp maybe_auto_select_location(socket, multiple_codes) do
# Show disambiguation UI
assign(socket, :selected_location, nil)
end
end
```
### 3. JavaScript QR Detection
#### Camera Integration
```javascript
// assets/js/qr_scanner.js
import jsQR from "jsqr";
export const QRScanner = {
mounted() {
this.video = this.el.querySelector('video');
this.canvas = this.el.querySelector('canvas');
this.context = this.canvas.getContext('2d');
this.startCamera();
this.scanLoop();
},
async startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Use back camera
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
this.video.srcObject = stream;
} catch (err) {
console.error('Camera access denied:', err);
}
},
scanLoop() {
if (this.video.readyState === this.video.HAVE_ENOUGH_DATA) {
this.canvas.width = this.video.videoWidth;
this.canvas.height = this.video.videoHeight;
this.context.drawImage(this.video, 0, 0);
const imageData = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height);
// Detect multiple QR codes
const codes = this.detectMultipleQRCodes(imageData);
if (codes.length > 0) {
this.pushEvent("qr_detected", { codes: codes });
}
}
requestAnimationFrame(() => this.scanLoop());
},
detectMultipleQRCodes(imageData) {
// Implementation for detecting multiple QR codes
// This is a simplified version - you'd need a more robust library
const detected = [];
// Scan in grid pattern to find multiple codes
const gridSize = 4;
const width = imageData.width / gridSize;
const height = imageData.height / gridSize;
for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) {
const subImageData = this.getSubImageData(
imageData,
x * width,
y * height,
width,
height
);
const code = jsQR(subImageData.data, subImageData.width, subImageData.height);
if (code && this.isStorageLocationQR(code.data)) {
detected.push({
data: code.data,
location: { x: x * width, y: y * height },
corners: code.location
});
}
}
}
return this.filterDuplicates(detected);
},
isStorageLocationQR(data) {
return data.startsWith('SL:');
}
};
```
## User Experience Flow
### 1. Adding Components with QR Scan
```
1. User clicks "Add Component"
2. Position field shows camera icon
3. Click camera → QR scanner opens
4. Scan storage location QR code
5. If multiple codes detected:
- Show overlay with detected locations
- User taps to select specific location
6. Location path auto-filled: "Shelf A → Drawer 2 → Box 5"
7. Component saved with storage_location_id
```
### 2. Filtering by Storage Location
```
1. Component list shows location filter dropdown
2. Filter options show hierarchical tree:
├── Shelf A
│ ├── Drawer 1
│ │ ├── Box 1
│ │ └── Box 2
│ └── Drawer 2
└── Shelf B
3. Select any level to filter components
4. Breadcrumb shows: "Shelf A → Drawer 2" (23 components)
```
### 3. Location Management
```
1. New "Storage Locations" section in admin
2. Add/edit locations with auto QR generation
3. Print QR labels with location hierarchy
4. Bulk QR code generation for initial setup
```
## Handling Multiple QR Codes in Same Image
### Strategy 1: Spatial Separation
- Calculate euclidean distance between QR code centers
- If distance < 100px → show disambiguation
- If distance > 100px → allow selection by tap
### Strategy 2: Hierarchy Preference
- Always prefer deepest level (most specific)
- If same level → show all options
- Color-code by hierarchy level in UI
### Strategy 3: Machine Learning (Future)
- Learn user selection patterns
- Predict most likely intended QR code
- Still allow manual override
## Migration Strategy
### Phase 1: Add Storage Locations
1. Create migration for storage_locations table
2. Add storage_location_id to components
3. Create admin interface for location management
### Phase 2: QR Code Generation
1. Add QR code generation to location creation
2. Implement QR code printing/export functionality
3. Generate codes for existing locations
### Phase 3: QR Code Scanning
1. Add camera permissions and JavaScript QR scanner
2. Implement single QR code detection first
3. Add multi-QR detection and disambiguation
### Phase 4: Advanced Features
1. Location-based filtering and search
2. Bulk operations by location
3. Location analytics and optimization
## Technical Dependencies
### Elixir Dependencies
```elixir
# mix.exs
{:qr_code, "~> 3.1"}, # QR code generation
{:image, "~> 0.37"}, # Image processing
{:ex_image_info, "~> 0.2.4"} # Image metadata
```
### JavaScript Dependencies
???
## Database Indexes for Performance
```sql
-- Fast location lookups
CREATE INDEX idx_components_storage_location_id ON components(storage_location_id);
-- Hierarchical queries
CREATE INDEX idx_storage_locations_path_gin ON storage_locations USING gin(path gin_trgm_ops);
-- QR code uniqueness and fast lookup
CREATE UNIQUE INDEX idx_storage_locations_qr_code ON storage_locations(qr_code);
```
This design provides a robust foundation for QR code-based storage management while handling the complexity of multiple codes in the same image through spatial analysis and user interaction patterns.

View File

@@ -16,7 +16,7 @@ services:
retries: 5
app:
build: .
image: git.maxboeer.com/schuwi/components-elixir:latest
ports:
- "4000:4000"
environment:
@@ -25,9 +25,12 @@ services:
PHX_HOST: "localhost"
PHX_SERVER: "true"
PORT: "4000"
UPLOADS_DIR: "/data/uploads"
depends_on:
db:
condition: service_healthy
volumes:
- uploaded_files:/data/uploads
command:
[
"/bin/sh",
@@ -37,3 +40,4 @@ services:
volumes:
postgres_data:
uploaded_files:

View File

@@ -31,6 +31,7 @@ defmodule ComponentsElixir.AprilTag do
def valid_apriltag_id?(id) when is_integer(id) do
id >= 0 and id < @tag36h11_count
end
def valid_apriltag_id?(_), do: false
@doc """
@@ -78,8 +79,8 @@ defmodule ComponentsElixir.AprilTag do
def used_apriltag_ids do
ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id
where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id
)
end
@@ -130,10 +131,11 @@ defmodule ComponentsElixir.AprilTag do
This should be run once during setup to pre-generate all AprilTag images.
"""
def generate_all_apriltag_svgs(opts \\ []) do
static_dir = Path.join([
Application.app_dir(:components_elixir, "priv/static"),
"apriltags"
])
static_dir =
Path.join([
Application.app_dir(:components_elixir, "priv/static"),
"apriltags"
])
# Ensure directory exists
File.mkdir_p!(static_dir)
@@ -143,21 +145,7 @@ defmodule ComponentsElixir.AprilTag do
results =
all_apriltag_ids()
|> Task.async_stream(
fn apriltag_id ->
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
file_path = Path.join(static_dir, filename)
if force_regenerate || !File.exists?(file_path) do
svg_content = generate_apriltag_svg(apriltag_id, opts)
case File.write(file_path, svg_content) do
:ok -> {:ok, apriltag_id, file_path}
{:error, reason} -> {:error, apriltag_id, reason}
end
else
{:ok, apriltag_id, file_path}
end
end,
&generate_apriltag_file(&1, static_dir, force_regenerate, opts),
timeout: :infinity,
max_concurrency: System.schedulers_online() * 2
)
@@ -174,6 +162,26 @@ defmodule ComponentsElixir.AprilTag do
}
end
defp generate_apriltag_file(apriltag_id, static_dir, force_regenerate, opts) do
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
file_path = Path.join(static_dir, filename)
if force_regenerate || !File.exists?(file_path) do
write_apriltag_file(apriltag_id, file_path, opts)
else
{:ok, apriltag_id, file_path}
end
end
defp write_apriltag_file(apriltag_id, file_path, opts) do
svg_content = generate_apriltag_svg(apriltag_id, opts)
case File.write(file_path, svg_content) do
:ok -> {:ok, apriltag_id, file_path}
{:error, reason} -> {:error, apriltag_id, reason}
end
end
@doc """
Cleans up AprilTag SVG file for a specific ID.
@@ -181,10 +189,12 @@ defmodule ComponentsElixir.AprilTag do
"""
def cleanup_apriltag_svg(apriltag_id) do
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
file_path = Path.join([
Application.app_dir(:components_elixir, "priv/static/apriltags"),
filename
])
file_path =
Path.join([
Application.app_dir(:components_elixir, "priv/static/apriltags"),
filename
])
if File.exists?(file_path) do
File.rm(file_path)

View File

@@ -16,6 +16,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
[_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)}
_ ->
nil
end
@@ -23,26 +24,30 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
# Extract patterns from PostScript file at compile time
@all_patterns (
path = Path.join([File.cwd!(), "apriltags.ps"])
path = Path.join([File.cwd!(), "apriltags.ps"])
if File.exists?(path) do
File.read!(path)
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|> Enum.map(fn line ->
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
[_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
else
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
end
)
if File.exists?(path) do
File.read!(path)
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|> Enum.map(fn line ->
case Regex.run(
~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/,
line
) do
[_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)}
_ ->
nil
end
end)
|> Enum.reject(&is_nil/1)
|> Map.new()
else
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
end
)
# Sample of real tag36h11 hex patterns from AprilRobotics repository
# This will be populated with patterns extracted from the PostScript file
@@ -64,7 +69,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|> Enum.reject(&is_nil/1)
|> Map.new()
else
%{} # Return empty map if file not found, will fall back to hardcoded patterns
# Return empty map if file not found, will fall back to hardcoded patterns
%{}
end
end
@@ -76,6 +82,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do
Map.get(@tag36h11_patterns, id)
end
def get_hex_pattern(_), do: nil
@doc """
@@ -97,6 +104,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
# Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp)
row_bytes = 3
rows =
for row <- 0..9 do
<<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes
@@ -104,12 +112,23 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
samples =
[
b0 >>> 6 &&& 0x3, b0 >>> 4 &&& 0x3, b0 >>> 2 &&& 0x3, b0 &&& 0x3,
b1 >>> 6 &&& 0x3, b1 >>> 4 &&& 0x3, b1 >>> 2 &&& 0x3, b1 &&& 0x3,
b2 >>> 6 &&& 0x3, b2 >>> 4 &&& 0x3, b2 >>> 2 &&& 0x3, b2 &&& 0x3
b0 >>> 6 &&& 0x3,
b0 >>> 4 &&& 0x3,
b0 >>> 2 &&& 0x3,
b0 &&& 0x3,
b1 >>> 6 &&& 0x3,
b1 >>> 4 &&& 0x3,
b1 >>> 2 &&& 0x3,
b1 &&& 0x3,
b2 >>> 6 &&& 0x3,
b2 >>> 4 &&& 0x3,
b2 >>> 2 &&& 0x3,
b2 &&& 0x3
]
|> Enum.take(10) # drop the 2 padding samples at end of row
|> Enum.map(&(&1 == 0)) # 0 = black, 3 = white → boolean
# drop the 2 padding samples at end of row
|> Enum.take(10)
# 0 = black, 3 = white → boolean
|> Enum.map(&(&1 == 0))
samples
end
@@ -133,7 +152,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
Only black modules are drawn over a white background.
"""
def binary_matrix_to_svg(binary_matrix, opts \\ []) do
size = Keyword.get(opts, :size, 200) # final CSS size in px
# final CSS size in px
size = Keyword.get(opts, :size, 200)
id_text = Keyword.get(opts, :id_text, "")
# binary_matrix is 10x10 of booleans: true=black, false=white
@@ -172,9 +192,11 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
<!-- caption -->
#{if id_text != "" do
~s(<text x="#{modules_w/2}" y="#{modules_h + 1.4}" text-anchor="middle"
~s(<text x="#{modules_w / 2}" y="#{modules_h + 1.4}" text-anchor="middle"
font-family="Arial" font-size="0.9">#{id_text}</text>)
else "" end}
else
""
end}
</svg>
"""
end
@@ -197,11 +219,13 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
opts_with_id = Keyword.put(opts, :id_text, id_text)
binary_matrix_to_svg(binary_matrix, opts_with_id)
end
end # Generate a placeholder pattern for IDs we don't have real data for yet
end
# Generate a placeholder pattern for IDs we don't have real data for yet
defp generate_placeholder_svg(id, opts) do
size = Keyword.get(opts, :size, 200)
margin = Keyword.get(opts, :margin, div(size, 10))
square_size = size - (2 * margin)
square_size = size - 2 * margin
"""
<svg width="#{size}" height="#{size + 30}" xmlns="http://www.w3.org/2000/svg">

View File

@@ -0,0 +1,151 @@
defmodule ComponentsElixir.DatasheetDownloader do
@moduledoc """
Module for downloading datasheet PDFs from URLs.
"""
require Logger
@doc """
Downloads a PDF from the given URL and saves it to the datasheets folder.
Returns {:ok, filename} on success or {:error, reason} on failure.
"""
def download_pdf_from_url(url) when is_binary(url) do
with {:ok, %URI{scheme: scheme}} when scheme in ["http", "https"] <- validate_url(url),
{:ok, filename} <- generate_filename(url),
{:ok, response} <- fetch_pdf(url),
:ok <- validate_pdf_content(response.body),
:ok <- save_file(filename, response.body) do
{:ok, filename}
else
{:error, reason} -> {:error, reason}
error -> {:error, "Unexpected error: #{inspect(error)}"}
end
end
def download_pdf_from_url(_), do: {:error, "Invalid URL"}
defp validate_url(url) do
case URI.parse(url) do
%URI{scheme: scheme} when scheme in ["http", "https"] ->
{:ok, URI.parse(url)}
_ ->
{:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."}
end
end
defp generate_filename(url) do
# Try to extract a meaningful filename from the URL
uri = URI.parse(url)
original_filename =
case Path.basename(uri.path || "") do
"" ->
"datasheet"
basename ->
# Remove extension and sanitize
basename
|> Path.rootname()
|> sanitize_filename()
end
# Create a unique filename with timestamp
timestamp = System.unique_integer([:positive])
filename = "#{timestamp}_#{original_filename}.pdf"
{:ok, filename}
end
defp sanitize_filename(filename) do
filename
# Replace non-word chars with underscores
|> String.replace(~r/[^\w\-_]/, "_")
# Replace multiple underscores with single
|> String.replace(~r/_+/, "_")
# Remove leading/trailing underscores
|> String.trim("_")
# Limit length
|> String.slice(0, 50)
|> case do
"" -> "datasheet"
name -> name
end
end
defp fetch_pdf(url) do
case Req.get(url,
redirect: true,
max_redirects: 5,
receive_timeout: 30_000,
headers: [
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
]
) do
{:ok, %Req.Response{status: 200} = response} ->
{:ok, response}
{:ok, %Req.Response{status: status}} ->
{:error, "HTTP error: #{status}"}
{:error, reason} ->
Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}")
{:error, "Download failed: #{inspect(reason)}"}
end
end
defp validate_pdf_content(body) do
# Check if the response body looks like a PDF (starts with %PDF)
case body do
<<"%PDF", _rest::binary>> ->
:ok
_ ->
{:error, "Downloaded content is not a valid PDF file"}
end
end
defp save_file(filename, content) do
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
datasheets_dir = Path.join([uploads_dir, "datasheets"])
file_path = Path.join(datasheets_dir, filename)
# Ensure the datasheets directory exists
case File.mkdir_p(datasheets_dir) do
:ok ->
case File.write(file_path, content) do
:ok ->
Logger.info("Successfully saved datasheet: #{filename}")
:ok
{:error, reason} ->
Logger.error("Failed to save datasheet file: #{inspect(reason)}")
{:error, "Failed to save file: #{inspect(reason)}"}
end
{:error, reason} ->
Logger.error("Failed to create datasheets directory: #{inspect(reason)}")
{:error, "Failed to create directory: #{inspect(reason)}"}
end
end
@doc """
Deletes a datasheet file from the filesystem.
"""
def delete_datasheet_file(nil), do: :ok
def delete_datasheet_file(""), do: :ok
def delete_datasheet_file(filename) do
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
file_path = Path.join([uploads_dir, "datasheets", filename])
case File.rm(file_path) do
:ok ->
Logger.info("Deleted datasheet file: #{filename}")
:ok
{:error, reason} ->
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
{:error, reason}
end
end
end

View File

@@ -11,99 +11,42 @@ defmodule ComponentsElixir.Inventory do
## Storage Locations
@doc """
Returns the list of storage locations with computed hierarchy fields.
Returns the list of storage locations with optimized parent preloading.
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
"""
def list_storage_locations do
# Get all locations with preloaded parents in a single query
locations = StorageLocation
|> order_by([sl], [asc: sl.name])
|> preload(:parent)
|> Repo.all()
# Compute hierarchy fields for all locations efficiently
processed_locations = compute_hierarchy_fields_batch(locations)
|> Enum.sort_by(&{&1.level, &1.name})
locations =
StorageLocation
|> order_by([sl], asc: sl.name)
|> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
# Ensure AprilTag SVGs exist for all locations
spawn(fn ->
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
end)
processed_locations
locations
end
# Efficient batch computation of hierarchy fields
defp compute_hierarchy_fields_batch(locations) do
# Create a map for quick parent lookup to avoid N+1 queries
location_map = Map.new(locations, fn loc -> {loc.id, loc} end)
Enum.map(locations, fn location ->
level = compute_level_efficient(location, location_map, 0)
path = compute_path_efficient(location, location_map, 0)
%{location | level: level, path: path}
end)
end
defp compute_level_efficient(%{parent_id: nil}, _location_map, _depth), do: 0
defp compute_level_efficient(%{parent_id: parent_id}, location_map, depth) when depth < 10 do
case Map.get(location_map, parent_id) do
nil -> 0 # Orphaned record
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
end
end
defp compute_level_efficient(_location, _location_map, _depth), do: 0 # Prevent infinite recursion
defp compute_path_efficient(%{parent_id: nil, name: name}, _location_map, _depth), do: name
defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth) when depth < 10 do
case Map.get(location_map, parent_id) do
nil -> name # Orphaned record
parent ->
parent_path = compute_path_efficient(parent, location_map, depth + 1)
"#{parent_path}/#{name}"
end
end
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name # Prevent infinite recursion
@doc """
Returns the list of root storage locations (no parent).
"""
def list_root_storage_locations do
StorageLocation
|> where([sl], is_nil(sl.parent_id))
|> order_by([sl], [asc: sl.name])
|> order_by([sl], asc: sl.name)
|> Repo.all()
end
@doc """
Gets a single storage location with computed hierarchy fields.
Gets a single storage location with preloaded associations.
"""
def get_storage_location!(id) do
location = StorageLocation
StorageLocation
|> preload(:parent)
|> Repo.get!(id)
# Compute hierarchy fields
level = compute_level_for_single(location)
path = compute_path_for_single(location)
%{location | level: level, path: path}
end
# Simple computation for single location (allows DB queries)
defp compute_level_for_single(%{parent_id: nil}), do: 0
defp compute_level_for_single(%{parent_id: parent_id}) do
case Repo.get(StorageLocation, parent_id) do
nil -> 0
parent -> 1 + compute_level_for_single(parent)
end
end
defp compute_path_for_single(%{parent_id: nil, name: name}), do: name
defp compute_path_for_single(%{parent_id: parent_id, name: name}) do
case Repo.get(StorageLocation, parent_id) do
nil -> name
parent -> "#{compute_path_for_single(parent)}/#{name}"
end
end
@doc """
@@ -114,13 +57,6 @@ defmodule ComponentsElixir.Inventory do
|> where([sl], sl.apriltag_id == ^apriltag_id)
|> preload(:parent)
|> Repo.one()
|> case do
nil -> nil
location ->
level = compute_level_for_single(location)
path = compute_path_for_single(location)
%{location | level: level, path: path}
end
end
@doc """
@@ -130,13 +66,15 @@ defmodule ComponentsElixir.Inventory do
# Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs)
result = %StorageLocation{}
|> StorageLocation.changeset(attrs)
|> Repo.insert()
result =
%StorageLocation{}
|> StorageLocation.changeset(attrs)
|> Repo.insert()
case result do
{:ok, location} ->
{:ok, location}
error ->
error
end
@@ -149,13 +87,15 @@ defmodule ComponentsElixir.Inventory do
# Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs)
result = storage_location
|> StorageLocation.changeset(attrs)
|> Repo.update()
result =
storage_location
|> StorageLocation.changeset(attrs)
|> Repo.update()
case result do
{:ok, updated_location} ->
{:ok, updated_location}
error ->
error
end
@@ -182,12 +122,14 @@ defmodule ComponentsElixir.Inventory do
case get_storage_location_by_apriltag_id(apriltag_id) do
nil ->
{:error, :not_found}
location ->
{:ok, %{
type: :storage_location,
location: location,
apriltag_id: apriltag_id
}}
{:ok,
%{
type: :storage_location,
location: location,
apriltag_id: apriltag_id
}}
end
end
@@ -195,8 +137,9 @@ defmodule ComponentsElixir.Inventory do
Computes the path for a storage location (for display purposes).
"""
def compute_storage_location_path(nil), do: nil
def compute_storage_location_path(%StorageLocation{} = location) do
compute_path_for_single(location)
StorageLocation.full_path(location)
end
# Convert string keys to atoms for consistency
@@ -205,6 +148,7 @@ defmodule ComponentsElixir.Inventory do
{key, value}, acc when is_binary(key) ->
atom_key = String.to_atom(key)
Map.put(acc, atom_key, value)
{key, value}, acc ->
Map.put(acc, key, value)
end)
@@ -213,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
## Categories
@doc """
Returns the list of categories.
Returns the list of categories with optimized parent preloading.
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
"""
def list_categories do
Category
|> preload(:parent)
|> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
end
@@ -258,6 +203,63 @@ defmodule ComponentsElixir.Inventory do
Category.changeset(category, attrs)
end
@doc """
Gets all category IDs that are descendants of the given category ID, including the category itself.
This is used for filtering components by category and all its subcategories.
Returns an empty list if the category doesn't exist.
Note: This implementation loads all categories into memory for traversal, which is efficient
for typical category tree sizes (hundreds of categories). For very large category trees,
a recursive CTE query could be used instead.
"""
def get_category_and_descendant_ids(category_id) when is_integer(category_id) do
categories = list_categories()
# Verify the category exists before getting descendants
case Enum.find(categories, &(&1.id == category_id)) do
nil ->
[]
_category ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
categories,
category_id,
& &1.parent_id
)
end
end
def get_category_and_descendant_ids(_), do: []
@doc """
Gets all storage location IDs that are descendants of the given storage location ID, including the location itself.
This is used for filtering components by storage location and all its sub-locations.
Returns an empty list if the storage location doesn't exist.
Note: This implementation loads all storage locations into memory for traversal, which is efficient
for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
a recursive CTE query could be used instead.
"""
def get_storage_location_and_descendant_ids(storage_location_id)
when is_integer(storage_location_id) do
storage_locations = list_storage_locations()
# Verify the storage location exists before getting descendants
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
nil ->
[]
_storage_location ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
storage_locations,
storage_location_id,
& &1.parent_id
)
end
end
def get_storage_location_and_descendant_ids(_), do: []
## Components
@doc """
@@ -266,7 +268,7 @@ defmodule ComponentsElixir.Inventory do
def list_components(opts \\ []) do
Component
|> apply_component_filters(opts)
|> order_by([c], [asc: c.name])
|> apply_component_sorting(opts)
|> preload([:category, :storage_location])
|> Repo.all()
end
@@ -274,24 +276,54 @@ defmodule ComponentsElixir.Inventory do
defp apply_component_filters(query, opts) do
Enum.reduce(opts, query, fn
{:category_id, category_id}, query when not is_nil(category_id) ->
where(query, [c], c.category_id == ^category_id)
# Get the category and all its descendant category IDs
category_ids = get_category_and_descendant_ids(category_id)
where(query, [c], c.category_id in ^category_ids)
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
where(query, [c], c.storage_location_id == ^storage_location_id)
# Get the storage location and all its descendant storage location IDs
storage_location_ids = get_storage_location_and_descendant_ids(storage_location_id)
where(query, [c], c.storage_location_id in ^storage_location_ids)
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
search_pattern = "%#{search_term}%"
where(query, [c],
where(
query,
[c],
ilike(c.name, ^search_pattern) or
ilike(c.description, ^search_pattern) or
ilike(c.keywords, ^search_pattern) or
ilike(c.position, ^search_pattern)
ilike(c.description, ^search_pattern) or
ilike(c.keywords, ^search_pattern) or
ilike(c.position, ^search_pattern)
)
_, query -> query
_, query ->
query
end)
end
defp apply_component_sorting(query, opts) do
sort_criteria = Keyword.get(opts, :sort_criteria, "name_asc")
sort_order = get_sort_order(sort_criteria)
order_by(query, [c], ^sort_order)
end
# Map of sort criteria to their corresponding sort orders
@sort_orders %{
"name_asc" => [asc: :name, asc: :id],
"name_desc" => [desc: :name, asc: :id],
"inserted_at_asc" => [asc: :inserted_at, asc: :id],
"inserted_at_desc" => [desc: :inserted_at, asc: :id],
"updated_at_asc" => [asc: :updated_at, asc: :id],
"updated_at_desc" => [desc: :updated_at, asc: :id],
"count_asc" => [asc: :count, asc: :id],
"count_desc" => [desc: :count, asc: :id]
}
defp get_sort_order(criteria) do
Map.get(@sort_orders, criteria, asc: :name, asc: :id)
end
@doc """
Gets a single component.
"""
@@ -310,6 +342,32 @@ defmodule ComponentsElixir.Inventory do
|> Repo.insert()
end
@doc """
Creates a component and downloads datasheet from URL if provided.
"""
def create_component_with_datasheet(attrs \\ %{}) do
# If a datasheet_url is provided, download it
updated_attrs =
case Map.get(attrs, "datasheet_url") do
url when is_binary(url) and url != "" ->
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
{:ok, filename} ->
Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} ->
# Continue without datasheet file if download fails
attrs
end
_ ->
attrs
end
%Component{}
|> Component.changeset(updated_attrs)
|> Repo.insert()
end
@doc """
Updates a component.
"""
@@ -319,6 +377,39 @@ defmodule ComponentsElixir.Inventory do
|> Repo.update()
end
@doc """
Updates a component and downloads datasheet from URL if provided.
"""
def update_component_with_datasheet(%Component{} = component, attrs) do
# If a datasheet_url is provided and changed, download it
updated_attrs =
case Map.get(attrs, "datasheet_url") do
url when is_binary(url) and url != "" and url != component.datasheet_url ->
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
{:ok, filename} ->
# Delete old datasheet file if it exists
if component.datasheet_filename do
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
component.datasheet_filename
)
end
Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} ->
# Keep existing filename if download fails
attrs
end
_ ->
attrs
end
component
|> Component.changeset(updated_attrs)
|> Repo.update()
end
@doc """
Deletes a component.
"""
@@ -339,10 +430,12 @@ defmodule ComponentsElixir.Inventory do
def get_inventory_stats do
total_components = Repo.aggregate(Component, :count, :id)
total_stock = Component
total_stock =
Component
|> Repo.aggregate(:sum, :count)
categories_with_components = Component
categories_with_components =
Component
|> distinct([c], c.category_id)
|> Repo.aggregate(:count, :category_id)
@@ -392,6 +485,7 @@ defmodule ComponentsElixir.Inventory do
"""
def decrement_component_count(%Component{} = component) do
new_count = max(0, component.count - 1)
component
|> Component.changeset(%{count: new_count})
|> Repo.update()

View File

@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
Categories can be hierarchical with parent-child relationships.
"""
use Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset
alias ComponentsElixir.Inventory.{Category, Component}
@@ -17,7 +18,7 @@ defmodule ComponentsElixir.Inventory.Category do
has_many :children, Category, foreign_key: :parent_id
has_many :components, Component
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
@doc """
Returns the full path of the category including parent names.
"""
def full_path(%Category{parent: nil} = category), do: category.name
def full_path(%Category{parent: %Category{} = parent} = category) do
"#{full_path(parent)} > #{category.name}"
end
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
category.name
@impl true
def full_path(%Category{} = category) do
Hierarchical.full_path(category, & &1.parent, path_separator())
end
@impl true
def parent(%Category{parent: parent}), do: parent
@impl true
def children(%Category{children: children}), do: children
@impl true
def path_separator(), do: " > "
@impl true
def entity_type(), do: :category
end

View File

@@ -18,18 +18,30 @@ defmodule ComponentsElixir.Inventory.Component do
field :legacy_position, :string
field :count, :integer, default: 0
field :datasheet_url, :string
field :datasheet_filename, :string
field :image_filename, :string
belongs_to :category, Category
belongs_to :storage_location, StorageLocation
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false
def changeset(component, attrs) do
component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id])
|> cast(attrs, [
:name,
:description,
:keywords,
:position,
:count,
:datasheet_url,
:datasheet_filename,
:image_filename,
:category_id,
:storage_location_id
])
|> validate_required([:name, :category_id])
|> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 2000)
@@ -59,25 +71,43 @@ defmodule ComponentsElixir.Inventory.Component do
|> cast(attrs, [:image_filename])
end
@doc """
Changeset for updating component datasheet.
"""
def datasheet_changeset(component, attrs) do
component
|> cast(attrs, [:datasheet_filename])
end
defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, url ->
if url && url != "" do
case URI.parse(url) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> []
_ -> [{field, "must be a valid URL"}]
end
else
[]
cond do
is_nil(url) or url == "" -> []
valid_url?(url) -> []
true -> [{field, "must be a valid URL"}]
end
end)
end
defp valid_url?(url) do
case URI.parse(url) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> true
_ -> false
end
end
@doc """
Returns true if the component has an image.
"""
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
def has_image?(_), do: false
@doc """
Returns true if the component has a datasheet file.
"""
def has_datasheet?(%__MODULE__{datasheet_filename: filename}) when is_binary(filename), do: true
def has_datasheet?(_), do: false
@doc """
Returns the search text for this component.
"""

View File

@@ -0,0 +1,358 @@
defmodule ComponentsElixir.Inventory.Hierarchical do
@moduledoc """
Shared hierarchical behavior for entities with parent-child relationships.
This module provides common functionality for:
- Path computation (e.g., "Parent > Child > Grandchild")
- Cycle detection and prevention
- Parent/child filtering for UI dropdowns
- Tree traversal utilities
Based on the elegant category implementation approach.
"""
@doc """
Computes full hierarchical path for an entity.
Uses recursive traversal of parent chain, loading parents from database if needed.
Optimized to minimize database queries by trying preloaded associations first.
## Examples
iex> category = %Category{name: "Resistors", parent: %Category{name: "Electronics", parent: nil}}
iex> Hierarchical.full_path(category, &(&1.parent))
"Electronics > Resistors"
"""
def full_path(entity, parent_accessor_fn, separator \\ " > ")
def full_path(nil, _parent_accessor_fn, _separator), do: ""
def full_path(entity, parent_accessor_fn, separator) do
case parent_accessor_fn.(entity) do
nil ->
entity.name
%Ecto.Association.NotLoaded{} ->
# Parent not loaded - fall back to database lookup
# This is a fallback and should be rare if preloading is done correctly
build_path_with_db_lookup(entity, separator)
parent ->
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
end
end
# Helper function to build path when parent associations are not loaded
# This is optimized to minimize database queries
defp build_path_with_db_lookup(entity, separator) do
# Build path by walking up the parent chain via database queries
# Collect parent names from root to leaf
path_parts = collect_path_from_root(entity, [])
Enum.join(path_parts, separator)
end
defp collect_path_from_root(entity, path_so_far) do
case entity.parent_id do
nil ->
# This is a root entity, add its name and return the complete path
[entity.name | path_so_far]
parent_id ->
# Load parent from database
case load_parent_entity(entity, parent_id) do
nil ->
# Parent not found (orphaned record), treat this as root
[entity.name | path_so_far]
parent ->
# Recursively get the path from the parent, then add current entity
collect_path_from_root(parent, [entity.name | path_so_far])
end
end
end
defp load_parent_entity(%{__struct__: module} = _entity, parent_id) do
# Note: This function makes individual database queries
# For better performance, consider preloading parent associations properly
# or implementing batch loading if this becomes a bottleneck
ComponentsElixir.Repo.get(module, parent_id)
end
@doc """
Filters entities to remove circular reference options for parent selection.
Prevents an entity from being its own ancestor.
## Examples
iex> categories = [%{id: 1, parent_id: nil}, %{id: 2, parent_id: 1}]
iex> Hierarchical.filter_parent_options(categories, 1, &(&1.id), &(&1.parent_id))
[%{id: 2, parent_id: 1}] # ID 1 filtered out (self-reference)
"""
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn)
def filter_parent_options(entities, nil, _id_accessor_fn, _parent_id_accessor_fn) do
entities
end
def filter_parent_options(entities, editing_entity_id, id_accessor_fn, parent_id_accessor_fn) do
entities
|> Enum.reject(fn entity ->
entity_id = id_accessor_fn.(entity)
# Remove self-reference
# Remove descendants (they would create a cycle)
entity_id == editing_entity_id ||
descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
end)
end
@doc """
Checks if an entity is a descendant of an ancestor entity.
Used for cycle detection in parent selection.
"""
def descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
case descendant do
nil -> false
entity -> descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn)
end
end
defp descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn) do
case parent_id_accessor_fn.(entity) do
nil ->
false
^ancestor_id ->
true
parent_id ->
parent = Enum.find(entities, fn e -> e.id == parent_id end)
case parent do
nil ->
false
parent_entity ->
descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end
end
end
@doc """
Gets all root entities (entities with no parent).
"""
def root_entities(entities, parent_id_accessor_fn) do
Enum.filter(entities, fn entity ->
is_nil(parent_id_accessor_fn.(entity))
end)
end
@doc """
Gets all child entities of a specific parent.
"""
def child_entities(entities, parent_id, parent_id_accessor_fn) do
Enum.filter(entities, fn entity ->
parent_id_accessor_fn.(entity) == parent_id
end)
end
@doc """
Gets all descendant IDs for a given entity ID, including the entity itself.
This recursively finds all children, grandchildren, etc.
## Examples
iex> categories = [
...> %{id: 1, parent_id: nil},
...> %{id: 2, parent_id: 1},
...> %{id: 3, parent_id: 2},
...> %{id: 4, parent_id: 1}
...> ]
iex> Hierarchical.descendant_ids(categories, 1, &(&1.parent_id))
[1, 2, 3, 4]
"""
def descendant_ids(entities, entity_id, parent_id_accessor_fn) do
[entity_id | get_descendant_ids_recursive(entities, entity_id, parent_id_accessor_fn)]
end
defp get_descendant_ids_recursive(entities, parent_id, parent_id_accessor_fn) do
children = child_entities(entities, parent_id, parent_id_accessor_fn)
Enum.flat_map(children, fn child ->
[child.id | get_descendant_ids_recursive(entities, child.id, parent_id_accessor_fn)]
end)
end
@doc """
Generates display name for entity including parent context.
For dropdown displays: "Parent > Child"
"""
def display_name(entity, parent_accessor_fn, separator \\ " > ") do
full_path(entity, parent_accessor_fn, separator)
end
@doc """
Generates options for a parent selection dropdown.
Includes proper filtering to prevent cycles and formatted display names.
Results are sorted hierarchically for intuitive navigation.
"""
def parent_select_options(
entities,
editing_entity_id,
parent_accessor_fn,
nil_option_text \\ "No parent"
) do
available_entities =
filter_parent_options(
entities,
editing_entity_id,
& &1.id,
& &1.parent_id
)
|> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
[{nil_option_text, nil}] ++ available_entities
end
@doc """
Generates options for a general selection dropdown (like filters).
Results are sorted hierarchically for intuitive navigation.
"""
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities =
entities
|> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
if nil_option_text do
[{nil_option_text, nil}] ++ sorted_entities
else
sorted_entities
end
end
@doc """
Computes the depth/level of an entity in the hierarchy.
Root entities have level 0.
"""
def compute_level(entity, parent_accessor_fn) do
case parent_accessor_fn.(entity) do
nil -> 0
%Ecto.Association.NotLoaded{} -> 0
parent -> 1 + compute_level(parent, parent_accessor_fn)
end
end
@doc """
Returns the separator string used for a specific entity type.
Categories use " > " while storage locations use " / ".
"""
def separator_for(:category), do: " > "
def separator_for(:storage_location), do: " / "
def separator_for(_), do: " > "
@doc """
Sorts entities hierarchically in depth-first order.
Each parent is followed immediately by all its children (recursively).
Within each level, entities are sorted alphabetically by name.
## Examples
iex> entities = [
...> %{id: 1, name: "Resistors", parent_id: nil},
...> %{id: 2, name: "Wire", parent_id: 1},
...> %{id: 3, name: "Capacitors", parent_id: nil},
...> %{id: 4, name: "Ceramic", parent_id: 3}
...> ]
iex> Hierarchical.sort_hierarchically(entities, &(&1.parent_id))
# Returns: [Capacitors, Capacitors>Ceramic, Resistors, Resistors>Wire]
"""
def sort_hierarchically(entities, parent_id_accessor_fn) do
# First, get all root entities sorted alphabetically
root_entities =
entities
|> root_entities(parent_id_accessor_fn)
|> Enum.sort_by(& &1.name)
# Then recursively add children after each parent
Enum.flat_map(root_entities, fn root ->
[root | sort_children_recursively(entities, root.id, parent_id_accessor_fn)]
end)
end
defp sort_children_recursively(entities, parent_id, parent_id_accessor_fn) do
children =
entities
|> child_entities(parent_id, parent_id_accessor_fn)
|> Enum.sort_by(& &1.name)
Enum.flat_map(children, fn child ->
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
end)
end
@doc """
Calculates component counts for an entity and all its descendants.
Returns a tuple of {self_count, children_count, total_count}.
## Parameters
- entity_id: The ID of the entity to count for
- all_entities: List of all entities in the hierarchy
- parent_id_accessor_fn: Function to get parent_id from an entity
- count_fn: Function that takes an entity_id and returns the direct count for that entity
## Examples
iex> count_fn = fn id -> MyRepo.count_components_for(id) end
iex> Hierarchical.count_with_descendants(1, entities, &(&1.parent_id), count_fn)
{3, 7, 10} # 3 in self, 7 in children, 10 total
"""
def count_with_descendants(entity_id, all_entities, parent_id_accessor_fn, count_fn) do
# Get direct count for this entity
self_count = count_fn.(entity_id)
# Get all descendant entity IDs (excluding self)
all_descendant_ids = descendant_ids(all_entities, entity_id, parent_id_accessor_fn)
descendant_ids_only = List.delete(all_descendant_ids, entity_id)
# Sum counts for all descendants
children_count =
Enum.reduce(descendant_ids_only, 0, fn id, acc ->
acc + count_fn.(id)
end)
{self_count, children_count, self_count + children_count}
end
@doc """
Formats component count display based on expansion state.
When collapsed: Shows total count only: "10 components"
When expanded: Shows breakdown: "10 components (3 self, 7 children)"
## Parameters
- self_count: Number of components directly in this entity
- children_count: Number of components in all descendant entities
- is_expanded: Whether the entity is currently expanded
- singular_noun: What to call a single item (default: "component")
- plural_noun: What to call multiple items (default: "components")
"""
def format_count_display(
self_count,
children_count,
is_expanded,
singular_noun \\ "component",
plural_noun \\ "components"
) do
total_count = self_count + children_count
count_noun = if total_count == 1, do: singular_noun, else: plural_noun
if is_expanded and children_count > 0 do
"#{total_count} #{count_noun} (#{self_count} self, #{children_count} children)"
else
"#{total_count} #{count_noun}"
end
end
end

View File

@@ -0,0 +1,41 @@
defmodule ComponentsElixir.Inventory.HierarchicalSchema do
@moduledoc """
Behaviour for schemas that implement hierarchical relationships.
Provides a contract for entities with parent-child relationships,
ensuring consistent interface across different hierarchical entities.
"""
@doc """
Returns the full hierarchical path as a string.
Example: "Electronics > Components > Resistors"
"""
@callback full_path(struct()) :: String.t()
@doc """
Returns the parent entity or nil if this is a root entity.
"""
@callback parent(struct()) :: struct() | nil
@doc """
Returns the children entities as a list.
"""
@callback children(struct()) :: [struct()]
@doc """
Returns the separator used for path display.
"""
@callback path_separator() :: String.t()
@doc """
Returns the entity type for use with the Hierarchical module.
"""
@callback entity_type() :: atom()
defmacro __using__(_opts) do
quote do
@behaviour ComponentsElixir.Inventory.HierarchicalSchema
alias ComponentsElixir.Inventory.Hierarchical
end
end
end

View File

@@ -3,9 +3,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
Schema for storage locations with hierarchical organization.
Storage locations can be nested (shelf -> drawer -> box) and each
has a unique QR code for quick scanning and identification.
has a unique AprilTag for quick scanning and identification.
"""
use Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset
import Ecto.Query
@@ -15,70 +16,44 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
field :name, :string
field :description, :string
field :apriltag_id, :integer
field :is_active, :boolean, default: true
# Computed/virtual fields - not stored in database
field :level, :integer, virtual: true
field :path, :string, virtual: true
# Only parent relationship is stored
belongs_to :parent, StorageLocation
has_many :children, StorageLocation, foreign_key: :parent_id
has_many :components, Component
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false
@doc false
def changeset(storage_location, attrs) do
storage_location
|> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id])
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
|> validate_required([:name])
|> validate_length(:name, min: 1, max: 100)
|> validate_length(:description, max: 500)
|> validate_apriltag_id()
|> foreign_key_constraint(:parent_id)
|> validate_no_circular_reference()
|> put_apriltag_id()
end
# Prevent circular references (location being its own ancestor)
defp validate_no_circular_reference(changeset) do
case get_change(changeset, :parent_id) do
nil -> changeset
parent_id ->
location_id = changeset.data.id
if location_id && would_create_cycle?(location_id, parent_id) do
add_error(changeset, :parent_id, "cannot be a descendant of this location")
else
changeset
end
end
# HierarchicalSchema implementations
@impl true
def full_path(%StorageLocation{} = storage_location) do
Hierarchical.full_path(storage_location, & &1.parent, path_separator())
end
defp would_create_cycle?(location_id, parent_id) do
# Check if parent_id is the same as location_id or any of its descendants
location_id == parent_id or
(parent_id && is_descendant_of?(parent_id, location_id))
end
@impl true
def parent(%StorageLocation{parent: parent}), do: parent
defp is_descendant_of?(potential_descendant, ancestor_id) do
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
nil -> false
%{parent_id: nil} -> false
%{parent_id: ^ancestor_id} -> true
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
end
end
@impl true
def children(%StorageLocation{children: children}), do: children
@doc """
Returns the full hierarchical path as a human-readable string.
"""
def full_path(storage_location) do
storage_location.path
|> String.split("/")
|> Enum.join("")
end
@impl true
def path_separator(), do: " / "
@impl true
def entity_type(), do: :storage_location
@doc """
Returns the AprilTag format for this storage location.
@@ -103,35 +78,14 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
end
end
# Compute the hierarchy level based on parent chain
def compute_level(%StorageLocation{parent_id: nil}), do: 0
def compute_level(%StorageLocation{parent: %StorageLocation{} = parent}) do
compute_level(parent) + 1
end
def compute_level(%StorageLocation{parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
compute_level(parent) + 1
end
# Compute the full path based on parent chain
def compute_path(%StorageLocation{name: name, parent_id: nil}), do: name
def compute_path(%StorageLocation{name: name, parent: %StorageLocation{} = parent}) do
"#{compute_path(parent)}/#{name}"
end
def compute_path(%StorageLocation{name: name, parent_id: parent_id}) when not is_nil(parent_id) do
# Parent not loaded, fetch it
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
"#{compute_path(parent)}/#{name}"
end
defp get_next_available_apriltag_id do
# Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id
)
used_ids =
ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id
)
# Find the first available ID (0-586)
0..586
@@ -140,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
nil ->
# All IDs are used - this should be handled at the application level
raise "All AprilTag IDs are in use"
id -> id
id ->
id
end
end
end

View File

@@ -1,233 +0,0 @@
defmodule ComponentsElixir.QRCode do
@moduledoc """
QR Code generation and parsing for storage locations.
Provides functionality to generate QR codes for storage locations
and parse them back to retrieve location information.
"""
@doc """
Generates a QR code data string for a storage location.
Format: SL:{level}:{qr_code}:{parent_qr_or_ROOT}
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123", parent: nil}
iex> ComponentsElixir.QRCode.generate_qr_data(location)
"SL:1:ABC123:ROOT"
iex> parent = %StorageLocation{qr_code: "SHELF1"}
iex> drawer = %StorageLocation{level: 2, qr_code: "DRAW01", parent: parent}
iex> ComponentsElixir.QRCode.generate_qr_data(drawer)
"SL:2:DRAW01:SHELF1"
"""
def generate_qr_data(storage_location) do
parent_code =
case storage_location.parent do
nil -> "ROOT"
parent -> parent.qr_code
end
"SL:#{storage_location.level}:#{storage_location.qr_code}:#{parent_code}"
end
@doc """
Parses a QR code string and extracts components.
## Examples
iex> ComponentsElixir.QRCode.parse_qr_data("SL:1:ABC123:ROOT")
{:ok, %{level: 1, code: "ABC123", parent: "ROOT"}}
iex> ComponentsElixir.QRCode.parse_qr_data("invalid")
{:error, :invalid_format}
"""
def parse_qr_data(qr_string) do
case String.split(qr_string, ":") do
["SL", level_str, code, parent] ->
case Integer.parse(level_str) do
{level, ""} ->
{:ok, %{level: level, code: code, parent: parent}}
_ ->
{:error, :invalid_level}
end
_ ->
{:error, :invalid_format}
end
end
@doc """
Validates if a string looks like a storage location QR code.
## Examples
iex> ComponentsElixir.QRCode.valid_storage_qr?("SL:1:ABC123:ROOT")
true
iex> ComponentsElixir.QRCode.valid_storage_qr?("COMP:12345")
false
"""
def valid_storage_qr?(qr_string) do
case parse_qr_data(qr_string) do
{:ok, _} -> true
_ -> false
end
end
@doc """
Generates a printable label data structure for a storage location.
This could be used to generate PDF labels or send to a label printer.
"""
def generate_label_data(storage_location) do
qr_data = generate_qr_data(storage_location)
%{
qr_code: qr_data,
name: storage_location.name,
path: storage_location.path,
level: storage_location.level,
description: storage_location.description
}
end
@doc """
Generates multiple QR codes for disambiguation testing.
This is useful for testing multi-QR detection scenarios.
"""
def generate_test_codes(storage_locations) when is_list(storage_locations) do
Enum.map(storage_locations, &generate_qr_data/1)
end
@doc """
Generates a QR code image (PNG) for a storage location.
Returns the binary PNG data that can be saved to disk or served directly.
## Options
- `:size` - The size of the QR code image in pixels (default: 200)
- `:background` - Background color as `{r, g, b}` tuple (default: white)
- `:foreground` - Foreground color as `{r, g, b}` tuple (default: black)
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123"}
iex> {:ok, png_data} = ComponentsElixir.QRCode.generate_qr_image(location)
iex> File.write!("/tmp/qr_code.png", png_data)
"""
def generate_qr_image(storage_location, _opts \\ []) do
qr_data = generate_qr_data(storage_location)
qr_data
|> QRCode.create()
|> QRCode.render(:png)
end
@doc """
Generates and saves a QR code image to the specified file path.
## Examples
iex> location = %StorageLocation{level: 1, qr_code: "ABC123"}
iex> ComponentsElixir.QRCode.save_qr_image(location, "/tmp/qr_code.png")
:ok
"""
def save_qr_image(storage_location, file_path, opts \\ []) do
case generate_qr_image(storage_location, opts) do
{:ok, png_data} ->
# Ensure directory exists
file_path
|> Path.dirname()
|> File.mkdir_p!()
File.write!(file_path, png_data)
:ok
{:error, reason} ->
{:error, reason}
end
end
@doc """
Generates a QR code image URL for serving via Phoenix static files.
This function generates the QR code image and saves it to the static directory,
returning a URL that can be used in templates.
## Examples
iex> location = %StorageLocation{id: 123, qr_code: "ABC123"}
iex> ComponentsElixir.QRCode.get_qr_image_url(location)
"/qr_codes/storage_location_123.png"
"""
def get_qr_image_url(storage_location, opts \\ []) do
filename = "storage_location_#{storage_location.id}.png"
file_path = Path.join([Application.app_dir(:components_elixir, "priv/static/user_generated/qr_codes"), filename])
# Generate and save the image if it doesn't exist or if regeneration is forced
force_regenerate = Keyword.get(opts, :force_regenerate, false)
if force_regenerate || !File.exists?(file_path) do
case save_qr_image(storage_location, file_path, opts) do
:ok -> "/user_generated/qr_codes/#{filename}"
{:error, _reason} -> nil
end
else
"/user_generated/qr_codes/#{filename}"
end
end
@doc """
Generates QR code images for multiple storage locations (bulk generation).
Returns a list of results indicating success or failure for each location.
## Examples
iex> locations = [location1, location2, location3]
iex> ComponentsElixir.QRCode.bulk_generate_images(locations)
[
{:ok, "/qr_codes/storage_location_1.png"},
{:ok, "/qr_codes/storage_location_2.png"},
{:error, "Failed to generate for location 3"}
]
"""
def bulk_generate_images(storage_locations, opts \\ []) do
# Use Task.async_stream for concurrent generation with back-pressure
storage_locations
|> Task.async_stream(
fn location ->
case get_qr_image_url(location, Keyword.put(opts, :force_regenerate, true)) do
nil -> {:error, "Failed to generate QR code for location #{location.id}"}
url -> {:ok, url}
end
end,
timeout: :infinity,
max_concurrency: System.schedulers_online() * 2
)
|> Enum.map(fn {:ok, result} -> result end)
end
@doc """
Cleans up QR code images for deleted storage locations.
Should be called when storage locations are deleted to prevent orphaned files.
"""
def cleanup_qr_image(storage_location_id) do
filename = "storage_location_#{storage_location_id}.png"
file_path = Path.join([Application.app_dir(:components_elixir, "priv/static/user_generated/qr_codes"), filename])
if File.exists?(file_path) do
File.rm(file_path)
else
:ok
end
end
end

View File

@@ -17,7 +17,7 @@ defmodule ComponentsElixirWeb do
those modules here.
"""
def static_paths, do: ~w(assets fonts images user_generated apriltags favicon.ico robots.txt)
def static_paths, do: ~w(assets fonts images apriltags favicon.ico robots.txt)
def router do
quote do

View File

@@ -195,7 +195,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
name={@name}
value="true"
checked={@checked}
class={@class || "checkbox checkbox-sm border-base-300 checked:bg-primary checked:border-primary"}
class={
@class ||
"checkbox checkbox-sm border-base-300 checked:bg-primary checked:border-primary"
}
{@rest}
/>{@label}
</span>
@@ -213,7 +216,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
<select
id={@id}
name={@name}
class={[@class || "w-full select bg-base-100 border-base-300 text-base-content", @errors != [] && (@error_class || "select-error border-error")]}
class={[
@class || "w-full select bg-base-100 border-base-300 text-base-content",
@errors != [] && (@error_class || "select-error border-error")
]}
multiple={@multiple}
{@rest}
>
@@ -235,7 +241,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
id={@id}
name={@name}
class={[
@class || "w-full textarea bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
@class ||
"w-full textarea bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
@errors != [] && (@error_class || "textarea-error border-error")
]}
{@rest}
@@ -258,7 +265,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[
@class || "w-full input bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
@class ||
"w-full input bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
@errors != [] && (@error_class || "input-error border-error")
]}
{@rest}

View File

@@ -0,0 +1,93 @@
defmodule ComponentsElixirWeb.FileController do
use ComponentsElixirWeb, :controller
def show(conn, %{"filename" => encoded_filename}) do
case decode_and_validate_filename(encoded_filename) do
{:ok, filename} ->
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
file_path = Path.join([uploads_dir, "images", filename])
if File.exists?(file_path) do
# Get the file's MIME type
mime_type = get_mime_type(filename)
conn
|> put_resp_content_type(mime_type)
# Cache for 1 day
|> put_resp_header("cache-control", "public, max-age=86400")
|> send_file(200, file_path)
else
conn
|> put_status(:not_found)
|> text("File not found")
end
{:error, reason} ->
conn
|> put_status(:bad_request)
|> text(reason)
end
end
def show_datasheet(conn, %{"filename" => encoded_filename}) do
case decode_and_validate_filename(encoded_filename) do
{:ok, filename} ->
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
file_path = Path.join([uploads_dir, "datasheets", filename])
if File.exists?(file_path) do
# Get the file's MIME type
mime_type = get_mime_type(filename)
conn
|> put_resp_content_type(mime_type)
# Cache for 1 day
|> put_resp_header("cache-control", "public, max-age=86400")
|> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
|> send_file(200, file_path)
else
conn
|> put_status(:not_found)
|> text("File not found")
end
{:error, reason} ->
conn
|> put_status(:bad_request)
|> text(reason)
end
end
defp decode_and_validate_filename(encoded_filename) do
try do
# URL decode the filename
decoded_filename = URI.decode(encoded_filename)
# Security validation: prevent directory traversal and only allow safe characters
# Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation
if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and
not String.contains?(decoded_filename, "..") and
not String.starts_with?(decoded_filename, "/") and
not String.contains?(decoded_filename, "\\") do
{:ok, decoded_filename}
else
{:error, "Invalid filename: contains unsafe characters"}
end
rescue
_ ->
{:error, "Invalid filename: cannot decode"}
end
end
defp get_mime_type(filename) do
case Path.extname(filename) |> String.downcase() do
".jpg" -> "image/jpeg"
".jpeg" -> "image/jpeg"
".png" -> "image/png"
".gif" -> "image/gif"
".webp" -> "image/webp"
".pdf" -> "application/pdf"
_ -> "application/octet-stream"
end
end
end

View File

@@ -2,14 +2,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.Category
alias ComponentsElixir.Inventory.{Category, Hierarchical}
@impl true
def mount(_params, session, socket) do
# Check authentication
unless Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
if Auth.authenticated?(session) do
categories = Inventory.list_categories()
{:ok,
@@ -20,7 +18,10 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:show_edit_form, false)
|> assign(:editing_category, nil)
|> assign(:form, nil)
|> assign(:expanded_categories, MapSet.new())
|> assign(:page_title, "Category Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end
end
@@ -45,12 +46,13 @@ defmodule ComponentsElixirWeb.CategoriesLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do
category = Inventory.get_category!(id)
# Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_category(category, %{
name: category.name,
description: category.description,
parent_id: category.parent_id
})
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
changeset =
Inventory.change_category(category, %{
name: category.name,
description: category.description,
parent_id: category.parent_id
})
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
form = to_form(changeset)
@@ -111,55 +113,49 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> reload_categories()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Cannot delete category - it may have components assigned or child categories")}
{:noreply,
put_flash(
socket,
:error,
"Cannot delete category - it may have components assigned or child categories"
)}
end
end
def handle_event("toggle_expand", %{"id" => id}, socket) do
category_id = String.to_integer(id)
expanded_categories = socket.assigns.expanded_categories
new_expanded =
if MapSet.member?(expanded_categories, category_id) do
MapSet.delete(expanded_categories, category_id)
else
MapSet.put(expanded_categories, category_id)
end
{:noreply, assign(socket, :expanded_categories, new_expanded)}
end
defp reload_categories(socket) do
categories = Inventory.list_categories()
assign(socket, :categories, categories)
end
defp parent_category_options(categories, editing_category_id \\ nil) do
available_categories =
categories
|> Enum.reject(fn cat ->
cat.id == editing_category_id ||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
end)
|> Enum.map(fn category ->
{category_display_name(category), category.id}
end)
[{"No parent (Root category)", nil}] ++ available_categories
end
defp is_descendant?(categories, descendant_id, ancestor_id) do
# Check if descendant_id is a descendant of ancestor_id
descendant = Enum.find(categories, fn cat -> cat.id == descendant_id end)
case descendant do
nil -> false
%{parent_id: nil} -> false
%{parent_id: parent_id} when parent_id == ancestor_id -> true
%{parent_id: parent_id} -> is_descendant?(categories, parent_id, ancestor_id)
end
end
defp category_display_name(category) do
if category.parent do
"#{category.parent.name} > #{category.name}"
else
category.name
end
Hierarchical.parent_select_options(
categories,
editing_category_id,
& &1.parent,
"No parent (Root category)"
)
end
defp root_categories(categories) do
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end)
Hierarchical.root_entities(categories, & &1.parent_id)
end
defp child_categories(categories, parent_id) do
Enum.filter(categories, fn cat -> cat.parent_id == parent_id end)
Hierarchical.child_entities(categories, parent_id, & &1.parent_id)
end
defp count_components_in_category(category_id) do
@@ -175,63 +171,181 @@ defmodule ComponentsElixirWeb.CategoriesLive do
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
# Icon size and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
{icon_size, button_size, text_size, title_tag} =
case assigns.depth do
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
assigns = assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
|> assign(:icon_size, icon_size)
|> assign(:button_size, button_size)
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:children, child_categories(assigns.categories, assigns.category.id))
children = child_categories(assigns.categories, assigns.category.id)
has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
# Calculate component counts including descendants
{self_count, children_count, _total_count} =
Hierarchical.count_with_descendants(
assigns.category.id,
assigns.categories,
& &1.parent_id,
&count_components_in_category/1
)
# Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns =
assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
|> assign(:icon_size, icon_size)
|> assign(:button_size, button_size)
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:children, children)
|> assign(:has_children, has_children)
|> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="flex items-center">
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mr-3"} />
<div>
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h3>
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex items-start flex-1 space-x-2">
<!-- Expand/Collapse button - always aligned to top -->
<%= if @has_children do %>
<button
phx-click="toggle_expand"
phx-value-id={@category.id}
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
>
<%= if @is_expanded do %>
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
<% end %>
<%= if @category.description do %>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
<% end %>
<p class="text-xs text-base-content/50 mt-1">
{count_components_in_category(@category.id)} components
</p>
</div>
</button>
<% else %>
<div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %>
<.icon
name="hero-folder"
class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
/>
<!-- Content area - always starts at same vertical position -->
<div class="flex-1">
<!-- Minimized view (default) -->
<%= unless @is_expanded do %>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h4>
<% end %>
<span class="text-xs text-base-content/50">
({@count_display})
</span>
</div>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@category.id}
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-3 h-3" />
</button>
<button
phx-click="delete_category"
phx-value-id={@category.id}
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-3 h-3" />
</button>
</div>
</div>
<% end %>
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?category_id=#{@category.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@category.name}
</.link>
</h4>
<% end %>
<%= if @category.description do %>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
<% end %>
<p class="text-xs text-base-content/50 mt-1">
{@count_display}
</p>
</div>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@category.id}
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
>
<.icon name="hero-pencil" class={@icon_size} />
</button>
<button
phx-click="delete_category"
phx-value-id={@category.id}
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
>
<.icon name="hero-trash" class={@icon_size} />
</button>
</div>
</div>
<% end %>
</div>
</div>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@category.id}
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
>
<.icon name="hero-pencil" class={@icon_size} />
</button>
<button
phx-click="delete_category"
phx-value-id={@category.id}
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
>
<.icon name="hero-trash" class={@icon_size} />
</button>
</div>
</div>
<!-- Render children recursively -->
<%= for child <- @children do %>
<.category_item category={child} categories={@categories} depth={@depth + 1} />
<!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %>
<%= for child <- @children do %>
<.category_item
category={child}
categories={@categories}
expanded_categories={@expanded_categories}
depth={@depth + 1}
/>
<% end %>
<% end %>
</div>
"""
@@ -273,8 +387,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div>
</div>
</div>
<!-- Add Category Modal -->
<!-- Add Category Modal -->
<%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -329,8 +443,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div>
</div>
<% end %>
<!-- Edit Category Modal -->
<!-- Edit Category Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -385,13 +499,15 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div>
</div>
<% end %>
<!-- Categories List -->
<!-- Categories List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2>
<p class="text-sm text-base-content/60 mt-1">Manage your component categories and subcategories</p>
<p class="text-sm text-base-content/60 mt-1">
Manage your component categories and subcategories
</p>
</div>
<%= if Enum.empty?(@categories) do %>
@@ -406,8 +522,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
phx-click="show_add_form"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
Add Category
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
</button>
</div>
</div>
@@ -416,7 +531,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<!-- Recursive Category Tree -->
<%= for category <- root_categories(@categories) do %>
<div class="px-6 py-4">
<.category_item category={category} categories={@categories} depth={0} />
<.category_item
category={category}
categories={@categories}
expanded_categories={@expanded_categories}
depth={0}
/>
</div>
<% end %>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -66,7 +66,7 @@ defmodule ComponentsElixirWeb.LoginLive do
<%= if @error_message do %>
<div class="text-red-600 text-sm text-center">
<%= @error_message %>
{@error_message}
</div>
<% end %>

View File

@@ -5,15 +5,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.StorageLocation
alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
alias ComponentsElixir.AprilTag
@impl true
def mount(_params, session, socket) do
# Check authentication
unless Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
if Auth.authenticated?(session) do
storage_locations = list_storage_locations()
{:ok,
@@ -26,7 +24,10 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:form, nil)
|> assign(:apriltag_scanner_open, false)
|> assign(:scanned_tags, [])
|> assign(:expanded_locations, MapSet.new())
|> assign(:page_title, "Storage Location Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end
end
@@ -53,12 +54,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do
location = Inventory.get_storage_location!(id)
# Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_storage_location(location, %{
name: location.name,
description: location.description,
parent_id: location.parent_id
})
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
changeset =
Inventory.change_storage_location(location, %{
name: location.name,
description: location.description,
parent_id: location.parent_id
})
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
form = to_form(changeset)
@@ -81,29 +83,31 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("save_location", %{"storage_location" => location_params}, socket) do
# Process AprilTag assignment based on mode
processed_params = case socket.assigns.apriltag_mode do
"none" ->
# Remove any apriltag_id from params to ensure it's nil
Map.delete(location_params, "apriltag_id")
processed_params =
case socket.assigns.apriltag_mode do
"none" ->
# Remove any apriltag_id from params to ensure it's nil
Map.delete(location_params, "apriltag_id")
"auto" ->
# Auto-assign next available AprilTag ID
case AprilTag.next_available_apriltag_id() do
nil ->
# No available IDs, proceed without AprilTag
Map.delete(location_params, "apriltag_id")
apriltag_id ->
Map.put(location_params, "apriltag_id", apriltag_id)
end
"auto" ->
# Auto-assign next available AprilTag ID
case AprilTag.next_available_apriltag_id() do
nil ->
# No available IDs, proceed without AprilTag
Map.delete(location_params, "apriltag_id")
"manual" ->
# Use the manually entered apriltag_id (validation will be handled by changeset)
location_params
apriltag_id ->
Map.put(location_params, "apriltag_id", apriltag_id)
end
_ ->
# Fallback: remove apriltag_id
Map.delete(location_params, "apriltag_id")
end
"manual" ->
# Use the manually entered apriltag_id (validation will be handled by changeset)
location_params
_ ->
# Fallback: remove apriltag_id
Map.delete(location_params, "apriltag_id")
end
case Inventory.create_storage_location(processed_params) do
{:ok, _location} ->
@@ -146,7 +150,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> reload_storage_locations()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")}
{:noreply,
put_flash(
socket,
:error,
"Cannot delete storage location - it may have components assigned or child locations"
)}
end
end
@@ -163,10 +172,17 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
nil ->
{:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")}
{:noreply,
put_flash(
socket,
:error,
"Storage location not found for AprilTag ID: #{apriltag_id}"
)}
location ->
scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags]
scanned_tags = [
%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags
]
{:noreply,
socket
@@ -183,25 +199,46 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, assign(socket, :scanned_tags, [])}
end
def handle_event("toggle_expand", %{"id" => id}, socket) do
location_id = String.to_integer(id)
expanded_locations = socket.assigns.expanded_locations
new_expanded =
if MapSet.member?(expanded_locations, location_id) do
MapSet.delete(expanded_locations, location_id)
else
MapSet.put(expanded_locations, location_id)
end
{:noreply, assign(socket, :expanded_locations, new_expanded)}
end
def handle_event("set_apriltag_mode", %{"mode" => mode}, socket) do
{:noreply, assign(socket, :apriltag_mode, mode)}
end
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
# Clear the apriltag_id field when switching modes
form = case mode do
"remove" ->
socket.assigns.form
|> Phoenix.Component.to_form()
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
"keep" ->
current_id = socket.assigns.editing_location.apriltag_id
socket.assigns.form
|> Phoenix.Component.to_form()
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
_ ->
socket.assigns.form
end
form =
case mode do
"remove" ->
socket.assigns.form
|> Phoenix.Component.to_form()
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
"keep" ->
current_id = socket.assigns.editing_location.apriltag_id
socket.assigns.form
|> Phoenix.Component.to_form()
|> Map.put(
:params,
Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)
)
_ ->
socket.assigns.form
end
{:noreply,
socket
@@ -220,7 +257,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
apriltag_url ->
filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
filename =
"#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
# Send file download to browser
{:noreply,
@@ -240,48 +278,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
available_locations =
storage_locations
|> Enum.reject(fn loc ->
loc.id == editing_location_id ||
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id))
end)
|> Enum.map(fn location ->
{location_display_name(location), location.id}
end)
[{"No parent (Root location)", nil}] ++ available_locations
end
defp is_descendant?(storage_locations, descendant_id, ancestor_id) do
# Check if descendant_id is a descendant of ancestor_id
descendant = Enum.find(storage_locations, fn loc -> loc.id == descendant_id end)
case descendant do
nil -> false
%{parent_id: nil} -> false
%{parent_id: parent_id} when parent_id == ancestor_id -> true
%{parent_id: parent_id} -> is_descendant?(storage_locations, parent_id, ancestor_id)
end
Hierarchical.parent_select_options(
storage_locations,
editing_location_id,
& &1.parent,
"No parent (Root location)"
)
end
defp location_display_name(location) do
if location.path do
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
location.path
|> String.split("/")
|> Enum.join(" > ")
else
location.name
end
StorageLocation.full_path(location)
end
defp root_storage_locations(storage_locations) do
Enum.filter(storage_locations, fn loc -> is_nil(loc.parent_id) end)
Hierarchical.root_entities(storage_locations, & &1.parent_id)
end
defp child_storage_locations(storage_locations, parent_id) do
Enum.filter(storage_locations, fn loc -> loc.parent_id == parent_id end)
Hierarchical.child_entities(storage_locations, parent_id, & &1.parent_id)
end
defp count_components_in_location(location_id) do
@@ -301,103 +315,231 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
# Icon size and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
{icon_size, button_size, text_size, title_tag} =
case assigns.depth do
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
# Different icons based on level - QR code is always present for storage locations
icon_name = case assigns.depth do
0 -> "hero-building-office" # Shelf/Room
1 -> "hero-archive-box" # Drawer/Cabinet
_ -> "hero-cube" # Box/Container
end
icon_name =
case assigns.depth do
# Shelf/Room
0 -> "hero-building-office"
# Drawer/Cabinet
1 -> "hero-archive-box"
# Box/Container
_ -> "hero-cube"
end
assigns = assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
|> assign(:icon_size, icon_size)
|> assign(:button_size, button_size)
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:icon_name, icon_name)
|> assign(:children, child_storage_locations(assigns.storage_locations, assigns.location.id))
children = child_storage_locations(assigns.storage_locations, assigns.location.id)
has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
# Calculate component counts including descendants
{self_count, children_count, _total_count} =
Hierarchical.count_with_descendants(
assigns.location.id,
assigns.storage_locations,
& &1.parent_id,
&count_components_in_location/1
)
# Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns =
assigns
|> assign(:margin_left, margin_left)
|> assign(:border_class, border_class)
|> assign(:icon_size, icon_size)
|> assign(:button_size, button_size)
|> assign(:text_size, text_size)
|> assign(:title_tag, title_tag)
|> assign(:icon_name, icon_name)
|> assign(:children, children)
|> assign(:has_children, has_children)
|> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
<div class="flex items-center justify-between">
<div class="flex items-center flex-1 space-x-4">
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"}"} />
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
<% else %>
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
<% end %>
<%= if @location.description do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
<% end %>
<div class="flex items-center space-x-2 mt-1">
<p class="text-xs text-base-content/50">
{count_components_in_location(@location.id)} components
</p>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<%= if @location.apriltag_id do %>
<div class="flex items-center space-x-3">
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-base-300 rounded bg-base-100"
onerror="this.style.display='none'"
/>
</div>
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex items-start flex-1 space-x-2">
<!-- Expand/Collapse button - always aligned to top -->
<%= if @has_children do %>
<button
phx-click="toggle_expand"
phx-value-id={@location.id}
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
>
<%= if @is_expanded do %>
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
<% else %>
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
<% end %>
<button
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
</div>
</button>
<% else %>
<div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
phx-click="show_edit_form"
phx-value-id={@location.id}
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-4 h-4" />
</button>
<button
phx-click="delete_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
<.icon
name={@icon_name}
class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
/>
<!-- Content area - always starts at same vertical position -->
<div class="flex-1">
<!-- Minimized view (default) -->
<%= unless @is_expanded do %>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h4>
<% end %>
<span class="text-xs text-base-content/50">
({@count_display})
</span>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
<div class="flex items-center space-x-2 ml-4">
<button
phx-click="show_edit_form"
phx-value-id={@location.id}
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-3 h-3" />
</button>
<button
phx-click="delete_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-3 h-3" />
</button>
</div>
</div>
<% end %>
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
<%= if @title_tag == "h3" do %>
<h3 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h3>
<% else %>
<h4 class={"#{@text_size} font-medium"}>
<.link
navigate={~p"/?storage_location_id=#{@location.id}"}
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
>
{@location.name}
</.link>
</h4>
<% end %>
<%= if @location.description do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
<% end %>
<div class="flex items-center space-x-2 mt-1">
<p class="text-xs text-base-content/50">
{@count_display}
</p>
<%= if @location.apriltag_id do %>
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
AprilTag: {@location.apriltag_id}
</span>
<% end %>
</div>
</div>
<div class="flex items-start space-x-3 ml-4">
<%= if @location.apriltag_id do %>
<%= if get_apriltag_url(@location) do %>
<div class="apriltag-container flex-shrink-0">
<img
src={get_apriltag_url(@location)}
alt={"AprilTag for #{@location.name}"}
class="w-16 h-auto border border-base-300 rounded bg-base-100"
onerror="this.style.display='none'"
/>
</div>
<% else %>
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<% end %>
<button
phx-click="download_apriltag"
phx-value-id={@location.id}
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> Download
</button>
<% end %>
<div class="flex items-center space-x-2">
<button
phx-click="show_edit_form"
phx-value-id={@location.id}
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
>
<.icon name="hero-pencil" class="w-4 h-4" />
</button>
<button
phx-click="delete_location"
phx-value-id={@location.id}
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
>
<.icon name="hero-trash" class="w-4 h-4" />
</button>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Render children recursively -->
<%= for child <- @children do %>
<.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} />
<!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %>
<%= for child <- @children do %>
<.location_item
location={child}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={@depth + 1}
/>
<% end %>
<% end %>
</div>
"""
@@ -449,8 +591,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
</div>
</div>
<!-- Add Location Modal -->
<!-- Add Location Modal -->
<%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -486,7 +628,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
<div>
<label class="block text-sm font-medium text-base-content">AprilTag ID (Optional)</label>
<label class="block text-sm font-medium text-base-content">
AprilTag ID (Optional)
</label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<input
@@ -544,10 +688,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32"
/>
<div class="text-xs text-base-content/60">
Available IDs: <%= length(@available_apriltag_ids) %> of 587
Available IDs: {length(@available_apriltag_ids)} of 587
<%= if length(@available_apriltag_ids) < 20 do %>
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %>
<%= if length(@available_apriltag_ids) > 10, do: "..." %>
<br />Next available: {@available_apriltag_ids
|> Enum.take(10)
|> Enum.join(", ")}
{if length(@available_apriltag_ids) > 10, do: "..."}
<% end %>
</div>
</div>
@@ -575,8 +721,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
</div>
<% end %>
<!-- Edit Location Modal -->
<!-- Edit Location Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -670,12 +816,14 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32"
/>
<div class="text-xs text-base-content/60">
Available IDs: <%= length(@available_apriltag_ids) %> of 587
Available IDs: {length(@available_apriltag_ids)} of 587
</div>
</div>
<% end %>
<p class="text-xs text-base-content/60 mt-1">
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>
Current: {if @editing_location.apriltag_id,
do: "ID #{@editing_location.apriltag_id}",
else: "None"}
</p>
</div>
</div>
@@ -700,8 +848,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
</div>
<% end %>
<!-- AprilTag Scanner Modal -->
<!-- AprilTag Scanner Modal -->
<%= if @apriltag_scanner_open do %>
<div class="fixed inset-0 bg-base-content/30 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -715,16 +863,20 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<.icon name="hero-x-mark" class="w-6 h-6" />
</button>
</div>
<!-- AprilTag Scanner Interface -->
<!-- AprilTag Scanner Interface -->
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center">
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" />
<p class="mt-2 text-sm text-base-content/70">Camera AprilTag scanner would go here</p>
<p class="text-xs text-base-content/60 mt-1">In a real implementation, this would use JavaScript AprilTag detection</p>
<!-- Test buttons for demo -->
<p class="text-xs text-base-content/60 mt-1">
In a real implementation, this would use JavaScript AprilTag detection
</p>
<!-- Test buttons for demo -->
<div class="mt-4 space-y-2">
<p class="text-sm font-medium text-base-content/80">Test with sample AprilTag IDs:</p>
<p class="text-sm font-medium text-base-content/80">
Test with sample AprilTag IDs:
</p>
<button
phx-click="apriltag_scanned"
phx-value-apriltag_id="0"
@@ -745,8 +897,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
</div>
<% end %>
<!-- Scanned Tags Display -->
<!-- Scanned Tags Display -->
<%= if length(@scanned_tags) > 0 do %>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
@@ -760,26 +912,35 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</button>
</div>
<div class="space-y-2">
<div :for={scan <- @scanned_tags} class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300">
<div
:for={scan <- @scanned_tags}
class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300"
>
<div>
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span>
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
<span class="font-medium text-base-content">
{location_display_name(scan.location)}
</span>
<span class="text-sm text-base-content/70 ml-2">
(AprilTag ID {scan.apriltag_id})
</span>
</div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
Level {scan.location.level}
Level {Hierarchical.compute_level(scan.location, & &1.parent)}
</span>
</div>
</div>
</div>
</div>
<% end %>
<!-- Storage Locations List -->
<!-- Storage Locations List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2>
<p class="text-sm text-base-content/60 mt-1">Manage your physical storage locations and AprilTags</p>
<p class="text-sm text-base-content/60 mt-1">
Manage your physical storage locations and AprilTags
</p>
</div>
<%= if Enum.empty?(@storage_locations) do %>
@@ -794,8 +955,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
phx-click="show_add_form"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
>
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
Add Location
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
</button>
</div>
</div>
@@ -804,7 +964,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<!-- Recursive Storage Location Tree -->
<%= for location <- root_storage_locations(@storage_locations) do %>
<div class="px-6 py-4">
<.location_item location={location} storage_locations={@storage_locations} depth={0} />
<.location_item
location={location}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={0}
/>
</div>
<% end %>
</div>

View File

@@ -24,6 +24,10 @@ defmodule ComponentsElixirWeb.Router do
live "/login", LoginLive, :index
get "/login/authenticate", AuthController, :authenticate
post "/logout", AuthController, :logout
# File serving endpoints
get "/uploads/images/:filename", FileController, :show
get "/uploads/datasheets/:filename", FileController, :show_datasheet
end
scope "/", ComponentsElixirWeb do

View File

@@ -25,9 +25,8 @@ defmodule Mix.Tasks.Apriltag.GenerateAll do
start_time = System.monotonic_time(:millisecond)
result = ComponentsElixir.AprilTag.generate_all_apriltag_svgs(
force_regenerate: force_regenerate
)
result =
ComponentsElixir.AprilTag.generate_all_apriltag_svgs(force_regenerate: force_regenerate)
end_time = System.monotonic_time(:millisecond)
duration = end_time - start_time
@@ -39,6 +38,7 @@ defmodule Mix.Tasks.Apriltag.GenerateAll do
if result.errors > 0 do
IO.puts("\nErrors encountered:")
result.results
|> Enum.filter(&match?({:error, _, _}, &1))
|> Enum.each(fn {:error, id, reason} ->

View File

@@ -60,13 +60,13 @@ defmodule ComponentsElixir.MixProject do
depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:qr_code, "~> 3.1"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}
{:bandit, "~> 1.5"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end

View File

@@ -1,6 +1,8 @@
%{
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
@@ -8,7 +10,6 @@
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"},
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
@@ -18,7 +19,6 @@
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"lazy_html": {:hex, :lazy_html, "0.1.7", "53aa9ebdbde8aec7c8ee03a8bdaec38dd56302995b0baeebf8dbe7cbdd550400", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "e115944e6ddb887c45cadfd660348934c318abec0341f7b7156e912b98d3eb95"},
"matrix_reloaded": {:hex, :matrix_reloaded, "2.3.0", "eea41bc6713021f8f51dde0c2d6b72e695a99098753baebf0760e10aed8fa777", [:mix], [{:ex_maybe, "~> 1.0", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}], "hexpm", "4013c0cebe5dfffc8f2316675b642fb2f5a1dfc4bdc40d2c0dfa0563358fa496"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
@@ -33,11 +33,8 @@
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"pngex": {:hex, :pngex, "0.1.2", "824c2da291fda236397729f236b29f87b98a434d58124ea9f7fa03d3b3cf8587", [:mix], [], "hexpm", "9f9f2d9aa286d03f6c317017a09e1b548fa0aa6b901291e24dbf65d8212b22b0"},
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
"qr_code": {:hex, :qr_code, "3.2.0", "416ad75b7284c1b43c3a248bae0304ac933dc16ba501af49f22c0262e55916e1", [:mix], [{:ex_maybe, "~> 1.1.1", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:matrix_reloaded, "~> 2.3", [hex: :matrix_reloaded, repo: "hexpm", optional: false]}, {:pngex, "~> 0.1.0", [hex: :pngex, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}, {:xml_builder, "~> 2.3", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "fc5366d61087753a781c2e2d2659fce71f91b6258c8341f0ee47f31d5a185497"},
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
"result": {:hex, :result, "1.7.2", "a57c569f7cf5c158d2299d3b5624a48b69bd1520d0771dc711bcf9f3916e8ab6", [:mix], [], "hexpm", "89f98e98cfbf64237ecf4913aa36b76b80463e087775d19953dc4b435a35f087"},
"swoosh": {:hex, :swoosh, "1.19.5", "5abd71be78302ba21be56a2b68d05c9946ff1f1bd254f949efef09d253b771ac", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c953f51ee0a8b237e0f4307c9cefd3eb1eb751c35fcdda2a8bccb991766473be"},
"tailwind": {:hex, :tailwind, "0.4.0", "4b2606713080437e3d94a0fa26527e7425737abc001f172b484b42f43e3274c0", [:mix], [], "hexpm", "530bd35699333f8ea0e9038d7146c2f0932dfec2e3636bd4a8016380c4bc382e"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
@@ -46,5 +43,4 @@
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
"xml_builder": {:hex, :xml_builder, "2.4.0", "b20d23077266c81f593360dc037ea398461dddb6638a329743da6c73afa56725", [:mix], [], "hexpm", "833e325bb997f032b5a1b740d2fd6feed3c18ca74627f9f5f30513a9ae1a232d"},
}

View File

@@ -14,7 +14,9 @@ defmodule ComponentsElixir.Repo.Migrations.MigrateQrToApriltag do
create unique_index(:storage_locations, [:apriltag_id])
# Add constraint to ensure apriltag_id is in valid range (0-586 for tag36h11)
create constraint(:storage_locations, :apriltag_id_range, check: "apriltag_id >= 0 AND apriltag_id <= 586")
create constraint(:storage_locations, :apriltag_id_range,
check: "apriltag_id >= 0 AND apriltag_id <= 586"
)
# Note: We keep qr_code_old for now in case we need to rollback
# It can be removed in a future migration after confirming everything works

View File

@@ -0,0 +1,9 @@
defmodule ComponentsElixir.Repo.Migrations.RemoveIsActiveFromStorageLocations do
use Ecto.Migration
def change do
alter table(:storage_locations) do
remove :is_active, :boolean
end
end
end

View File

@@ -0,0 +1,9 @@
defmodule ComponentsElixir.Repo.Migrations.AddDatasheetFilenameToComponents do
use Ecto.Migration
def change do
alter table(:components) do
add :datasheet_filename, :string
end
end
end

View File

@@ -1,7 +1,13 @@
# Script for populating the database. You can run it as:
# Script for populating the database with sample data. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# This seeds file creates:
# - Sample categories (with hierarchical subcategories)
# - Storage locations (with auto-assigned AprilTag IDs)
# - Sample electronic components with proper storage assignments
# - Generates all AprilTag SVG files for immediate use
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
@@ -10,7 +16,7 @@
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias ComponentsElixir.{Repo, Inventory}
alias ComponentsElixir.{Repo, Inventory, AprilTag}
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
# Clear existing data
@@ -19,84 +25,208 @@ Repo.delete_all(Category)
Repo.delete_all(StorageLocation)
# Create categories
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
{:ok, capacitors} = Inventory.create_category(%{name: "Capacitors", description: "Electrolytic, ceramic, and film capacitors"})
{:ok, semiconductors} = Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} = Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"})
{:ok, resistors} =
Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
{:ok, capacitors} =
Inventory.create_category(%{
name: "Capacitors",
description: "Electrolytic, ceramic, and film capacitors"
})
{:ok, semiconductors} =
Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} =
Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"})
# Create subcategories
{:ok, _through_hole_resistors} = Inventory.create_category(%{
name: "Through-hole",
description: "Traditional leaded resistors",
parent_id: resistors.id
})
{:ok, _through_hole_resistors} =
Inventory.create_category(%{
name: "Through-hole",
description: "Traditional leaded resistors",
parent_id: resistors.id
})
{:ok, _smd_resistors} = Inventory.create_category(%{
name: "SMD/SMT",
description: "Surface mount resistors",
parent_id: resistors.id
})
{:ok, _smd_resistors} =
Inventory.create_category(%{
name: "SMD/SMT",
description: "Surface mount resistors",
parent_id: resistors.id
})
{:ok, _ceramic_caps} = Inventory.create_category(%{
name: "Ceramic",
description: "Ceramic disc and multilayer capacitors",
parent_id: capacitors.id
})
{:ok, _ceramic_caps} =
Inventory.create_category(%{
name: "Ceramic",
description: "Ceramic disc and multilayer capacitors",
parent_id: capacitors.id
})
{:ok, _electrolytic_caps} = Inventory.create_category(%{
name: "Electrolytic",
description: "Polarized electrolytic capacitors",
parent_id: capacitors.id
})
{:ok, _electrolytic_caps} =
Inventory.create_category(%{
name: "Electrolytic",
description: "Polarized electrolytic capacitors",
parent_id: capacitors.id
})
# Create a DEEP category hierarchy to test fallback path (7+ levels)
{:ok, deep_cat_1} =
Inventory.create_category(%{
name: "Level 1",
description: "Deep hierarchy test",
parent_id: resistors.id
})
{:ok, deep_cat_2} =
Inventory.create_category(%{
name: "Level 2",
description: "Deep hierarchy test",
parent_id: deep_cat_1.id
})
{:ok, deep_cat_3} =
Inventory.create_category(%{
name: "Level 3",
description: "Deep hierarchy test",
parent_id: deep_cat_2.id
})
{:ok, deep_cat_4} =
Inventory.create_category(%{
name: "Level 4",
description: "Deep hierarchy test",
parent_id: deep_cat_3.id
})
{:ok, deep_cat_5} =
Inventory.create_category(%{
name: "Level 5",
description: "Deep hierarchy test",
parent_id: deep_cat_4.id
})
{:ok, deep_cat_6} =
Inventory.create_category(%{
name: "Level 6",
description: "Deep hierarchy test",
parent_id: deep_cat_5.id
})
{:ok, deep_cat_7} =
Inventory.create_category(%{
name: "Level 7",
description: "Deep hierarchy test - triggers fallback",
parent_id: deep_cat_6.id
})
# Create storage locations
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
{:ok, shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
{:ok, shelf_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_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
})
{: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_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_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
})
{: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_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
})
{:ok, _box_a2_2} =
Inventory.create_storage_location(%{
name: "Box 2",
description: "Transistors and diodes",
parent_id: drawer_a2.id
})
# Create a DEEP storage location hierarchy to test fallback path (7+ levels)
{:ok, deep_loc_1} =
Inventory.create_storage_location(%{
name: "Deep Level 1",
description: "Deep hierarchy test",
parent_id: box_a1_3.id
})
{:ok, deep_loc_2} =
Inventory.create_storage_location(%{
name: "Deep Level 2",
description: "Deep hierarchy test",
parent_id: deep_loc_1.id
})
{:ok, deep_loc_3} =
Inventory.create_storage_location(%{
name: "Deep Level 3",
description: "Deep hierarchy test",
parent_id: deep_loc_2.id
})
{:ok, deep_loc_4} =
Inventory.create_storage_location(%{
name: "Deep Level 4",
description: "Deep hierarchy test",
parent_id: deep_loc_3.id
})
{:ok, deep_loc_5} =
Inventory.create_storage_location(%{
name: "Deep Level 5",
description: "Deep hierarchy test",
parent_id: deep_loc_4.id
})
{:ok, deep_loc_6} =
Inventory.create_storage_location(%{
name: "Deep Level 6",
description: "Deep hierarchy test",
parent_id: deep_loc_5.id
})
{:ok, deep_loc_7} =
Inventory.create_storage_location(%{
name: "Deep Level 7",
description: "Deep hierarchy test - triggers fallback",
parent_id: deep_loc_6.id
})
# Create sample components
sample_components = [
@@ -138,7 +268,8 @@ sample_components = [
keywords: "microcontroller avr atmega328 arduino",
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",
datasheet_url:
"https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
category_id: semiconductors.id
},
%{
@@ -180,6 +311,23 @@ sample_components = [
storage_location_id: box_a1_1.id,
count: 100,
category_id: resistors.id
},
# Test components for deep hierarchies to ensure fallback path is exercised
%{
name: "Deep Category Test Component",
description: "Component in 7-level deep category hierarchy",
keywords: "test deep hierarchy category fallback",
storage_location_id: box_a1_1.id,
count: 1,
category_id: deep_cat_7.id
},
%{
name: "Deep Storage Test Component",
description: "Component in 7-level deep storage location hierarchy",
keywords: "test deep hierarchy storage fallback",
storage_location_id: deep_loc_7.id,
count: 1,
category_id: resistors.id
}
]
@@ -192,19 +340,48 @@ IO.puts("Categories: #{length(Inventory.list_categories())}")
IO.puts("Storage Locations: #{length(Inventory.list_storage_locations())}")
IO.puts("Components: #{length(Inventory.list_components())}")
IO.puts("")
IO.puts("Sample QR codes for testing:")
IO.puts("Sample AprilTag information:")
# Print some sample QR codes for testing
# Print AprilTag information for sample storage locations
sample_locations = [
Inventory.get_storage_location!(shelf_a.id),
Inventory.get_storage_location!(drawer_a1.id),
Inventory.get_storage_location!(box_a1_1.id),
Inventory.get_storage_location!(box_a2_1.id)
]
Enum.each(sample_locations, fn location ->
qr_data = ComponentsElixir.QRCode.generate_qr_data(location)
IO.puts("#{location.path}: #{qr_data}")
if location.apriltag_id do
apriltag_url = AprilTag.get_apriltag_url(location)
location_path = StorageLocation.full_path(location)
IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}")
IO.puts(" Download URL: #{apriltag_url}")
else
location_path = StorageLocation.full_path(location)
IO.puts("#{location_path}: No AprilTag assigned")
end
end)
# Generate all AprilTag SVGs for immediate use
IO.puts("Generating AprilTag SVG files...")
result = AprilTag.generate_all_apriltag_svgs()
IO.puts("Generated #{result.success}/#{result.total} AprilTag SVG files")
IO.puts("")
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
IO.puts("🎉 Database seeded successfully!")
IO.puts("📊 Summary:")
IO.puts(" Categories: #{length(Inventory.list_categories())}")
IO.puts(
" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)"
)
IO.puts(" Components: #{length(Inventory.list_components())}")
IO.puts("")
IO.puts("🏷️ AprilTag System:")
IO.puts(" - Each storage location has an auto-assigned AprilTag ID (0-586)")
IO.puts(" - SVG files available at /apriltags/tag36h11_id_XXX.svg")
IO.puts(" - Download AprilTags from storage location management page")
IO.puts("")
IO.puts("🔐 Login with password: changeme (or set AUTH_PASSWORD environment variable)")
IO.puts("🌐 Visit http://localhost:4000 to start using the system!")

View File

@@ -9,6 +9,7 @@ defmodule ComponentsElixirWeb.ErrorHTMLTest do
end
test "renders 500.html" do
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) ==
"Internal Server Error"
end
end

View File

@@ -2,7 +2,9 @@ defmodule ComponentsElixirWeb.ErrorJSONTest do
use ComponentsElixirWeb.ConnCase, async: true
test "renders 404" do
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{
errors: %{detail: "Not Found"}
}
end
test "renders 500" do

View File

@@ -3,6 +3,7 @@ defmodule ComponentsElixirWeb.PageControllerTest do
test "GET /", %{conn: conn} do
conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
# redirect to login
assert html_response(conn, 302)
end
end