Compare commits
34 Commits
1bdfea8d02
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6548a06b43 | ||
|
|
7ce80b6026 | ||
|
|
d620a9c620 | ||
|
|
4c7751f1ea | ||
| a714d5a28f | |||
| e33f700485 | |||
| cff6680f3a | |||
| 49b639e422 | |||
|
|
3b15318372 | ||
|
|
04db36c38d | ||
|
|
537a97cecc | ||
|
|
a6991b6877 | ||
|
|
32dea59c74 | ||
|
|
c6c218970c | ||
|
|
aaf278f7f9 | ||
|
|
f4ee768c52 | ||
|
|
72484c0d08 | ||
|
|
5d2e3f7768 | ||
|
|
086bc65ac1 | ||
|
|
c4a0b41e7d | ||
|
|
288d84614a | ||
|
|
8fe199f50c | ||
|
|
b68f8d92f7 | ||
|
|
a0348c7df9 | ||
|
|
e078770557 | ||
|
|
264adbfb98 | ||
|
|
963c9a3770 | ||
|
|
5a1775e836 | ||
|
|
6a1122c3be | ||
|
|
b6e137632a | ||
|
|
8848986953 | ||
|
|
68b0c0714e | ||
|
|
76b0a97d31 | ||
|
|
fa9bf74fd9 |
121
.gitea/workflows/README.md
Normal file
121
.gitea/workflows/README.md
Normal 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
|
||||
82
.gitea/workflows/code-quality.yml
Normal file
82
.gitea/workflows/code-quality.yml
Normal 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
|
||||
75
.gitea/workflows/docker-build.yml
Normal file
75
.gitea/workflows/docker-build.yml
Normal 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
125
.github/copilot-instructions.md
vendored
Normal 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
4
.gitignore
vendored
@@ -35,8 +35,8 @@ components_elixir-*.tar
|
||||
npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
# Ignore all user-generated content (uploads, QR codes, etc.)
|
||||
/priv/static/user_generated/
|
||||
# Ignore all dynamicly uploaded files.
|
||||
/uploads/
|
||||
|
||||
# Ignore customized Docker Compose file.
|
||||
docker-compose.yml
|
||||
|
||||
@@ -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.
|
||||
@@ -88,6 +88,10 @@ ENV LC_ALL en_US.UTF-8
|
||||
WORKDIR "/app"
|
||||
RUN chown nobody /app
|
||||
|
||||
# Create data directory for uploads
|
||||
RUN mkdir -p /data/uploads/images && \
|
||||
chown -R nobody:root /data/uploads
|
||||
|
||||
# set runner ENV
|
||||
ENV MIX_ENV="prod"
|
||||
|
||||
|
||||
440
README.md
440
README.md
@@ -1,48 +1,52 @@
|
||||
# Components Inventory - Elixir/Phoenix Implementation
|
||||
# Electronic Components Inventory System
|
||||
|
||||
A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inventory management system.
|
||||
A modern, comprehensive electronic component inventory management system built with Elixir/Phoenix LiveView. Features real-time updates, hierarchical organization, and advanced search capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
### ✨ Improvements over the original PHP version:
|
||||
|
||||
1. **Modern Architecture**
|
||||
- Phoenix LiveView for real-time, reactive UI without JavaScript
|
||||
- Ecto for type-safe database operations
|
||||
- Proper separation of concerns with Phoenix contexts
|
||||
- Built-in validation and error handling
|
||||
|
||||
2. **Enhanced User Experience**
|
||||
- Real-time search with no page refreshes
|
||||
- Responsive design with Tailwind CSS
|
||||
- Loading states and better feedback
|
||||
- Improved mobile experience
|
||||
|
||||
3. **Better Data Management**
|
||||
- Full-text search with PostgreSQL
|
||||
- Hierarchical categories with parent-child relationships
|
||||
- Proper foreign key constraints
|
||||
- Database migrations for schema management
|
||||
|
||||
4. **Security & Reliability**
|
||||
- CSRF protection built-in
|
||||
- SQL injection prevention through Ecto
|
||||
- Session-based authentication
|
||||
- Input validation and sanitization
|
||||
|
||||
### 🔧 Core Functionality
|
||||
|
||||
- **Component Management**: Add, edit, delete, and track electronic components
|
||||
- **Component Management**: Add, edit, delete, and track electronic components with real-time updates
|
||||
- **Inventory Tracking**: Monitor component quantities with increment/decrement buttons
|
||||
- **Search & Filter**: Fast search across component names, descriptions, and keywords
|
||||
- **Category Organization**: Hierarchical category system for better organization
|
||||
- **Category Management**: Add, edit, delete categories through the web interface with hierarchical support
|
||||
- **Storage Location System**: Hierarchical storage locations (shelf → drawer → box) with automatic AprilTag generation
|
||||
- **AprilTag Integration**: Automatic AprilTag generation and display for all storage locations with download capability
|
||||
- **Datasheet Links**: Direct links to component datasheets
|
||||
- **Real-time Updates**: All changes are immediately reflected in the interface
|
||||
- **Advanced Search & Filtering**:
|
||||
- Fast full-text search across component names, descriptions, and keywords
|
||||
- Filter by categories and storage locations (including subcategories/sublocations)
|
||||
- Clickable category and location filters for quick navigation
|
||||
- **Hierarchical Organization**:
|
||||
- Unlimited nesting for both categories and storage locations
|
||||
- Collapsible tree views for easy navigation
|
||||
- Visual breadcrumb paths (e.g., "Electronics > Resistors > Through-hole")
|
||||
- **Datasheet Management**:
|
||||
- Upload PDF datasheets directly or provide URLs for automatic download
|
||||
- Automatic datasheet retrieval from URLs with validation
|
||||
- Visual indicators for components with datasheets
|
||||
- Direct PDF viewing and download
|
||||
- **Storage Location System**:
|
||||
- Hierarchical locations (shelf → drawer → box) with AprilTag integration
|
||||
- Automatic AprilTag generation for physical labeling
|
||||
- Downloadable SVG AprilTags for printing
|
||||
- **Image Support**: Upload component images with preview and validation
|
||||
- **Real-time Interface**: All changes reflected immediately without page refresh
|
||||
|
||||
## Setup
|
||||
### 🎨 User Experience
|
||||
|
||||
- **Modern Responsive Design**: Works seamlessly on desktop and mobile
|
||||
- **Dark/Light Mode**: Automatic theme support with DaisyUI
|
||||
- **Interactive Components**:
|
||||
- Collapsible hierarchical views
|
||||
- Focus mode for detailed component viewing
|
||||
- Drag-and-drop file uploads with progress indicators
|
||||
- **Smart Navigation**: Clickable categories and locations for instant filtering
|
||||
- **Visual Feedback**: Loading states, progress bars, and clear error messages
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Elixir 1.15+
|
||||
- PostgreSQL 15+
|
||||
- Docker (optional, for containerized deployment)
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
@@ -51,143 +55,122 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
|
||||
|
||||
2. **Set up the database:**
|
||||
```bash
|
||||
# Using Docker (recommended)
|
||||
docker run --name components-postgres -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres
|
||||
|
||||
# Initialize database
|
||||
mix ecto.create
|
||||
mix ecto.migrate
|
||||
mix run priv/repo/seeds.exs
|
||||
```
|
||||
|
||||
3. **Start the server:**
|
||||
3. **Start the development server:**
|
||||
```bash
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
4. **Visit the application:**
|
||||
Open [http://localhost:4000](http://localhost:4000)
|
||||
4. **Access the application:**
|
||||
- Open [http://localhost:4000](http://localhost:4000)
|
||||
- Default password: `changeme`
|
||||
|
||||
## Authentication
|
||||
### Authentication
|
||||
|
||||
The application uses a simple password-based authentication system:
|
||||
- Default password: `changeme`
|
||||
- Set custom password via environment variable: `AUTH_PASSWORD=your_password`
|
||||
Simple password-based authentication:
|
||||
- Default: `changeme`
|
||||
- Custom: Set `AUTH_PASSWORD=your_password` environment variable
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Categories
|
||||
### Categories (Hierarchical)
|
||||
- `id`: Primary key
|
||||
- `name`: Category name (required)
|
||||
- `description`: Optional description
|
||||
- `parent_id`: Foreign key for hierarchical categories
|
||||
- Supports unlimited nesting levels
|
||||
- `parent_id`: Foreign key for hierarchical structure
|
||||
- **Features**: Unlimited nesting, full-path display, clickable filtering
|
||||
|
||||
### Storage Locations (Hierarchical)
|
||||
- `id`: Primary key
|
||||
- `name`: Location name (required)
|
||||
- `description`: Optional description
|
||||
- `parent_id`: Foreign key for hierarchical structure
|
||||
- `apriltag_id`: Optional AprilTag identifier for physical labeling
|
||||
- **Features**: AprilTag generation, SVG download, hierarchical filtering
|
||||
|
||||
### Components
|
||||
- `id`: Primary key
|
||||
- `name`: Component name (required)
|
||||
- `description`: Detailed description
|
||||
- `keywords`: Search keywords
|
||||
- `position`: Storage location/position
|
||||
- `count`: Current quantity (default: 0)
|
||||
- `datasheet_url`: Optional link to datasheet
|
||||
- `image_filename`: Optional image file name
|
||||
- `category_id`: Required foreign key to categories
|
||||
- `storage_location_id`: Optional foreign key to storage locations
|
||||
- `datasheet_url`: Optional URL to external datasheet
|
||||
- `datasheet_filename`: Optional uploaded PDF datasheet
|
||||
- `image_filename`: Optional uploaded component image
|
||||
|
||||
## Architecture
|
||||
## Technical Architecture
|
||||
|
||||
### Contexts
|
||||
- **`ComponentsElixir.Inventory`**: Business logic for components and categories
|
||||
- **`ComponentsElixir.Auth`**: Simple authentication system
|
||||
### Phoenix LiveView Application
|
||||
- **Real-time updates**: No page refreshes needed
|
||||
- **Phoenix Contexts**: Clean separation of business logic
|
||||
- **Ecto**: Type-safe database operations with migrations
|
||||
- **Authentication**: Session-based with CSRF protection
|
||||
|
||||
### Live Views
|
||||
- **`ComponentsElixirWeb.LoginLive`**: Authentication interface
|
||||
- **`ComponentsElixirWeb.ComponentsLive`**: Main component management interface
|
||||
- **`ComponentsElixirWeb.CategoriesLive`**: Category management interface
|
||||
- **`ComponentsElixirWeb.StorageLocationsLive`**: Hierarchical storage location management with AprilTags
|
||||
### Key Modules
|
||||
- `ComponentsElixir.Inventory`: Core business logic
|
||||
- `ComponentsElixir.DatasheetDownloader`: Automatic PDF retrieval
|
||||
- `ComponentsElixir.AprilTag`: SVG AprilTag generation
|
||||
- `ComponentsElixir.Inventory.Hierarchical`: Reusable hierarchy management
|
||||
- `ComponentsElixirWeb.*Live`: LiveView interfaces for real-time UI
|
||||
## Recent Features & Improvements
|
||||
|
||||
### Key Features
|
||||
- **Real-time updates**: Changes are immediately reflected without page refresh
|
||||
- **Infinite scroll**: Load more components as needed
|
||||
- **Search optimization**: Uses PostgreSQL full-text search for long queries, ILIKE for short ones
|
||||
- **Responsive design**: Works on desktop and mobile devices
|
||||
### ✅ Datasheet Management System
|
||||
- **Automatic Download**: Provide a URL and the system downloads the PDF automatically
|
||||
- **Direct Upload**: Upload PDF datasheets up to 10MB
|
||||
- **Smart Validation**: Ensures uploaded files are valid PDFs
|
||||
- **Visual Indicators**: Components with datasheets show clear visual cues
|
||||
- **Integrated Viewing**: Click component names to view datasheets directly
|
||||
|
||||
## API Comparison
|
||||
### ✅ Advanced Filtering & Navigation
|
||||
- **Hierarchical Filtering**: Filter by categories/locations including all subcategories/sublocations
|
||||
- **Click-to-Filter**: Click any category or location name to instantly filter components
|
||||
- **Collapsible Trees**: Expand/collapse category and storage location hierarchies
|
||||
- **Smart Search**: Combines full-text search with hierarchical filtering
|
||||
|
||||
| Original PHP | New Elixir/Phoenix | Improvement |
|
||||
|-------------|-------------------|-------------|
|
||||
| `getItems.php` | `Inventory.list_components/1` | Type-safe, composable queries |
|
||||
| `getCategories.php` | `Inventory.list_categories/0` | Proper associations, hierarchical support |
|
||||
| `addItem.php` | `Inventory.create_component/1` | Built-in validation, changesets |
|
||||
| Manual editing | `Inventory.update_component/2` | **NEW**: Full edit functionality with validation |
|
||||
| `changeAmount.php` | `Inventory.update_component_count/2` | Atomic operations, constraints |
|
||||
| Manual category management | `CategoriesLive` + `Inventory.create_category/1` | **NEW**: Full category CRUD with web interface |
|
||||
| Manual location tracking | `StorageLocationsLive` + `Inventory` context | **NEW**: Hierarchical storage locations with automatic AprilTags |
|
||||
| `imageUpload.php` | Phoenix LiveView file uploads with `.live_file_input` | **IMPLEMENTED**: Full image upload with preview, validation, and automatic cleanup |
|
||||
| Session management | Phoenix sessions + LiveView | Built-in CSRF protection |
|
||||
### ✅ Enhanced User Interface
|
||||
- **Focus Mode**: Detailed component view with full information display
|
||||
- **Responsive Design**: Optimized for mobile and desktop usage
|
||||
- **Consistent Sorting**: Robust sorting even with rapid data changes
|
||||
- **Visual Feedback**: Loading states, progress indicators, and clear error messages
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
### ✅ Production-Ready Deployment
|
||||
- **Docker Support**: Complete containerized deployment with database
|
||||
- **File Upload Optimization**: Improved handling in production environments
|
||||
- **Performance Tuning**: Optimized queries and caching for better responsiveness
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Component Management
|
||||
- **Barcode Support** - Generate and scan traditional barcodes in addition to AprilTags
|
||||
- **Camera Integration** - JavaScript-based AprilTag scanning with camera access for mobile/desktop
|
||||
- **Multi-AprilTag Detection** - Spatial analysis and disambiguation for multiple tags in same image
|
||||
- **Bulk Operations** - Import/export components from CSV, batch updates
|
||||
- **Search and Filtering** - Advanced search by specifications, tags, location
|
||||
- **Component Templates** - Reusable templates for common component types
|
||||
- **Version History** - Track changes to component specifications over time
|
||||
- **Barcode Support**: Generate and scan traditional barcodes alongside AprilTags
|
||||
- **Camera Integration**: Mobile/desktop AprilTag scanning with camera access
|
||||
- **Multi-Tag Detection**: Spatial analysis for multiple tags in the same view
|
||||
- **Bulk Operations**: CSV import/export and batch component updates
|
||||
- **Component Templates**: Reusable templates for common component types
|
||||
- **Specifications Tracking**: Detailed technical specifications with custom fields
|
||||
- **Version History**: Track changes to component data over time
|
||||
|
||||
### Storage Organization
|
||||
- **Physical Layout Mapping** - Visual representation of shelves, drawers, and boxes
|
||||
- **Bulk AprilTag Printing** - Generate printable sheets of AprilTags for labeling
|
||||
### Advanced Features
|
||||
- **API Integration**: Connect with distributor APIs for price and availability
|
||||
- **Low Stock Alerts**: Automatic notifications for components below threshold
|
||||
- **Bill of Materials (BOM)**: Project-based component tracking and allocation
|
||||
- **Purchase History**: Track component purchases and supplier information
|
||||
- **Location Mapping**: Visual representation of physical storage layout
|
||||
|
||||
## ✅ Recently Implemented Features
|
||||
|
||||
### Storage Location System Foundation ✅ **COMPLETED**
|
||||
- **Database Schema** ✅ Complete - Hierarchical storage locations with parent-child relationships
|
||||
- **Storage Location CRUD** ✅ Complete - Full create, read, update, delete operations via web interface
|
||||
- **Hierarchical Organization** ✅ Complete - Unlimited nesting (shelf → drawer → box)
|
||||
- **Web Interface** ✅ Complete - Storage locations management page with navigation
|
||||
- **Component-Storage Integration** ✅ Complete - Components can now be assigned to storage locations via dropdown interface
|
||||
|
||||
### AprilTag System 🚧 **PARTIALLY IMPLEMENTED**
|
||||
- **Visual AprilTag Generation** ❌ Partially Implemented - Placeholder SVGs generated
|
||||
- **Flexible Assignment Options** ✅ Complete - Auto-assign, manual selection, or no AprilTag assignment for storage locations
|
||||
- **AprilTag Download** ✅ Complete - Individual AprilTag SVG files can be downloaded for printing
|
||||
- **AprilTag Scanning** ❌ Missing - No camera integration or scanning functionality (future enhancement)
|
||||
- **AprilTag Processing** ❌ Missing - Backend logic for processing scanned tags (future enhancement)
|
||||
|
||||
### Image Upload System ✅ **COMPLETED**
|
||||
- **Phoenix LiveView file uploads** with `.live_file_input` component
|
||||
- **Image preview** during upload with progress indication
|
||||
- **File validation** (JPG, PNG, GIF up to 5MB)
|
||||
- **Automatic cleanup** of old images when updated or deleted
|
||||
- **Responsive image display** in component listings with fallback placeholders
|
||||
- **Upload error handling** with user-friendly messages
|
||||
|
||||
### Visual Datasheet Indicators ✅ **COMPLETED**
|
||||
- **Datasheet emoji** (📄) displayed next to component names when datasheet URL is present
|
||||
- **Clickable datasheet links** with clear visual indication
|
||||
- **Improved component listing** with image thumbnails and datasheet indicators
|
||||
|
||||
### Technical Implementation Details
|
||||
|
||||
#### Image Upload Architecture
|
||||
- **LiveView uploads** configured with `allow_upload/3` in mount
|
||||
- **File processing** with `consume_uploaded_entries/3` for secure file handling
|
||||
- **Unique filename generation** to prevent conflicts
|
||||
- **Static file serving** through Phoenix.Plug.Static with `/uploads` path
|
||||
- **Database integration** with `image_filename` field in components schema
|
||||
|
||||
#### Upload Features
|
||||
- **File type validation**: Only JPG, PNG, GIF files accepted
|
||||
- **Size limits**: Maximum 5MB per file
|
||||
- **Single file uploads**: One image per component
|
||||
- **Progress indication**: Real-time upload progress display
|
||||
- **Cancel functionality**: Users can cancel uploads in progress
|
||||
- **Preview system**: Live image preview before form submission
|
||||
|
||||
#### File Management
|
||||
- **Automatic cleanup**: Old images deleted when new ones uploaded
|
||||
- **Orphan prevention**: Images deleted when components are removed
|
||||
- **Error handling**: Graceful fallback for missing or corrupted files
|
||||
- **Static serving**: Images served directly through Phoenix static file handler
|
||||
### AprilTag Enhancement
|
||||
- **Scanner App**: Dedicated mobile app for inventory management
|
||||
- **Batch Printing**: Generate printable sheets of multiple AprilTags
|
||||
- **Smart Scanning**: Context-aware actions based on scanned tags
|
||||
|
||||
## Development
|
||||
|
||||
@@ -207,143 +190,86 @@ mix credo
|
||||
# Reset database
|
||||
mix ecto.reset
|
||||
|
||||
# Add new migration
|
||||
# Create new migration
|
||||
mix ecto.gen.migration add_feature
|
||||
|
||||
# Check migration status
|
||||
mix ecto.migrations
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
The project includes:
|
||||
- **Hot code reloading** for rapid development
|
||||
- **TailwindCSS** with live recompilation
|
||||
- **DaisyUI** for consistent component styling
|
||||
- **Database seeding** with sample data
|
||||
|
||||
## Deployment
|
||||
|
||||
### 🐳 Docker Deployment (Recommended)
|
||||
|
||||
#### Prerequisites
|
||||
- Docker and Docker Compose installed
|
||||
- Git for cloning the repository
|
||||
Docker provides the easiest deployment method with a pre-built container image including all dependencies.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. **Clone the repository:**
|
||||
1. **Download the docker-compose file:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd components_elixir
|
||||
```
|
||||
|
||||
2. **Copy docker-compose.yml.example to docker-compose.yml**
|
||||
Follow steps in [Customizing Docker Deployment](#customizing-docker-deployment).
|
||||
|
||||
3. **Build and run with Docker Compose:**
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
**⚠️ Build Time Notice**: The initial build can take 5-15 minutes because:
|
||||
- Tailwind CSS downloads ~500MB from GitHub (can be very slow)
|
||||
- ESBuild downloads additional build tools
|
||||
- Elixir compiles all dependencies
|
||||
- Network conditions may significantly affect download speeds
|
||||
|
||||
4. **Access the application:**
|
||||
- Open [http://localhost:4000](http://localhost:4000)
|
||||
- Default password: `changeme`
|
||||
|
||||
#### Docker Configuration Files
|
||||
|
||||
The project includes these Docker files:
|
||||
|
||||
- **`Dockerfile`** - Multi-stage build for production
|
||||
- **`docker-compose.yml`** - Local development/testing setup
|
||||
- **`.dockerignore`** - Excludes unnecessary files from build context
|
||||
|
||||
#### Customizing Docker Deployment
|
||||
|
||||
1. **Environment Variables**: Edit `docker-compose.yml` to customize:
|
||||
```yaml
|
||||
environment:
|
||||
DATABASE_URL: "ecto://postgres:postgres@db:5432/components_elixir_prod"
|
||||
SECRET_KEY_BASE: "your-secret-key-here" # Generate with: mix phx.gen.secret
|
||||
PHX_HOST: "localhost" # Change to your domain
|
||||
PHX_SERVER: "true"
|
||||
PORT: "4000"
|
||||
curl -O https://git.maxboeer.com/schuwi/component-system/raw/branch/main/docker-compose.yml.example
|
||||
mv docker-compose.yml.example docker-compose.yml
|
||||
```
|
||||
|
||||
2. **Generate a secure secret key:**
|
||||
|
||||
**With Elixir/Phoenix installed:**
|
||||
```bash
|
||||
# Run this locally to generate a new secret
|
||||
mix phx.gen.secret
|
||||
```
|
||||
|
||||
**Without Elixir/Phoenix (Linux/Unix):**
|
||||
```bash
|
||||
dd if=/dev/random bs=1 count=64 status=none | base64 -w0 | cut -c1-64
|
||||
```
|
||||
|
||||
> **Note**: The SECRET_KEY_BASE must be a cryptographically random string that's at least 64 characters long. Phoenix uses it to sign session cookies, CSRF tokens, and other security-sensitive data.
|
||||
|
||||
3. **Database Configuration**: The default setup includes:
|
||||
- PostgreSQL 15 container
|
||||
- Automatic database creation
|
||||
- Health checks to ensure proper startup order
|
||||
- Persistent data storage with Docker volumes
|
||||
3. **Configure environment** (edit `docker-compose.yml`):
|
||||
```yaml
|
||||
environment:
|
||||
SECRET_KEY_BASE: "your-generated-64-character-secret-key"
|
||||
AUTH_PASSWORD: "your-secure-password" # Login password for the app
|
||||
PHX_HOST: "localhost" # Change to your domain
|
||||
```
|
||||
|
||||
#### Production Docker Deployment
|
||||
4. **Deploy:**
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Access:** [http://localhost:4000](http://localhost:4000)
|
||||
|
||||
The container image is automatically built and published from the main branch at https://git.maxboeer.com/schuwi/component-system.
|
||||
|
||||
#### Production Configuration
|
||||
|
||||
For production environments:
|
||||
|
||||
1. **Create a production docker-compose.yml:**
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: components_user
|
||||
POSTGRES_PASSWORD: secure_db_password
|
||||
POSTGRES_DB: components_elixir_prod
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "80:4000"
|
||||
environment:
|
||||
DATABASE_URL: "ecto://components_user:secure_db_password@db:5432/components_elixir_prod"
|
||||
SECRET_KEY_BASE: "your-64-char-secret-key"
|
||||
PHX_HOST: "yourdomain.com"
|
||||
PHX_SERVER: "true"
|
||||
PORT: "4000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
command: ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
2. **Deploy to production:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
#### Docker Troubleshooting
|
||||
|
||||
**Build Issues:**
|
||||
- **Slow Tailwind download**: This is normal - GitHub releases can be slow
|
||||
- **Network timeouts**: Retry the build with `docker compose up --build`
|
||||
- **AprilTag compilation errors**: Ensure `apriltags.ps` file exists in project root
|
||||
|
||||
**Runtime Issues:**
|
||||
- **Database connection errors**: Wait for PostgreSQL health check to pass
|
||||
- **Permission errors**: Check file ownership and Docker user permissions
|
||||
- **Port conflicts**: Change the port mapping in docker-compose.yml
|
||||
|
||||
**Performance:**
|
||||
- **Slow startup**: First-time container startup includes database initialization
|
||||
- **Memory usage**: Elixir/Phoenix applications typically use 50-200MB RAM
|
||||
- **Storage**: PostgreSQL data persists in Docker volumes
|
||||
- **Use specific versions**: Pin to specific tags like `git.maxboeer.com/schuwi/components-elixir:v1.0.0` instead of `:latest`
|
||||
- **Available tags**:
|
||||
- `:latest` - Latest stable release from main branch
|
||||
- `:main` - Latest build from main branch
|
||||
- `:v*` - Specific version tags
|
||||
- `:snapshot-<hash>` - Specific commit builds
|
||||
- **Generate secure keys**: Generate a 64+ character random string for SECRET_KEY_BASE (see Quick Start section for methods)
|
||||
- **Set strong passwords**: Use AUTH_PASSWORD environment variable
|
||||
- **Configure domain**: Set PHX_HOST to your actual domain
|
||||
- **Database security**: Use strong PostgreSQL credentials
|
||||
- **SSL/HTTPS**: Configure reverse proxy (nginx, Caddy) for SSL termination
|
||||
|
||||
### 🚀 Traditional Deployment
|
||||
|
||||
For production deployment without Docker:
|
||||
For deployment without Docker:
|
||||
|
||||
1. **Set environment variables:**
|
||||
1. **Environment setup:**
|
||||
```bash
|
||||
export AUTH_PASSWORD=your_secure_password
|
||||
export SECRET_KEY_BASE=your_secret_key
|
||||
@@ -355,18 +281,24 @@ For production deployment without Docker:
|
||||
MIX_ENV=prod mix release
|
||||
```
|
||||
|
||||
3. **Run migrations:**
|
||||
3. **Database setup:**
|
||||
```bash
|
||||
_build/prod/rel/components_elixir/bin/components_elixir eval "ComponentsElixir.Release.migrate"
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a modernized, idiomatic Elixir/Phoenix implementation that maintains feature parity with the original PHP version while providing significant improvements in code quality, security, and user experience.
|
||||
This project follows Phoenix and Elixir best practices:
|
||||
|
||||
The application follows Phoenix and Elixir best practices:
|
||||
- Contexts for business logic
|
||||
- LiveView for interactive UI
|
||||
- Ecto for database operations
|
||||
- Comprehensive error handling
|
||||
- Input validation and sanitization
|
||||
- **Phoenix Contexts** for business logic separation
|
||||
- **LiveView** for real-time, interactive user interfaces
|
||||
- **Ecto** for type-safe database operations
|
||||
- **Comprehensive testing** with ExUnit
|
||||
- **Input validation** and sanitization throughout
|
||||
- **CSRF protection** and security best practices
|
||||
|
||||
The codebase emphasizes:
|
||||
- **Maintainability**: Clear module organization and documentation
|
||||
- **Performance**: Optimized queries and efficient data structures
|
||||
- **Reliability**: Comprehensive error handling and graceful degradation
|
||||
- **Extensibility**: Modular design for easy feature additions
|
||||
@@ -1,6 +1,6 @@
|
||||
import Config
|
||||
|
||||
# Configure your database
|
||||
# Configure the database
|
||||
config :components_elixir, ComponentsElixir.Repo,
|
||||
username: "postgres",
|
||||
password: "fCnPB8VQdPkhJAD29hq6sZEY",
|
||||
@@ -10,8 +10,13 @@ config :components_elixir, ComponentsElixir.Repo,
|
||||
show_sensitive_data_on_connection_error: true,
|
||||
pool_size: 10
|
||||
|
||||
# For development, we disable any cache and enable
|
||||
# debugging and code reloading.
|
||||
# For development work, log all queries
|
||||
# config :components_elixir, ComponentsElixir.Repo, log: false
|
||||
|
||||
# For development, use a local uploads directory
|
||||
config :components_elixir,
|
||||
uploads_dir: "./uploads"
|
||||
|
||||
#
|
||||
# The watchers configuration can be used to run external
|
||||
# watchers to your application. For example, we can use it
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import Config
|
||||
|
||||
# Runtime configuration for uploads directory
|
||||
config :components_elixir,
|
||||
uploads_dir: System.get_env("UPLOADS_DIR", "./uploads")
|
||||
|
||||
# config/runtime.exs is executed for all environments, including
|
||||
# during releases. It is executed after compilation and before the
|
||||
# system starts, so it is typically used to load production configuration
|
||||
|
||||
@@ -7,8 +7,8 @@ import Config
|
||||
# Run `mix help test` for more information.
|
||||
config :components_elixir, ComponentsElixir.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
hostname: "localhost",
|
||||
password: System.get_env("POSTGRES_PASSWORD") || "fCnPB8VQdPkhJAD29hq6sZEY",
|
||||
hostname: System.get_env("POSTGRES_HOSTNAME") || "localhost",
|
||||
database: "components_elixir_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
pool_size: System.schedulers_online() * 2
|
||||
|
||||
@@ -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.
|
||||
@@ -16,7 +16,7 @@ services:
|
||||
retries: 5
|
||||
|
||||
app:
|
||||
build: .
|
||||
image: git.maxboeer.com/schuwi/components-elixir:latest
|
||||
ports:
|
||||
- "4000:4000"
|
||||
environment:
|
||||
@@ -25,9 +25,12 @@ services:
|
||||
PHX_HOST: "localhost"
|
||||
PHX_SERVER: "true"
|
||||
PORT: "4000"
|
||||
UPLOADS_DIR: "/data/uploads"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- uploaded_files:/data/uploads
|
||||
command:
|
||||
[
|
||||
"/bin/sh",
|
||||
@@ -37,3 +40,4 @@ services:
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploaded_files:
|
||||
|
||||
@@ -31,6 +31,7 @@ defmodule ComponentsElixir.AprilTag do
|
||||
def valid_apriltag_id?(id) when is_integer(id) do
|
||||
id >= 0 and id < @tag36h11_count
|
||||
end
|
||||
|
||||
def valid_apriltag_id?(_), do: false
|
||||
|
||||
@doc """
|
||||
@@ -78,8 +79,8 @@ defmodule ComponentsElixir.AprilTag do
|
||||
def used_apriltag_ids do
|
||||
ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
end
|
||||
|
||||
@@ -130,10 +131,11 @@ defmodule ComponentsElixir.AprilTag do
|
||||
This should be run once during setup to pre-generate all AprilTag images.
|
||||
"""
|
||||
def generate_all_apriltag_svgs(opts \\ []) do
|
||||
static_dir = Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static"),
|
||||
"apriltags"
|
||||
])
|
||||
static_dir =
|
||||
Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static"),
|
||||
"apriltags"
|
||||
])
|
||||
|
||||
# Ensure directory exists
|
||||
File.mkdir_p!(static_dir)
|
||||
@@ -143,21 +145,7 @@ defmodule ComponentsElixir.AprilTag do
|
||||
results =
|
||||
all_apriltag_ids()
|
||||
|> Task.async_stream(
|
||||
fn apriltag_id ->
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join(static_dir, filename)
|
||||
|
||||
if force_regenerate || !File.exists?(file_path) do
|
||||
svg_content = generate_apriltag_svg(apriltag_id, opts)
|
||||
|
||||
case File.write(file_path, svg_content) do
|
||||
:ok -> {:ok, apriltag_id, file_path}
|
||||
{:error, reason} -> {:error, apriltag_id, reason}
|
||||
end
|
||||
else
|
||||
{:ok, apriltag_id, file_path}
|
||||
end
|
||||
end,
|
||||
&generate_apriltag_file(&1, static_dir, force_regenerate, opts),
|
||||
timeout: :infinity,
|
||||
max_concurrency: System.schedulers_online() * 2
|
||||
)
|
||||
@@ -174,6 +162,26 @@ defmodule ComponentsElixir.AprilTag do
|
||||
}
|
||||
end
|
||||
|
||||
defp generate_apriltag_file(apriltag_id, static_dir, force_regenerate, opts) do
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join(static_dir, filename)
|
||||
|
||||
if force_regenerate || !File.exists?(file_path) do
|
||||
write_apriltag_file(apriltag_id, file_path, opts)
|
||||
else
|
||||
{:ok, apriltag_id, file_path}
|
||||
end
|
||||
end
|
||||
|
||||
defp write_apriltag_file(apriltag_id, file_path, opts) do
|
||||
svg_content = generate_apriltag_svg(apriltag_id, opts)
|
||||
|
||||
case File.write(file_path, svg_content) do
|
||||
:ok -> {:ok, apriltag_id, file_path}
|
||||
{:error, reason} -> {:error, apriltag_id, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up AprilTag SVG file for a specific ID.
|
||||
|
||||
@@ -181,10 +189,12 @@ defmodule ComponentsElixir.AprilTag do
|
||||
"""
|
||||
def cleanup_apriltag_svg(apriltag_id) do
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static/apriltags"),
|
||||
filename
|
||||
])
|
||||
|
||||
file_path =
|
||||
Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static/apriltags"),
|
||||
filename
|
||||
])
|
||||
|
||||
if File.exists?(file_path) do
|
||||
File.rm(file_path)
|
||||
|
||||
@@ -16,6 +16,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
|
||||
[_, id_str, hex_pattern] ->
|
||||
{String.to_integer(id_str), String.downcase(hex_pattern)}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
@@ -23,26 +24,30 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
# Extract patterns from PostScript file at compile time
|
||||
@all_patterns (
|
||||
path = Path.join([File.cwd!(), "apriltags.ps"])
|
||||
path = Path.join([File.cwd!(), "apriltags.ps"])
|
||||
|
||||
if File.exists?(path) do
|
||||
File.read!(path)
|
||||
|> String.split("\n")
|
||||
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|
||||
|> Enum.map(fn line ->
|
||||
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
|
||||
[_, id_str, hex_pattern] ->
|
||||
{String.to_integer(id_str), String.downcase(hex_pattern)}
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Map.new()
|
||||
else
|
||||
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
|
||||
end
|
||||
)
|
||||
if File.exists?(path) do
|
||||
File.read!(path)
|
||||
|> String.split("\n")
|
||||
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|
||||
|> Enum.map(fn line ->
|
||||
case Regex.run(
|
||||
~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/,
|
||||
line
|
||||
) do
|
||||
[_, id_str, hex_pattern] ->
|
||||
{String.to_integer(id_str), String.downcase(hex_pattern)}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Map.new()
|
||||
else
|
||||
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
|
||||
end
|
||||
)
|
||||
|
||||
# Sample of real tag36h11 hex patterns from AprilRobotics repository
|
||||
# This will be populated with patterns extracted from the PostScript file
|
||||
@@ -64,7 +69,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Map.new()
|
||||
else
|
||||
%{} # Return empty map if file not found, will fall back to hardcoded patterns
|
||||
# Return empty map if file not found, will fall back to hardcoded patterns
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,6 +82,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do
|
||||
Map.get(@tag36h11_patterns, id)
|
||||
end
|
||||
|
||||
def get_hex_pattern(_), do: nil
|
||||
|
||||
@doc """
|
||||
@@ -97,6 +104,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
# Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp)
|
||||
row_bytes = 3
|
||||
|
||||
rows =
|
||||
for row <- 0..9 do
|
||||
<<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes
|
||||
@@ -104,12 +112,23 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
samples =
|
||||
[
|
||||
b0 >>> 6 &&& 0x3, b0 >>> 4 &&& 0x3, b0 >>> 2 &&& 0x3, b0 &&& 0x3,
|
||||
b1 >>> 6 &&& 0x3, b1 >>> 4 &&& 0x3, b1 >>> 2 &&& 0x3, b1 &&& 0x3,
|
||||
b2 >>> 6 &&& 0x3, b2 >>> 4 &&& 0x3, b2 >>> 2 &&& 0x3, b2 &&& 0x3
|
||||
b0 >>> 6 &&& 0x3,
|
||||
b0 >>> 4 &&& 0x3,
|
||||
b0 >>> 2 &&& 0x3,
|
||||
b0 &&& 0x3,
|
||||
b1 >>> 6 &&& 0x3,
|
||||
b1 >>> 4 &&& 0x3,
|
||||
b1 >>> 2 &&& 0x3,
|
||||
b1 &&& 0x3,
|
||||
b2 >>> 6 &&& 0x3,
|
||||
b2 >>> 4 &&& 0x3,
|
||||
b2 >>> 2 &&& 0x3,
|
||||
b2 &&& 0x3
|
||||
]
|
||||
|> Enum.take(10) # drop the 2 padding samples at end of row
|
||||
|> Enum.map(&(&1 == 0)) # 0 = black, 3 = white → boolean
|
||||
# drop the 2 padding samples at end of row
|
||||
|> Enum.take(10)
|
||||
# 0 = black, 3 = white → boolean
|
||||
|> Enum.map(&(&1 == 0))
|
||||
|
||||
samples
|
||||
end
|
||||
@@ -133,7 +152,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
Only black modules are drawn over a white background.
|
||||
"""
|
||||
def binary_matrix_to_svg(binary_matrix, opts \\ []) do
|
||||
size = Keyword.get(opts, :size, 200) # final CSS size in px
|
||||
# final CSS size in px
|
||||
size = Keyword.get(opts, :size, 200)
|
||||
id_text = Keyword.get(opts, :id_text, "")
|
||||
|
||||
# binary_matrix is 10x10 of booleans: true=black, false=white
|
||||
@@ -172,9 +192,11 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
<!-- caption -->
|
||||
#{if id_text != "" do
|
||||
~s(<text x="#{modules_w/2}" y="#{modules_h + 1.4}" text-anchor="middle"
|
||||
~s(<text x="#{modules_w / 2}" y="#{modules_h + 1.4}" text-anchor="middle"
|
||||
font-family="Arial" font-size="0.9">#{id_text}</text>)
|
||||
else "" end}
|
||||
else
|
||||
""
|
||||
end}
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
@@ -197,11 +219,13 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
opts_with_id = Keyword.put(opts, :id_text, id_text)
|
||||
binary_matrix_to_svg(binary_matrix, opts_with_id)
|
||||
end
|
||||
end # Generate a placeholder pattern for IDs we don't have real data for yet
|
||||
end
|
||||
|
||||
# Generate a placeholder pattern for IDs we don't have real data for yet
|
||||
defp generate_placeholder_svg(id, opts) do
|
||||
size = Keyword.get(opts, :size, 200)
|
||||
margin = Keyword.get(opts, :margin, div(size, 10))
|
||||
square_size = size - (2 * margin)
|
||||
square_size = size - 2 * margin
|
||||
|
||||
"""
|
||||
<svg width="#{size}" height="#{size + 30}" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
151
lib/components_elixir/datasheet_downloader.ex
Normal file
151
lib/components_elixir/datasheet_downloader.ex
Normal 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
|
||||
@@ -11,99 +11,42 @@ defmodule ComponentsElixir.Inventory do
|
||||
## Storage Locations
|
||||
|
||||
@doc """
|
||||
Returns the list of storage locations with computed hierarchy fields.
|
||||
Returns the list of storage locations with optimized parent preloading.
|
||||
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
||||
"""
|
||||
def list_storage_locations do
|
||||
# Get all locations with preloaded parents in a single query
|
||||
locations = StorageLocation
|
||||
|> order_by([sl], [asc: sl.name])
|
||||
|> preload(:parent)
|
||||
|> Repo.all()
|
||||
|
||||
# Compute hierarchy fields for all locations efficiently
|
||||
processed_locations = compute_hierarchy_fields_batch(locations)
|
||||
|> Enum.sort_by(&{&1.level, &1.name})
|
||||
locations =
|
||||
StorageLocation
|
||||
|> order_by([sl], asc: sl.name)
|
||||
|> preload(parent: [parent: [parent: [parent: :parent]]])
|
||||
|> Repo.all()
|
||||
|
||||
# Ensure AprilTag SVGs exist for all locations
|
||||
spawn(fn ->
|
||||
ComponentsElixir.AprilTag.generate_all_apriltag_svgs()
|
||||
end)
|
||||
|
||||
processed_locations
|
||||
locations
|
||||
end
|
||||
|
||||
# Efficient batch computation of hierarchy fields
|
||||
defp compute_hierarchy_fields_batch(locations) do
|
||||
# Create a map for quick parent lookup to avoid N+1 queries
|
||||
location_map = Map.new(locations, fn loc -> {loc.id, loc} end)
|
||||
|
||||
Enum.map(locations, fn location ->
|
||||
level = compute_level_efficient(location, location_map, 0)
|
||||
path = compute_path_efficient(location, location_map, 0)
|
||||
|
||||
%{location | level: level, path: path}
|
||||
end)
|
||||
end
|
||||
|
||||
defp compute_level_efficient(%{parent_id: nil}, _location_map, _depth), do: 0
|
||||
defp compute_level_efficient(%{parent_id: parent_id}, location_map, depth) when depth < 10 do
|
||||
case Map.get(location_map, parent_id) do
|
||||
nil -> 0 # Orphaned record
|
||||
parent -> 1 + compute_level_efficient(parent, location_map, depth + 1)
|
||||
end
|
||||
end
|
||||
defp compute_level_efficient(_location, _location_map, _depth), do: 0 # Prevent infinite recursion
|
||||
|
||||
defp compute_path_efficient(%{parent_id: nil, name: name}, _location_map, _depth), do: name
|
||||
defp compute_path_efficient(%{parent_id: parent_id, name: name}, location_map, depth) when depth < 10 do
|
||||
case Map.get(location_map, parent_id) do
|
||||
nil -> name # Orphaned record
|
||||
parent ->
|
||||
parent_path = compute_path_efficient(parent, location_map, depth + 1)
|
||||
"#{parent_path}/#{name}"
|
||||
end
|
||||
end
|
||||
defp compute_path_efficient(%{name: name}, _location_map, _depth), do: name # Prevent infinite recursion
|
||||
|
||||
@doc """
|
||||
Returns the list of root storage locations (no parent).
|
||||
"""
|
||||
def list_root_storage_locations do
|
||||
StorageLocation
|
||||
|> where([sl], is_nil(sl.parent_id))
|
||||
|> order_by([sl], [asc: sl.name])
|
||||
|> order_by([sl], asc: sl.name)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single storage location with computed hierarchy fields.
|
||||
Gets a single storage location with preloaded associations.
|
||||
"""
|
||||
def get_storage_location!(id) do
|
||||
location = StorageLocation
|
||||
StorageLocation
|
||||
|> preload(:parent)
|
||||
|> Repo.get!(id)
|
||||
|
||||
# Compute hierarchy fields
|
||||
level = compute_level_for_single(location)
|
||||
path = compute_path_for_single(location)
|
||||
%{location | level: level, path: path}
|
||||
end
|
||||
|
||||
# Simple computation for single location (allows DB queries)
|
||||
defp compute_level_for_single(%{parent_id: nil}), do: 0
|
||||
defp compute_level_for_single(%{parent_id: parent_id}) do
|
||||
case Repo.get(StorageLocation, parent_id) do
|
||||
nil -> 0
|
||||
parent -> 1 + compute_level_for_single(parent)
|
||||
end
|
||||
end
|
||||
|
||||
defp compute_path_for_single(%{parent_id: nil, name: name}), do: name
|
||||
defp compute_path_for_single(%{parent_id: parent_id, name: name}) do
|
||||
case Repo.get(StorageLocation, parent_id) do
|
||||
nil -> name
|
||||
parent -> "#{compute_path_for_single(parent)}/#{name}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -114,13 +57,6 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> where([sl], sl.apriltag_id == ^apriltag_id)
|
||||
|> preload(:parent)
|
||||
|> Repo.one()
|
||||
|> case do
|
||||
nil -> nil
|
||||
location ->
|
||||
level = compute_level_for_single(location)
|
||||
path = compute_path_for_single(location)
|
||||
%{location | level: level, path: path}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -130,13 +66,15 @@ defmodule ComponentsElixir.Inventory do
|
||||
# Convert string keys to atoms to maintain consistency
|
||||
attrs = normalize_string_keys(attrs)
|
||||
|
||||
result = %StorageLocation{}
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
result =
|
||||
%StorageLocation{}
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|
||||
case result do
|
||||
{:ok, location} ->
|
||||
{:ok, location}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
@@ -149,13 +87,15 @@ defmodule ComponentsElixir.Inventory do
|
||||
# Convert string keys to atoms to maintain consistency
|
||||
attrs = normalize_string_keys(attrs)
|
||||
|
||||
result = storage_location
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.update()
|
||||
result =
|
||||
storage_location
|
||||
|> StorageLocation.changeset(attrs)
|
||||
|> Repo.update()
|
||||
|
||||
case result do
|
||||
{:ok, updated_location} ->
|
||||
{:ok, updated_location}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
@@ -182,12 +122,14 @@ defmodule ComponentsElixir.Inventory do
|
||||
case get_storage_location_by_apriltag_id(apriltag_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
location ->
|
||||
{:ok, %{
|
||||
type: :storage_location,
|
||||
location: location,
|
||||
apriltag_id: apriltag_id
|
||||
}}
|
||||
{:ok,
|
||||
%{
|
||||
type: :storage_location,
|
||||
location: location,
|
||||
apriltag_id: apriltag_id
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -195,8 +137,9 @@ defmodule ComponentsElixir.Inventory do
|
||||
Computes the path for a storage location (for display purposes).
|
||||
"""
|
||||
def compute_storage_location_path(nil), do: nil
|
||||
|
||||
def compute_storage_location_path(%StorageLocation{} = location) do
|
||||
compute_path_for_single(location)
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
# Convert string keys to atoms for consistency
|
||||
@@ -205,6 +148,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
atom_key = String.to_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
|
||||
{key, value}, acc ->
|
||||
Map.put(acc, key, value)
|
||||
end)
|
||||
@@ -213,11 +157,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
## Categories
|
||||
|
||||
@doc """
|
||||
Returns the list of categories.
|
||||
Returns the list of categories with optimized parent preloading.
|
||||
Preloads up to 5 levels of parent associations to minimize database queries in full_path calculations.
|
||||
"""
|
||||
def list_categories do
|
||||
Category
|
||||
|> preload(:parent)
|
||||
|> preload(parent: [parent: [parent: [parent: :parent]]])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -258,6 +203,63 @@ defmodule ComponentsElixir.Inventory do
|
||||
Category.changeset(category, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets all category IDs that are descendants of the given category ID, including the category itself.
|
||||
This is used for filtering components by category and all its subcategories.
|
||||
Returns an empty list if the category doesn't exist.
|
||||
|
||||
Note: This implementation loads all categories into memory for traversal, which is efficient
|
||||
for typical category tree sizes (hundreds of categories). For very large category trees,
|
||||
a recursive CTE query could be used instead.
|
||||
"""
|
||||
def get_category_and_descendant_ids(category_id) when is_integer(category_id) do
|
||||
categories = list_categories()
|
||||
|
||||
# Verify the category exists before getting descendants
|
||||
case Enum.find(categories, &(&1.id == category_id)) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
_category ->
|
||||
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
|
||||
categories,
|
||||
category_id,
|
||||
& &1.parent_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def get_category_and_descendant_ids(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets all storage location IDs that are descendants of the given storage location ID, including the location itself.
|
||||
This is used for filtering components by storage location and all its sub-locations.
|
||||
Returns an empty list if the storage location doesn't exist.
|
||||
|
||||
Note: This implementation loads all storage locations into memory for traversal, which is efficient
|
||||
for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
|
||||
a recursive CTE query could be used instead.
|
||||
"""
|
||||
def get_storage_location_and_descendant_ids(storage_location_id)
|
||||
when is_integer(storage_location_id) do
|
||||
storage_locations = list_storage_locations()
|
||||
|
||||
# Verify the storage location exists before getting descendants
|
||||
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
_storage_location ->
|
||||
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
|
||||
storage_locations,
|
||||
storage_location_id,
|
||||
& &1.parent_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def get_storage_location_and_descendant_ids(_), do: []
|
||||
|
||||
## Components
|
||||
|
||||
@doc """
|
||||
@@ -266,7 +268,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
def list_components(opts \\ []) do
|
||||
Component
|
||||
|> apply_component_filters(opts)
|
||||
|> order_by([c], [asc: c.name])
|
||||
|> apply_component_sorting(opts)
|
||||
|> preload([:category, :storage_location])
|
||||
|> Repo.all()
|
||||
end
|
||||
@@ -274,24 +276,54 @@ defmodule ComponentsElixir.Inventory do
|
||||
defp apply_component_filters(query, opts) do
|
||||
Enum.reduce(opts, query, fn
|
||||
{:category_id, category_id}, query when not is_nil(category_id) ->
|
||||
where(query, [c], c.category_id == ^category_id)
|
||||
# Get the category and all its descendant category IDs
|
||||
category_ids = get_category_and_descendant_ids(category_id)
|
||||
where(query, [c], c.category_id in ^category_ids)
|
||||
|
||||
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
|
||||
where(query, [c], c.storage_location_id == ^storage_location_id)
|
||||
# Get the storage location and all its descendant storage location IDs
|
||||
storage_location_ids = get_storage_location_and_descendant_ids(storage_location_id)
|
||||
where(query, [c], c.storage_location_id in ^storage_location_ids)
|
||||
|
||||
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
|
||||
search_pattern = "%#{search_term}%"
|
||||
where(query, [c],
|
||||
|
||||
where(
|
||||
query,
|
||||
[c],
|
||||
ilike(c.name, ^search_pattern) or
|
||||
ilike(c.description, ^search_pattern) or
|
||||
ilike(c.keywords, ^search_pattern) or
|
||||
ilike(c.position, ^search_pattern)
|
||||
ilike(c.description, ^search_pattern) or
|
||||
ilike(c.keywords, ^search_pattern) or
|
||||
ilike(c.position, ^search_pattern)
|
||||
)
|
||||
|
||||
_, query -> query
|
||||
_, query ->
|
||||
query
|
||||
end)
|
||||
end
|
||||
|
||||
defp apply_component_sorting(query, opts) do
|
||||
sort_criteria = Keyword.get(opts, :sort_criteria, "name_asc")
|
||||
sort_order = get_sort_order(sort_criteria)
|
||||
order_by(query, [c], ^sort_order)
|
||||
end
|
||||
|
||||
# Map of sort criteria to their corresponding sort orders
|
||||
@sort_orders %{
|
||||
"name_asc" => [asc: :name, asc: :id],
|
||||
"name_desc" => [desc: :name, asc: :id],
|
||||
"inserted_at_asc" => [asc: :inserted_at, asc: :id],
|
||||
"inserted_at_desc" => [desc: :inserted_at, asc: :id],
|
||||
"updated_at_asc" => [asc: :updated_at, asc: :id],
|
||||
"updated_at_desc" => [desc: :updated_at, asc: :id],
|
||||
"count_asc" => [asc: :count, asc: :id],
|
||||
"count_desc" => [desc: :count, asc: :id]
|
||||
}
|
||||
|
||||
defp get_sort_order(criteria) do
|
||||
Map.get(@sort_orders, criteria, asc: :name, asc: :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single component.
|
||||
"""
|
||||
@@ -310,6 +342,32 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a component and downloads datasheet from URL if provided.
|
||||
"""
|
||||
def create_component_with_datasheet(attrs \\ %{}) do
|
||||
# If a datasheet_url is provided, download it
|
||||
updated_attrs =
|
||||
case Map.get(attrs, "datasheet_url") do
|
||||
url when is_binary(url) and url != "" ->
|
||||
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||
{:ok, filename} ->
|
||||
Map.put(attrs, "datasheet_filename", filename)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Continue without datasheet file if download fails
|
||||
attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
|
||||
%Component{}
|
||||
|> Component.changeset(updated_attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component.
|
||||
"""
|
||||
@@ -319,6 +377,39 @@ defmodule ComponentsElixir.Inventory do
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a component and downloads datasheet from URL if provided.
|
||||
"""
|
||||
def update_component_with_datasheet(%Component{} = component, attrs) do
|
||||
# If a datasheet_url is provided and changed, download it
|
||||
updated_attrs =
|
||||
case Map.get(attrs, "datasheet_url") do
|
||||
url when is_binary(url) and url != "" and url != component.datasheet_url ->
|
||||
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||
{:ok, filename} ->
|
||||
# Delete old datasheet file if it exists
|
||||
if component.datasheet_filename do
|
||||
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
|
||||
component.datasheet_filename
|
||||
)
|
||||
end
|
||||
|
||||
Map.put(attrs, "datasheet_filename", filename)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Keep existing filename if download fails
|
||||
attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
|
||||
component
|
||||
|> Component.changeset(updated_attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a component.
|
||||
"""
|
||||
@@ -339,10 +430,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
def get_inventory_stats do
|
||||
total_components = Repo.aggregate(Component, :count, :id)
|
||||
|
||||
total_stock = Component
|
||||
total_stock =
|
||||
Component
|
||||
|> Repo.aggregate(:sum, :count)
|
||||
|
||||
categories_with_components = Component
|
||||
categories_with_components =
|
||||
Component
|
||||
|> distinct([c], c.category_id)
|
||||
|> Repo.aggregate(:count, :category_id)
|
||||
|
||||
@@ -392,6 +485,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
"""
|
||||
def decrement_component_count(%Component{} = component) do
|
||||
new_count = max(0, component.count - 1)
|
||||
|
||||
component
|
||||
|> Component.changeset(%{count: new_count})
|
||||
|> Repo.update()
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
Categories can be hierarchical with parent-child relationships.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import Ecto.Changeset
|
||||
|
||||
alias ComponentsElixir.Inventory.{Category, Component}
|
||||
@@ -17,7 +18,7 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
has_many :children, Category, foreign_key: :parent_id
|
||||
has_many :components, Component
|
||||
|
||||
timestamps()
|
||||
timestamps(type: :naive_datetime_usec)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@@ -34,11 +35,20 @@ defmodule ComponentsElixir.Inventory.Category do
|
||||
@doc """
|
||||
Returns the full path of the category including parent names.
|
||||
"""
|
||||
def full_path(%Category{parent: nil} = category), do: category.name
|
||||
def full_path(%Category{parent: %Category{} = parent} = category) do
|
||||
"#{full_path(parent)} > #{category.name}"
|
||||
end
|
||||
def full_path(%Category{parent: %Ecto.Association.NotLoaded{}} = category) do
|
||||
category.name
|
||||
@impl true
|
||||
def full_path(%Category{} = category) do
|
||||
Hierarchical.full_path(category, & &1.parent, path_separator())
|
||||
end
|
||||
|
||||
@impl true
|
||||
def parent(%Category{parent: parent}), do: parent
|
||||
|
||||
@impl true
|
||||
def children(%Category{children: children}), do: children
|
||||
|
||||
@impl true
|
||||
def path_separator(), do: " > "
|
||||
|
||||
@impl true
|
||||
def entity_type(), do: :category
|
||||
end
|
||||
|
||||
@@ -18,18 +18,30 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
field :legacy_position, :string
|
||||
field :count, :integer, default: 0
|
||||
field :datasheet_url, :string
|
||||
field :datasheet_filename, :string
|
||||
field :image_filename, :string
|
||||
|
||||
belongs_to :category, Category
|
||||
belongs_to :storage_location, StorageLocation
|
||||
|
||||
timestamps()
|
||||
timestamps(type: :naive_datetime_usec)
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :image_filename, :category_id, :storage_location_id])
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:description,
|
||||
:keywords,
|
||||
:position,
|
||||
:count,
|
||||
:datasheet_url,
|
||||
:datasheet_filename,
|
||||
:image_filename,
|
||||
:category_id,
|
||||
:storage_location_id
|
||||
])
|
||||
|> validate_required([:name, :category_id])
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> validate_length(:description, max: 2000)
|
||||
@@ -59,25 +71,43 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
|> cast(attrs, [:image_filename])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Changeset for updating component datasheet.
|
||||
"""
|
||||
def datasheet_changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:datasheet_filename])
|
||||
end
|
||||
|
||||
defp validate_url(changeset, field) do
|
||||
validate_change(changeset, field, fn ^field, url ->
|
||||
if url && url != "" do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] -> []
|
||||
_ -> [{field, "must be a valid URL"}]
|
||||
end
|
||||
else
|
||||
[]
|
||||
cond do
|
||||
is_nil(url) or url == "" -> []
|
||||
valid_url?(url) -> []
|
||||
true -> [{field, "must be a valid URL"}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp valid_url?(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the component has an image.
|
||||
"""
|
||||
def has_image?(%__MODULE__{image_filename: filename}) when is_binary(filename), do: true
|
||||
def has_image?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns true if the component has a datasheet file.
|
||||
"""
|
||||
def has_datasheet?(%__MODULE__{datasheet_filename: filename}) when is_binary(filename), do: true
|
||||
def has_datasheet?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns the search text for this component.
|
||||
"""
|
||||
|
||||
358
lib/components_elixir/inventory/hierarchical.ex
Normal file
358
lib/components_elixir/inventory/hierarchical.ex
Normal 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
|
||||
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal file
41
lib/components_elixir/inventory/hierarchical_schema.ex
Normal 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
|
||||
@@ -3,9 +3,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
Schema for storage locations with hierarchical organization.
|
||||
|
||||
Storage locations can be nested (shelf -> drawer -> box) and each
|
||||
has a unique QR code for quick scanning and identification.
|
||||
has a unique AprilTag for quick scanning and identification.
|
||||
"""
|
||||
use Ecto.Schema
|
||||
use ComponentsElixir.Inventory.HierarchicalSchema
|
||||
import Ecto.Changeset
|
||||
import Ecto.Query
|
||||
|
||||
@@ -15,70 +16,44 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
field :apriltag_id, :integer
|
||||
field :is_active, :boolean, default: true
|
||||
|
||||
# Computed/virtual fields - not stored in database
|
||||
field :level, :integer, virtual: true
|
||||
field :path, :string, virtual: true
|
||||
|
||||
# Only parent relationship is stored
|
||||
belongs_to :parent, StorageLocation
|
||||
has_many :children, StorageLocation, foreign_key: :parent_id
|
||||
has_many :components, Component
|
||||
|
||||
timestamps()
|
||||
timestamps(type: :naive_datetime_usec)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@doc false
|
||||
def changeset(storage_location, attrs) do
|
||||
storage_location
|
||||
|> cast(attrs, [:name, :description, :parent_id, :is_active, :apriltag_id])
|
||||
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
|
||||
|> validate_required([:name])
|
||||
|> validate_length(:name, min: 1, max: 100)
|
||||
|> validate_length(:description, max: 500)
|
||||
|> validate_apriltag_id()
|
||||
|> foreign_key_constraint(:parent_id)
|
||||
|> validate_no_circular_reference()
|
||||
|> put_apriltag_id()
|
||||
end
|
||||
|
||||
# Prevent circular references (location being its own ancestor)
|
||||
defp validate_no_circular_reference(changeset) do
|
||||
case get_change(changeset, :parent_id) do
|
||||
nil -> changeset
|
||||
parent_id ->
|
||||
location_id = changeset.data.id
|
||||
if location_id && would_create_cycle?(location_id, parent_id) do
|
||||
add_error(changeset, :parent_id, "cannot be a descendant of this location")
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
# HierarchicalSchema implementations
|
||||
@impl true
|
||||
def full_path(%StorageLocation{} = storage_location) do
|
||||
Hierarchical.full_path(storage_location, & &1.parent, path_separator())
|
||||
end
|
||||
|
||||
defp would_create_cycle?(location_id, parent_id) do
|
||||
# Check if parent_id is the same as location_id or any of its descendants
|
||||
location_id == parent_id or
|
||||
(parent_id && is_descendant_of?(parent_id, location_id))
|
||||
end
|
||||
@impl true
|
||||
def parent(%StorageLocation{parent: parent}), do: parent
|
||||
|
||||
defp is_descendant_of?(potential_descendant, ancestor_id) do
|
||||
case ComponentsElixir.Repo.get(StorageLocation, potential_descendant) do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: ^ancestor_id} -> true
|
||||
%{parent_id: parent_id} -> is_descendant_of?(parent_id, ancestor_id)
|
||||
end
|
||||
end
|
||||
@impl true
|
||||
def children(%StorageLocation{children: children}), do: children
|
||||
|
||||
@doc """
|
||||
Returns the full hierarchical path as a human-readable string.
|
||||
"""
|
||||
def full_path(storage_location) do
|
||||
storage_location.path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" → ")
|
||||
end
|
||||
@impl true
|
||||
def path_separator(), do: " / "
|
||||
|
||||
@impl true
|
||||
def entity_type(), do: :storage_location
|
||||
|
||||
@doc """
|
||||
Returns the AprilTag format for this storage location.
|
||||
@@ -103,35 +78,14 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
end
|
||||
end
|
||||
|
||||
# Compute the hierarchy level based on parent chain
|
||||
def compute_level(%StorageLocation{parent_id: nil}), do: 0
|
||||
def compute_level(%StorageLocation{parent: %StorageLocation{} = parent}) do
|
||||
compute_level(parent) + 1
|
||||
end
|
||||
def compute_level(%StorageLocation{parent_id: parent_id}) when not is_nil(parent_id) do
|
||||
# Parent not loaded, fetch it
|
||||
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
|
||||
compute_level(parent) + 1
|
||||
end
|
||||
|
||||
# Compute the full path based on parent chain
|
||||
def compute_path(%StorageLocation{name: name, parent_id: nil}), do: name
|
||||
def compute_path(%StorageLocation{name: name, parent: %StorageLocation{} = parent}) do
|
||||
"#{compute_path(parent)}/#{name}"
|
||||
end
|
||||
def compute_path(%StorageLocation{name: name, parent_id: parent_id}) when not is_nil(parent_id) do
|
||||
# Parent not loaded, fetch it
|
||||
parent = ComponentsElixir.Inventory.get_storage_location!(parent_id)
|
||||
"#{compute_path(parent)}/#{name}"
|
||||
end
|
||||
|
||||
defp get_next_available_apriltag_id do
|
||||
# Get all used AprilTag IDs
|
||||
used_ids = ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
used_ids =
|
||||
ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
|
||||
# Find the first available ID (0-586)
|
||||
0..586
|
||||
@@ -140,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
nil ->
|
||||
# All IDs are used - this should be handled at the application level
|
||||
raise "All AprilTag IDs are in use"
|
||||
id -> id
|
||||
|
||||
id ->
|
||||
id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@ defmodule ComponentsElixirWeb do
|
||||
those modules here.
|
||||
"""
|
||||
|
||||
def static_paths, do: ~w(assets fonts images user_generated apriltags favicon.ico robots.txt)
|
||||
def static_paths, do: ~w(assets fonts images apriltags favicon.ico robots.txt)
|
||||
|
||||
def router do
|
||||
quote do
|
||||
|
||||
@@ -195,7 +195,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
|
||||
name={@name}
|
||||
value="true"
|
||||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm border-base-300 checked:bg-primary checked:border-primary"}
|
||||
class={
|
||||
@class ||
|
||||
"checkbox checkbox-sm border-base-300 checked:bg-primary checked:border-primary"
|
||||
}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
</span>
|
||||
@@ -213,7 +216,10 @@ defmodule ComponentsElixirWeb.CoreComponents do
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[@class || "w-full select bg-base-100 border-base-300 text-base-content", @errors != [] && (@error_class || "select-error border-error")]}
|
||||
class={[
|
||||
@class || "w-full select bg-base-100 border-base-300 text-base-content",
|
||||
@errors != [] && (@error_class || "select-error border-error")
|
||||
]}
|
||||
multiple={@multiple}
|
||||
{@rest}
|
||||
>
|
||||
@@ -235,7 +241,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
|
||||
id={@id}
|
||||
name={@name}
|
||||
class={[
|
||||
@class || "w-full textarea bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
|
||||
@class ||
|
||||
"w-full textarea bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
|
||||
@errors != [] && (@error_class || "textarea-error border-error")
|
||||
]}
|
||||
{@rest}
|
||||
@@ -258,7 +265,8 @@ defmodule ComponentsElixirWeb.CoreComponents do
|
||||
id={@id}
|
||||
value={Phoenix.HTML.Form.normalize_value(@type, @value)}
|
||||
class={[
|
||||
@class || "w-full input bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
|
||||
@class ||
|
||||
"w-full input bg-base-100 border-base-300 text-base-content placeholder:text-base-content/50",
|
||||
@errors != [] && (@error_class || "input-error border-error")
|
||||
]}
|
||||
{@rest}
|
||||
|
||||
93
lib/components_elixir_web/controllers/file_controller.ex
Normal file
93
lib/components_elixir_web/controllers/file_controller.ex
Normal 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
|
||||
@@ -2,14 +2,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.Category
|
||||
alias ComponentsElixir.Inventory.{Category, Hierarchical}
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Check authentication
|
||||
unless Auth.authenticated?(session) do
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
else
|
||||
if Auth.authenticated?(session) do
|
||||
categories = Inventory.list_categories()
|
||||
|
||||
{:ok,
|
||||
@@ -20,7 +18,10 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
|> assign(:show_edit_form, false)
|
||||
|> assign(:editing_category, nil)
|
||||
|> assign(:form, nil)
|
||||
|> assign(:expanded_categories, MapSet.new())
|
||||
|> assign(:page_title, "Category Management")}
|
||||
else
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,12 +46,13 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
def handle_event("show_edit_form", %{"id" => id}, socket) do
|
||||
category = Inventory.get_category!(id)
|
||||
# Create a changeset with current values forced into changes for proper form display
|
||||
changeset = Inventory.change_category(category, %{
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
parent_id: category.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
|
||||
changeset =
|
||||
Inventory.change_category(category, %{
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
parent_id: category.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
|
||||
|
||||
form = to_form(changeset)
|
||||
|
||||
@@ -111,55 +113,49 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
|> reload_categories()}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Cannot delete category - it may have components assigned or child categories")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Cannot delete category - it may have components assigned or child categories"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_expand", %{"id" => id}, socket) do
|
||||
category_id = String.to_integer(id)
|
||||
expanded_categories = socket.assigns.expanded_categories
|
||||
|
||||
new_expanded =
|
||||
if MapSet.member?(expanded_categories, category_id) do
|
||||
MapSet.delete(expanded_categories, category_id)
|
||||
else
|
||||
MapSet.put(expanded_categories, category_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :expanded_categories, new_expanded)}
|
||||
end
|
||||
|
||||
defp reload_categories(socket) do
|
||||
categories = Inventory.list_categories()
|
||||
assign(socket, :categories, categories)
|
||||
end
|
||||
|
||||
defp parent_category_options(categories, editing_category_id \\ nil) do
|
||||
available_categories =
|
||||
categories
|
||||
|> Enum.reject(fn cat ->
|
||||
cat.id == editing_category_id ||
|
||||
(editing_category_id && is_descendant?(categories, cat.id, editing_category_id))
|
||||
end)
|
||||
|> Enum.map(fn category ->
|
||||
{category_display_name(category), category.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root category)", nil}] ++ available_categories
|
||||
end
|
||||
|
||||
defp is_descendant?(categories, descendant_id, ancestor_id) do
|
||||
# Check if descendant_id is a descendant of ancestor_id
|
||||
descendant = Enum.find(categories, fn cat -> cat.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: parent_id} when parent_id == ancestor_id -> true
|
||||
%{parent_id: parent_id} -> is_descendant?(categories, parent_id, ancestor_id)
|
||||
end
|
||||
end
|
||||
|
||||
defp category_display_name(category) do
|
||||
if category.parent do
|
||||
"#{category.parent.name} > #{category.name}"
|
||||
else
|
||||
category.name
|
||||
end
|
||||
Hierarchical.parent_select_options(
|
||||
categories,
|
||||
editing_category_id,
|
||||
& &1.parent,
|
||||
"No parent (Root category)"
|
||||
)
|
||||
end
|
||||
|
||||
defp root_categories(categories) do
|
||||
Enum.filter(categories, fn cat -> is_nil(cat.parent_id) end)
|
||||
Hierarchical.root_entities(categories, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp child_categories(categories, parent_id) do
|
||||
Enum.filter(categories, fn cat -> cat.parent_id == parent_id end)
|
||||
Hierarchical.child_entities(categories, parent_id, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp count_components_in_category(category_id) do
|
||||
@@ -175,63 +171,181 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
|
||||
|
||||
# Icon size and button size based on depth
|
||||
{icon_size, button_size, text_size, title_tag} = case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
{icon_size, button_size, text_size, title_tag} =
|
||||
case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
|
||||
assigns = assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:children, child_categories(assigns.categories, assigns.category.id))
|
||||
children = child_categories(assigns.categories, assigns.category.id)
|
||||
has_children = !Enum.empty?(children)
|
||||
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
|
||||
|
||||
# Calculate component counts including descendants
|
||||
{self_count, children_count, _total_count} =
|
||||
Hierarchical.count_with_descendants(
|
||||
assigns.category.id,
|
||||
assigns.categories,
|
||||
& &1.parent_id,
|
||||
&count_components_in_category/1
|
||||
)
|
||||
|
||||
# Format count display
|
||||
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:children, children)
|
||||
|> assign(:has_children, has_children)
|
||||
|> assign(:is_expanded, is_expanded)
|
||||
|> assign(:count_display, count_display)
|
||||
|
||||
~H"""
|
||||
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center">
|
||||
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mr-3"} />
|
||||
<div>
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h3>
|
||||
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
|
||||
<div class="flex items-start flex-1 space-x-2">
|
||||
<!-- Expand/Collapse button - always aligned to top -->
|
||||
<%= if @has_children do %>
|
||||
<button
|
||||
phx-click="toggle_expand"
|
||||
phx-value-id={@category.id}
|
||||
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
|
||||
>
|
||||
<%= if @is_expanded do %>
|
||||
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@category.name}</h4>
|
||||
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
|
||||
<% end %>
|
||||
<%= if @category.description do %>
|
||||
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
|
||||
<% end %>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
{count_components_in_category(@category.id)} components
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-6"></div>
|
||||
<!-- Spacer for alignment -->
|
||||
<% end %>
|
||||
|
||||
<.icon
|
||||
name="hero-folder"
|
||||
class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
|
||||
/>
|
||||
|
||||
<!-- Content area - always starts at same vertical position -->
|
||||
<div class="flex-1">
|
||||
<!-- Minimized view (default) -->
|
||||
<%= unless @is_expanded do %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?category_id=#{@category.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@category.name}
|
||||
</.link>
|
||||
</h3>
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?category_id=#{@category.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@category.name}
|
||||
</.link>
|
||||
</h4>
|
||||
<% end %>
|
||||
<span class="text-xs text-base-content/50">
|
||||
({@count_display})
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@category.id}
|
||||
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_category"
|
||||
phx-value-id={@category.id}
|
||||
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
|
||||
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Expanded view -->
|
||||
<%= if @is_expanded do %>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?category_id=#{@category.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@category.name}
|
||||
</.link>
|
||||
</h3>
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?category_id=#{@category.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@category.name}
|
||||
</.link>
|
||||
</h4>
|
||||
<% end %>
|
||||
<%= if @category.description do %>
|
||||
<p class="text-sm text-base-content/60 mt-1">{@category.description}</p>
|
||||
<% end %>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
{@count_display}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@category.id}
|
||||
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
|
||||
>
|
||||
<.icon name="hero-pencil" class={@icon_size} />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_category"
|
||||
phx-value-id={@category.id}
|
||||
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
|
||||
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
|
||||
>
|
||||
<.icon name="hero-trash" class={@icon_size} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@category.id}
|
||||
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"}
|
||||
>
|
||||
<.icon name="hero-pencil" class={@icon_size} />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_category"
|
||||
phx-value-id={@category.id}
|
||||
data-confirm="Are you sure you want to delete this category? This action cannot be undone."
|
||||
class={"inline-flex items-center #{@button_size} border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"}
|
||||
>
|
||||
<.icon name="hero-trash" class={@icon_size} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Render children recursively -->
|
||||
<%= for child <- @children do %>
|
||||
<.category_item category={child} categories={@categories} depth={@depth + 1} />
|
||||
|
||||
<!-- Render children recursively (only when expanded) -->
|
||||
<%= if @is_expanded do %>
|
||||
<%= for child <- @children do %>
|
||||
<.category_item
|
||||
category={child}
|
||||
categories={@categories}
|
||||
expanded_categories={@expanded_categories}
|
||||
depth={@depth + 1}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
@@ -273,8 +387,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Category Modal -->
|
||||
|
||||
<!-- Add Category Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -329,8 +443,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -385,13 +499,15 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Categories List -->
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">Manage your component categories and subcategories</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Manage your component categories and subcategories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@categories) do %>
|
||||
@@ -406,8 +522,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
phx-click="show_add_form"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
||||
Add Category
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,7 +531,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
<!-- Recursive Category Tree -->
|
||||
<%= for category <- root_categories(@categories) do %>
|
||||
<div class="px-6 py-4">
|
||||
<.category_item category={category} categories={@categories} depth={0} />
|
||||
<.category_item
|
||||
category={category}
|
||||
categories={@categories}
|
||||
expanded_categories={@expanded_categories}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -66,7 +66,7 @@ defmodule ComponentsElixirWeb.LoginLive do
|
||||
|
||||
<%= if @error_message do %>
|
||||
<div class="text-red-600 text-sm text-center">
|
||||
<%= @error_message %>
|
||||
{@error_message}
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
@@ -5,15 +5,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.StorageLocation
|
||||
alias ComponentsElixir.Inventory.{StorageLocation, Hierarchical}
|
||||
alias ComponentsElixir.AprilTag
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Check authentication
|
||||
unless Auth.authenticated?(session) do
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
else
|
||||
if Auth.authenticated?(session) do
|
||||
storage_locations = list_storage_locations()
|
||||
|
||||
{:ok,
|
||||
@@ -26,7 +24,10 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|> assign(:form, nil)
|
||||
|> assign(:apriltag_scanner_open, false)
|
||||
|> assign(:scanned_tags, [])
|
||||
|> assign(:expanded_locations, MapSet.new())
|
||||
|> assign(:page_title, "Storage Location Management")}
|
||||
else
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,12 +54,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
def handle_event("show_edit_form", %{"id" => id}, socket) do
|
||||
location = Inventory.get_storage_location!(id)
|
||||
# Create a changeset with current values forced into changes for proper form display
|
||||
changeset = Inventory.change_storage_location(location, %{
|
||||
name: location.name,
|
||||
description: location.description,
|
||||
parent_id: location.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
|
||||
changeset =
|
||||
Inventory.change_storage_location(location, %{
|
||||
name: location.name,
|
||||
description: location.description,
|
||||
parent_id: location.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
|
||||
|
||||
form = to_form(changeset)
|
||||
|
||||
@@ -81,29 +83,31 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|
||||
def handle_event("save_location", %{"storage_location" => location_params}, socket) do
|
||||
# Process AprilTag assignment based on mode
|
||||
processed_params = case socket.assigns.apriltag_mode do
|
||||
"none" ->
|
||||
# Remove any apriltag_id from params to ensure it's nil
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
processed_params =
|
||||
case socket.assigns.apriltag_mode do
|
||||
"none" ->
|
||||
# Remove any apriltag_id from params to ensure it's nil
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
|
||||
"auto" ->
|
||||
# Auto-assign next available AprilTag ID
|
||||
case AprilTag.next_available_apriltag_id() do
|
||||
nil ->
|
||||
# No available IDs, proceed without AprilTag
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
apriltag_id ->
|
||||
Map.put(location_params, "apriltag_id", apriltag_id)
|
||||
end
|
||||
"auto" ->
|
||||
# Auto-assign next available AprilTag ID
|
||||
case AprilTag.next_available_apriltag_id() do
|
||||
nil ->
|
||||
# No available IDs, proceed without AprilTag
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
|
||||
"manual" ->
|
||||
# Use the manually entered apriltag_id (validation will be handled by changeset)
|
||||
location_params
|
||||
apriltag_id ->
|
||||
Map.put(location_params, "apriltag_id", apriltag_id)
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Fallback: remove apriltag_id
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
end
|
||||
"manual" ->
|
||||
# Use the manually entered apriltag_id (validation will be handled by changeset)
|
||||
location_params
|
||||
|
||||
_ ->
|
||||
# Fallback: remove apriltag_id
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
end
|
||||
|
||||
case Inventory.create_storage_location(processed_params) do
|
||||
{:ok, _location} ->
|
||||
@@ -146,7 +150,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|> reload_storage_locations()}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Cannot delete storage location - it may have components assigned or child locations"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -163,10 +172,17 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
|
||||
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Storage location not found for AprilTag ID: #{apriltag_id}"
|
||||
)}
|
||||
|
||||
location ->
|
||||
scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags]
|
||||
scanned_tags = [
|
||||
%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags
|
||||
]
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -183,25 +199,46 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
{:noreply, assign(socket, :scanned_tags, [])}
|
||||
end
|
||||
|
||||
def handle_event("toggle_expand", %{"id" => id}, socket) do
|
||||
location_id = String.to_integer(id)
|
||||
expanded_locations = socket.assigns.expanded_locations
|
||||
|
||||
new_expanded =
|
||||
if MapSet.member?(expanded_locations, location_id) do
|
||||
MapSet.delete(expanded_locations, location_id)
|
||||
else
|
||||
MapSet.put(expanded_locations, location_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :expanded_locations, new_expanded)}
|
||||
end
|
||||
|
||||
def handle_event("set_apriltag_mode", %{"mode" => mode}, socket) do
|
||||
{:noreply, assign(socket, :apriltag_mode, mode)}
|
||||
end
|
||||
|
||||
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
|
||||
# Clear the apriltag_id field when switching modes
|
||||
form = case mode do
|
||||
"remove" ->
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
|
||||
"keep" ->
|
||||
current_id = socket.assigns.editing_location.apriltag_id
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
|
||||
_ ->
|
||||
socket.assigns.form
|
||||
end
|
||||
form =
|
||||
case mode do
|
||||
"remove" ->
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
|
||||
|
||||
"keep" ->
|
||||
current_id = socket.assigns.editing_location.apriltag_id
|
||||
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(
|
||||
:params,
|
||||
Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)
|
||||
)
|
||||
|
||||
_ ->
|
||||
socket.assigns.form
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -220,7 +257,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
|
||||
|
||||
apriltag_url ->
|
||||
filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
|
||||
filename =
|
||||
"#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
|
||||
|
||||
# Send file download to browser
|
||||
{:noreply,
|
||||
@@ -240,48 +278,24 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
end
|
||||
|
||||
defp parent_location_options(storage_locations, editing_location_id \\ nil) do
|
||||
available_locations =
|
||||
storage_locations
|
||||
|> Enum.reject(fn loc ->
|
||||
loc.id == editing_location_id ||
|
||||
(editing_location_id && is_descendant?(storage_locations, loc.id, editing_location_id))
|
||||
end)
|
||||
|> Enum.map(fn location ->
|
||||
{location_display_name(location), location.id}
|
||||
end)
|
||||
|
||||
[{"No parent (Root location)", nil}] ++ available_locations
|
||||
end
|
||||
|
||||
defp is_descendant?(storage_locations, descendant_id, ancestor_id) do
|
||||
# Check if descendant_id is a descendant of ancestor_id
|
||||
descendant = Enum.find(storage_locations, fn loc -> loc.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
%{parent_id: nil} -> false
|
||||
%{parent_id: parent_id} when parent_id == ancestor_id -> true
|
||||
%{parent_id: parent_id} -> is_descendant?(storage_locations, parent_id, ancestor_id)
|
||||
end
|
||||
Hierarchical.parent_select_options(
|
||||
storage_locations,
|
||||
editing_location_id,
|
||||
& &1.parent,
|
||||
"No parent (Root location)"
|
||||
)
|
||||
end
|
||||
|
||||
defp location_display_name(location) do
|
||||
if location.path do
|
||||
# Convert path from "Shelf A/Drawer 2/Box 1" to "Shelf A > Drawer 2 > Box 1"
|
||||
location.path
|
||||
|> String.split("/")
|
||||
|> Enum.join(" > ")
|
||||
else
|
||||
location.name
|
||||
end
|
||||
StorageLocation.full_path(location)
|
||||
end
|
||||
|
||||
defp root_storage_locations(storage_locations) do
|
||||
Enum.filter(storage_locations, fn loc -> is_nil(loc.parent_id) end)
|
||||
Hierarchical.root_entities(storage_locations, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp child_storage_locations(storage_locations, parent_id) do
|
||||
Enum.filter(storage_locations, fn loc -> loc.parent_id == parent_id end)
|
||||
Hierarchical.child_entities(storage_locations, parent_id, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp count_components_in_location(location_id) do
|
||||
@@ -301,103 +315,231 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
|
||||
|
||||
# Icon size and button size based on depth
|
||||
{icon_size, button_size, text_size, title_tag} = case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
{icon_size, button_size, text_size, title_tag} =
|
||||
case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
|
||||
# Different icons based on level - QR code is always present for storage locations
|
||||
icon_name = case assigns.depth do
|
||||
0 -> "hero-building-office" # Shelf/Room
|
||||
1 -> "hero-archive-box" # Drawer/Cabinet
|
||||
_ -> "hero-cube" # Box/Container
|
||||
end
|
||||
icon_name =
|
||||
case assigns.depth do
|
||||
# Shelf/Room
|
||||
0 -> "hero-building-office"
|
||||
# Drawer/Cabinet
|
||||
1 -> "hero-archive-box"
|
||||
# Box/Container
|
||||
_ -> "hero-cube"
|
||||
end
|
||||
|
||||
assigns = assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:icon_name, icon_name)
|
||||
|> assign(:children, child_storage_locations(assigns.storage_locations, assigns.location.id))
|
||||
children = child_storage_locations(assigns.storage_locations, assigns.location.id)
|
||||
has_children = !Enum.empty?(children)
|
||||
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
|
||||
|
||||
# Calculate component counts including descendants
|
||||
{self_count, children_count, _total_count} =
|
||||
Hierarchical.count_with_descendants(
|
||||
assigns.location.id,
|
||||
assigns.storage_locations,
|
||||
& &1.parent_id,
|
||||
&count_components_in_location/1
|
||||
)
|
||||
|
||||
# Format count display
|
||||
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
|
||||
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:icon_name, icon_name)
|
||||
|> assign(:children, children)
|
||||
|> assign(:has_children, has_children)
|
||||
|> assign(:is_expanded, is_expanded)
|
||||
|> assign(:count_display, count_display)
|
||||
|
||||
~H"""
|
||||
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-1 space-x-4">
|
||||
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"}"} />
|
||||
<div class="flex-1">
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h3>
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium #{if @depth == 0, do: "text-base-content", else: "text-base-content/80"}"}>{@location.name}</h4>
|
||||
<% end %>
|
||||
<%= if @location.description do %>
|
||||
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
|
||||
<% end %>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<p class="text-xs text-base-content/50">
|
||||
{count_components_in_location(@location.id)} components
|
||||
</p>
|
||||
<%= if @location.apriltag_id do %>
|
||||
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
|
||||
AprilTag: {@location.apriltag_id}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<%= if @location.apriltag_id do %>
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= if get_apriltag_url(@location) do %>
|
||||
<div class="apriltag-container flex-shrink-0">
|
||||
<img
|
||||
src={get_apriltag_url(@location)}
|
||||
alt={"AprilTag for #{@location.name}"}
|
||||
class="w-16 h-auto border border-base-300 rounded bg-base-100"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between p-2 hover:bg-base-50 rounded-md">
|
||||
<div class="flex items-start flex-1 space-x-2">
|
||||
<!-- Expand/Collapse button - always aligned to top -->
|
||||
<%= if @has_children do %>
|
||||
<button
|
||||
phx-click="toggle_expand"
|
||||
phx-value-id={@location.id}
|
||||
class="flex-shrink-0 p-1 hover:bg-base-200 rounded mt-0.5"
|
||||
>
|
||||
<%= if @is_expanded do %>
|
||||
<.icon name="hero-chevron-down" class="w-4 h-4 text-base-content/60" />
|
||||
<% else %>
|
||||
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
|
||||
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
|
||||
</div>
|
||||
<.icon name="hero-chevron-right" class="w-4 h-4 text-base-content/60" />
|
||||
<% end %>
|
||||
<button
|
||||
phx-click="download_apriltag"
|
||||
phx-value-id={@location.id}
|
||||
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
|
||||
title="Download AprilTag"
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-6"></div>
|
||||
<!-- Spacer for alignment -->
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@location.id}
|
||||
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_location"
|
||||
phx-value-id={@location.id}
|
||||
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
|
||||
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<.icon
|
||||
name={@icon_name}
|
||||
class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
|
||||
/>
|
||||
|
||||
<!-- Content area - always starts at same vertical position -->
|
||||
<div class="flex-1">
|
||||
<!-- Minimized view (default) -->
|
||||
<%= unless @is_expanded do %>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?storage_location_id=#{@location.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@location.name}
|
||||
</.link>
|
||||
</h3>
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?storage_location_id=#{@location.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@location.name}
|
||||
</.link>
|
||||
</h4>
|
||||
<% end %>
|
||||
<span class="text-xs text-base-content/50">
|
||||
({@count_display})
|
||||
</span>
|
||||
<%= if @location.apriltag_id do %>
|
||||
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
|
||||
AprilTag: {@location.apriltag_id}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 ml-4">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@location.id}
|
||||
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_location"
|
||||
phx-value-id={@location.id}
|
||||
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
|
||||
class="inline-flex items-center p-1.5 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Expanded view -->
|
||||
<%= if @is_expanded do %>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<%= if @title_tag == "h3" do %>
|
||||
<h3 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?storage_location_id=#{@location.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@location.name}
|
||||
</.link>
|
||||
</h3>
|
||||
<% else %>
|
||||
<h4 class={"#{@text_size} font-medium"}>
|
||||
<.link
|
||||
navigate={~p"/?storage_location_id=#{@location.id}"}
|
||||
class={"#{if @depth == 0, do: "text-base-content", else: "text-base-content/80"} hover:text-primary hover:underline"}
|
||||
>
|
||||
{@location.name}
|
||||
</.link>
|
||||
</h4>
|
||||
<% end %>
|
||||
<%= if @location.description do %>
|
||||
<p class="text-sm text-base-content/60 mt-1">{@location.description}</p>
|
||||
<% end %>
|
||||
<div class="flex items-center space-x-2 mt-1">
|
||||
<p class="text-xs text-base-content/50">
|
||||
{@count_display}
|
||||
</p>
|
||||
<%= if @location.apriltag_id do %>
|
||||
<span class="text-xs bg-base-200 text-base-content/80 px-2 py-1 rounded">
|
||||
AprilTag: {@location.apriltag_id}
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start space-x-3 ml-4">
|
||||
<%= if @location.apriltag_id do %>
|
||||
<%= if get_apriltag_url(@location) do %>
|
||||
<div class="apriltag-container flex-shrink-0">
|
||||
<img
|
||||
src={get_apriltag_url(@location)}
|
||||
alt={"AprilTag for #{@location.name}"}
|
||||
class="w-16 h-auto border border-base-300 rounded bg-base-100"
|
||||
onerror="this.style.display='none'"
|
||||
/>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="w-16 h-16 border border-base-300 rounded bg-base-200 flex items-center justify-center flex-shrink-0">
|
||||
<.icon name="hero-qr-code" class="w-8 h-8 text-base-content/50" />
|
||||
</div>
|
||||
<% end %>
|
||||
<button
|
||||
phx-click="download_apriltag"
|
||||
phx-value-id={@location.id}
|
||||
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
|
||||
title="Download AprilTag"
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> Download
|
||||
</button>
|
||||
<% end %>
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
phx-click="show_edit_form"
|
||||
phx-value-id={@location.id}
|
||||
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<.icon name="hero-pencil" class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
phx-click="delete_location"
|
||||
phx-value-id={@location.id}
|
||||
data-confirm="Are you sure you want to delete this storage location? This action cannot be undone."
|
||||
class="inline-flex items-center p-2 border border-transparent rounded-full shadow-sm text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<.icon name="hero-trash" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Render children recursively -->
|
||||
<%= for child <- @children do %>
|
||||
<.location_item location={child} storage_locations={@storage_locations} depth={@depth + 1} />
|
||||
|
||||
<!-- Render children recursively (only when expanded) -->
|
||||
<%= if @is_expanded do %>
|
||||
<%= for child <- @children do %>
|
||||
<.location_item
|
||||
location={child}
|
||||
storage_locations={@storage_locations}
|
||||
expanded_locations={@expanded_locations}
|
||||
depth={@depth + 1}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
@@ -449,8 +591,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Location Modal -->
|
||||
|
||||
<!-- Add Location Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -486,7 +628,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">AprilTag ID (Optional)</label>
|
||||
<label class="block text-sm font-medium text-base-content">
|
||||
AprilTag ID (Optional)
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
@@ -544,10 +688,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
class="w-32"
|
||||
/>
|
||||
<div class="text-xs text-base-content/60">
|
||||
Available IDs: <%= length(@available_apriltag_ids) %> of 587
|
||||
Available IDs: {length(@available_apriltag_ids)} of 587
|
||||
<%= if length(@available_apriltag_ids) < 20 do %>
|
||||
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %>
|
||||
<%= if length(@available_apriltag_ids) > 10, do: "..." %>
|
||||
<br />Next available: {@available_apriltag_ids
|
||||
|> Enum.take(10)
|
||||
|> Enum.join(", ")}
|
||||
{if length(@available_apriltag_ids) > 10, do: "..."}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -575,8 +721,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Edit Location Modal -->
|
||||
|
||||
<!-- Edit Location Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -670,12 +816,14 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
class="w-32"
|
||||
/>
|
||||
<div class="text-xs text-base-content/60">
|
||||
Available IDs: <%= length(@available_apriltag_ids) %> of 587
|
||||
Available IDs: {length(@available_apriltag_ids)} of 587
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>
|
||||
Current: {if @editing_location.apriltag_id,
|
||||
do: "ID #{@editing_location.apriltag_id}",
|
||||
else: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -700,8 +848,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- AprilTag Scanner Modal -->
|
||||
|
||||
<!-- AprilTag Scanner Modal -->
|
||||
<%= if @apriltag_scanner_open do %>
|
||||
<div class="fixed inset-0 bg-base-content/30 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-10 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -715,16 +863,20 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AprilTag Scanner Interface -->
|
||||
|
||||
<!-- AprilTag Scanner Interface -->
|
||||
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center">
|
||||
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" />
|
||||
<p class="mt-2 text-sm text-base-content/70">Camera AprilTag scanner would go here</p>
|
||||
<p class="text-xs text-base-content/60 mt-1">In a real implementation, this would use JavaScript AprilTag detection</p>
|
||||
|
||||
<!-- Test buttons for demo -->
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
In a real implementation, this would use JavaScript AprilTag detection
|
||||
</p>
|
||||
|
||||
<!-- Test buttons for demo -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<p class="text-sm font-medium text-base-content/80">Test with sample AprilTag IDs:</p>
|
||||
<p class="text-sm font-medium text-base-content/80">
|
||||
Test with sample AprilTag IDs:
|
||||
</p>
|
||||
<button
|
||||
phx-click="apriltag_scanned"
|
||||
phx-value-apriltag_id="0"
|
||||
@@ -745,8 +897,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Scanned Tags Display -->
|
||||
|
||||
<!-- Scanned Tags Display -->
|
||||
<%= if length(@scanned_tags) > 0 do %>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
@@ -760,26 +912,35 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div :for={scan <- @scanned_tags} class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300">
|
||||
<div
|
||||
:for={scan <- @scanned_tags}
|
||||
class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
|
||||
<span class="font-medium text-base-content">
|
||||
{location_display_name(scan.location)}
|
||||
</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
(AprilTag ID {scan.apriltag_id})
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
||||
Level {scan.location.level}
|
||||
Level {Hierarchical.compute_level(scan.location, & &1.parent)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Storage Locations List -->
|
||||
|
||||
<!-- Storage Locations List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">Manage your physical storage locations and AprilTags</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Manage your physical storage locations and AprilTags
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@storage_locations) do %>
|
||||
@@ -794,8 +955,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
phx-click="show_add_form"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
||||
Add Location
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -804,7 +964,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<!-- Recursive Storage Location Tree -->
|
||||
<%= for location <- root_storage_locations(@storage_locations) do %>
|
||||
<div class="px-6 py-4">
|
||||
<.location_item location={location} storage_locations={@storage_locations} depth={0} />
|
||||
<.location_item
|
||||
location={location}
|
||||
storage_locations={@storage_locations}
|
||||
expanded_locations={@expanded_locations}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,10 @@ defmodule ComponentsElixirWeb.Router do
|
||||
live "/login", LoginLive, :index
|
||||
get "/login/authenticate", AuthController, :authenticate
|
||||
post "/logout", AuthController, :logout
|
||||
|
||||
# File serving endpoints
|
||||
get "/uploads/images/:filename", FileController, :show
|
||||
get "/uploads/datasheets/:filename", FileController, :show_datasheet
|
||||
end
|
||||
|
||||
scope "/", ComponentsElixirWeb do
|
||||
|
||||
@@ -25,9 +25,8 @@ defmodule Mix.Tasks.Apriltag.GenerateAll do
|
||||
|
||||
start_time = System.monotonic_time(:millisecond)
|
||||
|
||||
result = ComponentsElixir.AprilTag.generate_all_apriltag_svgs(
|
||||
force_regenerate: force_regenerate
|
||||
)
|
||||
result =
|
||||
ComponentsElixir.AprilTag.generate_all_apriltag_svgs(force_regenerate: force_regenerate)
|
||||
|
||||
end_time = System.monotonic_time(:millisecond)
|
||||
duration = end_time - start_time
|
||||
@@ -39,6 +38,7 @@ defmodule Mix.Tasks.Apriltag.GenerateAll do
|
||||
|
||||
if result.errors > 0 do
|
||||
IO.puts("\nErrors encountered:")
|
||||
|
||||
result.results
|
||||
|> Enum.filter(&match?({:error, _, _}, &1))
|
||||
|> Enum.each(fn {:error, id, reason} ->
|
||||
|
||||
4
mix.exs
4
mix.exs
@@ -60,13 +60,13 @@ defmodule ComponentsElixir.MixProject do
|
||||
depth: 1},
|
||||
{:swoosh, "~> 1.16"},
|
||||
{:req, "~> 0.5"},
|
||||
{:qr_code, "~> 3.1"},
|
||||
{:telemetry_metrics, "~> 1.0"},
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
{:gettext, "~> 0.26"},
|
||||
{:jason, "~> 1.2"},
|
||||
{:dns_cluster, "~> 0.2.0"},
|
||||
{:bandit, "~> 1.5"}
|
||||
{:bandit, "~> 1.5"},
|
||||
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
8
mix.lock
8
mix.lock
@@ -1,6 +1,8 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
@@ -8,7 +10,6 @@
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"},
|
||||
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||
@@ -18,7 +19,6 @@
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.7", "53aa9ebdbde8aec7c8ee03a8bdaec38dd56302995b0baeebf8dbe7cbdd550400", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "e115944e6ddb887c45cadfd660348934c318abec0341f7b7156e912b98d3eb95"},
|
||||
"matrix_reloaded": {:hex, :matrix_reloaded, "2.3.0", "eea41bc6713021f8f51dde0c2d6b72e695a99098753baebf0760e10aed8fa777", [:mix], [{:ex_maybe, "~> 1.0", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}], "hexpm", "4013c0cebe5dfffc8f2316675b642fb2f5a1dfc4bdc40d2c0dfa0563358fa496"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
@@ -33,11 +33,8 @@
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"pngex": {:hex, :pngex, "0.1.2", "824c2da291fda236397729f236b29f87b98a434d58124ea9f7fa03d3b3cf8587", [:mix], [], "hexpm", "9f9f2d9aa286d03f6c317017a09e1b548fa0aa6b901291e24dbf65d8212b22b0"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"qr_code": {:hex, :qr_code, "3.2.0", "416ad75b7284c1b43c3a248bae0304ac933dc16ba501af49f22c0262e55916e1", [:mix], [{:ex_maybe, "~> 1.1.1", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:matrix_reloaded, "~> 2.3", [hex: :matrix_reloaded, repo: "hexpm", optional: false]}, {:pngex, "~> 0.1.0", [hex: :pngex, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}, {:xml_builder, "~> 2.3", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "fc5366d61087753a781c2e2d2659fce71f91b6258c8341f0ee47f31d5a185497"},
|
||||
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
|
||||
"result": {:hex, :result, "1.7.2", "a57c569f7cf5c158d2299d3b5624a48b69bd1520d0771dc711bcf9f3916e8ab6", [:mix], [], "hexpm", "89f98e98cfbf64237ecf4913aa36b76b80463e087775d19953dc4b435a35f087"},
|
||||
"swoosh": {:hex, :swoosh, "1.19.5", "5abd71be78302ba21be56a2b68d05c9946ff1f1bd254f949efef09d253b771ac", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c953f51ee0a8b237e0f4307c9cefd3eb1eb751c35fcdda2a8bccb991766473be"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.0", "4b2606713080437e3d94a0fa26527e7425737abc001f172b484b42f43e3274c0", [:mix], [], "hexpm", "530bd35699333f8ea0e9038d7146c2f0932dfec2e3636bd4a8016380c4bc382e"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
@@ -46,5 +43,4 @@
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
|
||||
"xml_builder": {:hex, :xml_builder, "2.4.0", "b20d23077266c81f593360dc037ea398461dddb6638a329743da6c73afa56725", [:mix], [], "hexpm", "833e325bb997f032b5a1b740d2fd6feed3c18ca74627f9f5f30513a9ae1a232d"},
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ defmodule ComponentsElixir.Repo.Migrations.MigrateQrToApriltag do
|
||||
create unique_index(:storage_locations, [:apriltag_id])
|
||||
|
||||
# Add constraint to ensure apriltag_id is in valid range (0-586 for tag36h11)
|
||||
create constraint(:storage_locations, :apriltag_id_range, check: "apriltag_id >= 0 AND apriltag_id <= 586")
|
||||
create constraint(:storage_locations, :apriltag_id_range,
|
||||
check: "apriltag_id >= 0 AND apriltag_id <= 586"
|
||||
)
|
||||
|
||||
# Note: We keep qr_code_old for now in case we need to rollback
|
||||
# It can be removed in a future migration after confirming everything works
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,7 +1,13 @@
|
||||
# Script for populating the database. You can run it as:
|
||||
# Script for populating the database with sample data. You can run it as:
|
||||
#
|
||||
# mix run priv/repo/seeds.exs
|
||||
#
|
||||
# This seeds file creates:
|
||||
# - Sample categories (with hierarchical subcategories)
|
||||
# - Storage locations (with auto-assigned AprilTag IDs)
|
||||
# - Sample electronic components with proper storage assignments
|
||||
# - Generates all AprilTag SVG files for immediate use
|
||||
#
|
||||
# Inside the script, you can read and write to any of your
|
||||
# repositories directly:
|
||||
#
|
||||
@@ -10,7 +16,7 @@
|
||||
# We recommend using the bang functions (`insert!`, `update!`
|
||||
# and so on) as they will fail if something goes wrong.
|
||||
|
||||
alias ComponentsElixir.{Repo, Inventory}
|
||||
alias ComponentsElixir.{Repo, Inventory, AprilTag}
|
||||
alias ComponentsElixir.Inventory.{Category, Component, StorageLocation}
|
||||
|
||||
# Clear existing data
|
||||
@@ -19,84 +25,208 @@ Repo.delete_all(Category)
|
||||
Repo.delete_all(StorageLocation)
|
||||
|
||||
# Create categories
|
||||
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
|
||||
{:ok, capacitors} = Inventory.create_category(%{name: "Capacitors", description: "Electrolytic, ceramic, and film capacitors"})
|
||||
{:ok, semiconductors} = Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
|
||||
{:ok, connectors} = Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"})
|
||||
{:ok, resistors} =
|
||||
Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
|
||||
|
||||
{:ok, capacitors} =
|
||||
Inventory.create_category(%{
|
||||
name: "Capacitors",
|
||||
description: "Electrolytic, ceramic, and film capacitors"
|
||||
})
|
||||
|
||||
{:ok, semiconductors} =
|
||||
Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
|
||||
|
||||
{:ok, connectors} =
|
||||
Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"})
|
||||
|
||||
# Create subcategories
|
||||
{:ok, _through_hole_resistors} = Inventory.create_category(%{
|
||||
name: "Through-hole",
|
||||
description: "Traditional leaded resistors",
|
||||
parent_id: resistors.id
|
||||
})
|
||||
{:ok, _through_hole_resistors} =
|
||||
Inventory.create_category(%{
|
||||
name: "Through-hole",
|
||||
description: "Traditional leaded resistors",
|
||||
parent_id: resistors.id
|
||||
})
|
||||
|
||||
{:ok, _smd_resistors} = Inventory.create_category(%{
|
||||
name: "SMD/SMT",
|
||||
description: "Surface mount resistors",
|
||||
parent_id: resistors.id
|
||||
})
|
||||
{:ok, _smd_resistors} =
|
||||
Inventory.create_category(%{
|
||||
name: "SMD/SMT",
|
||||
description: "Surface mount resistors",
|
||||
parent_id: resistors.id
|
||||
})
|
||||
|
||||
{:ok, _ceramic_caps} = Inventory.create_category(%{
|
||||
name: "Ceramic",
|
||||
description: "Ceramic disc and multilayer capacitors",
|
||||
parent_id: capacitors.id
|
||||
})
|
||||
{:ok, _ceramic_caps} =
|
||||
Inventory.create_category(%{
|
||||
name: "Ceramic",
|
||||
description: "Ceramic disc and multilayer capacitors",
|
||||
parent_id: capacitors.id
|
||||
})
|
||||
|
||||
{:ok, _electrolytic_caps} = Inventory.create_category(%{
|
||||
name: "Electrolytic",
|
||||
description: "Polarized electrolytic capacitors",
|
||||
parent_id: capacitors.id
|
||||
})
|
||||
{:ok, _electrolytic_caps} =
|
||||
Inventory.create_category(%{
|
||||
name: "Electrolytic",
|
||||
description: "Polarized electrolytic capacitors",
|
||||
parent_id: capacitors.id
|
||||
})
|
||||
|
||||
# Create a DEEP category hierarchy to test fallback path (7+ levels)
|
||||
{:ok, deep_cat_1} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 1",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: resistors.id
|
||||
})
|
||||
|
||||
{:ok, deep_cat_2} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 2",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_cat_1.id
|
||||
})
|
||||
|
||||
{:ok, deep_cat_3} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 3",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_cat_2.id
|
||||
})
|
||||
|
||||
{:ok, deep_cat_4} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 4",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_cat_3.id
|
||||
})
|
||||
|
||||
{:ok, deep_cat_5} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 5",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_cat_4.id
|
||||
})
|
||||
|
||||
{:ok, deep_cat_6} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 6",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_cat_5.id
|
||||
})
|
||||
|
||||
{:ok, deep_cat_7} =
|
||||
Inventory.create_category(%{
|
||||
name: "Level 7",
|
||||
description: "Deep hierarchy test - triggers fallback",
|
||||
parent_id: deep_cat_6.id
|
||||
})
|
||||
|
||||
# Create storage locations
|
||||
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
|
||||
{:ok, shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
|
||||
{:ok, shelf_a} =
|
||||
Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
|
||||
|
||||
{:ok, _shelf_b} =
|
||||
Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
|
||||
|
||||
# Create drawers on Shelf A
|
||||
{:ok, drawer_a1} = Inventory.create_storage_location(%{
|
||||
name: "Drawer 1",
|
||||
description: "Resistors and capacitors",
|
||||
parent_id: shelf_a.id
|
||||
})
|
||||
{:ok, drawer_a1} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Drawer 1",
|
||||
description: "Resistors and capacitors",
|
||||
parent_id: shelf_a.id
|
||||
})
|
||||
|
||||
{:ok, drawer_a2} = Inventory.create_storage_location(%{
|
||||
name: "Drawer 2",
|
||||
description: "Semiconductors and ICs",
|
||||
parent_id: shelf_a.id
|
||||
})
|
||||
{:ok, drawer_a2} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Drawer 2",
|
||||
description: "Semiconductors and ICs",
|
||||
parent_id: shelf_a.id
|
||||
})
|
||||
|
||||
# Create boxes in Drawer A1
|
||||
{:ok, box_a1_1} = Inventory.create_storage_location(%{
|
||||
name: "Box 1",
|
||||
description: "Through-hole resistors",
|
||||
parent_id: drawer_a1.id
|
||||
})
|
||||
{:ok, box_a1_1} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Box 1",
|
||||
description: "Through-hole resistors",
|
||||
parent_id: drawer_a1.id
|
||||
})
|
||||
|
||||
{:ok, box_a1_2} = Inventory.create_storage_location(%{
|
||||
name: "Box 2",
|
||||
description: "SMD resistors",
|
||||
parent_id: drawer_a1.id
|
||||
})
|
||||
{:ok, _box_a1_2} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Box 2",
|
||||
description: "SMD resistors",
|
||||
parent_id: drawer_a1.id
|
||||
})
|
||||
|
||||
{:ok, box_a1_3} = Inventory.create_storage_location(%{
|
||||
name: "Box 3",
|
||||
description: "Ceramic capacitors",
|
||||
parent_id: drawer_a1.id
|
||||
})
|
||||
{:ok, box_a1_3} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Box 3",
|
||||
description: "Ceramic capacitors",
|
||||
parent_id: drawer_a1.id
|
||||
})
|
||||
|
||||
# Create boxes in Drawer A2
|
||||
{:ok, box_a2_1} = Inventory.create_storage_location(%{
|
||||
name: "Box 1",
|
||||
description: "Microcontrollers",
|
||||
parent_id: drawer_a2.id
|
||||
})
|
||||
{:ok, box_a2_1} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Box 1",
|
||||
description: "Microcontrollers",
|
||||
parent_id: drawer_a2.id
|
||||
})
|
||||
|
||||
{:ok, _box_a2_2} = Inventory.create_storage_location(%{
|
||||
name: "Box 2",
|
||||
description: "Transistors and diodes",
|
||||
parent_id: drawer_a2.id
|
||||
})
|
||||
{:ok, _box_a2_2} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Box 2",
|
||||
description: "Transistors and diodes",
|
||||
parent_id: drawer_a2.id
|
||||
})
|
||||
|
||||
# Create a DEEP storage location hierarchy to test fallback path (7+ levels)
|
||||
{:ok, deep_loc_1} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 1",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: box_a1_3.id
|
||||
})
|
||||
|
||||
{:ok, deep_loc_2} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 2",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_loc_1.id
|
||||
})
|
||||
|
||||
{:ok, deep_loc_3} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 3",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_loc_2.id
|
||||
})
|
||||
|
||||
{:ok, deep_loc_4} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 4",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_loc_3.id
|
||||
})
|
||||
|
||||
{:ok, deep_loc_5} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 5",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_loc_4.id
|
||||
})
|
||||
|
||||
{:ok, deep_loc_6} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 6",
|
||||
description: "Deep hierarchy test",
|
||||
parent_id: deep_loc_5.id
|
||||
})
|
||||
|
||||
{:ok, deep_loc_7} =
|
||||
Inventory.create_storage_location(%{
|
||||
name: "Deep Level 7",
|
||||
description: "Deep hierarchy test - triggers fallback",
|
||||
parent_id: deep_loc_6.id
|
||||
})
|
||||
|
||||
# Create sample components
|
||||
sample_components = [
|
||||
@@ -138,7 +268,8 @@ sample_components = [
|
||||
keywords: "microcontroller avr atmega328 arduino",
|
||||
storage_location_id: box_a2_1.id,
|
||||
count: 10,
|
||||
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
|
||||
datasheet_url:
|
||||
"https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
|
||||
category_id: semiconductors.id
|
||||
},
|
||||
%{
|
||||
@@ -180,6 +311,23 @@ sample_components = [
|
||||
storage_location_id: box_a1_1.id,
|
||||
count: 100,
|
||||
category_id: resistors.id
|
||||
},
|
||||
# Test components for deep hierarchies to ensure fallback path is exercised
|
||||
%{
|
||||
name: "Deep Category Test Component",
|
||||
description: "Component in 7-level deep category hierarchy",
|
||||
keywords: "test deep hierarchy category fallback",
|
||||
storage_location_id: box_a1_1.id,
|
||||
count: 1,
|
||||
category_id: deep_cat_7.id
|
||||
},
|
||||
%{
|
||||
name: "Deep Storage Test Component",
|
||||
description: "Component in 7-level deep storage location hierarchy",
|
||||
keywords: "test deep hierarchy storage fallback",
|
||||
storage_location_id: deep_loc_7.id,
|
||||
count: 1,
|
||||
category_id: resistors.id
|
||||
}
|
||||
]
|
||||
|
||||
@@ -192,19 +340,48 @@ IO.puts("Categories: #{length(Inventory.list_categories())}")
|
||||
IO.puts("Storage Locations: #{length(Inventory.list_storage_locations())}")
|
||||
IO.puts("Components: #{length(Inventory.list_components())}")
|
||||
IO.puts("")
|
||||
IO.puts("Sample QR codes for testing:")
|
||||
IO.puts("Sample AprilTag information:")
|
||||
|
||||
# Print some sample QR codes for testing
|
||||
# Print AprilTag information for sample storage locations
|
||||
sample_locations = [
|
||||
Inventory.get_storage_location!(shelf_a.id),
|
||||
Inventory.get_storage_location!(drawer_a1.id),
|
||||
Inventory.get_storage_location!(box_a1_1.id),
|
||||
Inventory.get_storage_location!(box_a2_1.id)
|
||||
]
|
||||
|
||||
Enum.each(sample_locations, fn location ->
|
||||
qr_data = ComponentsElixir.QRCode.generate_qr_data(location)
|
||||
IO.puts("#{location.path}: #{qr_data}")
|
||||
if location.apriltag_id do
|
||||
apriltag_url = AprilTag.get_apriltag_url(location)
|
||||
location_path = StorageLocation.full_path(location)
|
||||
IO.puts("#{location_path}: AprilTag ID #{location.apriltag_id}")
|
||||
IO.puts(" Download URL: #{apriltag_url}")
|
||||
else
|
||||
location_path = StorageLocation.full_path(location)
|
||||
IO.puts("#{location_path}: No AprilTag assigned")
|
||||
end
|
||||
end)
|
||||
|
||||
# Generate all AprilTag SVGs for immediate use
|
||||
IO.puts("Generating AprilTag SVG files...")
|
||||
result = AprilTag.generate_all_apriltag_svgs()
|
||||
IO.puts("Generated #{result.success}/#{result.total} AprilTag SVG files")
|
||||
|
||||
IO.puts("")
|
||||
IO.puts("Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
||||
IO.puts("🎉 Database seeded successfully!")
|
||||
IO.puts("📊 Summary:")
|
||||
IO.puts(" Categories: #{length(Inventory.list_categories())}")
|
||||
|
||||
IO.puts(
|
||||
" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)"
|
||||
)
|
||||
|
||||
IO.puts(" Components: #{length(Inventory.list_components())}")
|
||||
IO.puts("")
|
||||
IO.puts("🏷️ AprilTag System:")
|
||||
IO.puts(" - Each storage location has an auto-assigned AprilTag ID (0-586)")
|
||||
IO.puts(" - SVG files available at /apriltags/tag36h11_id_XXX.svg")
|
||||
IO.puts(" - Download AprilTags from storage location management page")
|
||||
IO.puts("")
|
||||
IO.puts("🔐 Login with password: changeme (or set AUTH_PASSWORD environment variable)")
|
||||
IO.puts("🌐 Visit http://localhost:4000 to start using the system!")
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule ComponentsElixirWeb.ErrorHTMLTest do
|
||||
end
|
||||
|
||||
test "renders 500.html" do
|
||||
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
|
||||
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) ==
|
||||
"Internal Server Error"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,9 @@ defmodule ComponentsElixirWeb.ErrorJSONTest do
|
||||
use ComponentsElixirWeb.ConnCase, async: true
|
||||
|
||||
test "renders 404" do
|
||||
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{
|
||||
errors: %{detail: "Not Found"}
|
||||
}
|
||||
end
|
||||
|
||||
test "renders 500" do
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule ComponentsElixirWeb.PageControllerTest do
|
||||
|
||||
test "GET /", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
||||
# redirect to login
|
||||
assert html_response(conn, 302)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user