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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
case URI.parse(url) do case URI.parse(url) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> %URI{scheme: scheme} when scheme in ["http", "https"] ->
{:ok, URI.parse(url)} {:ok, URI.parse(url)}
_ -> _ ->
{:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."} {:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."}
end end
@@ -36,9 +37,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp generate_filename(url) do defp generate_filename(url) do
# Try to extract a meaningful filename from the URL # Try to extract a meaningful filename from the URL
uri = URI.parse(url) uri = URI.parse(url)
original_filename = original_filename =
case Path.basename(uri.path || "") do case Path.basename(uri.path || "") do
"" -> "datasheet" "" ->
"datasheet"
basename -> basename ->
# Remove extension and sanitize # Remove extension and sanitize
basename basename
@@ -54,10 +58,14 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp sanitize_filename(filename) do defp sanitize_filename(filename) do
filename filename
|> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores # Replace non-word chars with underscores
|> String.replace(~r/_+/, "_") # Replace multiple underscores with single |> String.replace(~r/[^\w\-_]/, "_")
|> String.trim("_") # Remove leading/trailing underscores # Replace multiple underscores with single
|> String.slice(0, 50) # Limit length |> String.replace(~r/_+/, "_")
# Remove leading/trailing underscores
|> String.trim("_")
# Limit length
|> String.slice(0, 50)
|> case do |> case do
"" -> "datasheet" "" -> "datasheet"
name -> name name -> name
@@ -66,17 +74,19 @@ defmodule ComponentsElixir.DatasheetDownloader do
defp fetch_pdf(url) do defp fetch_pdf(url) do
case Req.get(url, case Req.get(url,
redirect: true, redirect: true,
max_redirects: 5, max_redirects: 5,
receive_timeout: 30_000, receive_timeout: 30_000,
headers: [ headers: [
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"} {"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
] ]
) do ) do
{:ok, %Req.Response{status: 200} = response} -> {:ok, %Req.Response{status: 200} = response} ->
{:ok, response} {:ok, response}
{:ok, %Req.Response{status: status}} -> {:ok, %Req.Response{status: status}} ->
{:error, "HTTP error: #{status}"} {:error, "HTTP error: #{status}"}
{:error, reason} -> {:error, reason} ->
Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}") Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}")
{:error, "Download failed: #{inspect(reason)}"} {:error, "Download failed: #{inspect(reason)}"}
@@ -88,6 +98,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
case body do case body do
<<"%PDF", _rest::binary>> -> <<"%PDF", _rest::binary>> ->
:ok :ok
_ -> _ ->
{:error, "Downloaded content is not a valid PDF file"} {:error, "Downloaded content is not a valid PDF file"}
end end
@@ -105,10 +116,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
:ok -> :ok ->
Logger.info("Successfully saved datasheet: #{filename}") Logger.info("Successfully saved datasheet: #{filename}")
:ok :ok
{:error, reason} -> {:error, reason} ->
Logger.error("Failed to save datasheet file: #{inspect(reason)}") Logger.error("Failed to save datasheet file: #{inspect(reason)}")
{:error, "Failed to save file: #{inspect(reason)}"} {:error, "Failed to save file: #{inspect(reason)}"}
end end
{:error, reason} -> {:error, reason} ->
Logger.error("Failed to create datasheets directory: #{inspect(reason)}") Logger.error("Failed to create datasheets directory: #{inspect(reason)}")
{:error, "Failed to create directory: #{inspect(reason)}"} {:error, "Failed to create directory: #{inspect(reason)}"}
@@ -129,6 +142,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
:ok -> :ok ->
Logger.info("Deleted datasheet file: #{filename}") Logger.info("Deleted datasheet file: #{filename}")
:ok :ok
{:error, reason} -> {:error, reason} ->
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}") Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
{:error, reason} {:error, reason}

View File

@@ -19,7 +19,7 @@ defmodule ComponentsElixir.Inventory do
locations = locations =
StorageLocation StorageLocation
|> order_by([sl], asc: sl.name) |> order_by([sl], asc: sl.name)
|> preload([parent: [parent: [parent: [parent: :parent]]]]) |> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all() |> Repo.all()
# Ensure AprilTag SVGs exist for all locations # Ensure AprilTag SVGs exist for all locations
@@ -162,7 +162,7 @@ defmodule ComponentsElixir.Inventory do
""" """
def list_categories do def list_categories do
Category Category
|> preload([parent: [parent: [parent: [parent: :parent]]]]) |> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all() |> Repo.all()
end end
@@ -217,8 +217,15 @@ defmodule ComponentsElixir.Inventory do
# Verify the category exists before getting descendants # Verify the category exists before getting descendants
case Enum.find(categories, &(&1.id == category_id)) do case Enum.find(categories, &(&1.id == category_id)) do
nil -> [] nil ->
_category -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(categories, category_id, &(&1.parent_id)) []
_category ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
categories,
category_id,
& &1.parent_id
)
end end
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, for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
a recursive CTE query could be used instead. 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() storage_locations = list_storage_locations()
# Verify the storage location exists before getting descendants # Verify the storage location exists before getting descendants
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
nil -> [] nil ->
_storage_location -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(storage_locations, storage_location_id, &(&1.parent_id)) []
_storage_location ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
storage_locations,
storage_location_id,
& &1.parent_id
)
end end
end end
@@ -288,18 +303,25 @@ defmodule ComponentsElixir.Inventory do
end end
defp apply_component_sorting(query, opts) do defp apply_component_sorting(query, opts) do
case Keyword.get(opts, :sort_criteria, "name_asc") do sort_criteria = Keyword.get(opts, :sort_criteria, "name_asc")
"name_asc" -> order_by(query, [c], [asc: c.name, asc: c.id]) sort_order = get_sort_order(sort_criteria)
"name_desc" -> order_by(query, [c], [desc: c.name, asc: c.id]) order_by(query, [c], ^sort_order)
"inserted_at_asc" -> order_by(query, [c], [asc: c.inserted_at, asc: c.id]) end
"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]) # Map of sort criteria to their corresponding sort orders
"updated_at_desc" -> order_by(query, [c], [desc: c.updated_at, asc: c.id]) @sort_orders %{
"count_asc" -> order_by(query, [c], [asc: c.count, asc: c.id]) "name_asc" => [asc: :name, asc: :id],
"count_desc" -> order_by(query, [c], [desc: c.count, asc: c.id]) "name_desc" => [desc: :name, asc: :id],
# Default fallback "inserted_at_asc" => [asc: :inserted_at, asc: :id],
_ -> order_by(query, [c], [asc: c.name, asc: c.id]) "inserted_at_desc" => [desc: :inserted_at, asc: :id],
end "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 end
@doc """ @doc """
@@ -331,10 +353,12 @@ defmodule ComponentsElixir.Inventory do
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
{:ok, filename} -> {:ok, filename} ->
Map.put(attrs, "datasheet_filename", filename) Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} -> {:error, _reason} ->
# Continue without datasheet file if download fails # Continue without datasheet file if download fails
attrs attrs
end end
_ -> _ ->
attrs attrs
end end
@@ -365,13 +389,18 @@ defmodule ComponentsElixir.Inventory do
{:ok, filename} -> {:ok, filename} ->
# Delete old datasheet file if it exists # Delete old datasheet file if it exists
if component.datasheet_filename do if component.datasheet_filename do
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename) ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
component.datasheet_filename
)
end end
Map.put(attrs, "datasheet_filename", filename) Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} -> {:error, _reason} ->
# Keep existing filename if download fails # Keep existing filename if download fails
attrs attrs
end end
_ -> _ ->
attrs attrs
end end

View File

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

View File

@@ -30,7 +30,18 @@ defmodule ComponentsElixir.Inventory.Component do
@doc false @doc false
def changeset(component, attrs) do def changeset(component, attrs) do
component component
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :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_required([:name, :category_id])
|> validate_length(:name, min: 1, max: 255) |> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 2000) |> validate_length(:description, max: 2000)
@@ -70,17 +81,21 @@ defmodule ComponentsElixir.Inventory.Component do
defp validate_url(changeset, field) do defp validate_url(changeset, field) do
validate_change(changeset, field, fn ^field, url -> validate_change(changeset, field, fn ^field, url ->
if url && url != "" do cond do
case URI.parse(url) do is_nil(url) or url == "" -> []
%URI{scheme: scheme} when scheme in ["http", "https"] -> [] valid_url?(url) -> []
_ -> [{field, "must be a valid URL"}] true -> [{field, "must be a valid URL"}]
end
else
[]
end end
end) end)
end end
defp valid_url?(url) do
case URI.parse(url) do
%URI{scheme: scheme} when scheme in ["http", "https"] -> true
_ -> false
end
end
@doc """ @doc """
Returns true if the component has an image. Returns true if the component has an image.
""" """

View File

@@ -29,10 +29,12 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
case parent_accessor_fn.(entity) do case parent_accessor_fn.(entity) do
nil -> nil ->
entity.name entity.name
%Ecto.Association.NotLoaded{} -> %Ecto.Association.NotLoaded{} ->
# Parent not loaded - fall back to database lookup # Parent not loaded - fall back to database lookup
# This is a fallback and should be rare if preloading is done correctly # This is a fallback and should be rare if preloading is done correctly
build_path_with_db_lookup(entity, separator) build_path_with_db_lookup(entity, separator)
parent -> parent ->
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}" "#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
end end
@@ -52,12 +54,14 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
nil -> nil ->
# This is a root entity, add its name and return the complete path # This is a root entity, add its name and return the complete path
[entity.name | path_so_far] [entity.name | path_so_far]
parent_id -> parent_id ->
# Load parent from database # Load parent from database
case load_parent_entity(entity, parent_id) do case load_parent_entity(entity, parent_id) do
nil -> nil ->
# Parent not found (orphaned record), treat this as root # Parent not found (orphaned record), treat this as root
[entity.name | path_so_far] [entity.name | path_so_far]
parent -> parent ->
# Recursively get the path from the parent, then add current entity # Recursively get the path from the parent, then add current entity
collect_path_from_root(parent, [entity.name | path_so_far]) 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) entity_id = id_accessor_fn.(entity)
# Remove self-reference # Remove self-reference
entity_id == editing_entity_id ||
# Remove descendants (they would create a cycle) # 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)
end end
@@ -103,24 +107,32 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
Checks if an entity is a descendant of an ancestor entity. Checks if an entity is a descendant of an ancestor entity.
Used for cycle detection in parent selection. 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) descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
case descendant do case descendant do
nil -> false 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
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 case parent_id_accessor_fn.(entity) do
nil -> false nil ->
^ancestor_id -> true false
^ancestor_id ->
true
parent_id -> parent_id ->
parent = Enum.find(entities, fn e -> e.id == parent_id end) parent = Enum.find(entities, fn e -> e.id == parent_id end)
case parent do case parent do
nil -> false nil ->
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn) false
parent_entity ->
descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end end
end end
end end
@@ -182,15 +194,20 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
Includes proper filtering to prevent cycles and formatted display names. Includes proper filtering to prevent cycles and formatted display names.
Results are sorted hierarchically for intuitive navigation. 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 = available_entities =
filter_parent_options( filter_parent_options(
entities, entities,
editing_entity_id, editing_entity_id,
&(&1.id), & &1.id,
&(&1.parent_id) & &1.parent_id
) )
|> sort_hierarchically(&(&1.parent_id)) |> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity -> |> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id} {display_name(entity, parent_accessor_fn), entity.id}
end) end)
@@ -205,7 +222,7 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities = sorted_entities =
entities entities
|> sort_hierarchically(&(&1.parent_id)) |> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity -> |> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id} {display_name(entity, parent_accessor_fn), entity.id}
end) end)
@@ -300,9 +317,10 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
descendant_ids_only = List.delete(all_descendant_ids, entity_id) descendant_ids_only = List.delete(all_descendant_ids, entity_id)
# Sum counts for all descendants # Sum counts for all descendants
children_count = Enum.reduce(descendant_ids_only, 0, fn id, acc -> children_count =
acc + count_fn.(id) Enum.reduce(descendant_ids_only, 0, fn id, acc ->
end) acc + count_fn.(id)
end)
{self_count, children_count, self_count + children_count} {self_count, children_count, self_count + children_count}
end end
@@ -320,7 +338,13 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
- singular_noun: What to call a single item (default: "component") - singular_noun: What to call a single item (default: "component")
- plural_noun: What to call multiple items (default: "components") - 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 total_count = self_count + children_count
count_noun = if total_count == 1, do: singular_noun, else: plural_noun 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) timestamps(type: :naive_datetime_usec)
end end
@doc false @doc false
def changeset(storage_location, attrs) do def changeset(storage_location, attrs) do
storage_location storage_location
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id]) |> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
@@ -40,7 +40,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
# HierarchicalSchema implementations # HierarchicalSchema implementations
@impl true @impl true
def full_path(%StorageLocation{} = storage_location) do 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 end
@impl true @impl true
@@ -80,11 +80,12 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
defp get_next_available_apriltag_id do defp get_next_available_apriltag_id do
# Get all used AprilTag IDs # Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all( used_ids =
from sl in ComponentsElixir.Inventory.StorageLocation, ComponentsElixir.Repo.all(
where: not is_nil(sl.apriltag_id), from sl in ComponentsElixir.Inventory.StorageLocation,
select: sl.apriltag_id where: not is_nil(sl.apriltag_id),
) select: sl.apriltag_id
)
# Find the first available ID (0-586) # Find the first available ID (0-586)
0..586 0..586
@@ -93,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
nil -> nil ->
# All IDs are used - this should be handled at the application level # All IDs are used - this should be handled at the application level
raise "All AprilTag IDs are in use" raise "All AprilTag IDs are in use"
id -> id
id ->
id
end end
end end
end end

View File

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

View File

@@ -13,7 +13,8 @@ defmodule ComponentsElixirWeb.FileController do
conn conn
|> put_resp_content_type(mime_type) |> 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) |> send_file(200, file_path)
else else
conn conn
@@ -40,7 +41,8 @@ defmodule ComponentsElixirWeb.FileController do
conn conn
|> put_resp_content_type(mime_type) |> 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}\"") |> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
|> send_file(200, file_path) |> send_file(200, file_path)
else else
@@ -64,9 +66,9 @@ defmodule ComponentsElixirWeb.FileController do
# Security validation: prevent directory traversal and only allow safe characters # Security validation: prevent directory traversal and only allow safe characters
# Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation # Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation
if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and
not String.contains?(decoded_filename, "..") and not String.contains?(decoded_filename, "..") and
not String.starts_with?(decoded_filename, "/") and not String.starts_with?(decoded_filename, "/") and
not String.contains?(decoded_filename, "\\") do not String.contains?(decoded_filename, "\\") do
{:ok, decoded_filename} {:ok, decoded_filename}
else else
{:error, "Invalid filename: contains unsafe characters"} {:error, "Invalid filename: contains unsafe characters"}

View File

@@ -7,9 +7,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Check authentication # Check authentication
unless Auth.authenticated?(session) do if Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
categories = Inventory.list_categories() categories = Inventory.list_categories()
{:ok, {:ok,
@@ -22,6 +20,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> assign(:form, nil) |> assign(:form, nil)
|> assign(:expanded_categories, MapSet.new()) |> assign(:expanded_categories, MapSet.new())
|> assign(:page_title, "Category Management")} |> assign(:page_title, "Category Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end end
end end
@@ -46,12 +46,13 @@ defmodule ComponentsElixirWeb.CategoriesLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do def handle_event("show_edit_form", %{"id" => id}, socket) do
category = Inventory.get_category!(id) category = Inventory.get_category!(id)
# Create a changeset with current values forced into changes for proper form display # Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_category(category, %{ changeset =
name: category.name, Inventory.change_category(category, %{
description: category.description, name: category.name,
parent_id: category.parent_id description: category.description,
}) parent_id: category.parent_id
|> Ecto.Changeset.force_change(:parent_id, category.parent_id) })
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
form = to_form(changeset) form = to_form(changeset)
@@ -112,7 +113,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|> reload_categories()} |> reload_categories()}
{:error, _changeset} -> {:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Cannot delete category - it may have components assigned or child categories")} {:noreply,
put_flash(
socket,
:error,
"Cannot delete category - it may have components assigned or child categories"
)}
end end
end end
@@ -120,11 +126,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
category_id = String.to_integer(id) category_id = String.to_integer(id)
expanded_categories = socket.assigns.expanded_categories expanded_categories = socket.assigns.expanded_categories
new_expanded = if MapSet.member?(expanded_categories, category_id) do new_expanded =
MapSet.delete(expanded_categories, category_id) if MapSet.member?(expanded_categories, category_id) do
else MapSet.delete(expanded_categories, category_id)
MapSet.put(expanded_categories, category_id) else
end MapSet.put(expanded_categories, category_id)
end
{:noreply, assign(socket, :expanded_categories, new_expanded)} {:noreply, assign(socket, :expanded_categories, new_expanded)}
end end
@@ -138,17 +145,17 @@ defmodule ComponentsElixirWeb.CategoriesLive do
Hierarchical.parent_select_options( Hierarchical.parent_select_options(
categories, categories,
editing_category_id, editing_category_id,
&(&1.parent), & &1.parent,
"No parent (Root category)" "No parent (Root category)"
) )
end end
defp root_categories(categories) do defp root_categories(categories) do
Hierarchical.root_entities(categories, &(&1.parent_id)) Hierarchical.root_entities(categories, & &1.parent_id)
end end
defp child_categories(categories, parent_id) do 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 end
defp count_components_in_category(category_id) do 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: "" border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
# Icon size and button size based on depth # Icon size and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do {icon_size, button_size, text_size, title_tag} =
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} case assigns.depth do
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
end _ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
children = child_categories(assigns.categories, assigns.category.id) children = child_categories(assigns.categories, assigns.category.id)
has_children = !Enum.empty?(children) has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id) is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
# Calculate component counts including descendants # Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants( {self_count, children_count, _total_count} =
assigns.category.id, Hierarchical.count_with_descendants(
assigns.categories, assigns.category.id,
&(&1.parent_id), assigns.categories,
&count_components_in_category/1 & &1.parent_id,
) &count_components_in_category/1
)
# Format count display # Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded) count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns assigns =
|> assign(:margin_left, margin_left) assigns
|> assign(:border_class, border_class) |> assign(:margin_left, margin_left)
|> assign(:icon_size, icon_size) |> assign(:border_class, border_class)
|> assign(:button_size, button_size) |> assign(:icon_size, icon_size)
|> assign(:text_size, text_size) |> assign(:button_size, button_size)
|> assign(:title_tag, title_tag) |> assign(:text_size, text_size)
|> assign(:children, children) |> assign(:title_tag, title_tag)
|> assign(:has_children, has_children) |> assign(:children, children)
|> assign(:is_expanded, is_expanded) |> assign(:has_children, has_children)
|> assign(:count_display, count_display) |> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H""" ~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}> <div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
@@ -215,12 +225,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<% end %> <% end %>
</button> </button>
<% else %> <% else %>
<div class="w-6"></div> <!-- Spacer for alignment --> <div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %> <% end %>
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} /> <.icon
name="hero-folder"
<!-- Content area - always starts at same vertical position --> 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"> <div class="flex-1">
<!-- Minimized view (default) --> <!-- Minimized view (default) -->
<%= unless @is_expanded do %> <%= unless @is_expanded do %>
@@ -268,8 +282,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Expanded view --> <!-- Expanded view -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
@@ -321,11 +335,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Render children recursively (only when expanded) --> <!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<%= for child <- @children 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 %>
<% end %> <% end %>
</div> </div>
@@ -368,8 +387,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Add Category Modal --> <!-- Add Category Modal -->
<%= if @show_add_form do %> <%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50"> <div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100"> <div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -424,8 +443,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Edit Category Modal --> <!-- Edit Category Modal -->
<%= if @show_edit_form do %> <%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50"> <div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100"> <div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -480,13 +499,15 @@ defmodule ComponentsElixirWeb.CategoriesLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Categories List --> <!-- Categories List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md"> <div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300"> <div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2> <h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2>
<p class="text-sm text-base-content/60 mt-1">Manage your component categories and subcategories</p> <p class="text-sm text-base-content/60 mt-1">
Manage your component categories and subcategories
</p>
</div> </div>
<%= if Enum.empty?(@categories) do %> <%= if Enum.empty?(@categories) do %>
@@ -501,8 +522,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
phx-click="show_add_form" phx-click="show_add_form"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
> >
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
Add Category
</button> </button>
</div> </div>
</div> </div>
@@ -511,7 +531,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
<!-- Recursive Category Tree --> <!-- Recursive Category Tree -->
<%= for category <- root_categories(@categories) do %> <%= for category <- root_categories(@categories) do %>
<div class="px-6 py-4"> <div class="px-6 py-4">
<.category_item category={category} categories={@categories} expanded_categories={@expanded_categories} depth={0} /> <.category_item
category={category}
categories={@categories}
expanded_categories={@expanded_categories}
depth={0}
/>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

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

View File

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

View File

@@ -11,9 +11,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
@impl true @impl true
def mount(_params, session, socket) do def mount(_params, session, socket) do
# Check authentication # Check authentication
unless Auth.authenticated?(session) do if Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
storage_locations = list_storage_locations() storage_locations = list_storage_locations()
{:ok, {:ok,
@@ -28,6 +26,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> assign(:scanned_tags, []) |> assign(:scanned_tags, [])
|> assign(:expanded_locations, MapSet.new()) |> assign(:expanded_locations, MapSet.new())
|> assign(:page_title, "Storage Location Management")} |> assign(:page_title, "Storage Location Management")}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end end
end end
@@ -54,12 +54,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("show_edit_form", %{"id" => id}, socket) do def handle_event("show_edit_form", %{"id" => id}, socket) do
location = Inventory.get_storage_location!(id) location = Inventory.get_storage_location!(id)
# Create a changeset with current values forced into changes for proper form display # Create a changeset with current values forced into changes for proper form display
changeset = Inventory.change_storage_location(location, %{ changeset =
name: location.name, Inventory.change_storage_location(location, %{
description: location.description, name: location.name,
parent_id: location.parent_id description: location.description,
}) parent_id: location.parent_id
|> Ecto.Changeset.force_change(:parent_id, location.parent_id) })
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
form = to_form(changeset) form = to_form(changeset)
@@ -82,29 +83,31 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("save_location", %{"storage_location" => location_params}, socket) do def handle_event("save_location", %{"storage_location" => location_params}, socket) do
# Process AprilTag assignment based on mode # Process AprilTag assignment based on mode
processed_params = case socket.assigns.apriltag_mode do processed_params =
"none" -> case socket.assigns.apriltag_mode do
# Remove any apriltag_id from params to ensure it's nil "none" ->
Map.delete(location_params, "apriltag_id") # Remove any apriltag_id from params to ensure it's nil
Map.delete(location_params, "apriltag_id")
"auto" -> "auto" ->
# Auto-assign next available AprilTag ID # Auto-assign next available AprilTag ID
case AprilTag.next_available_apriltag_id() do case AprilTag.next_available_apriltag_id() do
nil -> nil ->
# No available IDs, proceed without AprilTag # No available IDs, proceed without AprilTag
Map.delete(location_params, "apriltag_id") Map.delete(location_params, "apriltag_id")
apriltag_id ->
Map.put(location_params, "apriltag_id", apriltag_id)
end
"manual" -> apriltag_id ->
# Use the manually entered apriltag_id (validation will be handled by changeset) Map.put(location_params, "apriltag_id", apriltag_id)
location_params end
_ -> "manual" ->
# Fallback: remove apriltag_id # Use the manually entered apriltag_id (validation will be handled by changeset)
Map.delete(location_params, "apriltag_id") location_params
end
_ ->
# Fallback: remove apriltag_id
Map.delete(location_params, "apriltag_id")
end
case Inventory.create_storage_location(processed_params) do case Inventory.create_storage_location(processed_params) do
{:ok, _location} -> {:ok, _location} ->
@@ -147,7 +150,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|> reload_storage_locations()} |> reload_storage_locations()}
{:error, _changeset} -> {:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")} {:noreply,
put_flash(
socket,
:error,
"Cannot delete storage location - it may have components assigned or child locations"
)}
end end
end end
@@ -164,10 +172,17 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 -> {apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
nil -> nil ->
{:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")} {:noreply,
put_flash(
socket,
:error,
"Storage location not found for AprilTag ID: #{apriltag_id}"
)}
location -> location ->
scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags] scanned_tags = [
%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags
]
{:noreply, {:noreply,
socket socket
@@ -188,11 +203,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
location_id = String.to_integer(id) location_id = String.to_integer(id)
expanded_locations = socket.assigns.expanded_locations expanded_locations = socket.assigns.expanded_locations
new_expanded = if MapSet.member?(expanded_locations, location_id) do new_expanded =
MapSet.delete(expanded_locations, location_id) if MapSet.member?(expanded_locations, location_id) do
else MapSet.delete(expanded_locations, location_id)
MapSet.put(expanded_locations, location_id) else
end MapSet.put(expanded_locations, location_id)
end
{:noreply, assign(socket, :expanded_locations, new_expanded)} {:noreply, assign(socket, :expanded_locations, new_expanded)}
end end
@@ -203,19 +219,26 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
# Clear the apriltag_id field when switching modes # Clear the apriltag_id field when switching modes
form = case mode do form =
"remove" -> case mode do
socket.assigns.form "remove" ->
|> Phoenix.Component.to_form() socket.assigns.form
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil)) |> Phoenix.Component.to_form()
"keep" -> |> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
current_id = socket.assigns.editing_location.apriltag_id
socket.assigns.form "keep" ->
|> Phoenix.Component.to_form() current_id = socket.assigns.editing_location.apriltag_id
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
_ -> socket.assigns.form
socket.assigns.form |> Phoenix.Component.to_form()
end |> Map.put(
:params,
Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)
)
_ ->
socket.assigns.form
end
{:noreply, {:noreply,
socket socket
@@ -234,7 +257,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")} {:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
apriltag_url -> apriltag_url ->
filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg" filename =
"#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
# Send file download to browser # Send file download to browser
{:noreply, {:noreply,
@@ -257,7 +281,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
Hierarchical.parent_select_options( Hierarchical.parent_select_options(
storage_locations, storage_locations,
editing_location_id, editing_location_id,
&(&1.parent), & &1.parent,
"No parent (Root location)" "No parent (Root location)"
) )
end end
@@ -267,11 +291,11 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
end end
defp root_storage_locations(storage_locations) do defp root_storage_locations(storage_locations) do
Hierarchical.root_entities(storage_locations, &(&1.parent_id)) Hierarchical.root_entities(storage_locations, & &1.parent_id)
end end
defp child_storage_locations(storage_locations, parent_id) do defp child_storage_locations(storage_locations, parent_id) do
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id)) Hierarchical.child_entities(storage_locations, parent_id, & &1.parent_id)
end end
defp count_components_in_location(location_id) do defp count_components_in_location(location_id) do
@@ -291,46 +315,53 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: "" border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
# Icon size and button size based on depth # Icon size and button size based on depth
{icon_size, button_size, text_size, title_tag} = case assigns.depth do {icon_size, button_size, text_size, title_tag} =
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"} case assigns.depth do
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"} 0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"} 1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
end _ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
end
# Different icons based on level - QR code is always present for storage locations # Different icons based on level - QR code is always present for storage locations
icon_name = case assigns.depth do icon_name =
0 -> "hero-building-office" # Shelf/Room case assigns.depth do
1 -> "hero-archive-box" # Drawer/Cabinet # Shelf/Room
_ -> "hero-cube" # Box/Container 0 -> "hero-building-office"
end # Drawer/Cabinet
1 -> "hero-archive-box"
# Box/Container
_ -> "hero-cube"
end
children = child_storage_locations(assigns.storage_locations, assigns.location.id) children = child_storage_locations(assigns.storage_locations, assigns.location.id)
has_children = !Enum.empty?(children) has_children = !Enum.empty?(children)
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id) is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
# Calculate component counts including descendants # Calculate component counts including descendants
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants( {self_count, children_count, _total_count} =
assigns.location.id, Hierarchical.count_with_descendants(
assigns.storage_locations, assigns.location.id,
&(&1.parent_id), assigns.storage_locations,
&count_components_in_location/1 & &1.parent_id,
) &count_components_in_location/1
)
# Format count display # Format count display
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded) count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
assigns = assigns assigns =
|> assign(:margin_left, margin_left) assigns
|> assign(:border_class, border_class) |> assign(:margin_left, margin_left)
|> assign(:icon_size, icon_size) |> assign(:border_class, border_class)
|> assign(:button_size, button_size) |> assign(:icon_size, icon_size)
|> assign(:text_size, text_size) |> assign(:button_size, button_size)
|> assign(:title_tag, title_tag) |> assign(:text_size, text_size)
|> assign(:icon_name, icon_name) |> assign(:title_tag, title_tag)
|> assign(:children, children) |> assign(:icon_name, icon_name)
|> assign(:has_children, has_children) |> assign(:children, children)
|> assign(:is_expanded, is_expanded) |> assign(:has_children, has_children)
|> assign(:count_display, count_display) |> assign(:is_expanded, is_expanded)
|> assign(:count_display, count_display)
~H""" ~H"""
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}> <div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
@@ -350,12 +381,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<% end %> <% end %>
</button> </button>
<% else %> <% else %>
<div class="w-6"></div> <!-- Spacer for alignment --> <div class="w-6"></div>
<!-- Spacer for alignment -->
<% end %> <% end %>
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} /> <.icon
name={@icon_name}
<!-- Content area - always starts at same vertical position --> 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"> <div class="flex-1">
<!-- Minimized view (default) --> <!-- Minimized view (default) -->
<%= unless @is_expanded do %> <%= unless @is_expanded do %>
@@ -408,8 +443,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Expanded view --> <!-- Expanded view -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <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" 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" title="Download AprilTag"
> >
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> <.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> Download
Download
</button> </button>
<% end %> <% end %>
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
@@ -495,11 +529,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Render children recursively (only when expanded) --> <!-- Render children recursively (only when expanded) -->
<%= if @is_expanded do %> <%= if @is_expanded do %>
<%= for child <- @children 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 %>
<% end %> <% end %>
</div> </div>
@@ -552,8 +591,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
</div> </div>
<!-- Add Location Modal --> <!-- Add Location Modal -->
<%= if @show_add_form do %> <%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50"> <div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100"> <div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -589,7 +628,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-base-content">AprilTag ID (Optional)</label> <label class="block text-sm font-medium text-base-content">
AprilTag ID (Optional)
</label>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<input <input
@@ -647,10 +688,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32" class="w-32"
/> />
<div class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60">
Available IDs: <%= length(@available_apriltag_ids) %> of 587 Available IDs: {length(@available_apriltag_ids)} of 587
<%= if length(@available_apriltag_ids) < 20 do %> <%= if length(@available_apriltag_ids) < 20 do %>
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %> <br />Next available: {@available_apriltag_ids
<%= if length(@available_apriltag_ids) > 10, do: "..." %> |> Enum.take(10)
|> Enum.join(", ")}
{if length(@available_apriltag_ids) > 10, do: "..."}
<% end %> <% end %>
</div> </div>
</div> </div>
@@ -678,8 +721,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Edit Location Modal --> <!-- Edit Location Modal -->
<%= if @show_edit_form do %> <%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50"> <div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100"> <div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -773,12 +816,14 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
class="w-32" class="w-32"
/> />
<div class="text-xs text-base-content/60"> <div class="text-xs text-base-content/60">
Available IDs: <%= length(@available_apriltag_ids) %> of 587 Available IDs: {length(@available_apriltag_ids)} of 587
</div> </div>
</div> </div>
<% end %> <% end %>
<p class="text-xs text-base-content/60 mt-1"> <p class="text-xs text-base-content/60 mt-1">
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %> Current: {if @editing_location.apriltag_id,
do: "ID #{@editing_location.apriltag_id}",
else: "None"}
</p> </p>
</div> </div>
</div> </div>
@@ -803,8 +848,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- AprilTag Scanner Modal --> <!-- AprilTag Scanner Modal -->
<%= if @apriltag_scanner_open do %> <%= if @apriltag_scanner_open do %>
<div class="fixed inset-0 bg-base-content/30 bg-opacity-50 overflow-y-auto h-full w-full z-50"> <div class="fixed inset-0 bg-base-content/30 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div class="relative top-10 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100"> <div class="relative top-10 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
@@ -818,16 +863,20 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<.icon name="hero-x-mark" class="w-6 h-6" /> <.icon name="hero-x-mark" class="w-6 h-6" />
</button> </button>
</div> </div>
<!-- AprilTag Scanner Interface --> <!-- AprilTag Scanner Interface -->
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center"> <div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center">
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" /> <.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" />
<p class="mt-2 text-sm text-base-content/70">Camera AprilTag scanner would go here</p> <p class="mt-2 text-sm text-base-content/70">Camera AprilTag scanner would go here</p>
<p class="text-xs text-base-content/60 mt-1">In a real implementation, this would use JavaScript AprilTag detection</p> <p class="text-xs text-base-content/60 mt-1">
In a real implementation, this would use JavaScript AprilTag detection
<!-- Test buttons for demo --> </p>
<!-- Test buttons for demo -->
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<p class="text-sm font-medium text-base-content/80">Test with sample AprilTag IDs:</p> <p class="text-sm font-medium text-base-content/80">
Test with sample AprilTag IDs:
</p>
<button <button
phx-click="apriltag_scanned" phx-click="apriltag_scanned"
phx-value-apriltag_id="0" phx-value-apriltag_id="0"
@@ -848,8 +897,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Scanned Tags Display --> <!-- Scanned Tags Display -->
<%= if length(@scanned_tags) > 0 do %> <%= if length(@scanned_tags) > 0 do %>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div class="bg-green-50 border border-green-200 rounded-lg p-4"> <div class="bg-green-50 border border-green-200 rounded-lg p-4">
@@ -863,26 +912,35 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
</button> </button>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<div :for={scan <- @scanned_tags} class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300"> <div
:for={scan <- @scanned_tags}
class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300"
>
<div> <div>
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span> <span class="font-medium text-base-content">
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span> {location_display_name(scan.location)}
</span>
<span class="text-sm text-base-content/70 ml-2">
(AprilTag ID {scan.apriltag_id})
</span>
</div> </div>
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded"> <span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %> Level {Hierarchical.compute_level(scan.location, & &1.parent)}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- Storage Locations List --> <!-- Storage Locations List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md"> <div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
<div class="px-6 py-4 border-b border-base-300"> <div class="px-6 py-4 border-b border-base-300">
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2> <h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2>
<p class="text-sm text-base-content/60 mt-1">Manage your physical storage locations and AprilTags</p> <p class="text-sm text-base-content/60 mt-1">
Manage your physical storage locations and AprilTags
</p>
</div> </div>
<%= if Enum.empty?(@storage_locations) do %> <%= if Enum.empty?(@storage_locations) do %>
@@ -897,8 +955,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
phx-click="show_add_form" phx-click="show_add_form"
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700" class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
> >
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> <.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
Add Location
</button> </button>
</div> </div>
</div> </div>
@@ -907,7 +964,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
<!-- Recursive Storage Location Tree --> <!-- Recursive Storage Location Tree -->
<%= for location <- root_storage_locations(@storage_locations) do %> <%= for location <- root_storage_locations(@storage_locations) do %>
<div class="px-6 py-4"> <div class="px-6 py-4">
<.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} /> <.location_item
location={location}
storage_locations={@storage_locations}
expanded_locations={@expanded_locations}
depth={0}
/>
</div> </div>
<% end %> <% end %>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,102 +25,208 @@ Repo.delete_all(Category)
Repo.delete_all(StorageLocation) Repo.delete_all(StorageLocation)
# Create categories # Create categories
{:ok, resistors} = Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"}) {:ok, resistors} =
{:ok, capacitors} = Inventory.create_category(%{name: "Capacitors", description: "Electrolytic, ceramic, and film capacitors"}) Inventory.create_category(%{name: "Resistors", description: "Various types of resistors"})
{:ok, semiconductors} = Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} = Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"}) {:ok, capacitors} =
Inventory.create_category(%{
name: "Capacitors",
description: "Electrolytic, ceramic, and film capacitors"
})
{:ok, semiconductors} =
Inventory.create_category(%{name: "Semiconductors", description: "ICs, transistors, diodes"})
{:ok, connectors} =
Inventory.create_category(%{name: "Connectors", description: "Headers, terminals, plugs"})
# Create subcategories # Create subcategories
{:ok, _through_hole_resistors} = Inventory.create_category(%{ {:ok, _through_hole_resistors} =
name: "Through-hole", Inventory.create_category(%{
description: "Traditional leaded resistors", name: "Through-hole",
parent_id: resistors.id description: "Traditional leaded resistors",
}) parent_id: resistors.id
})
{:ok, _smd_resistors} = Inventory.create_category(%{ {:ok, _smd_resistors} =
name: "SMD/SMT", Inventory.create_category(%{
description: "Surface mount resistors", name: "SMD/SMT",
parent_id: resistors.id description: "Surface mount resistors",
}) parent_id: resistors.id
})
{:ok, _ceramic_caps} = Inventory.create_category(%{ {:ok, _ceramic_caps} =
name: "Ceramic", Inventory.create_category(%{
description: "Ceramic disc and multilayer capacitors", name: "Ceramic",
parent_id: capacitors.id description: "Ceramic disc and multilayer capacitors",
}) parent_id: capacitors.id
})
{:ok, _electrolytic_caps} = Inventory.create_category(%{ {:ok, _electrolytic_caps} =
name: "Electrolytic", Inventory.create_category(%{
description: "Polarized electrolytic capacitors", name: "Electrolytic",
parent_id: capacitors.id description: "Polarized electrolytic capacitors",
}) parent_id: capacitors.id
})
# Create a DEEP category hierarchy to test fallback path (7+ levels) # 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_1} =
{:ok, deep_cat_2} = Inventory.create_category(%{name: "Level 2", description: "Deep hierarchy test", parent_id: deep_cat_1.id}) Inventory.create_category(%{
{:ok, deep_cat_3} = Inventory.create_category(%{name: "Level 3", description: "Deep hierarchy test", parent_id: deep_cat_2.id}) name: "Level 1",
{:ok, deep_cat_4} = Inventory.create_category(%{name: "Level 4", description: "Deep hierarchy test", parent_id: deep_cat_3.id}) description: "Deep hierarchy test",
{:ok, deep_cat_5} = Inventory.create_category(%{name: "Level 5", description: "Deep hierarchy test", parent_id: deep_cat_4.id}) parent_id: resistors.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_2} =
Inventory.create_category(%{
name: "Level 2",
description: "Deep hierarchy test",
parent_id: deep_cat_1.id
})
{:ok, deep_cat_3} =
Inventory.create_category(%{
name: "Level 3",
description: "Deep hierarchy test",
parent_id: deep_cat_2.id
})
{:ok, deep_cat_4} =
Inventory.create_category(%{
name: "Level 4",
description: "Deep hierarchy test",
parent_id: deep_cat_3.id
})
{:ok, deep_cat_5} =
Inventory.create_category(%{
name: "Level 5",
description: "Deep hierarchy test",
parent_id: deep_cat_4.id
})
{:ok, deep_cat_6} =
Inventory.create_category(%{
name: "Level 6",
description: "Deep hierarchy test",
parent_id: deep_cat_5.id
})
{:ok, deep_cat_7} =
Inventory.create_category(%{
name: "Level 7",
description: "Deep hierarchy test - triggers fallback",
parent_id: deep_cat_6.id
})
# Create storage locations # Create storage locations
{:ok, shelf_a} = Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"}) {:ok, shelf_a} =
{:ok, _shelf_b} = Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"}) Inventory.create_storage_location(%{name: "Shelf A", description: "Main electronics shelf"})
{:ok, _shelf_b} =
Inventory.create_storage_location(%{name: "Shelf B", description: "Components overflow shelf"})
# Create drawers on Shelf A # Create drawers on Shelf A
{:ok, drawer_a1} = Inventory.create_storage_location(%{ {:ok, drawer_a1} =
name: "Drawer 1", Inventory.create_storage_location(%{
description: "Resistors and capacitors", name: "Drawer 1",
parent_id: shelf_a.id description: "Resistors and capacitors",
}) parent_id: shelf_a.id
})
{:ok, drawer_a2} = Inventory.create_storage_location(%{ {:ok, drawer_a2} =
name: "Drawer 2", Inventory.create_storage_location(%{
description: "Semiconductors and ICs", name: "Drawer 2",
parent_id: shelf_a.id description: "Semiconductors and ICs",
}) parent_id: shelf_a.id
})
# Create boxes in Drawer A1 # Create boxes in Drawer A1
{:ok, box_a1_1} = Inventory.create_storage_location(%{ {:ok, box_a1_1} =
name: "Box 1", Inventory.create_storage_location(%{
description: "Through-hole resistors", name: "Box 1",
parent_id: drawer_a1.id description: "Through-hole resistors",
}) parent_id: drawer_a1.id
})
{:ok, _box_a1_2} = Inventory.create_storage_location(%{ {:ok, _box_a1_2} =
name: "Box 2", Inventory.create_storage_location(%{
description: "SMD resistors", name: "Box 2",
parent_id: drawer_a1.id description: "SMD resistors",
}) parent_id: drawer_a1.id
})
{:ok, box_a1_3} = Inventory.create_storage_location(%{ {:ok, box_a1_3} =
name: "Box 3", Inventory.create_storage_location(%{
description: "Ceramic capacitors", name: "Box 3",
parent_id: drawer_a1.id description: "Ceramic capacitors",
}) parent_id: drawer_a1.id
})
# Create boxes in Drawer A2 # Create boxes in Drawer A2
{:ok, box_a2_1} = Inventory.create_storage_location(%{ {:ok, box_a2_1} =
name: "Box 1", Inventory.create_storage_location(%{
description: "Microcontrollers", name: "Box 1",
parent_id: drawer_a2.id description: "Microcontrollers",
}) parent_id: drawer_a2.id
})
{:ok, _box_a2_2} = Inventory.create_storage_location(%{ {:ok, _box_a2_2} =
name: "Box 2", Inventory.create_storage_location(%{
description: "Transistors and diodes", name: "Box 2",
parent_id: drawer_a2.id description: "Transistors and diodes",
}) parent_id: drawer_a2.id
})
# Create a DEEP storage location hierarchy to test fallback path (7+ levels) # 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_1} =
{:ok, deep_loc_2} = Inventory.create_storage_location(%{name: "Deep Level 2", description: "Deep hierarchy test", parent_id: deep_loc_1.id}) Inventory.create_storage_location(%{
{:ok, deep_loc_3} = Inventory.create_storage_location(%{name: "Deep Level 3", description: "Deep hierarchy test", parent_id: deep_loc_2.id}) name: "Deep Level 1",
{:ok, deep_loc_4} = Inventory.create_storage_location(%{name: "Deep Level 4", description: "Deep hierarchy test", parent_id: deep_loc_3.id}) description: "Deep hierarchy test",
{:ok, deep_loc_5} = Inventory.create_storage_location(%{name: "Deep Level 5", description: "Deep hierarchy test", parent_id: deep_loc_4.id}) parent_id: box_a1_3.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_2} =
Inventory.create_storage_location(%{
name: "Deep Level 2",
description: "Deep hierarchy test",
parent_id: deep_loc_1.id
})
{:ok, deep_loc_3} =
Inventory.create_storage_location(%{
name: "Deep Level 3",
description: "Deep hierarchy test",
parent_id: deep_loc_2.id
})
{:ok, deep_loc_4} =
Inventory.create_storage_location(%{
name: "Deep Level 4",
description: "Deep hierarchy test",
parent_id: deep_loc_3.id
})
{:ok, deep_loc_5} =
Inventory.create_storage_location(%{
name: "Deep Level 5",
description: "Deep hierarchy test",
parent_id: deep_loc_4.id
})
{:ok, deep_loc_6} =
Inventory.create_storage_location(%{
name: "Deep Level 6",
description: "Deep hierarchy test",
parent_id: deep_loc_5.id
})
{:ok, deep_loc_7} =
Inventory.create_storage_location(%{
name: "Deep Level 7",
description: "Deep hierarchy test - triggers fallback",
parent_id: deep_loc_6.id
})
# Create sample components # Create sample components
sample_components = [ sample_components = [
@@ -162,7 +268,8 @@ sample_components = [
keywords: "microcontroller avr atmega328 arduino", keywords: "microcontroller avr atmega328 arduino",
storage_location_id: box_a2_1.id, storage_location_id: box_a2_1.id,
count: 10, count: 10,
datasheet_url: "https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf", datasheet_url:
"https://ww1.microchip.com/downloads/en/DeviceDoc/Atmel-7810-Automotive-Microcontrollers-ATmega328P_Datasheet.pdf",
category_id: semiconductors.id category_id: semiconductors.id
}, },
%{ %{
@@ -264,7 +371,11 @@ IO.puts("")
IO.puts("🎉 Database seeded successfully!") IO.puts("🎉 Database seeded successfully!")
IO.puts("📊 Summary:") IO.puts("📊 Summary:")
IO.puts(" Categories: #{length(Inventory.list_categories())}") 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(" Components: #{length(Inventory.list_components())}")
IO.puts("") IO.puts("")
IO.puts("🏷️ AprilTag System:") IO.puts("🏷️ AprilTag System:")

View File

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

View File

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

View File

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