Compare commits

...

21 Commits

Author SHA1 Message Date
Schuwi
6548a06b43 ci: fix container tags again
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 1m53s
Docker Build and Publish / docker-build (push) Successful in 1m2s
2025-09-21 11:33:15 +02:00
Schuwi
7ce80b6026 ci: fix container tag policy
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 1m53s
Docker Build and Publish / docker-build (push) Failing after 25s
- only move `latest` on tagged releases
2025-09-21 10:58:39 +02:00
Schuwi
d620a9c620 docs: use pre-built docker image 2025-09-21 10:53:19 +02:00
Schuwi
4c7751f1ea ci: re-enable docker cache
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 3m41s
Docker Build and Publish / docker-build (push) Successful in 4m38s
2025-09-21 10:35:00 +02:00
a714d5a28f ci: disable docker cache for now
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 22m7s
Docker Build and Publish / docker-build (push) Successful in 4m4s
2025-09-20 20:15:19 +02:00
e33f700485 Merge pull request 'ci: fix quality checks pipeline' (#5) from schuwi-patch-1 into main
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Successful in 22m8s
Docker Build and Publish / docker-build (push) Failing after 5m9s
Reviewed-on: #5
2025-09-20 18:58:40 +02:00
cff6680f3a ci: change postgres hostname to service
All checks were successful
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (pull_request) Successful in 22m9s
2025-09-20 17:01:27 +02:00
49b639e422 ci: allow setting db hostname
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (pull_request) Has been cancelled
2025-09-20 16:58:44 +02:00
Schuwi
3b15318372 ci: remove arm64 support for now
Some checks failed
Code Quality / Code Quality (Elixir 1.15.7 OTP 26.2) (push) Failing after 13m29s
Docker Build and Publish / docker-build (push) Failing after 5m12s
2025-09-20 12:47:45 +02:00
Schuwi
04db36c38d ci: fix missing ssl library 2025-09-20 12:30:21 +02:00
Schuwi
537a97cecc ci: add gitea ci/cd pipeline
Some checks failed
Code Quality / Code Quality (Elixir 1.15 OTP 26.0) (push) Failing after 2m16s
Docker Build and Publish / docker-build (push) Failing after 6m37s
2025-09-20 12:24:50 +02:00
Schuwi
a6991b6877 docs: add GitHub Copilot instructions 2025-09-20 12:08:50 +02:00
Schuwi
32dea59c74 test: rudimentary fix 2025-09-20 11:55:59 +02:00
Schuwi
c6c218970c style: format codebase 2025-09-20 11:52:43 +02:00
Schuwi
aaf278f7f9 style: prevent formatter issue 2025-09-20 11:52:05 +02:00
Schuwi
f4ee768c52 refactor: cleanup mix credo issues 2025-09-20 11:36:30 +02:00
Schuwi
72484c0d08 docs: update README 2025-09-20 11:25:58 +02:00
Schuwi
5d2e3f7768 feat: datasheet upload and auto-retrieve
- store datasheet PDFs on the server
- download PDF automatically when given a link
2025-09-19 23:09:29 +02:00
Schuwi
086bc65ac1 fix: inconsistent sorting
on components that were inserted in quick succession
2025-09-19 22:43:25 +02:00
Schuwi
c4a0b41e7d feat: filter by category/location on click
- add filtering by storage location
2025-09-19 22:12:58 +02:00
Schuwi
288d84614a feat: collapsable hierarchical view 2025-09-19 21:54:34 +02:00
32 changed files with 2435 additions and 839 deletions

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

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

View File

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

View File

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

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

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

431
README.md
View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ services:
retries: 5
app:
build: .
image: git.maxboeer.com/schuwi/components-elixir:latest
ports:
- "4000:4000"
environment:

View File

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

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ defmodule ComponentsElixir.Inventory do
locations =
StorageLocation
|> order_by([sl], asc: sl.name)
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
# Ensure AprilTag SVGs exist for all locations
@@ -162,7 +162,7 @@ defmodule ComponentsElixir.Inventory do
"""
def list_categories do
Category
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|> preload(parent: [parent: [parent: [parent: :parent]]])
|> Repo.all()
end
@@ -217,13 +217,49 @@ defmodule ComponentsElixir.Inventory do
# Verify the category exists before getting descendants
case Enum.find(categories, &(&1.id == category_id)) do
nil -> []
_category -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(categories, category_id, &(&1.parent_id))
nil ->
[]
_category ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
categories,
category_id,
& &1.parent_id
)
end
end
def get_category_and_descendant_ids(_), do: []
@doc """
Gets all storage location IDs that are descendants of the given storage location ID, including the location itself.
This is used for filtering components by storage location and all its sub-locations.
Returns an empty list if the storage location doesn't exist.
Note: This implementation loads all storage locations into memory for traversal, which is efficient
for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
a recursive CTE query could be used instead.
"""
def get_storage_location_and_descendant_ids(storage_location_id)
when is_integer(storage_location_id) do
storage_locations = list_storage_locations()
# Verify the storage location exists before getting descendants
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
nil ->
[]
_storage_location ->
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
storage_locations,
storage_location_id,
& &1.parent_id
)
end
end
def get_storage_location_and_descendant_ids(_), do: []
## Components
@doc """
@@ -245,7 +281,9 @@ defmodule ComponentsElixir.Inventory do
where(query, [c], c.category_id in ^category_ids)
{:storage_location_id, storage_location_id}, query when not is_nil(storage_location_id) ->
where(query, [c], c.storage_location_id == ^storage_location_id)
# Get the storage location and all its descendant storage location IDs
storage_location_ids = get_storage_location_and_descendant_ids(storage_location_id)
where(query, [c], c.storage_location_id in ^storage_location_ids)
{:search, search_term}, query when is_binary(search_term) and search_term != "" ->
search_pattern = "%#{search_term}%"
@@ -265,18 +303,25 @@ defmodule ComponentsElixir.Inventory do
end
defp apply_component_sorting(query, opts) do
case Keyword.get(opts, :sort_criteria, "name_asc") do
"name_asc" -> order_by(query, [c], asc: c.name)
"name_desc" -> order_by(query, [c], desc: c.name)
"inserted_at_asc" -> order_by(query, [c], asc: c.inserted_at)
"inserted_at_desc" -> order_by(query, [c], desc: c.inserted_at)
"updated_at_asc" -> order_by(query, [c], asc: c.updated_at)
"updated_at_desc" -> order_by(query, [c], desc: c.updated_at)
"count_asc" -> order_by(query, [c], asc: c.count)
"count_desc" -> order_by(query, [c], desc: c.count)
# Default fallback
_ -> order_by(query, [c], asc: c.name)
end
sort_criteria = Keyword.get(opts, :sort_criteria, "name_asc")
sort_order = get_sort_order(sort_criteria)
order_by(query, [c], ^sort_order)
end
# Map of sort criteria to their corresponding sort orders
@sort_orders %{
"name_asc" => [asc: :name, asc: :id],
"name_desc" => [desc: :name, asc: :id],
"inserted_at_asc" => [asc: :inserted_at, asc: :id],
"inserted_at_desc" => [desc: :inserted_at, asc: :id],
"updated_at_asc" => [asc: :updated_at, asc: :id],
"updated_at_desc" => [desc: :updated_at, asc: :id],
"count_asc" => [asc: :count, asc: :id],
"count_desc" => [desc: :count, asc: :id]
}
defp get_sort_order(criteria) do
Map.get(@sort_orders, criteria, asc: :name, asc: :id)
end
@doc """
@@ -297,6 +342,32 @@ defmodule ComponentsElixir.Inventory do
|> Repo.insert()
end
@doc """
Creates a component and downloads datasheet from URL if provided.
"""
def create_component_with_datasheet(attrs \\ %{}) do
# If a datasheet_url is provided, download it
updated_attrs =
case Map.get(attrs, "datasheet_url") do
url when is_binary(url) and url != "" ->
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
{:ok, filename} ->
Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} ->
# Continue without datasheet file if download fails
attrs
end
_ ->
attrs
end
%Component{}
|> Component.changeset(updated_attrs)
|> Repo.insert()
end
@doc """
Updates a component.
"""
@@ -306,6 +377,39 @@ defmodule ComponentsElixir.Inventory do
|> Repo.update()
end
@doc """
Updates a component and downloads datasheet from URL if provided.
"""
def update_component_with_datasheet(%Component{} = component, attrs) do
# If a datasheet_url is provided and changed, download it
updated_attrs =
case Map.get(attrs, "datasheet_url") do
url when is_binary(url) and url != "" and url != component.datasheet_url ->
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
{:ok, filename} ->
# Delete old datasheet file if it exists
if component.datasheet_filename do
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
component.datasheet_filename
)
end
Map.put(attrs, "datasheet_filename", filename)
{:error, _reason} ->
# Keep existing filename if download fails
attrs
end
_ ->
attrs
end
component
|> Component.changeset(updated_attrs)
|> Repo.update()
end
@doc """
Deletes a component.
"""

View File

@@ -18,7 +18,7 @@ defmodule ComponentsElixir.Inventory.Category do
has_many :children, Category, foreign_key: :parent_id
has_many :components, Component
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false
@@ -37,7 +37,7 @@ defmodule ComponentsElixir.Inventory.Category do
"""
@impl true
def full_path(%Category{} = category) do
Hierarchical.full_path(category, &(&1.parent), path_separator())
Hierarchical.full_path(category, & &1.parent, path_separator())
end
@impl true

View File

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

View File

@@ -29,10 +29,12 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
case parent_accessor_fn.(entity) do
nil ->
entity.name
%Ecto.Association.NotLoaded{} ->
# Parent not loaded - fall back to database lookup
# This is a fallback and should be rare if preloading is done correctly
build_path_with_db_lookup(entity, separator)
parent ->
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
end
@@ -52,12 +54,14 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
nil ->
# This is a root entity, add its name and return the complete path
[entity.name | path_so_far]
parent_id ->
# Load parent from database
case load_parent_entity(entity, parent_id) do
nil ->
# Parent not found (orphaned record), treat this as root
[entity.name | path_so_far]
parent ->
# Recursively get the path from the parent, then add current entity
collect_path_from_root(parent, [entity.name | path_so_far])
@@ -93,9 +97,9 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
entity_id = id_accessor_fn.(entity)
# Remove self-reference
entity_id == editing_entity_id ||
# Remove descendants (they would create a cycle)
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
entity_id == editing_entity_id ||
descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
end)
end
@@ -103,24 +107,32 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
Checks if an entity is a descendant of an ancestor entity.
Used for cycle detection in parent selection.
"""
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
def descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
case descendant do
nil -> false
entity -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
entity -> descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn)
end
end
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
defp descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn) do
case parent_id_accessor_fn.(entity) do
nil -> false
^ancestor_id -> true
nil ->
false
^ancestor_id ->
true
parent_id ->
parent = Enum.find(entities, fn e -> e.id == parent_id end)
case parent do
nil -> false
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
nil ->
false
parent_entity ->
descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
end
end
end
@@ -182,15 +194,20 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
Includes proper filtering to prevent cycles and formatted display names.
Results are sorted hierarchically for intuitive navigation.
"""
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do
def parent_select_options(
entities,
editing_entity_id,
parent_accessor_fn,
nil_option_text \\ "No parent"
) do
available_entities =
filter_parent_options(
entities,
editing_entity_id,
&(&1.id),
&(&1.parent_id)
& &1.id,
& &1.parent_id
)
|> sort_hierarchically(&(&1.parent_id))
|> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
@@ -205,7 +222,7 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
sorted_entities =
entities
|> sort_hierarchically(&(&1.parent_id))
|> sort_hierarchically(& &1.parent_id)
|> Enum.map(fn entity ->
{display_name(entity, parent_accessor_fn), entity.id}
end)
@@ -275,4 +292,67 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
[child | sort_children_recursively(entities, child.id, parent_id_accessor_fn)]
end)
end
@doc """
Calculates component counts for an entity and all its descendants.
Returns a tuple of {self_count, children_count, total_count}.
## Parameters
- entity_id: The ID of the entity to count for
- all_entities: List of all entities in the hierarchy
- parent_id_accessor_fn: Function to get parent_id from an entity
- count_fn: Function that takes an entity_id and returns the direct count for that entity
## Examples
iex> count_fn = fn id -> MyRepo.count_components_for(id) end
iex> Hierarchical.count_with_descendants(1, entities, &(&1.parent_id), count_fn)
{3, 7, 10} # 3 in self, 7 in children, 10 total
"""
def count_with_descendants(entity_id, all_entities, parent_id_accessor_fn, count_fn) do
# Get direct count for this entity
self_count = count_fn.(entity_id)
# Get all descendant entity IDs (excluding self)
all_descendant_ids = descendant_ids(all_entities, entity_id, parent_id_accessor_fn)
descendant_ids_only = List.delete(all_descendant_ids, entity_id)
# Sum counts for all descendants
children_count =
Enum.reduce(descendant_ids_only, 0, fn id, acc ->
acc + count_fn.(id)
end)
{self_count, children_count, self_count + children_count}
end
@doc """
Formats component count display based on expansion state.
When collapsed: Shows total count only: "10 components"
When expanded: Shows breakdown: "10 components (3 self, 7 children)"
## Parameters
- self_count: Number of components directly in this entity
- children_count: Number of components in all descendant entities
- is_expanded: Whether the entity is currently expanded
- singular_noun: What to call a single item (default: "component")
- plural_noun: What to call multiple items (default: "components")
"""
def format_count_display(
self_count,
children_count,
is_expanded,
singular_noun \\ "component",
plural_noun \\ "components"
) do
total_count = self_count + children_count
count_noun = if total_count == 1, do: singular_noun, else: plural_noun
if is_expanded and children_count > 0 do
"#{total_count} #{count_noun} (#{self_count} self, #{children_count} children)"
else
"#{total_count} #{count_noun}"
end
end
end

View File

@@ -22,10 +22,10 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
has_many :children, StorageLocation, foreign_key: :parent_id
has_many :components, Component
timestamps()
timestamps(type: :naive_datetime_usec)
end
@doc false
@doc false
def changeset(storage_location, attrs) do
storage_location
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
@@ -40,7 +40,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
# HierarchicalSchema implementations
@impl true
def full_path(%StorageLocation{} = storage_location) do
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
Hierarchical.full_path(storage_location, & &1.parent, path_separator())
end
@impl true
@@ -80,11 +80,12 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
defp get_next_available_apriltag_id do
# Get all used AprilTag IDs
used_ids = ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id
)
used_ids =
ComponentsElixir.Repo.all(
from sl in ComponentsElixir.Inventory.StorageLocation,
where: not is_nil(sl.apriltag_id),
select: sl.apriltag_id
)
# Find the first available ID (0-586)
0..586
@@ -93,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
nil ->
# All IDs are used - this should be handled at the application level
raise "All AprilTag IDs are in use"
id -> id
id ->
id
end
end
end

View File

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

View File

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

View File

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

View File

@@ -2,16 +2,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
use ComponentsElixirWeb, :live_view
alias ComponentsElixir.{Inventory, Auth}
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
alias ComponentsElixir.Inventory.{Component, StorageLocation, Hierarchical}
@items_per_page 20
@impl true
def mount(_params, session, socket) do
# Check authentication
unless Auth.authenticated?(session) do
{:ok, socket |> push_navigate(to: ~p"/login")}
else
if Auth.authenticated?(session) do
categories = Inventory.list_categories()
storage_locations = Inventory.list_storage_locations()
stats = Inventory.component_stats()
@@ -25,6 +23,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|> assign(:search, "")
|> assign(:sort_criteria, "name_asc")
|> assign(:selected_category, nil)
|> assign(:selected_storage_location, nil)
|> assign(:show_advanced_filters, false)
|> assign(:offset, 0)
|> assign(:components, [])
|> assign(:has_more, false)
@@ -45,7 +45,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
max_entries: 1,
max_file_size: 5_000_000
)
|> allow_upload(:datasheet,
accept: ~w(.pdf),
max_entries: 1,
max_file_size: 10_000_000
)
|> load_components()}
else
{:ok, socket |> push_navigate(to: ~p"/login")}
end
end
@@ -53,11 +60,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
def handle_params(params, _uri, socket) do
search = Map.get(params, "search", "")
criteria = Map.get(params, "criteria", "name_asc")
category_id = parse_filter_id(Map.get(params, "category_id"))
storage_location_id = parse_filter_id(Map.get(params, "storage_location_id"))
# Show advanced filters if storage location is being used
show_advanced = not is_nil(storage_location_id)
{:noreply,
socket
|> assign(:search, search)
|> assign(:sort_criteria, criteria)
|> assign(:selected_category, category_id)
|> assign(:selected_storage_location, storage_location_id)
|> assign(:show_advanced_filters, show_advanced)
|> assign(:offset, 0)
|> load_components()}
end
@@ -88,21 +103,61 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
def handle_event("category_filter", %{"category_id" => ""}, socket) do
query_string = build_query_params_without_category(socket)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_category, nil)
|> assign(:offset, 0)
|> load_components()}
|> load_components()
|> push_patch(to: path)}
end
def handle_event("category_filter", %{"category_id" => category_id}, socket) do
category_id = String.to_integer(category_id)
query_string = build_query_params_with_category(socket, category_id)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_category, category_id)
|> assign(:offset, 0)
|> load_components()}
|> load_components()
|> push_patch(to: path)}
end
def handle_event("storage_location_filter", %{"storage_location_id" => ""}, socket) do
query_string = build_query_params_with_storage_location(socket, nil)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_storage_location, nil)
|> assign(:offset, 0)
|> load_components()
|> push_patch(to: path)}
end
def handle_event(
"storage_location_filter",
%{"storage_location_id" => storage_location_id},
socket
) do
storage_location_id = String.to_integer(storage_location_id)
query_string = build_query_params_with_storage_location(socket, storage_location_id)
path = if query_string == "", do: "/", else: "/?" <> query_string
{:noreply,
socket
|> assign(:selected_storage_location, storage_location_id)
|> assign(:offset, 0)
|> load_components()
|> push_patch(to: path)}
end
def handle_event("toggle_advanced_filters", _params, socket) do
{:noreply, assign(socket, :show_advanced_filters, !socket.assigns.show_advanced_filters)}
end
def handle_event("load_more", _params, socket) do
@@ -304,11 +359,18 @@ defmodule ComponentsElixirWeb.ComponentsLive do
{:noreply, cancel_upload(socket, :image, ref)}
end
def handle_event("save_component", %{"component" => component_params}, socket) do
# Handle any uploaded images
updated_params = save_uploaded_image(socket, component_params)
def handle_event("cancel-datasheet-upload", %{"ref" => ref}, socket) do
{:noreply, cancel_upload(socket, :datasheet, ref)}
end
case Inventory.create_component(updated_params) do
def handle_event("save_component", %{"component" => component_params}, socket) do
# Handle any uploaded files
updated_params =
socket
|> save_uploaded_image(component_params)
|> save_uploaded_datasheet(socket)
case Inventory.create_component_with_datasheet(updated_params) do
{:ok, _component} ->
{:noreply,
socket
@@ -323,10 +385,16 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
def handle_event("save_edit", %{"component" => component_params}, socket) do
# Handle any uploaded images
updated_params = save_uploaded_image(socket, component_params)
# Handle any uploaded files
updated_params =
socket
|> save_uploaded_image(component_params)
|> save_uploaded_datasheet(socket)
case Inventory.update_component(socket.assigns.editing_component, updated_params) do
case Inventory.update_component_with_datasheet(
socket.assigns.editing_component,
updated_params
) do
{:ok, _component} ->
{:noreply,
socket
@@ -370,58 +438,123 @@ defmodule ComponentsElixirWeb.ComponentsLive do
DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt
if should_reload do
# Normal loading - query database with current sort criteria
filters =
[
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn
{_, v} when is_nil(v) -> true
{:search, v} when v == "" -> true
{_, _} -> false
end)
%{components: new_components, has_more: has_more} =
Inventory.paginate_components(filters)
components =
if append do
socket.assigns.components ++ new_components
else
new_components
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
load_components_from_db(socket, append)
else
# Frozen - just update the specific component in place without reordering
if socket.assigns.interacting_with do
updated_components =
Enum.map(socket.assigns.components, fn component ->
if to_string(component.id) == socket.assigns.interacting_with do
# Reload this specific component to get updated count
Inventory.get_component!(component.id)
else
component
end
end)
assign(socket, :components, updated_components)
else
socket
end
update_frozen_components(socket)
end
end
defp load_components_from_db(socket, append) do
filters = build_component_filters(socket)
%{components: new_components, has_more: has_more} = Inventory.paginate_components(filters)
components =
if append do
socket.assigns.components ++ new_components
else
new_components
end
socket
|> assign(:components, components)
|> assign(:has_more, has_more)
end
defp update_frozen_components(socket) do
if socket.assigns.interacting_with do
updated_components = update_interacting_component(socket)
assign(socket, :components, updated_components)
else
socket
end
end
defp update_interacting_component(socket) do
interacting_id = socket.assigns.interacting_with
Enum.map(socket.assigns.components, fn component ->
if to_string(component.id) == interacting_id do
# Reload this specific component to get updated count
Inventory.get_component!(component.id)
else
component
end
end)
end
defp build_component_filters(socket) do
[
search: socket.assigns.search,
sort_criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
storage_location_id: socket.assigns.selected_storage_location,
limit: @items_per_page,
offset: socket.assigns.offset
]
|> Enum.reject(fn
{_, v} when is_nil(v) -> true
{:search, v} when v == "" -> true
{_, _} -> false
end)
end
defp build_query_params(socket, overrides) do
params = %{
search: Map.get(overrides, :search, socket.assigns.search),
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria)
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria),
category_id: Map.get(overrides, :category_id, socket.assigns.selected_category),
storage_location_id:
Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp parse_filter_id(nil), do: nil
defp parse_filter_id(""), do: nil
defp parse_filter_id(id) when is_binary(id) do
case Integer.parse(id) do
{int_id, ""} -> int_id
_ -> nil
end
end
defp parse_filter_id(id) when is_integer(id), do: id
defp build_query_params_with_category(socket, category_id) do
params = %{
search: socket.assigns.search,
criteria: socket.assigns.sort_criteria,
category_id: category_id,
storage_location_id: socket.assigns.selected_storage_location
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp build_query_params_with_storage_location(socket, storage_location_id) do
params = %{
search: socket.assigns.search,
criteria: socket.assigns.sort_criteria,
category_id: socket.assigns.selected_category,
storage_location_id: storage_location_id
}
params
|> Enum.reject(fn {_k, v} -> is_nil(v) or v == "" end)
|> URI.encode_query()
end
defp build_query_params_without_category(socket) do
params = %{
search: socket.assigns.search,
criteria: socket.assigns.sort_criteria,
storage_location_id: socket.assigns.selected_storage_location
}
params
@@ -430,7 +563,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
defp category_options(categories) do
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
Hierarchical.select_options(categories, & &1.parent, "Select a category")
end
defp storage_location_display_name(location) do
@@ -438,7 +571,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
end
defp storage_location_options(storage_locations) do
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
Hierarchical.select_options(storage_locations, & &1.parent, "No storage location")
end
@impl true
@@ -487,7 +620,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
</div>
<!-- Filters -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex flex-col sm:flex-row gap-4">
@@ -558,9 +691,46 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %>
</form>
</div>
<div class="flex items-end">
<button
phx-click="toggle_advanced_filters"
class="inline-flex items-center px-3 py-2 border border-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
>
<.icon name="hero-adjustments-horizontal" class="w-4 h-4 mr-2" />
{if @show_advanced_filters, do: "Hide", else: "More"} Filters
</button>
</div>
</div>
<!-- Advanced Filters (Collapsible) -->
<%= if @show_advanced_filters do %>
<div class="mt-4 p-4 bg-base-100 border border-base-300 rounded-md">
<div class="flex flex-col sm:flex-row gap-4">
<div>
<label class="block text-sm font-medium text-base-content mb-2">
Storage Location
</label>
<form phx-change="storage_location_filter">
<select
name="storage_location_id"
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
>
<option value="" selected={is_nil(@selected_storage_location)}>
All Storage Locations
</option>
<%= for {location_name, location_id} <- Hierarchical.select_options(@storage_locations, &(&1.parent)) do %>
<option value={location_id} selected={@selected_storage_location == location_id}>
{location_name}
</option>
<% end %>
</select>
</form>
</div>
</div>
</div>
<% end %>
</div>
<!-- Add Component Modal -->
<%= if @show_add_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -674,6 +844,51 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %>
</div>
<div>
<label class="block text-sm font-medium text-base-content">Datasheet Upload</label>
<div class="mt-1">
<.live_file_input
upload={@uploads.datasheet}
class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-secondary/10 file:text-secondary hover:file:bg-secondary/20"
/>
</div>
<p class="mt-1 text-xs text-base-content/60">
PDF files up to 10MB (or enter URL above to auto-download)
</p>
<%= for err <- upload_errors(@uploads.datasheet) do %>
<p class="text-error text-sm mt-1">{Phoenix.Naming.humanize(err)}</p>
<% end %>
<%= for entry <- @uploads.datasheet.entries do %>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-lg bg-secondary/10 flex items-center justify-center">
<.icon name="hero-document-text" class="w-6 h-6 text-secondary" />
</div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-base-content">{entry.client_name}</p>
<p class="text-sm text-base-content/60">{entry.progress}%</p>
</div>
</div>
<button
type="button"
phx-click="cancel-datasheet-upload"
phx-value-ref={entry.ref}
aria-label="cancel"
class="text-error hover:text-error/80"
>
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.datasheet) do %>
<p class="mt-1 text-sm text-error">{upload_error_to_string(err)}</p>
<% end %>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@@ -694,7 +909,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<!-- Edit Component Modal -->
<%= if @show_edit_form do %>
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
@@ -818,6 +1033,63 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<% end %>
</div>
<div>
<label class="block text-sm font-medium text-base-content">Datasheet</label>
<%= if @editing_component && @editing_component.datasheet_filename do %>
<div class="mt-1 mb-2">
<p class="text-sm text-base-content/70">Current datasheet:</p>
<a
href={"/uploads/datasheets/#{@editing_component.datasheet_filename}"}
target="_blank"
class="inline-flex items-center text-primary hover:text-primary/80"
>
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> View PDF
</a>
</div>
<% end %>
<div class="mt-1">
<.live_file_input
upload={@uploads.datasheet}
class="block w-full text-sm text-base-content file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-secondary/10 file:text-secondary hover:file:bg-secondary/20"
/>
</div>
<p class="mt-1 text-xs text-base-content/60">
PDF files up to 10MB (leave empty to keep current, or enter URL above to auto-download)
</p>
<%= for err <- upload_errors(@uploads.datasheet) do %>
<p class="text-error text-sm mt-1">{Phoenix.Naming.humanize(err)}</p>
<% end %>
<%= for entry <- @uploads.datasheet.entries do %>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-lg bg-secondary/10 flex items-center justify-center">
<.icon name="hero-document-text" class="w-6 h-6 text-secondary" />
</div>
</div>
<div class="ml-3">
<p class="text-sm font-medium text-base-content">{entry.client_name}</p>
<p class="text-sm text-base-content/60">{entry.progress}%</p>
</div>
</div>
<button
type="button"
phx-click="cancel-datasheet-upload"
phx-value-ref={entry.ref}
aria-label="cancel"
class="text-error hover:text-error/80"
>
<.icon name="hero-x-mark" class="w-5 h-5" />
</button>
</div>
<% end %>
<%= for err <- upload_errors(@uploads.datasheet) do %>
<p class="mt-1 text-sm text-error">{upload_error_to_string(err)}</p>
<% end %>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button
type="button"
@@ -838,7 +1110,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<!-- Components List -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
@@ -867,19 +1139,28 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<div class="flex items-start justify-between">
<div class="flex items-center min-w-0 flex-1">
<h3 class="text-lg font-semibold text-primary select-text">
<%= if component.datasheet_url do %>
<a
href={component.datasheet_url}
target="_blank"
class="hover:text-primary/80"
>
<%= cond do %>
<% component.datasheet_filename -> %>
<a
href={"/uploads/datasheets/#{component.datasheet_filename}"}
target="_blank"
class="hover:text-primary/80"
>
{component.name}
</a>
<% component.datasheet_url -> %>
<a
href={component.datasheet_url}
target="_blank"
class="hover:text-primary/80"
>
{component.name}
</a>
<% true -> %>
{component.name}
</a>
<% else %>
{component.name}
<% end %>
</h3>
<%= if component.datasheet_url do %>
<%= if component.datasheet_url || component.datasheet_filename do %>
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
<% end %>
</div>
@@ -898,7 +1179,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</button>
</div>
</div>
<!-- Content area with image and details -->
<div class="flex gap-6">
<!-- Large Image -->
@@ -924,18 +1205,22 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Details -->
<div class="flex-1 space-y-4 select-text">
<!-- Full Description -->
<%= if component.description do %>
<div>
<h4 class="text-sm font-medium text-base-content mb-2">Description</h4>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation --%>
<p class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap">{component.description}</p>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
Use phx-no-format so the formatter won't break the layout. --%>
<p
phx-no-format
class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap"
>{component.description}</p>
</div>
<% end %>
<!-- Metadata Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<%= if component.storage_location do %>
@@ -989,10 +1274,47 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
</div>
<% end %>
<%= if component.datasheet_filename || component.datasheet_url do %>
<div class="flex items-start">
<.icon
name="hero-document-text"
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
/>
<div>
<span class="font-medium text-base-content">Datasheet:</span>
<div class="space-y-1 mt-1">
<%= if component.datasheet_filename do %>
<div>
<a
href={"/uploads/datasheets/#{component.datasheet_filename}"}
target="_blank"
class="inline-flex items-center text-primary hover:text-primary/80 text-sm"
>
<.icon name="hero-document-arrow-down" class="w-4 h-4 mr-1" />
View PDF (Downloaded)
</a>
</div>
<% end %>
<%= if component.datasheet_url do %>
<div>
<a
href={component.datasheet_url}
target="_blank"
class="inline-flex items-center text-base-content/70 hover:text-primary text-sm"
>
<.icon name="hero-link" class="w-4 h-4 mr-1" /> Original URL
</a>
</div>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
<button
@@ -1055,19 +1377,28 @@ defmodule ComponentsElixirWeb.ComponentsLive do
<div class="flex items-start justify-between">
<div class="flex items-center min-w-0 flex-1">
<p class="text-sm font-medium text-primary truncate">
<%= if component.datasheet_url do %>
<a
href={component.datasheet_url}
target="_blank"
class="hover:text-primary/80"
>
<%= cond do %>
<% component.datasheet_filename -> %>
<a
href={"/uploads/datasheets/#{component.datasheet_filename}"}
target="_blank"
class="hover:text-primary/80"
>
{component.name}
</a>
<% component.datasheet_url -> %>
<a
href={component.datasheet_url}
target="_blank"
class="hover:text-primary/80"
>
{component.name}
</a>
<% true -> %>
{component.name}
</a>
<% else %>
{component.name}
<% end %>
</p>
<%= if component.datasheet_url do %>
<%= if component.datasheet_url || component.datasheet_filename do %>
<span class="ml-2 text-primary" title="Datasheet available">📄</span>
<% end %>
</div>
@@ -1077,15 +1408,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</span>
</div>
</div>
<!-- Middle row: Description -->
<%= if component.description do %>
<div class="mt-1">
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation --%>
<p class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap">{component.description}</p>
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
Use phx-no-format so the formatter won't break the layout. --%>
<p
phx-no-format
class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap"
>{component.description}</p>
</div>
<% end %>
<!-- Bottom row: Metadata -->
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
<%= if component.storage_location do %>
@@ -1113,7 +1448,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
</div>
<% end %>
</div>
<!-- Keywords row -->
<%= if component.keywords do %>
<div class="mt-2">
@@ -1196,7 +1531,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
>
<!-- Background overlay -->
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
<!-- Modal content -->
<div
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
@@ -1214,7 +1549,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
×
</button>
</div>
<!-- Content -->
<div class="p-6 bg-base-100 rounded-b-lg">
<div class="text-center">
@@ -1238,9 +1573,6 @@ defmodule ComponentsElixirWeb.ComponentsLive do
# Helper functions for image upload handling
defp save_uploaded_image(socket, component_params) do
IO.puts("=== DEBUG: Starting save_uploaded_image ===")
IO.inspect(socket.assigns.uploads.image.entries, label: "Upload entries")
uploaded_files =
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
@@ -1248,10 +1580,39 @@ defmodule ComponentsElixirWeb.ComponentsLive do
upload_dir = Path.join([uploads_dir, "images"])
dest = Path.join(upload_dir, filename)
IO.puts("=== DEBUG: Processing upload ===")
IO.puts("Filename: #{filename}")
IO.puts("Upload dir: #{upload_dir}")
IO.puts("Destination: #{dest}")
# Ensure the upload directory exists
File.mkdir_p!(upload_dir)
# Copy the file
case File.cp(path, dest) do
:ok ->
{:ok, filename}
{:error, reason} ->
{:postpone, {:error, reason}}
end
end)
case uploaded_files do
[filename] when is_binary(filename) ->
Map.put(component_params, "image_filename", filename)
[] ->
component_params
_error ->
component_params
end
end
# Helper function for datasheet upload handling
defp save_uploaded_datasheet(component_params, socket) do
uploaded_files =
consume_uploaded_entries(socket, :datasheet, fn %{path: path}, entry ->
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
uploads_dir = Application.get_env(:components_elixir, :uploads_dir)
upload_dir = Path.join([uploads_dir, "datasheets"])
dest = Path.join(upload_dir, filename)
# Ensure the upload directory exists
File.mkdir_p!(upload_dir)
@@ -1259,36 +1620,23 @@ defmodule ComponentsElixirWeb.ComponentsLive do
# Copy the file
case File.cp(path, dest) do
:ok ->
IO.puts("=== DEBUG: File copy successful ===")
{:ok, filename}
{:error, reason} ->
IO.puts("=== DEBUG: File copy failed: #{inspect(reason)} ===")
{:postpone, {:error, reason}}
end
end)
IO.inspect(uploaded_files, label: "Uploaded files result")
case uploaded_files do
[filename] when is_binary(filename) ->
Map.put(component_params, "datasheet_filename", filename)
result =
case uploaded_files do
[filename] when is_binary(filename) ->
IO.puts("=== DEBUG: Adding filename to params: #{filename} ===")
Map.put(component_params, "image_filename", filename)
[] ->
component_params
[] ->
IO.puts("=== DEBUG: No files uploaded ===")
component_params
_error ->
IO.puts("=== DEBUG: Upload error ===")
IO.inspect(uploaded_files, label: "Unexpected upload result")
component_params
end
IO.inspect(result, label: "Final component_params")
IO.puts("=== DEBUG: End save_uploaded_image ===")
result
_error ->
component_params
end
end
defp delete_image_file(nil), do: :ok

View File

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

View File

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

View File

@@ -25,8 +25,9 @@ defmodule ComponentsElixirWeb.Router do
get "/login/authenticate", AuthController, :authenticate
post "/logout", AuthController, :logout
# File serving endpoint
# File serving endpoints
get "/uploads/images/:filename", FileController, :show
get "/uploads/datasheets/:filename", FileController, :show_datasheet
end
scope "/", ComponentsElixirWeb do

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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