Compare commits

...

7 Commits

Author SHA1 Message Date
Schuwi
537a97cecc ci: add gitea ci/cd pipeline
Some checks failed
Code Quality / Code Quality (Elixir 1.15 OTP 26.0) (push) Failing after 2m16s
Docker Build and Publish / docker-build (push) Failing after 6m37s
2025-09-20 12:24:50 +02:00
Schuwi
a6991b6877 docs: add GitHub Copilot instructions 2025-09-20 12:08:50 +02:00
Schuwi
32dea59c74 test: rudimentary fix 2025-09-20 11:55:59 +02:00
Schuwi
c6c218970c style: format codebase 2025-09-20 11:52:43 +02:00
Schuwi
aaf278f7f9 style: prevent formatter issue 2025-09-20 11:52:05 +02:00
Schuwi
f4ee768c52 refactor: cleanup mix credo issues 2025-09-20 11:36:30 +02:00
Schuwi
72484c0d08 docs: update README 2025-09-20 11:25:58 +02:00
29 changed files with 1413 additions and 777 deletions

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

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

View File

@@ -0,0 +1,75 @@
name: Code Quality
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
code-quality:
runs-on: ubuntu-latest
name: Code Quality (Elixir ${{matrix.elixir}} OTP ${{matrix.otp}})
strategy:
matrix:
otp: ['26.0']
elixir: ['1.15']
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: 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_PASSWORD: postgres
- name: Run precommit (should pass if all above passed)
run: mix precommit
env:
POSTGRES_PASSWORD: postgres

View File

@@ -0,0 +1,69 @@
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: |
type=ref,event=branch
type=ref,event=tag
type=raw,value=latest,enable={{is_default_branch}}
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,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
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 "- **Tags**: " >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
if [[ "${{ github.ref_type }}" == "tag" ]]; then
echo "- **Build Type**: Release build for tag \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
else
echo "- **Build Type**: Snapshot build for branch \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
fi
echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY

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

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

424
README.md
View File

@@ -1,48 +1,52 @@
# Components Inventory - Elixir/Phoenix Implementation
# Electronic Components Inventory System
A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inventory management system.
A modern, comprehensive electronic component inventory management system built with Elixir/Phoenix LiveView. Features real-time updates, hierarchical organization, and advanced search capabilities.
## Features
### ✨ Improvements over the original PHP version:
1. **Modern Architecture**
- Phoenix LiveView for real-time, reactive UI without JavaScript
- Ecto for type-safe database operations
- Proper separation of concerns with Phoenix contexts
- Built-in validation and error handling
2. **Enhanced User Experience**
- Real-time search with no page refreshes
- Responsive design with Tailwind CSS
- Loading states and better feedback
- Improved mobile experience
3. **Better Data Management**
- Full-text search with PostgreSQL
- Hierarchical categories with parent-child relationships
- Proper foreign key constraints
- Database migrations for schema management
4. **Security & Reliability**
- CSRF protection built-in
- SQL injection prevention through Ecto
- Session-based authentication
- Input validation and sanitization
### 🔧 Core Functionality
- **Component Management**: Add, edit, delete, and track electronic components
- **Component Management**: Add, edit, delete, and track electronic components with real-time updates
- **Inventory Tracking**: Monitor component quantities with increment/decrement buttons
- **Search & Filter**: Fast search across component names, descriptions, and keywords
- **Category Organization**: Hierarchical category system for better organization
- **Category Management**: Add, edit, delete categories through the web interface with hierarchical support
- **Storage Location System**: Hierarchical storage locations (shelf → drawer → box) with automatic AprilTag generation
- **AprilTag Integration**: Automatic AprilTag generation and display for all storage locations with download capability
- **Datasheet Links**: Direct links to component datasheets
- **Real-time Updates**: All changes are immediately reflected in the interface
- **Advanced Search & Filtering**:
- Fast full-text search across component names, descriptions, and keywords
- Filter by categories and storage locations (including subcategories/sublocations)
- Clickable category and location filters for quick navigation
- **Hierarchical Organization**:
- Unlimited nesting for both categories and storage locations
- Collapsible tree views for easy navigation
- Visual breadcrumb paths (e.g., "Electronics > Resistors > Through-hole")
- **Datasheet Management**:
- Upload PDF datasheets directly or provide URLs for automatic download
- Automatic datasheet retrieval from URLs with validation
- Visual indicators for components with datasheets
- Direct PDF viewing and download
- **Storage Location System**:
- Hierarchical locations (shelf → drawer → box) with AprilTag integration
- Automatic AprilTag generation for physical labeling
- Downloadable SVG AprilTags for printing
- **Image Support**: Upload component images with preview and validation
- **Real-time Interface**: All changes reflected immediately without page refresh
## Setup
### 🎨 User Experience
- **Modern Responsive Design**: Works seamlessly on desktop and mobile
- **Dark/Light Mode**: Automatic theme support with DaisyUI
- **Interactive Components**:
- Collapsible hierarchical views
- Focus mode for detailed component viewing
- Drag-and-drop file uploads with progress indicators
- **Smart Navigation**: Clickable categories and locations for instant filtering
- **Visual Feedback**: Loading states, progress bars, and clear error messages
## Quick Start
### Prerequisites
- Elixir 1.15+
- PostgreSQL 15+
- Docker (optional, for containerized deployment)
### Development Setup
1. **Install dependencies:**
```bash
@@ -51,144 +55,122 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
2. **Set up the database:**
```bash
docker run --name components-postgres -p 5432:5432 -e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres # password: config/dev.exs
# 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
@@ -208,151 +190,65 @@ 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 all dependencies included.
#### Quick Start
1. **Clone the repository:**
1. **Clone and setup:**
```bash
git clone <repository-url>
cd components_elixir
cp docker-compose.yml.example docker-compose.yml
```
2. **Copy docker-compose.yml.example to docker-compose.yml**
Follow steps in [Customizing Docker Deployment](#customizing-docker-deployment).
2. **Configure environment** (edit `docker-compose.yml`):
```yaml
environment:
SECRET_KEY_BASE: "your-64-character-secret-key" # Generate with: mix phx.gen.secret
AUTH_PASSWORD: "your-secure-password"
PHX_HOST: "localhost" # Change to your domain
```
3. **Build and run with Docker Compose:**
3. **Deploy:**
```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:** [http://localhost:4000](http://localhost:4000)
4. **Access the application:**
- Open [http://localhost:4000](http://localhost:4000)
- Default password: `changeme`
#### Docker Configuration Files
The project includes these Docker files:
- **`Dockerfile`** - Multi-stage build for production
- **`docker-compose.yml`** - Local development/testing setup
- **`.dockerignore`** - Excludes unnecessary files from build context
#### Customizing Docker Deployment
1. **Environment Variables**: Edit `docker-compose.yml` to customize:
```yaml
environment:
DATABASE_URL: "ecto://postgres:postgres@db:5432/components_elixir_prod"
SECRET_KEY_BASE: "your-secret-key-here" # Generate with: mix phx.gen.secret
PHX_HOST: "localhost" # Change to your domain
PHX_SERVER: "true"
PORT: "4000"
```
2. **Generate a secure secret key:**
**With Elixir/Phoenix installed:**
```bash
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
#### Production Docker Deployment
#### 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
- **Generate secure keys**: Use `mix phx.gen.secret` for SECRET_KEY_BASE
- **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
@@ -364,18 +260,24 @@ For production deployment without Docker:
MIX_ENV=prod mix release
```
3. **Run migrations:**
3. **Database setup:**
```bash
_build/prod/rel/components_elixir/bin/components_elixir eval "ComponentsElixir.Release.migrate"
```
## Contributing
This is a modernized, idiomatic Elixir/Phoenix implementation that maintains feature parity with the original PHP version while providing significant improvements in code quality, security, and user experience.
This project follows Phoenix and Elixir best practices:
The application follows Phoenix and Elixir best practices:
- Contexts for business logic
- LiveView for interactive UI
- Ecto for database operations
- Comprehensive error handling
- Input validation and sanitization
- **Phoenix Contexts** for business logic separation
- **LiveView** for real-time, interactive user interfaces
- **Ecto** for type-safe database operations
- **Comprehensive testing** with ExUnit
- **Input validation** and sanitization throughout
- **CSRF protection** and security best practices
The codebase emphasizes:
- **Maintainability**: Clear module organization and documentation
- **Performance**: Optimized queries and efficient data structures
- **Reliability**: Comprehensive error handling and graceful degradation
- **Extensibility**: Modular design for easy feature additions

View File

@@ -16,6 +16,7 @@ config :components_elixir, ComponentsElixir.Repo,
# For development, use a local uploads directory
config :components_elixir,
uploads_dir: "./uploads"
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ defmodule ComponentsElixir.DatasheetDownloader 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
@@ -36,9 +37,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
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"
"" ->
"datasheet"
basename ->
# Remove extension and sanitize
basename
@@ -54,10 +58,14 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp sanitize_filename(filename) do
filename
|> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores
|> String.replace(~r/_+/, "_") # Replace multiple underscores with single
|> String.trim("_") # Remove leading/trailing underscores
|> String.slice(0, 50) # Limit length
# 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
@@ -66,17 +74,19 @@ defmodule ComponentsElixir.DatasheetDownloader do
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
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)}"}
@@ -88,6 +98,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
case body do
<<"%PDF", _rest::binary>> ->
:ok
_ ->
{:error, "Downloaded content is not a valid PDF file"}
end
@@ -105,10 +116,12 @@ defmodule ComponentsElixir.DatasheetDownloader 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)}"}
@@ -129,6 +142,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
:ok ->
Logger.info("Deleted datasheet file: #{filename}")
:ok
{:error, reason} ->
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
{:error, reason}

View File

@@ -19,7 +19,7 @@ defmodule ComponentsElixir.Inventory do
locations =
StorageLocation
|> order_by([sl], asc: sl.name)
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
# Ensure AprilTag SVGs exist for all locations
@@ -162,7 +162,7 @@ defmodule ComponentsElixir.Inventory do
"""
def list_categories do
Category
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
end
@@ -217,8 +217,15 @@ defmodule ComponentsElixir.Inventory do
# 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))
nil ->
[]
_category ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
categories,
category_id,
& &1.parent_id
)
end
end
@@ -233,13 +240,21 @@ defmodule ComponentsElixir.Inventory do
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
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))
nil ->
[]
_storage_location ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
storage_locations,
storage_location_id,
& &1.parent_id
)
end
end
@@ -288,18 +303,25 @@ defmodule ComponentsElixir.Inventory do
end
defp apply_component_sorting(query, opts) do
case Keyword.get(opts, :sort_criteria, "name_asc") do
"name_asc" -> order_by(query, [c], [asc: c.name, asc: c.id])
"name_desc" -> order_by(query, [c], [desc: c.name, asc: c.id])
"inserted_at_asc" -> order_by(query, [c], [asc: c.inserted_at, asc: c.id])
"inserted_at_desc" -> order_by(query, [c], [desc: c.inserted_at, asc: c.id])
"updated_at_asc" -> order_by(query, [c], [asc: c.updated_at, asc: c.id])
"updated_at_desc" -> order_by(query, [c], [desc: c.updated_at, asc: c.id])
"count_asc" -> order_by(query, [c], [asc: c.count, asc: c.id])
"count_desc" -> order_by(query, [c], [desc: c.count, asc: c.id])
# Default fallback
_ -> order_by(query, [c], [asc: c.name, asc: c.id])
end
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 """
@@ -331,10 +353,12 @@ defmodule ComponentsElixir.Inventory do
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
@@ -365,13 +389,18 @@ defmodule ComponentsElixir.Inventory do
{:ok, filename} ->
# Delete old datasheet file if it exists
if component.datasheet_filename do
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename)
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

View File

@@ -37,7 +37,7 @@ defmodule ComponentsElixir.Inventory.Category do
"""
@impl true
def full_path(%Category{} = category) do
Hierarchical.full_path(category, &(&1.parent), path_separator())
Hierarchical.full_path(category, & &1.parent, path_separator())
end
@impl true

View File

@@ -30,7 +30,18 @@ defmodule ComponentsElixir.Inventory.Component do
@doc false
def changeset(component, attrs) do
component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :datasheet_filename, :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)
@@ -70,17 +81,21 @@ defmodule ComponentsElixir.Inventory.Component do
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.
"""

View File

@@ -29,10 +29,12 @@ defmodule ComponentsElixir.Inventory.Hierarchical 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
@@ -52,12 +54,14 @@ defmodule ComponentsElixir.Inventory.Hierarchical 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])
@@ -93,9 +97,9 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
entity_id = id_accessor_fn.(entity)
# Remove self-reference
entity_id == editing_entity_id ||
# Remove descendants (they would create a cycle)
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
entity_id == editing_entity_id ||
descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
end)
end
@@ -103,24 +107,32 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
Checks if an entity is a descendant of an ancestor entity.
Used for cycle detection in parent selection.
"""
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
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 -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
entity -> descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn)
end
end
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
defp descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn) do
case parent_id_accessor_fn.(entity) do
nil -> false
^ancestor_id -> true
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 -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
nil ->
false
parent_entity ->
descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end
end
end
@@ -182,15 +194,20 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
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
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)
& &1.id,
& &1.parent_id
)
|> sort_hierarchically(&(&1.parent_id))
|> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
@@ -205,7 +222,7 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities =
entities
|> sort_hierarchically(&(&1.parent_id))
|> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
@@ -300,9 +317,10 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
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)
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
@@ -320,7 +338,13 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
- 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
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

View File

@@ -25,7 +25,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
timestamps(type: :naive_datetime_usec)
end
@doc false
@doc false
def changeset(storage_location, attrs) do
storage_location
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
@@ -40,7 +40,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
# HierarchicalSchema implementations
@impl true
def full_path(%StorageLocation{} = storage_location) do
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
Hierarchical.full_path(storage_location, & &1.parent, path_separator())
end
@impl true
@@ -80,11 +80,12 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
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
@@ -93,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
nil ->
# All IDs are used - this should be handled at the application level
raise "All AprilTag IDs are in use"
id -> id
id ->
id
end
end
end

View File

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

View File

@@ -13,7 +13,8 @@ defmodule ComponentsElixirWeb.FileController do
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
# Cache for 1 day
|> put_resp_header("cache-control", "public, max-age=86400")
|> send_file(200, file_path)
else
conn
@@ -40,7 +41,8 @@ defmodule ComponentsElixirWeb.FileController do
conn
|> put_resp_content_type(mime_type)
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
# 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
@@ -64,9 +66,9 @@ defmodule ComponentsElixirWeb.FileController do
# 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
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"}

View File

@@ -7,9 +7,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
@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,
@@ -22,6 +20,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:form, nil)
|> assign(:expanded_categories, MapSet.new())
|> assign(:page_title, "Category Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end
end
@@ -46,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)
@@ -112,7 +113,12 @@ 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
@@ -120,11 +126,12 @@ defmodule ComponentsElixirWeb.CategoriesLive 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
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
@@ -138,17 +145,17 @@ defmodule ComponentsElixirWeb.CategoriesLive do
Hierarchical.parent_select_options(
categories,
editing_category_id,
&(&1.parent),
& &1.parent,
"No parent (Root category)"
)
end
defp root_categories(categories) do
Hierarchical.root_entities(categories, &(&1.parent_id))
Hierarchical.root_entities(categories, & &1.parent_id)
end
defp child_categories(categories, parent_id) do
Hierarchical.child_entities(categories, parent_id, &(&1.parent_id))
Hierarchical.child_entities(categories, parent_id, & &1.parent_id)
end
defp count_components_in_category(category_id) do
@@ -164,38 +171,41 @@ 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
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
)
{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)
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"}>
@@ -215,12 +225,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<% end %>
</button>
<% else %>
<div class="w-6"></div> <!-- Spacer for alignment -->
<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 -->
<.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 %>
@@ -268,8 +282,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div>
</div>
<% end %>
<!-- Expanded view -->
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
@@ -321,11 +335,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div>
</div>
</div>
<!-- Render children recursively (only when expanded) -->
<!-- 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} />
<.category_item
category={child}
categories={@categories}
expanded_categories={@expanded_categories}
depth={@depth + 1}
/>
<% end %>
<% end %>
</div>
@@ -368,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">
@@ -424,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">
@@ -480,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 %>
@@ -501,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>
@@ -511,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} expanded_categories={@expanded_categories} depth={0} />
<.category_item
category={category}
categories={@categories}
expanded_categories={@expanded_categories}
depth={0}
/>
</div>
<% end %>
</div>

View File

@@ -2,16 +2,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
alias ComponentsElixir.Inventory.{Component, StorageLocation, Hierarchical}
@items_per_page 20
@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()
storage_locations = Inventory.list_storage_locations()
stats = Inventory.component_stats()
@@ -53,6 +51,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
max_file_size: 10_000_000
)
|> load_components()}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end
end
@@ -139,7 +139,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> push_patch(to: path)}
end
def handle_event("storage_location_filter", %{"storage_location_id" => storage_location_id}, socket) do
def handle_event(
"storage_location_filter",
%{"storage_location_id" => storage_location_id},
socket
) do
storage_location_id = String.to_integer(storage_location_id)
query_string = build_query_params_with_storage_location(socket, storage_location_id)
path = if query_string == "", do: "/", else: "/?" <> query_string
@@ -387,7 +391,10 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> save_uploaded_image(component_params)
|> save_uploaded_datasheet(socket)
case Inventory.update_component_with_datasheet(socket.assigns.editing_component, updated_params) do
case Inventory.update_component_with_datasheet(
socket.assigns.editing_component,
updated_params
) do
{:ok, _component} ->
{:noreply,
socket
@@ -431,61 +438,73 @@ defmodule ComponentsElixirWeb.ComponentsLive do
DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt
if should_reload do
# Normal loading - query database with current sort criteria
filters =
[
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
storage_location_id: socket.assigns.selected_storage_location,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn
{_, v} when is_nil(v) -> true
{:search, v} when v == "" -> true
{_, _} -> false
end)
%{components: new_components, has_more: has_more} =
Inventory.paginate_components(filters)
components =
if append do
socket.assigns.components ++ new_components
else
new_components
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
load_components_from_db(socket, append)
else
# Frozen - just update the specific component in place without reordering
if socket.assigns.interacting_with do
updated_components =
Enum.map(socket.assigns.components, fn component ->
if to_string(component.id) == socket.assigns.interacting_with do
# Reload this specific component to get updated count
Inventory.get_component!(component.id)
else
component
end
end)
assign(socket, :components, updated_components)
else
socket
end
update_frozen_components(socket)
end
end
defp load_components_from_db(socket, append) do
filters = build_component_filters(socket)
%{components: new_components, has_more: has_more} = Inventory.paginate_components(filters)
components =
if append do
socket.assigns.components ++ new_components
else
new_components
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
end
defp update_frozen_components(socket) do
if socket.assigns.interacting_with do
updated_components = update_interacting_component(socket)
assign(socket, :components, updated_components)
else
socket
end
end
defp update_interacting_component(socket) do
interacting_id = socket.assigns.interacting_with
Enum.map(socket.assigns.components, fn component ->
if to_string(component.id) == interacting_id do
# Reload this specific component to get updated count
Inventory.get_component!(component.id)
else
component
end
end)
end
defp build_component_filters(socket) do
[
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
storage_location_id: socket.assigns.selected_storage_location,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn
{_, v} when is_nil(v) -> true
{:search, v} when v == "" -> true
{_, _} -> false
end)
end
defp build_query_params(socket, overrides) do
params = %{
search: Map.get(overrides, :search, socket.assigns.search),
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria),
category_id: Map.get(overrides, :category_id, socket.assigns.selected_category),
storage_location_id: Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
storage_location_id:
Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
}
params
@@ -495,12 +514,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
defp parse_filter_id(nil), do: nil
defp parse_filter_id(""), do: nil
defp parse_filter_id(id) when is_binary(id) do
case Integer.parse(id) do
{int_id, ""} -> int_id
_ -> nil
end
end
defp parse_filter_id(id) when is_integer(id), do: id
defp build_query_params_with_category(socket, category_id) do
@@ -542,7 +563,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
defp category_options(categories) do
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
Hierarchical.select_options(categories, & &1.parent, "Select a category")
end
defp storage_location_display_name(location) do
@@ -550,7 +571,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
defp storage_location_options(storage_locations) do
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
Hierarchical.select_options(storage_locations, & &1.parent, "No storage location")
end
@impl true
@@ -599,7 +620,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
</div>
<!-- Filters -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row gap-4">
@@ -676,23 +697,27 @@ defmodule ComponentsElixirWeb.ComponentsLive do
class="inline-flex items-center px-3 py-2 border border-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<.icon name="hero-adjustments-horizontal" class="w-4 h-4 mr-2" />
<%= if @show_advanced_filters, do: "Hide", else: "More" %> Filters
{if @show_advanced_filters, do: "Hide", else: "More"} Filters
</button>
</div>
</div>
<!-- Advanced Filters (Collapsible) -->
<!-- Advanced Filters (Collapsible) -->
<%= if @show_advanced_filters do %>
<div class="mt-4 p-4 bg-base-100 border border-base-300 rounded-md">
<div class="flex flex-col sm:flex-row gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">Storage Location</label>
<label class="block text-sm font-medium text-base-content mb-2">
Storage Location
</label>
<form phx-change="storage_location_filter">
<select
name="storage_location_id"
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
>
<option value="" selected={is_nil(@selected_storage_location)}>All Storage Locations</option>
<option value="" selected={is_nil(@selected_storage_location)}>
All Storage Locations
</option>
<%= for {location_name, location_id} <- Hierarchical.select_options(@storage_locations, &(&1.parent)) do %>
<option value={location_id} selected={@selected_storage_location == location_id}>
{location_name}
@@ -705,7 +730,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Add Component Modal -->
<%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -884,7 +909,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<!-- Edit Component Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -1018,8 +1043,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
target="_blank"
class="inline-flex items-center text-primary hover:text-primary/80"
>
<.icon name="hero-document-text" class="w-4 h-4 mr-1" />
View PDF
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> View PDF
</a>
</div>
<% end %>
@@ -1086,7 +1110,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<!-- Components List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
@@ -1155,7 +1179,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</button>
</div>
</div>
<!-- Content area with image and details -->
<div class="flex gap-6">
<!-- Large Image -->
@@ -1181,18 +1205,22 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Details -->
<div class="flex-1 space-y-4 select-text">
<!-- Full Description -->
<%= if component.description do %>
<div>
<h4 class="text-sm font-medium text-base-content mb-2">Description</h4>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation --%>
<p class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap">{component.description}</p>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
Use phx-no-format so the formatter won't break the layout. --%>
<p
phx-no-format
class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap"
>{component.description}</p>
</div>
<% end %>
<!-- Metadata Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<%= if component.storage_location do %>
@@ -1249,7 +1277,10 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<%= if component.datasheet_filename || component.datasheet_url do %>
<div class="flex items-start">
<.icon name="hero-document-text" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" />
<.icon
name="hero-document-text"
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
/>
<div>
<span class="font-medium text-base-content">Datasheet:</span>
<div class="space-y-1 mt-1">
@@ -1272,8 +1303,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
target="_blank"
class="inline-flex items-center text-base-content/70 hover:text-primary text-sm"
>
<.icon name="hero-link" class="w-4 h-4 mr-1" />
Original URL
<.icon name="hero-link" class="w-4 h-4 mr-1" /> Original URL
</a>
</div>
<% end %>
@@ -1284,7 +1314,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
<button
@@ -1378,15 +1408,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</span>
</div>
</div>
<!-- Middle row: Description -->
<%= if component.description do %>
<div class="mt-1">
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation --%>
<p class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap">{component.description}</p>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
Use phx-no-format so the formatter won't break the layout. --%>
<p
phx-no-format
class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap"
>{component.description}</p>
</div>
<% end %>
<!-- Bottom row: Metadata -->
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
<%= if component.storage_location do %>
@@ -1414,7 +1448,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Keywords row -->
<%= if component.keywords do %>
<div class="mt-2">
@@ -1497,7 +1531,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
>
<!-- Background overlay -->
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
<!-- Modal content -->
<div
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
@@ -1515,7 +1549,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
×
</button>
</div>
<!-- Content -->
<div class="p-6 bg-base-100 rounded-b-lg">
<div class="text-center">
@@ -1539,9 +1573,6 @@ defmodule ComponentsElixirWeb.ComponentsLive do
# Helper functions for image upload handling
defp save_uploaded_image(socket, component_params) do
IO.puts("=== DEBUG: Starting save_uploaded_image ===")
IO.inspect(socket.assigns.uploads.image.entries, label: "Upload entries")
uploaded_files =
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
@@ -1549,47 +1580,29 @@ defmodule ComponentsElixirWeb.ComponentsLive do
upload_dir = Path.join([uploads_dir, "images"])
dest = Path.join(upload_dir, filename)
IO.puts("=== DEBUG: Processing upload ===")
IO.puts("Filename: #{filename}")
IO.puts("Upload dir: #{upload_dir}")
IO.puts("Destination: #{dest}")
# Ensure the upload directory exists
File.mkdir_p!(upload_dir)
# Copy the file
case File.cp(path, dest) do
:ok ->
IO.puts("=== DEBUG: File copy successful ===")
{:ok, filename}
{:error, reason} ->
IO.puts("=== DEBUG: File copy failed: #{inspect(reason)} ===")
{:postpone, {:error, reason}}
end
end)
IO.inspect(uploaded_files, label: "Uploaded files result")
case uploaded_files do
[filename] when is_binary(filename) ->
Map.put(component_params, "image_filename", filename)
result =
case uploaded_files do
[filename] when is_binary(filename) ->
IO.puts("=== DEBUG: Adding filename to params: #{filename} ===")
Map.put(component_params, "image_filename", filename)
[] ->
component_params
[] ->
IO.puts("=== DEBUG: No files uploaded ===")
component_params
_error ->
IO.puts("=== DEBUG: Upload error ===")
IO.inspect(uploaded_files, label: "Unexpected upload result")
component_params
end
IO.inspect(result, label: "Final component_params")
IO.puts("=== DEBUG: End save_uploaded_image ===")
result
_error ->
component_params
end
end
# Helper function for datasheet upload handling

View File

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

View File

@@ -11,9 +11,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
@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,
@@ -28,6 +26,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:scanned_tags, [])
|> assign(:expanded_locations, MapSet.new())
|> assign(:page_title, "Storage Location Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end
end
@@ -54,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)
@@ -82,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} ->
@@ -147,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
@@ -164,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
@@ -188,11 +203,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive 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
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
@@ -203,19 +219,26 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
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
@@ -234,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,
@@ -257,7 +281,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
Hierarchical.parent_select_options(
storage_locations,
editing_location_id,
&(&1.parent),
& &1.parent,
"No parent (Root location)"
)
end
@@ -267,11 +291,11 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end
defp root_storage_locations(storage_locations) do
Hierarchical.root_entities(storage_locations, &(&1.parent_id))
Hierarchical.root_entities(storage_locations, & &1.parent_id)
end
defp child_storage_locations(storage_locations, parent_id) do
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id))
Hierarchical.child_entities(storage_locations, parent_id, & &1.parent_id)
end
defp count_components_in_location(location_id) do
@@ -291,46 +315,53 @@ 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
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
)
{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)
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"}>
@@ -350,12 +381,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<% end %>
</button>
<% else %>
<div class="w-6"></div> <!-- Spacer for alignment -->
<div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %>
<.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 -->
<.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 %>
@@ -408,8 +443,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
</div>
<% end %>
<!-- Expanded view -->
<!-- Expanded view -->
<%= if @is_expanded do %>
<div class="flex items-start justify-between">
<div class="flex-1">
@@ -468,8 +503,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
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
<.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">
@@ -495,11 +529,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div>
</div>
</div>
<!-- Render children recursively (only when expanded) -->
<!-- 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} />
<.location_item
location={child}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={@depth + 1}
/>
<% end %>
<% end %>
</div>
@@ -552,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">
@@ -589,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
@@ -647,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>
@@ -678,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">
@@ -773,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>
@@ -803,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">
@@ -818,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"
@@ -848,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">
@@ -863,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 <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
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 %>
@@ -897,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>
@@ -907,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} expanded_locations={@expanded_locations} depth={0} />
<.location_item
location={location}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={0}
/>
</div>
<% end %>
</div>

View File

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

View File

@@ -65,7 +65,8 @@ defmodule ComponentsElixir.MixProject do
{:gettext, "~> 0.26"},
{:jason, "~> 1.2"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}
{:bandit, "~> 1.5"},
{:credo, "~> 1.7", only: [:dev, :test], runtime: false}
]
end

View File

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

View File

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

View File

@@ -25,102 +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})
{: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})
{: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 = [
@@ -162,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
},
%{
@@ -264,7 +371,11 @@ IO.puts("")
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(
" Storage Locations: #{length(Inventory.list_storage_locations())} (with auto-assigned AprilTags)"
)
IO.puts(" Components: #{length(Inventory.list_components())}")
IO.puts("")
IO.puts("🏷️ AprilTag System:")

View File

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

View File

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

View File

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