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 npm-debug.log
/assets/node_modules/ /assets/node_modules/
# Ignore all user-generated content (uploads, QR codes, etc.) # Ignore all dynamicly uploaded files.
/priv/static/user_generated/ /uploads/
# Ignore customized Docker Compose file. # Ignore customized Docker Compose file.
docker-compose.yml 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" WORKDIR "/app"
RUN chown nobody /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 # set runner ENV
ENV MIX_ENV="prod" 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 ## 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 ### 🔧 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 - **Inventory Tracking**: Monitor component quantities with increment/decrement buttons
- **Search & Filter**: Fast search across component names, descriptions, and keywords - **Advanced Search & Filtering**:
- **Category Organization**: Hierarchical category system for better organization - Fast full-text search across component names, descriptions, and keywords
- **Category Management**: Add, edit, delete categories through the web interface with hierarchical support - Filter by categories and storage locations (including subcategories/sublocations)
- **Storage Location System**: Hierarchical storage locations (shelf → drawer → box) with automatic AprilTag generation - Clickable category and location filters for quick navigation
- **AprilTag Integration**: Automatic AprilTag generation and display for all storage locations with download capability - **Hierarchical Organization**:
- **Datasheet Links**: Direct links to component datasheets - Unlimited nesting for both categories and storage locations
- **Real-time Updates**: All changes are immediately reflected in the interface - 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:** 1. **Install dependencies:**
```bash ```bash
@@ -51,143 +55,122 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
2. **Set up the database:** 2. **Set up the database:**
```bash ```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.create
mix ecto.migrate mix ecto.migrate
mix run priv/repo/seeds.exs mix run priv/repo/seeds.exs
``` ```
3. **Start the server:** 3. **Start the development server:**
```bash ```bash
mix phx.server mix phx.server
``` ```
4. **Visit the application:** 4. **Access the application:**
Open [http://localhost:4000](http://localhost:4000) - Open [http://localhost:4000](http://localhost:4000)
- Default password: `changeme`
## Authentication ### Authentication
The application uses a simple password-based authentication system: Simple password-based authentication:
- Default password: `changeme` - Default: `changeme`
- Set custom password via environment variable: `AUTH_PASSWORD=your_password` - Custom: Set `AUTH_PASSWORD=your_password` environment variable
## Database Schema ## Database Schema
### Categories ### Categories (Hierarchical)
- `id`: Primary key - `id`: Primary key
- `name`: Category name (required) - `name`: Category name (required)
- `description`: Optional description - `description`: Optional description
- `parent_id`: Foreign key for hierarchical categories - `parent_id`: Foreign key for hierarchical structure
- Supports unlimited nesting levels - **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 ### Components
- `id`: Primary key - `id`: Primary key
- `name`: Component name (required) - `name`: Component name (required)
- `description`: Detailed description - `description`: Detailed description
- `keywords`: Search keywords - `keywords`: Search keywords
- `position`: Storage location/position
- `count`: Current quantity (default: 0) - `count`: Current quantity (default: 0)
- `datasheet_url`: Optional link to datasheet
- `image_filename`: Optional image file name
- `category_id`: Required foreign key to categories - `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 ### Phoenix LiveView Application
- **`ComponentsElixir.Inventory`**: Business logic for components and categories - **Real-time updates**: No page refreshes needed
- **`ComponentsElixir.Auth`**: Simple authentication system - **Phoenix Contexts**: Clean separation of business logic
- **Ecto**: Type-safe database operations with migrations
- **Authentication**: Session-based with CSRF protection
### Live Views ### Key Modules
- **`ComponentsElixirWeb.LoginLive`**: Authentication interface - `ComponentsElixir.Inventory`: Core business logic
- **`ComponentsElixirWeb.ComponentsLive`**: Main component management interface - `ComponentsElixir.DatasheetDownloader`: Automatic PDF retrieval
- **`ComponentsElixirWeb.CategoriesLive`**: Category management interface - `ComponentsElixir.AprilTag`: SVG AprilTag generation
- **`ComponentsElixirWeb.StorageLocationsLive`**: Hierarchical storage location management with AprilTags - `ComponentsElixir.Inventory.Hierarchical`: Reusable hierarchy management
- `ComponentsElixirWeb.*Live`: LiveView interfaces for real-time UI
## Recent Features & Improvements
### Key Features ### ✅ Datasheet Management System
- **Real-time updates**: Changes are immediately reflected without page refresh - **Automatic Download**: Provide a URL and the system downloads the PDF automatically
- **Infinite scroll**: Load more components as needed - **Direct Upload**: Upload PDF datasheets up to 10MB
- **Search optimization**: Uses PostgreSQL full-text search for long queries, ILIKE for short ones - **Smart Validation**: Ensures uploaded files are valid PDFs
- **Responsive design**: Works on desktop and mobile devices - **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 | ### ✅ Enhanced User Interface
|-------------|-------------------|-------------| - **Focus Mode**: Detailed component view with full information display
| `getItems.php` | `Inventory.list_components/1` | Type-safe, composable queries | - **Responsive Design**: Optimized for mobile and desktop usage
| `getCategories.php` | `Inventory.list_categories/0` | Proper associations, hierarchical support | - **Consistent Sorting**: Robust sorting even with rapid data changes
| `addItem.php` | `Inventory.create_component/1` | Built-in validation, changesets | - **Visual Feedback**: Loading states, progress indicators, and clear error messages
| 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 |
## 🚀 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 ### Component Management
- **Barcode Support** - Generate and scan traditional barcodes in addition to AprilTags - **Barcode Support**: Generate and scan traditional barcodes alongside AprilTags
- **Camera Integration** - JavaScript-based AprilTag scanning with camera access for mobile/desktop - **Camera Integration**: Mobile/desktop AprilTag scanning with camera access
- **Multi-AprilTag Detection** - Spatial analysis and disambiguation for multiple tags in same image - **Multi-Tag Detection**: Spatial analysis for multiple tags in the same view
- **Bulk Operations** - Import/export components from CSV, batch updates - **Bulk Operations**: CSV import/export and batch component updates
- **Search and Filtering** - Advanced search by specifications, tags, location - **Component Templates**: Reusable templates for common component types
- **Component Templates** - Reusable templates for common component types - **Specifications Tracking**: Detailed technical specifications with custom fields
- **Version History** - Track changes to component specifications over time - **Version History**: Track changes to component data over time
### Storage Organization ### Advanced Features
- **Physical Layout Mapping** - Visual representation of shelves, drawers, and boxes - **API Integration**: Connect with distributor APIs for price and availability
- **Bulk AprilTag Printing** - Generate printable sheets of AprilTags for labeling - **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 ### AprilTag Enhancement
- **Scanner App**: Dedicated mobile app for inventory management
### Storage Location System Foundation ✅ **COMPLETED** - **Batch Printing**: Generate printable sheets of multiple AprilTags
- **Database Schema** ✅ Complete - Hierarchical storage locations with parent-child relationships - **Smart Scanning**: Context-aware actions based on scanned tags
- **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
## Development ## Development
@@ -207,143 +190,86 @@ mix credo
# Reset database # Reset database
mix ecto.reset mix ecto.reset
# Add new migration # Create new migration
mix ecto.gen.migration add_feature mix ecto.gen.migration add_feature
# Check migration status # Check migration status
mix ecto.migrations 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 ## Deployment
### 🐳 Docker Deployment (Recommended) ### 🐳 Docker Deployment (Recommended)
#### Prerequisites Docker provides the easiest deployment method with a pre-built container image including all dependencies.
- Docker and Docker Compose installed
- Git for cloning the repository
#### Quick Start #### Quick Start
1. **Clone the repository:** 1. **Download the docker-compose file:**
```bash ```bash
git clone <repository-url> curl -O https://git.maxboeer.com/schuwi/component-system/raw/branch/main/docker-compose.yml.example
cd components_elixir mv docker-compose.yml.example docker-compose.yml
```
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"
``` ```
2. **Generate a secure secret key:** 2. **Generate a secure secret key:**
**With Elixir/Phoenix installed:**
```bash ```bash
# Run this locally to generate a new secret
mix phx.gen.secret mix phx.gen.secret
``` ```
3. **Database Configuration**: The default setup includes: **Without Elixir/Phoenix (Linux/Unix):**
- PostgreSQL 15 container ```bash
- Automatic database creation dd if=/dev/random bs=1 count=64 status=none | base64 -w0 | cut -c1-64
- Health checks to ensure proper startup order ```
- Persistent data storage with Docker volumes
#### Production Docker Deployment > **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. **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
```
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: For production environments:
1. **Create a production docker-compose.yml:** - **Use specific versions**: Pin to specific tags like `git.maxboeer.com/schuwi/components-elixir:v1.0.0` instead of `:latest`
```yaml - **Available tags**:
services: - `:latest` - Latest stable release from main branch
db: - `:main` - Latest build from main branch
image: postgres:15 - `:v*` - Specific version tags
environment: - `:snapshot-<hash>` - Specific commit builds
POSTGRES_USER: components_user - **Generate secure keys**: Generate a 64+ character random string for SECRET_KEY_BASE (see Quick Start section for methods)
POSTGRES_PASSWORD: secure_db_password - **Set strong passwords**: Use AUTH_PASSWORD environment variable
POSTGRES_DB: components_elixir_prod - **Configure domain**: Set PHX_HOST to your actual domain
volumes: - **Database security**: Use strong PostgreSQL credentials
- postgres_data:/var/lib/postgresql/data - **SSL/HTTPS**: Configure reverse proxy (nginx, Caddy) for SSL termination
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
### 🚀 Traditional Deployment ### 🚀 Traditional Deployment
For production deployment without Docker: For deployment without Docker:
1. **Set environment variables:** 1. **Environment setup:**
```bash ```bash
export AUTH_PASSWORD=your_secure_password export AUTH_PASSWORD=your_secure_password
export SECRET_KEY_BASE=your_secret_key export SECRET_KEY_BASE=your_secret_key
@@ -355,18 +281,24 @@ For production deployment without Docker:
MIX_ENV=prod mix release MIX_ENV=prod mix release
``` ```
3. **Run migrations:** 3. **Database setup:**
```bash ```bash
_build/prod/rel/components_elixir/bin/components_elixir eval "ComponentsElixir.Release.migrate" _build/prod/rel/components_elixir/bin/components_elixir eval "ComponentsElixir.Release.migrate"
``` ```
## Contributing ## 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: - **Phoenix Contexts** for business logic separation
- Contexts for business logic - **LiveView** for real-time, interactive user interfaces
- LiveView for interactive UI - **Ecto** for type-safe database operations
- Ecto for database operations - **Comprehensive testing** with ExUnit
- Comprehensive error handling - **Input validation** and sanitization throughout
- Input validation and sanitization - **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 import Config
# Configure your database # Configure the database
config :components_elixir, ComponentsElixir.Repo, config :components_elixir, ComponentsElixir.Repo,
username: "postgres", username: "postgres",
password: "fCnPB8VQdPkhJAD29hq6sZEY", password: "fCnPB8VQdPkhJAD29hq6sZEY",
@@ -10,8 +10,13 @@ config :components_elixir, ComponentsElixir.Repo,
show_sensitive_data_on_connection_error: true, show_sensitive_data_on_connection_error: true,
pool_size: 10 pool_size: 10
# For development, we disable any cache and enable # For development work, log all queries
# debugging and code reloading. # 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 # The watchers configuration can be used to run external
# watchers to your application. For example, we can use it # watchers to your application. For example, we can use it

View File

@@ -1,5 +1,9 @@
import Config 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 # config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the # during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration # 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. # Run `mix help test` for more information.
config :components_elixir, ComponentsElixir.Repo, config :components_elixir, ComponentsElixir.Repo,
username: "postgres", username: "postgres",
password: "postgres", password: System.get_env("POSTGRES_PASSWORD") || "fCnPB8VQdPkhJAD29hq6sZEY",
hostname: "localhost", hostname: System.get_env("POSTGRES_HOSTNAME") || "localhost",
database: "components_elixir_test#{System.get_env("MIX_TEST_PARTITION")}", database: "components_elixir_test#{System.get_env("MIX_TEST_PARTITION")}",
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: System.schedulers_online() * 2 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 retries: 5
app: app:
build: . image: git.maxboeer.com/schuwi/components-elixir:latest
ports: ports:
- "4000:4000" - "4000:4000"
environment: environment:
@@ -25,9 +25,12 @@ services:
PHX_HOST: "localhost" PHX_HOST: "localhost"
PHX_SERVER: "true" PHX_SERVER: "true"
PORT: "4000" PORT: "4000"
UPLOADS_DIR: "/data/uploads"
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
volumes:
- uploaded_files:/data/uploads
command: command:
[ [
"/bin/sh", "/bin/sh",
@@ -37,3 +40,4 @@ services:
volumes: volumes:
postgres_data: postgres_data:
uploaded_files:

View File

@@ -31,6 +31,7 @@ defmodule ComponentsElixir.AprilTag do
def valid_apriltag_id?(id) when is_integer(id) do def valid_apriltag_id?(id) when is_integer(id) do
id >= 0 and id < @tag36h11_count id >= 0 and id < @tag36h11_count
end end
def valid_apriltag_id?(_), do: false def valid_apriltag_id?(_), do: false
@doc """ @doc """
@@ -78,8 +79,8 @@ defmodule ComponentsElixir.AprilTag do
def used_apriltag_ids do def used_apriltag_ids do
ComponentsElixir.Repo.all( ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation, from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id), where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id select: sl.apriltag_id
) )
end end
@@ -130,10 +131,11 @@ defmodule ComponentsElixir.AprilTag do
This should be run once during setup to pre-generate all AprilTag images. This should be run once during setup to pre-generate all AprilTag images.
""" """
def generate_all_apriltag_svgs(opts \\ []) do def generate_all_apriltag_svgs(opts \\ []) do
static_dir = Path.join([ static_dir =
Application.app_dir(:components_elixir, "priv/static"), Path.join([
"apriltags" Application.app_dir(:components_elixir, "priv/static"),
]) "apriltags"
])
# Ensure directory exists # Ensure directory exists
File.mkdir_p!(static_dir) File.mkdir_p!(static_dir)
@@ -143,21 +145,7 @@ defmodule ComponentsElixir.AprilTag do
results = results =
all_apriltag_ids() all_apriltag_ids()
|> Task.async_stream( |> Task.async_stream(
fn apriltag_id -> &generate_apriltag_file(&1, static_dir, force_regenerate, opts),
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,
timeout: :infinity, timeout: :infinity,
max_concurrency: System.schedulers_online() * 2 max_concurrency: System.schedulers_online() * 2
) )
@@ -174,6 +162,26 @@ defmodule ComponentsElixir.AprilTag do
} }
end 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 """ @doc """
Cleans up AprilTag SVG file for a specific ID. Cleans up AprilTag SVG file for a specific ID.
@@ -181,10 +189,12 @@ defmodule ComponentsElixir.AprilTag do
""" """
def cleanup_apriltag_svg(apriltag_id) do def cleanup_apriltag_svg(apriltag_id) do
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg" 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"), file_path =
filename Path.join([
]) Application.app_dir(:components_elixir, "priv/static/apriltags"),
filename
])
if File.exists?(file_path) do if File.exists?(file_path) do
File.rm(file_path) 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 case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
[_, id_str, hex_pattern] -> [_, id_str, hex_pattern] ->
{String.to_integer(id_str), String.downcase(hex_pattern)} {String.to_integer(id_str), String.downcase(hex_pattern)}
_ -> _ ->
nil nil
end end
@@ -23,26 +24,30 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
# Extract patterns from PostScript file at compile time # Extract patterns from PostScript file at compile time
@all_patterns ( @all_patterns (
path = Path.join([File.cwd!(), "apriltags.ps"]) path = Path.join([File.cwd!(), "apriltags.ps"])
if File.exists?(path) do if File.exists?(path) do
File.read!(path) File.read!(path)
|> String.split("\n") |> String.split("\n")
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11")) |> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|> Enum.map(fn line -> |> Enum.map(fn line ->
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do case Regex.run(
[_, id_str, hex_pattern] -> ~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/,
{String.to_integer(id_str), String.downcase(hex_pattern)} line
_ -> ) do
nil [_, id_str, hex_pattern] ->
end {String.to_integer(id_str), String.downcase(hex_pattern)}
end)
|> Enum.reject(&is_nil/1) _ ->
|> Map.new() nil
else end
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation." 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 # Sample of real tag36h11 hex patterns from AprilRobotics repository
# This will be populated with patterns extracted from the PostScript file # 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) |> Enum.reject(&is_nil/1)
|> Map.new() |> Map.new()
else 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
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 def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do
Map.get(@tag36h11_patterns, id) Map.get(@tag36h11_patterns, id)
end end
def get_hex_pattern(_), do: nil def get_hex_pattern(_), do: nil
@doc """ @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) # Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp)
row_bytes = 3 row_bytes = 3
rows = rows =
for row <- 0..9 do for row <- 0..9 do
<<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes <<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes
@@ -104,12 +112,23 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
samples = samples =
[ [
b0 >>> 6 &&& 0x3, b0 >>> 4 &&& 0x3, b0 >>> 2 &&& 0x3, b0 &&& 0x3, b0 >>> 6 &&& 0x3,
b1 >>> 6 &&& 0x3, b1 >>> 4 &&& 0x3, b1 >>> 2 &&& 0x3, b1 &&& 0x3, b0 >>> 4 &&& 0x3,
b2 >>> 6 &&& 0x3, b2 >>> 4 &&& 0x3, b2 >>> 2 &&& 0x3, b2 &&& 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 # drop the 2 padding samples at end of row
|> Enum.map(&(&1 == 0)) # 0 = black, 3 = white → boolean |> Enum.take(10)
# 0 = black, 3 = white → boolean
|> Enum.map(&(&1 == 0))
samples samples
end end
@@ -133,7 +152,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
Only black modules are drawn over a white background. Only black modules are drawn over a white background.
""" """
def binary_matrix_to_svg(binary_matrix, opts \\ []) do 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, "") id_text = Keyword.get(opts, :id_text, "")
# binary_matrix is 10x10 of booleans: true=black, false=white # binary_matrix is 10x10 of booleans: true=black, false=white
@@ -172,9 +192,11 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
<!-- caption --> <!-- caption -->
#{if id_text != "" do #{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>) font-family="Arial" font-size="0.9">#{id_text}</text>)
else "" end} else
""
end}
</svg> </svg>
""" """
end end
@@ -197,11 +219,13 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
opts_with_id = Keyword.put(opts, :id_text, id_text) opts_with_id = Keyword.put(opts, :id_text, id_text)
binary_matrix_to_svg(binary_matrix, opts_with_id) binary_matrix_to_svg(binary_matrix, opts_with_id)
end 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 defp generate_placeholder_svg(id, opts) do
size = Keyword.get(opts, :size, 200) size = Keyword.get(opts, :size, 200)
margin = Keyword.get(opts, :margin, div(size, 10)) 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"> <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 ## Storage Locations
@doc """ @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 def list_storage_locations do
# Get all locations with preloaded parents in a single query # Get all locations with preloaded parents in a single query
locations = StorageLocation locations =
|> order_by([sl], [asc: sl.name]) StorageLocation
|> preload(:parent) |> order_by([sl], asc: sl.name)
|> Repo.all() |> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
# Compute hierarchy fields for all locations efficiently
processed_locations = compute_hierarchy_fields_batch(locations)
|> Enum.sort_by(&{&1.level, &1.name})
# Ensure AprilTag SVGs exist for all locations # Ensure AprilTag SVGs exist for all locations
spawn(fn -> spawn(fn ->
ComponentsElixir.AprilTag.generate_all_apriltag_svgs() ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
end) end)
processed_locations locations
end 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 """ @doc """
Returns the list of root storage locations (no parent). Returns the list of root storage locations (no parent).
""" """
def list_root_storage_locations do def list_root_storage_locations do
StorageLocation StorageLocation
|> where([sl], is_nil(sl.parent_id)) |> where([sl], is_nil(sl.parent_id))
|> order_by([sl], [asc: sl.name]) |> order_by([sl], asc: sl.name)
|> Repo.all() |> Repo.all()
end end
@doc """ @doc """
Gets a single storage location with computed hierarchy fields. Gets a single storage location with preloaded associations.
""" """
def get_storage_location!(id) do def get_storage_location!(id) do
location = StorageLocation StorageLocation
|> preload(:parent) |> preload(:parent)
|> Repo.get!(id) |> 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 end
@doc """ @doc """
@@ -114,13 +57,6 @@ defmodule ComponentsElixir.Inventory do
|> where([sl], sl.apriltag_id == ^apriltag_id) |> where([sl], sl.apriltag_id == ^apriltag_id)
|> preload(:parent) |> preload(:parent)
|> Repo.one() |> 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 end
@doc """ @doc """
@@ -130,13 +66,15 @@ defmodule ComponentsElixir.Inventory do
# Convert string keys to atoms to maintain consistency # Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs) attrs = normalize_string_keys(attrs)
result = %StorageLocation{} result =
|> StorageLocation.changeset(attrs) %StorageLocation{}
|> Repo.insert() |> StorageLocation.changeset(attrs)
|> Repo.insert()
case result do case result do
{:ok, location} -> {:ok, location} ->
{:ok, location} {:ok, location}
error -> error ->
error error
end end
@@ -149,13 +87,15 @@ defmodule ComponentsElixir.Inventory do
# Convert string keys to atoms to maintain consistency # Convert string keys to atoms to maintain consistency
attrs = normalize_string_keys(attrs) attrs = normalize_string_keys(attrs)
result = storage_location result =
|> StorageLocation.changeset(attrs) storage_location
|> Repo.update() |> StorageLocation.changeset(attrs)
|> Repo.update()
case result do case result do
{:ok, updated_location} -> {:ok, updated_location} ->
{:ok, updated_location} {:ok, updated_location}
error -> error ->
error error
end end
@@ -182,12 +122,14 @@ defmodule ComponentsElixir.Inventory do
case get_storage_location_by_apriltag_id(apriltag_id) do case get_storage_location_by_apriltag_id(apriltag_id) do
nil -> nil ->
{:error, :not_found} {:error, :not_found}
location -> location ->
{:ok, %{ {:ok,
type: :storage_location, %{
location: location, type: :storage_location,
apriltag_id: apriltag_id location: location,
}} apriltag_id: apriltag_id
}}
end end
end end
@@ -195,8 +137,9 @@ defmodule ComponentsElixir.Inventory do
Computes the path for a storage location (for display purposes). Computes the path for a storage location (for display purposes).
""" """
def compute_storage_location_path(nil), do: nil def compute_storage_location_path(nil), do: nil
def compute_storage_location_path(%StorageLocation{} = location) do def compute_storage_location_path(%StorageLocation{} = location) do
compute_path_for_single(location) StorageLocation.full_path(location)
end end
# Convert string keys to atoms for consistency # Convert string keys to atoms for consistency
@@ -205,6 +148,7 @@ defmodule ComponentsElixir.Inventory do
{key, value}, acc when is_binary(key) -> {key, value}, acc when is_binary(key) ->
atom_key = String.to_atom(key) atom_key = String.to_atom(key)
Map.put(acc, atom_key, value) Map.put(acc, atom_key, value)
{key, value}, acc -> {key, value}, acc ->
Map.put(acc, key, value) Map.put(acc, key, value)
end) end)
@@ -213,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
## Categories ## Categories
@doc """ @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 def list_categories do
Category Category
|> preload(:parent) |> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all() |> Repo.all()
end end
@@ -258,6 +203,63 @@ defmodule ComponentsElixir.Inventory do
Category.changeset(category, attrs) Category.changeset(category, attrs)
end 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 ## Components
@doc """ @doc """
@@ -266,7 +268,7 @@ defmodule ComponentsElixir.Inventory do
def list_components(opts \\ []) do def list_components(opts \\ []) do
Component Component
|> apply_component_filters(opts) |> apply_component_filters(opts)
|> order_by([c], [asc: c.name]) |> apply_component_sorting(opts)
|> preload([:category, :storage_location]) |> preload([:category, :storage_location])
|> Repo.all() |> Repo.all()
end end
@@ -274,24 +276,54 @@ defmodule ComponentsElixir.Inventory do
defp apply_component_filters(query, opts) do defp apply_component_filters(query, opts) do
Enum.reduce(opts, query, fn Enum.reduce(opts, query, fn
{:category_id, category_id}, query when not is_nil(category_id) -> {: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) -> {: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, search_term}, query when is_binary(search_term) and search_term != "" ->
search_pattern = "%#{search_term}%" search_pattern = "%#{search_term}%"
where(query, [c],
where(
query,
[c],
ilike(c.name, ^search_pattern) or ilike(c.name, ^search_pattern) or
ilike(c.description, ^search_pattern) or ilike(c.description, ^search_pattern) or
ilike(c.keywords, ^search_pattern) or ilike(c.keywords, ^search_pattern) or
ilike(c.position, ^search_pattern) ilike(c.position, ^search_pattern)
) )
_, query -> query _, query ->
query
end) end)
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 """ @doc """
Gets a single component. Gets a single component.
""" """
@@ -310,6 +342,32 @@ defmodule ComponentsElixir.Inventory do
|> Repo.insert() |> Repo.insert()
end 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 """ @doc """
Updates a component. Updates a component.
""" """
@@ -319,6 +377,39 @@ defmodule ComponentsElixir.Inventory do
|> Repo.update() |> Repo.update()
end 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 """ @doc """
Deletes a component. Deletes a component.
""" """
@@ -339,10 +430,12 @@ defmodule ComponentsElixir.Inventory do
def get_inventory_stats do def get_inventory_stats do
total_components = Repo.aggregate(Component, :count, :id) total_components = Repo.aggregate(Component, :count, :id)
total_stock = Component total_stock =
Component
|> Repo.aggregate(:sum, :count) |> Repo.aggregate(:sum, :count)
categories_with_components = Component categories_with_components =
Component
|> distinct([c], c.category_id) |> distinct([c], c.category_id)
|> Repo.aggregate(:count, :category_id) |> Repo.aggregate(:count, :category_id)
@@ -392,6 +485,7 @@ defmodule ComponentsElixir.Inventory do
""" """
def decrement_component_count(%Component{} = component) do def decrement_component_count(%Component{} = component) do
new_count = max(0, component.count - 1) new_count = max(0, component.count - 1)
component component
|> Component.changeset(%{count: new_count}) |> Component.changeset(%{count: new_count})
|> Repo.update() |> Repo.update()

View File

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

View File

@@ -18,18 +18,30 @@ defmodule ComponentsElixir.Inventory.Component do
field :legacy_position, :string field :legacy_position, :string
field :count, :integer, default: 0 field :count, :integer, default: 0
field :datasheet_url, :string field :datasheet_url, :string
field :datasheet_filename, :string
field :image_filename, :string field :image_filename, :string
belongs_to :category, Category belongs_to :category, Category
belongs_to :storage_location, StorageLocation belongs_to :storage_location, StorageLocation
timestamps() timestamps(type: :naive_datetime_usec)
end end
@doc false @doc false
def changeset(component, attrs) do def changeset(component, attrs) do
component component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :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_required([:name, :category_id])
|> validate_length(:name, min: 1, max: 255) |> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 2000) |> validate_length(:description, max: 2000)
@@ -59,25 +71,43 @@ defmodule ComponentsElixir.Inventory.Component do
|> cast(attrs, [:image_filename]) |> cast(attrs, [:image_filename])
end 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 defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, url -> validate_change(changeset, field, fn ^field, url ->
if url && url != "" do cond do
case URI.parse(url) do is_nil(url) or url == "" -> []
%URI{scheme: scheme} when scheme in ["http", "https"] -> [] valid_url?(url) -> []
_ -> [{field, "must be a valid URL"}] true -> [{field, "must be a valid URL"}]
end
else
[]
end end
end) 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 """ @doc """
Returns true if the component has an image. Returns true if the component has an image.
""" """
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
def has_image?(_), do: false 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 """ @doc """
Returns the search text for this component. 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. Schema for storage locations with hierarchical organization.
Storage locations can be nested (shelf -> drawer -> box) and each 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 Ecto.Schema
use ComponentsElixir.Inventory.HierarchicalSchema
import Ecto.Changeset import Ecto.Changeset
import Ecto.Query import Ecto.Query
@@ -15,70 +16,44 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
field :name, :string field :name, :string
field :description, :string field :description, :string
field :apriltag_id, :integer 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 # Only parent relationship is stored
belongs_to :parent, StorageLocation belongs_to :parent, StorageLocation
has_many :children, StorageLocation, foreign_key: :parent_id has_many :children, StorageLocation, foreign_key: :parent_id
has_many :components, Component has_many :components, Component
timestamps() timestamps(type: :naive_datetime_usec)
end end
@doc false @doc false
def changeset(storage_location, attrs) do def changeset(storage_location, attrs) do
storage_location storage_location
|> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id]) |> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
|> validate_required([:name]) |> validate_required([:name])
|> validate_length(:name, min: 1, max: 100) |> validate_length(:name, min: 1, max: 100)
|> validate_length(:description, max: 500) |> validate_length(:description, max: 500)
|> validate_apriltag_id() |> validate_apriltag_id()
|> foreign_key_constraint(:parent_id) |> foreign_key_constraint(:parent_id)
|> validate_no_circular_reference()
|> put_apriltag_id() |> put_apriltag_id()
end end
# Prevent circular references (location being its own ancestor) # HierarchicalSchema implementations
defp validate_no_circular_reference(changeset) do @impl true
case get_change(changeset, :parent_id) do def full_path(%StorageLocation{} = storage_location) do
nil -> changeset Hierarchical.full_path(storage_location, & &1.parent, path_separator())
parent_id ->
location_id = changeset.data.id
if location_id && would_create_cycle?(location_id, parent_id) do
add_error(changeset, :parent_id, "cannot be a descendant of this location")
else
changeset
end
end
end end
defp would_create_cycle?(location_id, parent_id) do @impl true
# Check if parent_id is the same as location_id or any of its descendants def parent(%StorageLocation{parent: parent}), do: parent
location_id == parent_id or
(parent_id && is_descendant_of?(parent_id, location_id))
end
defp is_descendant_of?(potential_descendant, ancestor_id) do @impl true
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do def children(%StorageLocation{children: children}), do: children
nil -> false
%{parent_id: nil} -> false
%{parent_id: ^ancestor_id} -> true
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
end
end
@doc """ @impl true
Returns the full hierarchical path as a human-readable string. def path_separator(), do: " / "
"""
def full_path(storage_location) do @impl true
storage_location.path def entity_type(), do: :storage_location
|> String.split("/")
|> Enum.join("")
end
@doc """ @doc """
Returns the AprilTag format for this storage location. Returns the AprilTag format for this storage location.
@@ -103,35 +78,14 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
end end
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 defp get_next_available_apriltag_id do
# Get all used AprilTag IDs # Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all( used_ids =
from sl in ComponentsElixir.Inventory.StorageLocation, ComponentsElixir.Repo.all(
where: not is_nil(sl.apriltag_id), from sl in ComponentsElixir.Inventory.StorageLocation,
select: sl.apriltag_id where: not is_nil(sl.apriltag_id),
) select: sl.apriltag_id
)
# Find the first available ID (0-586) # Find the first available ID (0-586)
0..586 0..586
@@ -140,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
nil -> nil ->
# All IDs are used - this should be handled at the application level # All IDs are used - this should be handled at the application level
raise "All AprilTag IDs are in use" raise "All AprilTag IDs are in use"
id -> id
id ->
id
end end
end 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. 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 def router do
quote do quote do

View File

@@ -195,7 +195,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
name={@name} name={@name}
value="true" value="true"
checked={@checked} 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} {@rest}
/>{@label} />{@label}
</span> </span>
@@ -213,7 +216,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
<select <select
id={@id} id={@id}
name={@name} 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} multiple={@multiple}
{@rest} {@rest}
> >
@@ -235,7 +241,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
id={@id} id={@id}
name={@name} name={@name}
class={[ 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") @errors != [] && (@error_class || "textarea-error border-error")
]} ]}
{@rest} {@rest}
@@ -258,7 +265,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
id={@id} id={@id}
value={Phoenix.HTML.Form.normalize_value(@type, @value)} value={Phoenix.HTML.Form.normalize_value(@type, @value)}
class={[ 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") @errors != [] && (@error_class || "input-error border-error")
]} ]}
{@rest} {@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 use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth} alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.Category alias ComponentsElixir.Inventory.{Category, Hierarchical}
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Check authentication # Check authentication
unless Auth.authenticated?(session) do if Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
categories = Inventory.list_categories() categories = Inventory.list_categories()
{:ok, {:ok,
@@ -20,7 +18,10 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:show_edit_form, false) |> assign(:show_edit_form, false)
|> assign(:editing_category, nil) |> assign(:editing_category, nil)
|> assign(:form, nil) |> assign(:form, nil)
|> assign(:expanded_categories, MapSet.new())
|> assign(:page_title, "Category Management")} |> assign(:page_title, "Category Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end end
end end
@@ -45,12 +46,13 @@ defmodule ComponentsElixirWeb.CategoriesLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do def handle_event("show_edit_form", %{"id" => id}, socket) do
category = Inventory.get_category!(id) category = Inventory.get_category!(id)
# Create a changeset with current values forced into changes for proper form display # Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_category(category, %{ changeset =
name: category.name, Inventory.change_category(category, %{
description: category.description, name: category.name,
parent_id: category.parent_id description: category.description,
}) parent_id: category.parent_id
|> Ecto.Changeset.force_change(:parent_id, category.parent_id) })
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
form = to_form(changeset) form = to_form(changeset)
@@ -111,55 +113,49 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> reload_categories()} |> reload_categories()}
{:error, _changeset} -> {: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
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 defp reload_categories(socket) do
categories = Inventory.list_categories() categories = Inventory.list_categories()
assign(socket, :categories, categories) assign(socket, :categories, categories)
end end
defp parent_category_options(categories, editing_category_id \\ nil) do defp parent_category_options(categories, editing_category_id \\ nil) do
available_categories = Hierarchical.parent_select_options(
categories categories,
|> Enum.reject(fn cat -> editing_category_id,
cat.id == editing_category_id || & &1.parent,
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id)) "No parent (Root category)"
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
end end
defp root_categories(categories) do defp root_categories(categories) do
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end) Hierarchical.root_entities(categories, & &1.parent_id)
end end
defp child_categories(categories, parent_id) do 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 end
defp count_components_in_category(category_id) do 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: "" 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 and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do {icon_size, button_size, text_size, title_tag} =
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} case assigns.depth do
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
end _ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
assigns = assigns children = child_categories(assigns.categories, assigns.category.id)
|> assign(:margin_left, margin_left) has_children = !Enum.empty?(children)
|> assign(:border_class, border_class) is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
|> assign(:icon_size, icon_size)
|> assign(:button_size, button_size) # Calculate component counts including descendants
|> assign(:text_size, text_size) {self_count, children_count, _total_count} =
|> assign(:title_tag, title_tag) Hierarchical.count_with_descendants(
|> assign(:children, child_categories(assigns.categories, assigns.category.id)) 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""" ~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}> <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-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex-1"> <div class="flex items-start flex-1 space-x-2">
<div class="flex items-center"> <!-- Expand/Collapse button - always aligned to top -->
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mr-3"} /> <%= if @has_children do %>
<div> <button
<%= if @title_tag == "h3" do %> phx-click="toggle_expand"
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h3> 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 %> <% 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 %> <% end %>
<%= if @category.description do %> </button>
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p> <% else %>
<% end %> <div class="w-6"></div>
<p class="text-xs text-base-content/50 mt-1"> <!-- Spacer for alignment -->
{count_components_in_category(@category.id)} components <% end %>
</p>
</div> <.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> </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> </div>
<!-- Render children recursively -->
<%= for child <- @children do %> <!-- Render children recursively (only when expanded) -->
<.category_item category={child} categories={@categories} depth={@depth + 1} /> <%= if @is_expanded do %>
<%= for child <- @children do %>
<.category_item
category={child}
categories={@categories}
expanded_categories={@expanded_categories}
depth={@depth + 1}
/>
<% end %>
<% end %> <% end %>
</div> </div>
""" """
@@ -274,7 +388,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<!-- Add Category Modal --> <!-- Add Category Modal -->
<%= if @show_add_form do %> <%= 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="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"> <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">
@@ -330,7 +444,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
<% end %> <% end %>
<!-- Edit Category Modal --> <!-- Edit Category Modal -->
<%= if @show_edit_form do %> <%= 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="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"> <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">
@@ -386,12 +500,14 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
<% end %> <% end %>
<!-- Categories List --> <!-- Categories List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300"> <div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2> <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> </div>
<%= if Enum.empty?(@categories) do %> <%= if Enum.empty?(@categories) do %>
@@ -406,8 +522,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
phx-click="show_add_form" 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" 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" /> <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
Add Category
</button> </button>
</div> </div>
</div> </div>
@@ -416,7 +531,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<!-- Recursive Category Tree --> <!-- Recursive Category Tree -->
<%= for category <- root_categories(@categories) do %> <%= for category <- root_categories(@categories) do %>
<div class="px-6 py-4"> <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> </div>
<% end %> <% end %>
</div> </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 %> <%= if @error_message do %>
<div class="text-red-600 text-sm text-center"> <div class="text-red-600 text-sm text-center">
<%= @error_message %> {@error_message}
</div> </div>
<% end %> <% end %>

View File

@@ -5,15 +5,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
use ComponentsElixirWeb, :live_view use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth} alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.StorageLocation alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
alias ComponentsElixir.AprilTag alias ComponentsElixir.AprilTag
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Check authentication # Check authentication
unless Auth.authenticated?(session) do if Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
storage_locations = list_storage_locations() storage_locations = list_storage_locations()
{:ok, {:ok,
@@ -26,7 +24,10 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:form, nil) |> assign(:form, nil)
|> assign(:apriltag_scanner_open, false) |> assign(:apriltag_scanner_open, false)
|> assign(:scanned_tags, []) |> assign(:scanned_tags, [])
|> assign(:expanded_locations, MapSet.new())
|> assign(:page_title, "Storage Location Management")} |> assign(:page_title, "Storage Location Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end end
end end
@@ -53,12 +54,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do def handle_event("show_edit_form", %{"id" => id}, socket) do
location = Inventory.get_storage_location!(id) location = Inventory.get_storage_location!(id)
# Create a changeset with current values forced into changes for proper form display # Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_storage_location(location, %{ changeset =
name: location.name, Inventory.change_storage_location(location, %{
description: location.description, name: location.name,
parent_id: location.parent_id description: location.description,
}) parent_id: location.parent_id
|> Ecto.Changeset.force_change(:parent_id, location.parent_id) })
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
form = to_form(changeset) form = to_form(changeset)
@@ -81,29 +83,31 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("save_location", %{"storage_location" => location_params}, socket) do def handle_event("save_location", %{"storage_location" => location_params}, socket) do
# Process AprilTag assignment based on mode # Process AprilTag assignment based on mode
processed_params = case socket.assigns.apriltag_mode do processed_params =
"none" -> case socket.assigns.apriltag_mode do
# Remove any apriltag_id from params to ensure it's nil "none" ->
Map.delete(location_params, "apriltag_id") # Remove any apriltag_id from params to ensure it's nil
Map.delete(location_params, "apriltag_id")
"auto" -> "auto" ->
# Auto-assign next available AprilTag ID # Auto-assign next available AprilTag ID
case AprilTag.next_available_apriltag_id() do case AprilTag.next_available_apriltag_id() do
nil -> nil ->
# No available IDs, proceed without AprilTag # No available IDs, proceed without AprilTag
Map.delete(location_params, "apriltag_id") Map.delete(location_params, "apriltag_id")
apriltag_id ->
Map.put(location_params, "apriltag_id", apriltag_id)
end
"manual" -> apriltag_id ->
# Use the manually entered apriltag_id (validation will be handled by changeset) Map.put(location_params, "apriltag_id", apriltag_id)
location_params end
_ -> "manual" ->
# Fallback: remove apriltag_id # Use the manually entered apriltag_id (validation will be handled by changeset)
Map.delete(location_params, "apriltag_id") location_params
end
_ ->
# Fallback: remove apriltag_id
Map.delete(location_params, "apriltag_id")
end
case Inventory.create_storage_location(processed_params) do case Inventory.create_storage_location(processed_params) do
{:ok, _location} -> {:ok, _location} ->
@@ -146,7 +150,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> reload_storage_locations()} |> reload_storage_locations()}
{:error, _changeset} -> {: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
end end
@@ -163,10 +172,17 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 -> {apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
nil -> 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 -> 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, {:noreply,
socket socket
@@ -183,25 +199,46 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, assign(socket, :scanned_tags, [])} {:noreply, assign(socket, :scanned_tags, [])}
end 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 def handle_event("set_apriltag_mode", %{"mode" => mode}, socket) do
{:noreply, assign(socket, :apriltag_mode, mode)} {:noreply, assign(socket, :apriltag_mode, mode)}
end end
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
# Clear the apriltag_id field when switching modes # Clear the apriltag_id field when switching modes
form = case mode do form =
"remove" -> case mode do
socket.assigns.form "remove" ->
|> Phoenix.Component.to_form() socket.assigns.form
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil)) |> Phoenix.Component.to_form()
"keep" -> |> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
current_id = socket.assigns.editing_location.apriltag_id
socket.assigns.form "keep" ->
|> Phoenix.Component.to_form() current_id = socket.assigns.editing_location.apriltag_id
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
_ -> socket.assigns.form
socket.assigns.form |> Phoenix.Component.to_form()
end |> Map.put(
:params,
Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)
)
_ ->
socket.assigns.form
end
{:noreply, {:noreply,
socket socket
@@ -220,7 +257,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")} {:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
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 # Send file download to browser
{:noreply, {:noreply,
@@ -240,48 +278,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end end
defp parent_location_options(storage_locations, editing_location_id \\ nil) do defp parent_location_options(storage_locations, editing_location_id \\ nil) do
available_locations = Hierarchical.parent_select_options(
storage_locations storage_locations,
|> Enum.reject(fn loc -> editing_location_id,
loc.id == editing_location_id || & &1.parent,
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id)) "No parent (Root location)"
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
end end
defp location_display_name(location) do defp location_display_name(location) do
if location.path do StorageLocation.full_path(location)
# 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
end end
defp root_storage_locations(storage_locations) do 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 end
defp child_storage_locations(storage_locations, parent_id) do 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 end
defp count_components_in_location(location_id) do 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: "" 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 and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do {icon_size, button_size, text_size, title_tag} =
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} case assigns.depth do
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
end _ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
# Different icons based on level - QR code is always present for storage locations # Different icons based on level - QR code is always present for storage locations
icon_name = case assigns.depth do icon_name =
0 -> "hero-building-office" # Shelf/Room case assigns.depth do
1 -> "hero-archive-box" # Drawer/Cabinet # Shelf/Room
_ -> "hero-cube" # Box/Container 0 -> "hero-building-office"
end # Drawer/Cabinet
1 -> "hero-archive-box"
# Box/Container
_ -> "hero-cube"
end
assigns = assigns children = child_storage_locations(assigns.storage_locations, assigns.location.id)
|> assign(:margin_left, margin_left) has_children = !Enum.empty?(children)
|> assign(:border_class, border_class) is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
|> assign(:icon_size, icon_size)
|> assign(:button_size, button_size) # Calculate component counts including descendants
|> assign(:text_size, text_size) {self_count, children_count, _total_count} =
|> assign(:title_tag, title_tag) Hierarchical.count_with_descendants(
|> assign(:icon_name, icon_name) assigns.location.id,
|> assign(:children, child_storage_locations(assigns.storage_locations, 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""" ~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}> <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-start justify-between p-2 hover:bg-base-50 rounded-md">
<div class="flex items-center flex-1 space-x-4"> <div class="flex items-start flex-1 space-x-2">
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"}"} /> <!-- Expand/Collapse button - always aligned to top -->
<div class="flex-1"> <%= if @has_children do %>
<%= if @title_tag == "h3" do %> <button
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3> phx-click="toggle_expand"
<% else %> phx-value-id={@location.id}
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4> class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
<% end %> >
<%= if @location.description do %> <%= if @is_expanded do %>
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p> <.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
<% 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>
<% else %> <% 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-chevron-right" class="w-4 h-4 text-base-content/60" />
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
</div>
<% end %> <% end %>
<button </button>
phx-click="download_apriltag" <% else %>
phx-value-id={@location.id} <div class="w-6"></div>
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" <!-- Spacer for alignment -->
title="Download AprilTag"
>
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
Download
</button>
</div>
<% end %> <% end %>
</div>
<div class="flex items-center space-x-2 ml-4"> <.icon
<button name={@icon_name}
phx-click="show_edit_form" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
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"
> <!-- Content area - always starts at same vertical position -->
<.icon name="hero-pencil" class="w-4 h-4" /> <div class="flex-1">
</button> <!-- Minimized view (default) -->
<button <%= unless @is_expanded do %>
phx-click="delete_location" <div class="flex items-center justify-between">
phx-value-id={@location.id} <div class="flex items-center space-x-3">
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone." <%= if @title_tag == "h3" do %>
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700" <h3 class={"#{@text_size} font-medium"}>
> <.link
<.icon name="hero-trash" class="w-4 h-4" /> navigate={~p"/?storage_location_id=#{@location.id}"}
</button> 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>
</div> </div>
<!-- Render children recursively -->
<%= for child <- @children do %> <!-- Render children recursively (only when expanded) -->
<.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} /> <%= 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 %> <% end %>
</div> </div>
""" """
@@ -450,7 +592,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<!-- Add Location Modal --> <!-- Add Location Modal -->
<%= if @show_add_form do %> <%= 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="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"> <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>
<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="space-y-2">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input <input
@@ -544,10 +688,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32" class="w-32"
/> />
<div class="text-xs text-base-content/60"> <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 %> <%= if length(@available_apriltag_ids) < 20 do %>
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %> <br />Next available: {@available_apriltag_ids
<%= if length(@available_apriltag_ids) > 10, do: "..." %> |> Enum.take(10)
|> Enum.join(", ")}
{if length(@available_apriltag_ids) > 10, do: "..."}
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -576,7 +722,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
<% end %> <% end %>
<!-- Edit Location Modal --> <!-- Edit Location Modal -->
<%= if @show_edit_form do %> <%= 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="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"> <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" class="w-32"
/> />
<div class="text-xs text-base-content/60"> <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>
</div> </div>
<% end %> <% end %>
<p class="text-xs text-base-content/60 mt-1"> <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> </p>
</div> </div>
</div> </div>
@@ -701,7 +849,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
<% end %> <% end %>
<!-- AprilTag Scanner Modal --> <!-- AprilTag Scanner Modal -->
<%= if @apriltag_scanner_open do %> <%= 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="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"> <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">
@@ -716,15 +864,19 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</button> </button>
</div> </div>
<!-- AprilTag Scanner Interface --> <!-- AprilTag Scanner Interface -->
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center"> <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" /> <.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="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> <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 --> <!-- Test buttons for demo -->
<div class="mt-4 space-y-2"> <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 <button
phx-click="apriltag_scanned" phx-click="apriltag_scanned"
phx-value-apriltag_id="0" phx-value-apriltag_id="0"
@@ -746,7 +898,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
<% end %> <% end %>
<!-- Scanned Tags Display --> <!-- Scanned Tags Display -->
<%= if length(@scanned_tags) > 0 do %> <%= 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="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"> <div class="bg-green-50 border border-green-200 rounded-lg p-4">
@@ -760,13 +912,20 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</button> </button>
</div> </div>
<div class="space-y-2"> <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> <div>
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span> <span class="font-medium text-base-content">
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span> {location_display_name(scan.location)}
</span>
<span class="text-sm text-base-content/70 ml-2">
(AprilTag ID {scan.apriltag_id})
</span>
</div> </div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded"> <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> </span>
</div> </div>
</div> </div>
@@ -774,12 +933,14 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
<% end %> <% 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="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="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300"> <div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2> <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> </div>
<%= if Enum.empty?(@storage_locations) do %> <%= if Enum.empty?(@storage_locations) do %>
@@ -794,8 +955,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
phx-click="show_add_form" 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" 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" /> <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
Add Location
</button> </button>
</div> </div>
</div> </div>
@@ -804,7 +964,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<!-- Recursive Storage Location Tree --> <!-- Recursive Storage Location Tree -->
<%= for location <- root_storage_locations(@storage_locations) do %> <%= for location <- root_storage_locations(@storage_locations) do %>
<div class="px-6 py-4"> <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> </div>
<% end %> <% end %>
</div> </div>

View File

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

View File

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

View File

@@ -60,13 +60,13 @@ defmodule ComponentsElixir.MixProject do
depth: 1}, depth: 1},
{:swoosh, "~> 1.16"}, {:swoosh, "~> 1.16"},
{:req, "~> 0.5"}, {:req, "~> 0.5"},
{:qr_code, "~> 3.1"},
{:telemetry_metrics, "~> 1.0"}, {:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"}, {:telemetry_poller, "~> 1.0"},
{:gettext, "~> 0.26"}, {:gettext, "~> 0.26"},
{:jason, "~> 1.2"}, {:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"}, {:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"} {:bandit, "~> 1.5"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
] ]
end 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"}, "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"}, "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"}, "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"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "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"}, "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"}, "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"}, "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"}, "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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"}, "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": {: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"}, "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"}, "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"}, "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"}, "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"}, "tailwind": {:hex, :tailwind, "0.4.0", "4b2606713080437e3d94a0fa26527e7425737abc001f172b484b42f43e3274c0", [:mix], [], "hexpm", "530bd35699333f8ea0e9038d7146c2f0932dfec2e3636bd4a8016380c4bc382e"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, "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"}, "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": {: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"}, "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]) create unique_index(:storage_locations, [:apriltag_id])
# Add constraint to ensure apriltag_id is in valid range (0-586 for tag36h11) # 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 # 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 # 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 # 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 # Inside the script, you can read and write to any of your
# repositories directly: # repositories directly:
# #
@@ -10,7 +16,7 @@
# We recommend using the bang functions (`insert!`, `update!` # We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong. # and so on) as they will fail if something goes wrong.
alias ComponentsElixir.{Repo, Inventory} alias ComponentsElixir.{Repo, Inventory, AprilTag}
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation} alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
# Clear existing data # Clear existing data
@@ -19,84 +25,208 @@ Repo.delete_all(Category)
Repo.delete_all(StorageLocation) Repo.delete_all(StorageLocation)
# Create categories # Create categories
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"}) {:ok, resistors} =
{:ok, capacitors} = Inventory.create_category(%{name: "Capacitors", description: "Electrolytic, ceramic, and film capacitors"}) Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
{:ok, semiconductors} = Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} = Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"}) {: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 # Create subcategories
{:ok, _through_hole_resistors} = Inventory.create_category(%{ {:ok, _through_hole_resistors} =
name: "Through-hole", Inventory.create_category(%{
description: "Traditional leaded resistors", name: "Through-hole",
parent_id: resistors.id description: "Traditional leaded resistors",
}) parent_id: resistors.id
})
{:ok, _smd_resistors} = Inventory.create_category(%{ {:ok, _smd_resistors} =
name: "SMD/SMT", Inventory.create_category(%{
description: "Surface mount resistors", name: "SMD/SMT",
parent_id: resistors.id description: "Surface mount resistors",
}) parent_id: resistors.id
})
{:ok, _ceramic_caps} = Inventory.create_category(%{ {:ok, _ceramic_caps} =
name: "Ceramic", Inventory.create_category(%{
description: "Ceramic disc and multilayer capacitors", name: "Ceramic",
parent_id: capacitors.id description: "Ceramic disc and multilayer capacitors",
}) parent_id: capacitors.id
})
{:ok, _electrolytic_caps} = Inventory.create_category(%{ {:ok, _electrolytic_caps} =
name: "Electrolytic", Inventory.create_category(%{
description: "Polarized electrolytic capacitors", name: "Electrolytic",
parent_id: capacitors.id 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 # Create storage locations
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"}) {:ok, shelf_a} =
{:ok, shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"}) 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 # Create drawers on Shelf A
{:ok, drawer_a1} = Inventory.create_storage_location(%{ {:ok, drawer_a1} =
name: "Drawer 1", Inventory.create_storage_location(%{
description: "Resistors and capacitors", name: "Drawer 1",
parent_id: shelf_a.id description: "Resistors and capacitors",
}) parent_id: shelf_a.id
})
{:ok, drawer_a2} = Inventory.create_storage_location(%{ {:ok, drawer_a2} =
name: "Drawer 2", Inventory.create_storage_location(%{
description: "Semiconductors and ICs", name: "Drawer 2",
parent_id: shelf_a.id description: "Semiconductors and ICs",
}) parent_id: shelf_a.id
})
# Create boxes in Drawer A1 # Create boxes in Drawer A1
{:ok, box_a1_1} = Inventory.create_storage_location(%{ {:ok, box_a1_1} =
name: "Box 1", Inventory.create_storage_location(%{
description: "Through-hole resistors", name: "Box 1",
parent_id: drawer_a1.id description: "Through-hole resistors",
}) parent_id: drawer_a1.id
})
{:ok, box_a1_2} = Inventory.create_storage_location(%{ {:ok, _box_a1_2} =
name: "Box 2", Inventory.create_storage_location(%{
description: "SMD resistors", name: "Box 2",
parent_id: drawer_a1.id description: "SMD resistors",
}) parent_id: drawer_a1.id
})
{:ok, box_a1_3} = Inventory.create_storage_location(%{ {:ok, box_a1_3} =
name: "Box 3", Inventory.create_storage_location(%{
description: "Ceramic capacitors", name: "Box 3",
parent_id: drawer_a1.id description: "Ceramic capacitors",
}) parent_id: drawer_a1.id
})
# Create boxes in Drawer A2 # Create boxes in Drawer A2
{:ok, box_a2_1} = Inventory.create_storage_location(%{ {:ok, box_a2_1} =
name: "Box 1", Inventory.create_storage_location(%{
description: "Microcontrollers", name: "Box 1",
parent_id: drawer_a2.id description: "Microcontrollers",
}) parent_id: drawer_a2.id
})
{:ok, _box_a2_2} = Inventory.create_storage_location(%{ {:ok, _box_a2_2} =
name: "Box 2", Inventory.create_storage_location(%{
description: "Transistors and diodes", name: "Box 2",
parent_id: drawer_a2.id 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 # Create sample components
sample_components = [ sample_components = [
@@ -138,7 +268,8 @@ sample_components = [
keywords: "microcontroller avr atmega328 arduino", keywords: "microcontroller avr atmega328 arduino",
storage_location_id: box_a2_1.id, storage_location_id: box_a2_1.id,
count: 10, count: 10,
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf", datasheet_url:
"https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
category_id: semiconductors.id category_id: semiconductors.id
}, },
%{ %{
@@ -180,6 +311,23 @@ sample_components = [
storage_location_id: box_a1_1.id, storage_location_id: box_a1_1.id,
count: 100, count: 100,
category_id: resistors.id 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("Storage Locations: #{length(Inventory.list_storage_locations())}")
IO.puts("Components: #{length(Inventory.list_components())}") IO.puts("Components: #{length(Inventory.list_components())}")
IO.puts("") IO.puts("")
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 = [ sample_locations = [
Inventory.get_storage_location!(shelf_a.id), Inventory.get_storage_location!(shelf_a.id),
Inventory.get_storage_location!(drawer_a1.id), Inventory.get_storage_location!(drawer_a1.id),
Inventory.get_storage_location!(box_a1_1.id), Inventory.get_storage_location!(box_a1_1.id),
Inventory.get_storage_location!(box_a2_1.id) Inventory.get_storage_location!(box_a2_1.id)
] ]
Enum.each(sample_locations, fn location -> Enum.each(sample_locations, fn location ->
qr_data = ComponentsElixir.QRCode.generate_qr_data(location) if location.apriltag_id do
IO.puts("#{location.path}: #{qr_data}") 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) 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("")
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 end
test "renders 500.html" do 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
end end

View File

@@ -2,7 +2,9 @@ defmodule ComponentsElixirWeb.ErrorJSONTest do
use ComponentsElixirWeb.ConnCase, async: true use ComponentsElixirWeb.ConnCase, async: true
test "renders 404" do 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 end
test "renders 500" do test "renders 500" do

View File

@@ -3,6 +3,7 @@ defmodule ComponentsElixirWeb.PageControllerTest do
test "GET /", %{conn: conn} do test "GET /", %{conn: conn} do
conn = get(conn, ~p"/") 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
end end