Compare commits
7 Commits
5d2e3f7768
...
537a97cecc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
537a97cecc | ||
|
|
a6991b6877 | ||
|
|
32dea59c74 | ||
|
|
c6c218970c | ||
|
|
aaf278f7f9 | ||
|
|
f4ee768c52 | ||
|
|
72484c0d08 |
121
.gitea/workflows/README.md
Normal file
121
.gitea/workflows/README.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Gitea CI/CD Pipeline
|
||||
|
||||
This directory contains Gitea Actions workflows for automated code quality checks and Docker image publishing.
|
||||
|
||||
## Workflows
|
||||
|
||||
### 1. Code Quality (`code-quality.yml`)
|
||||
|
||||
Runs on every push to main and pull requests targeting main. This workflow:
|
||||
|
||||
- Sets up Elixir 1.15 with OTP 26
|
||||
- Installs dependencies and restores caches for faster builds
|
||||
- Checks for unused dependencies
|
||||
- Compiles with warnings as errors (enforces clean compilation)
|
||||
- Validates code formatting (`mix format --check-formatted`)
|
||||
- Runs the full test suite
|
||||
- Executes `mix precommit` to ensure all quality checks pass
|
||||
|
||||
**Important**: This workflow will fail if `mix precommit` hasn't been run locally, ensuring code quality standards are maintained.
|
||||
|
||||
### 2. Docker Build and Publish (`docker-build.yml`)
|
||||
|
||||
Publishes Docker images to the Gitea container registry:
|
||||
|
||||
- **Snapshot builds**: For every commit to main branch
|
||||
- Tagged as: `latest`, `main`, `snapshot-{sha}`
|
||||
- **Release builds**: For every tagged commit (e.g., `v1.0.0`)
|
||||
- Tagged as: `{tag-name}`, `latest`
|
||||
|
||||
Features:
|
||||
- Multi-platform builds (linux/amd64, linux/arm64)
|
||||
- Build caching for faster subsequent builds
|
||||
- Comprehensive metadata and labels
|
||||
|
||||
## Setup Requirements
|
||||
|
||||
### 1. Gitea Configuration
|
||||
|
||||
Update the `REGISTRY` environment variable in `docker-build.yml`:
|
||||
```yaml
|
||||
env:
|
||||
REGISTRY: your-gitea-instance.com # Replace with your Gitea URL
|
||||
```
|
||||
|
||||
### 2. Required Secrets
|
||||
|
||||
Create the following secret in your Gitea repository settings:
|
||||
|
||||
- `GITEAX_TOKEN`: Personal Access Token with package write permissions
|
||||
- Go to your Gitea instance → Settings → Applications → Generate New Token
|
||||
- Select "write:packages" scope
|
||||
- Add this token as a repository secret named `GITEAX_TOKEN`
|
||||
|
||||
> Gitea Actions currently do not support package repository authorization like GitHub Actions, so a PAT is necessary for publishing.
|
||||
> See https://github.com/go-gitea/gitea/issues/23642#issuecomment-2119876692.
|
||||
|
||||
### 3. Container Registry
|
||||
|
||||
Enable the container registry in your Gitea instance if not already enabled. The published images will be available at:
|
||||
```
|
||||
{your-gitea-instance}/{owner}/components-elixir
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### For Developers
|
||||
|
||||
Before pushing code, always run:
|
||||
```bash
|
||||
mix precommit
|
||||
```
|
||||
|
||||
This ensures your code will pass the CI quality checks.
|
||||
|
||||
### For Releases
|
||||
|
||||
To create a release:
|
||||
1. Tag your commit: `git tag v1.0.0`
|
||||
2. Push the tag: `git push origin v1.0.0`
|
||||
3. The pipeline will automatically build and publish a release image
|
||||
|
||||
### For Snapshots
|
||||
|
||||
Snapshot builds are created automatically for every commit to the main branch.
|
||||
|
||||
## Docker Image Usage
|
||||
|
||||
Pull and run the latest snapshot:
|
||||
```bash
|
||||
docker pull {your-gitea-instance}/{owner}/components-elixir:latest
|
||||
docker run -p 4000:4000 {your-gitea-instance}/{owner}/components-elixir:latest
|
||||
```
|
||||
|
||||
Pull and run a specific release:
|
||||
```bash
|
||||
docker pull {your-gitea-instance}/{owner}/components-elixir:v1.0.0
|
||||
docker run -p 4000:4000 {your-gitea-instance}/{owner}/components-elixir:v1.0.0
|
||||
```
|
||||
|
||||
## Gitea Actions Limitations
|
||||
|
||||
This pipeline is designed with Gitea Actions limitations in mind:
|
||||
- No `concurrency`, `run-name`, `permissions`, or `timeout-minutes` support
|
||||
- Limited expression support (only `always()` function)
|
||||
- Simple `runs-on` syntax only
|
||||
- No package repository authorization - uses Personal Access Token instead
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Authentication Issues
|
||||
- Ensure `GITEAX_TOKEN` secret is properly set with package write permissions
|
||||
- Verify the token hasn't expired
|
||||
|
||||
### Build Failures
|
||||
- Check that `mix precommit` passes locally
|
||||
- Ensure all tests pass with the test database configuration
|
||||
- Verify Docker build works locally: `docker build -t test .`
|
||||
|
||||
### Registry Issues
|
||||
- Confirm container registry is enabled in your Gitea instance
|
||||
- Check that the registry URL in the workflow matches your Gitea instance
|
||||
75
.gitea/workflows/code-quality.yml
Normal file
75
.gitea/workflows/code-quality.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
name: Code Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
code-quality:
|
||||
runs-on: ubuntu-latest
|
||||
name: Code Quality (Elixir ${{matrix.elixir}} OTP ${{matrix.otp}})
|
||||
strategy:
|
||||
matrix:
|
||||
otp: ['26.0']
|
||||
elixir: ['1.15']
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
ports: ['5432:5432']
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Elixir
|
||||
uses: erlef/setup-beam@v1
|
||||
with:
|
||||
elixir-version: ${{matrix.elixir}}
|
||||
otp-version: ${{matrix.otp}}
|
||||
|
||||
- name: Restore dependencies cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: deps
|
||||
key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: ${{ runner.os }}-mix-
|
||||
|
||||
- name: Restore compiled code cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: _build
|
||||
key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: mix deps.get
|
||||
|
||||
- name: Check for unused dependencies
|
||||
run: mix deps.unlock --check-unused
|
||||
|
||||
- name: Compile with warnings as errors
|
||||
run: mix compile --warnings-as-errors
|
||||
|
||||
- name: Check code formatting
|
||||
run: mix format --check-formatted
|
||||
|
||||
- name: Run tests
|
||||
run: mix test
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
|
||||
- name: Run precommit (should pass if all above passed)
|
||||
run: mix precommit
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
69
.gitea/workflows/docker-build.yml
Normal file
69
.gitea/workflows/docker-build.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Docker Build and Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["v*"]
|
||||
|
||||
env:
|
||||
REGISTRY: git.maxboeer.com
|
||||
IMAGE_NAME: components-elixir
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=snapshot-{{sha}},enable={{is_default_branch}}
|
||||
|
||||
- name: Log in to Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITEAX_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
ELIXIR_VERSION=1.15
|
||||
OTP_VERSION=26
|
||||
DEBIAN_VERSION=bookworm-slim
|
||||
|
||||
- name: Generate summary
|
||||
run: |
|
||||
echo "## Docker Build Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Image**: \`${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "${{ github.ref_type }}" == "tag" ]]; then
|
||||
echo "- **Build Type**: Release build for tag \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "- **Build Type**: Snapshot build for branch \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "- **Commit**: \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
125
.github/copilot-instructions.md
vendored
Normal file
125
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,125 @@
|
||||
# GitHub Copilot Instructions
|
||||
|
||||
This is an electronic component inventory management system built with Phoenix LiveView. Follow these project-specific patterns for effective contributions.
|
||||
|
||||
## Project Architecture
|
||||
|
||||
**Core Structure**: Phoenix 1.8 with LiveView-first architecture for real-time inventory management
|
||||
- `lib/components_elixir/` - Business logic contexts (Inventory, AprilTag, Auth)
|
||||
- `lib/components_elixir_web/live/` - LiveView modules for real-time UI
|
||||
- Key contexts: `Inventory` (components/categories/locations), `DatasheetDownloader`, `AprilTag`
|
||||
|
||||
**Hierarchical Data Pattern**: Both categories and storage locations use unlimited nesting
|
||||
```elixir
|
||||
# Use Hierarchical module for tree operations
|
||||
alias ComponentsElixir.Inventory.Hierarchical
|
||||
Hierarchical.build_tree(categories) # Converts flat list to nested structure
|
||||
```
|
||||
|
||||
**Authentication**: Simple session-based auth using `ComponentsElixir.Auth`
|
||||
- All LiveViews must check `Auth.authenticated?(session)` in mount/3
|
||||
- Default password: "changeme" (configurable via `AUTH_PASSWORD` env var)
|
||||
|
||||
## Key Development Workflows
|
||||
|
||||
**Development Setup**:
|
||||
```bash
|
||||
mix deps.get
|
||||
mix ecto.setup # Creates DB, runs migrations, seeds data
|
||||
mix phx.server # Starts dev server with hot reload
|
||||
```
|
||||
|
||||
**Code Quality (Critical)**: Always run before committing
|
||||
```bash
|
||||
mix precommit # Compiles with warnings-as-errors, formats, runs tests
|
||||
```
|
||||
|
||||
**Database Operations**:
|
||||
```bash
|
||||
mix ecto.reset # Drop/recreate/migrate/seed database
|
||||
mix ecto.gen.migration # Generate new migration
|
||||
```
|
||||
|
||||
## LiveView Patterns
|
||||
|
||||
**Stream-Based Components**: Use LiveView streams for component lists to prevent memory issues:
|
||||
```elixir
|
||||
# In LiveView
|
||||
stream(socket, :components, components)
|
||||
|
||||
# In template
|
||||
<div id="components" phx-update="stream">
|
||||
<div :for={{id, component} <- @streams.components} id={id}>
|
||||
<!-- component content -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**File Upload Handling**: Uses Phoenix.LiveView.Upload with custom validators
|
||||
- Datasheets: PDF only, 10MB max, stored in `uploads/datasheets/`
|
||||
- Images: JPEG/PNG only, 5MB max, stored in `uploads/images/`
|
||||
|
||||
## Database Conventions
|
||||
|
||||
**Hierarchical Relationships**: Self-referential `parent_id` foreign keys
|
||||
```elixir
|
||||
# Schema pattern for categories/locations
|
||||
field :parent_id, :id
|
||||
belongs_to :parent, __MODULE__
|
||||
has_many :children, __MODULE__, foreign_key: :parent_id
|
||||
```
|
||||
|
||||
**Component Search**: Full-text search across name, description, keywords
|
||||
```elixir
|
||||
# Use ilike for case-insensitive search
|
||||
from c in Component, where: ilike(c.name, ^"%#{search}%")
|
||||
```
|
||||
|
||||
## Asset Management
|
||||
|
||||
**DaisyUI Components**: Prefer DaisyUI classes for consistent styling:
|
||||
- `btn btn-primary` for buttons
|
||||
- `card card-compact` for component cards
|
||||
- `badge` for category/location tags
|
||||
|
||||
## External Dependencies
|
||||
|
||||
**AprilTag Generation**: SVG-based tags for physical location labeling
|
||||
```elixir
|
||||
ComponentsElixir.AprilTag.generate_svg(tag_id) # Returns SVG string
|
||||
```
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
**Factory Pattern**: Use explicit test data setup, not factories
|
||||
- Seed realistic test data in `test/support/` modules
|
||||
- Test both hierarchical and flat data structures
|
||||
|
||||
## Production Deployment
|
||||
|
||||
**Docker-First**: Primary deployment method via `docker-compose.yml`
|
||||
- Environment: `SECRET_KEY_BASE`, `AUTH_PASSWORD`, `PHX_HOST`
|
||||
- File uploads mounted as volumes: `./uploads:/app/uploads`
|
||||
|
||||
**Release Commands**: Database migrations via release tasks
|
||||
```bash
|
||||
# In production container
|
||||
./bin/components_elixir eval "ComponentsElixir.Release.migrate"
|
||||
```
|
||||
|
||||
## CI/CD with Gitea Actions
|
||||
|
||||
**Gitea Actions Limitations** (important differences from GitHub Actions):
|
||||
- No `concurrency`, `run-name`, `permissions`, `timeout-minutes`, `continue-on-error` support
|
||||
- Limited expression support (only `always()` function)
|
||||
- No package repository authorization - use Personal Access Token for OCI publishing
|
||||
- Problem matchers and error annotations are ignored
|
||||
- Simple `runs-on` syntax only (`runs-on: ubuntu-latest`, not complex selectors)
|
||||
|
||||
**CI Pipeline Will Include**:
|
||||
- Code quality checks (`mix precommit`)
|
||||
- Test execution across Elixir/OTP versions
|
||||
- Docker image building and publishing (requires PAT for package registry)
|
||||
- Database migration testing
|
||||
|
||||
Ensure all code passes `mix precommit` before pushing, as this will be enforced in CI.
|
||||
424
README.md
424
README.md
@@ -1,48 +1,52 @@
|
||||
# Components Inventory - Elixir/Phoenix Implementation
|
||||
# Electronic Components Inventory System
|
||||
|
||||
A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inventory management system.
|
||||
A modern, comprehensive electronic component inventory management system built with Elixir/Phoenix LiveView. Features real-time updates, hierarchical organization, and advanced search capabilities.
|
||||
|
||||
## Features
|
||||
|
||||
### ✨ Improvements over the original PHP version:
|
||||
|
||||
1. **Modern Architecture**
|
||||
- Phoenix LiveView for real-time, reactive UI without JavaScript
|
||||
- Ecto for type-safe database operations
|
||||
- Proper separation of concerns with Phoenix contexts
|
||||
- Built-in validation and error handling
|
||||
|
||||
2. **Enhanced User Experience**
|
||||
- Real-time search with no page refreshes
|
||||
- Responsive design with Tailwind CSS
|
||||
- Loading states and better feedback
|
||||
- Improved mobile experience
|
||||
|
||||
3. **Better Data Management**
|
||||
- Full-text search with PostgreSQL
|
||||
- Hierarchical categories with parent-child relationships
|
||||
- Proper foreign key constraints
|
||||
- Database migrations for schema management
|
||||
|
||||
4. **Security & Reliability**
|
||||
- CSRF protection built-in
|
||||
- SQL injection prevention through Ecto
|
||||
- Session-based authentication
|
||||
- Input validation and sanitization
|
||||
|
||||
### 🔧 Core Functionality
|
||||
|
||||
- **Component Management**: Add, edit, delete, and track electronic components
|
||||
- **Component Management**: Add, edit, delete, and track electronic components with real-time updates
|
||||
- **Inventory Tracking**: Monitor component quantities with increment/decrement buttons
|
||||
- **Search & Filter**: Fast search across component names, descriptions, and keywords
|
||||
- **Category Organization**: Hierarchical category system for better organization
|
||||
- **Category Management**: Add, edit, delete categories through the web interface with hierarchical support
|
||||
- **Storage Location System**: Hierarchical storage locations (shelf → drawer → box) with automatic AprilTag generation
|
||||
- **AprilTag Integration**: Automatic AprilTag generation and display for all storage locations with download capability
|
||||
- **Datasheet Links**: Direct links to component datasheets
|
||||
- **Real-time Updates**: All changes are immediately reflected in the interface
|
||||
- **Advanced Search & Filtering**:
|
||||
- Fast full-text search across component names, descriptions, and keywords
|
||||
- Filter by categories and storage locations (including subcategories/sublocations)
|
||||
- Clickable category and location filters for quick navigation
|
||||
- **Hierarchical Organization**:
|
||||
- Unlimited nesting for both categories and storage locations
|
||||
- Collapsible tree views for easy navigation
|
||||
- Visual breadcrumb paths (e.g., "Electronics > Resistors > Through-hole")
|
||||
- **Datasheet Management**:
|
||||
- Upload PDF datasheets directly or provide URLs for automatic download
|
||||
- Automatic datasheet retrieval from URLs with validation
|
||||
- Visual indicators for components with datasheets
|
||||
- Direct PDF viewing and download
|
||||
- **Storage Location System**:
|
||||
- Hierarchical locations (shelf → drawer → box) with AprilTag integration
|
||||
- Automatic AprilTag generation for physical labeling
|
||||
- Downloadable SVG AprilTags for printing
|
||||
- **Image Support**: Upload component images with preview and validation
|
||||
- **Real-time Interface**: All changes reflected immediately without page refresh
|
||||
|
||||
## Setup
|
||||
### 🎨 User Experience
|
||||
|
||||
- **Modern Responsive Design**: Works seamlessly on desktop and mobile
|
||||
- **Dark/Light Mode**: Automatic theme support with DaisyUI
|
||||
- **Interactive Components**:
|
||||
- Collapsible hierarchical views
|
||||
- Focus mode for detailed component viewing
|
||||
- Drag-and-drop file uploads with progress indicators
|
||||
- **Smart Navigation**: Clickable categories and locations for instant filtering
|
||||
- **Visual Feedback**: Loading states, progress bars, and clear error messages
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Elixir 1.15+
|
||||
- PostgreSQL 15+
|
||||
- Docker (optional, for containerized deployment)
|
||||
|
||||
### Development Setup
|
||||
|
||||
1. **Install dependencies:**
|
||||
```bash
|
||||
@@ -51,144 +55,122 @@ A modern, idiomatic Elixir/Phoenix port of the original PHP-based component inve
|
||||
|
||||
2. **Set up the database:**
|
||||
```bash
|
||||
docker run --name components-postgres -p 5432:5432 -e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres # password: config/dev.exs
|
||||
# Using Docker (recommended)
|
||||
docker run --name components-postgres -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=fCnPB8VQdPkhJAD29hq6sZEY -d postgres
|
||||
|
||||
# Initialize database
|
||||
mix ecto.create
|
||||
mix ecto.migrate
|
||||
mix run priv/repo/seeds.exs
|
||||
```
|
||||
|
||||
3. **Start the server:**
|
||||
3. **Start the development server:**
|
||||
```bash
|
||||
mix phx.server
|
||||
```
|
||||
|
||||
4. **Visit the application:**
|
||||
Open [http://localhost:4000](http://localhost:4000)
|
||||
4. **Access the application:**
|
||||
- Open [http://localhost:4000](http://localhost:4000)
|
||||
- Default password: `changeme`
|
||||
|
||||
## Authentication
|
||||
### Authentication
|
||||
|
||||
The application uses a simple password-based authentication system:
|
||||
- Default password: `changeme`
|
||||
- Set custom password via environment variable: `AUTH_PASSWORD=your_password`
|
||||
Simple password-based authentication:
|
||||
- Default: `changeme`
|
||||
- Custom: Set `AUTH_PASSWORD=your_password` environment variable
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Categories
|
||||
### Categories (Hierarchical)
|
||||
- `id`: Primary key
|
||||
- `name`: Category name (required)
|
||||
- `description`: Optional description
|
||||
- `parent_id`: Foreign key for hierarchical categories
|
||||
- Supports unlimited nesting levels
|
||||
- `parent_id`: Foreign key for hierarchical structure
|
||||
- **Features**: Unlimited nesting, full-path display, clickable filtering
|
||||
|
||||
### Storage Locations (Hierarchical)
|
||||
- `id`: Primary key
|
||||
- `name`: Location name (required)
|
||||
- `description`: Optional description
|
||||
- `parent_id`: Foreign key for hierarchical structure
|
||||
- `apriltag_id`: Optional AprilTag identifier for physical labeling
|
||||
- **Features**: AprilTag generation, SVG download, hierarchical filtering
|
||||
|
||||
### Components
|
||||
- `id`: Primary key
|
||||
- `name`: Component name (required)
|
||||
- `description`: Detailed description
|
||||
- `keywords`: Search keywords
|
||||
- `position`: Storage location/position
|
||||
- `count`: Current quantity (default: 0)
|
||||
- `datasheet_url`: Optional link to datasheet
|
||||
- `image_filename`: Optional image file name
|
||||
- `category_id`: Required foreign key to categories
|
||||
- `storage_location_id`: Optional foreign key to storage locations
|
||||
- `datasheet_url`: Optional URL to external datasheet
|
||||
- `datasheet_filename`: Optional uploaded PDF datasheet
|
||||
- `image_filename`: Optional uploaded component image
|
||||
|
||||
## Architecture
|
||||
## Technical Architecture
|
||||
|
||||
### Contexts
|
||||
- **`ComponentsElixir.Inventory`**: Business logic for components and categories
|
||||
- **`ComponentsElixir.Auth`**: Simple authentication system
|
||||
### Phoenix LiveView Application
|
||||
- **Real-time updates**: No page refreshes needed
|
||||
- **Phoenix Contexts**: Clean separation of business logic
|
||||
- **Ecto**: Type-safe database operations with migrations
|
||||
- **Authentication**: Session-based with CSRF protection
|
||||
|
||||
### Live Views
|
||||
- **`ComponentsElixirWeb.LoginLive`**: Authentication interface
|
||||
- **`ComponentsElixirWeb.ComponentsLive`**: Main component management interface
|
||||
- **`ComponentsElixirWeb.CategoriesLive`**: Category management interface
|
||||
- **`ComponentsElixirWeb.StorageLocationsLive`**: Hierarchical storage location management with AprilTags
|
||||
### Key Modules
|
||||
- `ComponentsElixir.Inventory`: Core business logic
|
||||
- `ComponentsElixir.DatasheetDownloader`: Automatic PDF retrieval
|
||||
- `ComponentsElixir.AprilTag`: SVG AprilTag generation
|
||||
- `ComponentsElixir.Inventory.Hierarchical`: Reusable hierarchy management
|
||||
- `ComponentsElixirWeb.*Live`: LiveView interfaces for real-time UI
|
||||
## Recent Features & Improvements
|
||||
|
||||
### Key Features
|
||||
- **Real-time updates**: Changes are immediately reflected without page refresh
|
||||
- **Infinite scroll**: Load more components as needed
|
||||
- **Search optimization**: Uses PostgreSQL full-text search for long queries, ILIKE for short ones
|
||||
- **Responsive design**: Works on desktop and mobile devices
|
||||
### ✅ Datasheet Management System
|
||||
- **Automatic Download**: Provide a URL and the system downloads the PDF automatically
|
||||
- **Direct Upload**: Upload PDF datasheets up to 10MB
|
||||
- **Smart Validation**: Ensures uploaded files are valid PDFs
|
||||
- **Visual Indicators**: Components with datasheets show clear visual cues
|
||||
- **Integrated Viewing**: Click component names to view datasheets directly
|
||||
|
||||
## API Comparison
|
||||
### ✅ Advanced Filtering & Navigation
|
||||
- **Hierarchical Filtering**: Filter by categories/locations including all subcategories/sublocations
|
||||
- **Click-to-Filter**: Click any category or location name to instantly filter components
|
||||
- **Collapsible Trees**: Expand/collapse category and storage location hierarchies
|
||||
- **Smart Search**: Combines full-text search with hierarchical filtering
|
||||
|
||||
| Original PHP | New Elixir/Phoenix | Improvement |
|
||||
|-------------|-------------------|-------------|
|
||||
| `getItems.php` | `Inventory.list_components/1` | Type-safe, composable queries |
|
||||
| `getCategories.php` | `Inventory.list_categories/0` | Proper associations, hierarchical support |
|
||||
| `addItem.php` | `Inventory.create_component/1` | Built-in validation, changesets |
|
||||
| Manual editing | `Inventory.update_component/2` | **NEW**: Full edit functionality with validation |
|
||||
| `changeAmount.php` | `Inventory.update_component_count/2` | Atomic operations, constraints |
|
||||
| Manual category management | `CategoriesLive` + `Inventory.create_category/1` | **NEW**: Full category CRUD with web interface |
|
||||
| Manual location tracking | `StorageLocationsLive` + `Inventory` context | **NEW**: Hierarchical storage locations with automatic AprilTags |
|
||||
| `imageUpload.php` | Phoenix LiveView file uploads with `.live_file_input` | **IMPLEMENTED**: Full image upload with preview, validation, and automatic cleanup |
|
||||
| Session management | Phoenix sessions + LiveView | Built-in CSRF protection |
|
||||
### ✅ Enhanced User Interface
|
||||
- **Focus Mode**: Detailed component view with full information display
|
||||
- **Responsive Design**: Optimized for mobile and desktop usage
|
||||
- **Consistent Sorting**: Robust sorting even with rapid data changes
|
||||
- **Visual Feedback**: Loading states, progress indicators, and clear error messages
|
||||
|
||||
## 🚀 Future Enhancements
|
||||
### ✅ Production-Ready Deployment
|
||||
- **Docker Support**: Complete containerized deployment with database
|
||||
- **File Upload Optimization**: Improved handling in production environments
|
||||
- **Performance Tuning**: Optimized queries and caching for better responsiveness
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Component Management
|
||||
- **Barcode Support** - Generate and scan traditional barcodes in addition to AprilTags
|
||||
- **Camera Integration** - JavaScript-based AprilTag scanning with camera access for mobile/desktop
|
||||
- **Multi-AprilTag Detection** - Spatial analysis and disambiguation for multiple tags in same image
|
||||
- **Bulk Operations** - Import/export components from CSV, batch updates
|
||||
- **Search and Filtering** - Advanced search by specifications, tags, location
|
||||
- **Component Templates** - Reusable templates for common component types
|
||||
- **Version History** - Track changes to component specifications over time
|
||||
- **Barcode Support**: Generate and scan traditional barcodes alongside AprilTags
|
||||
- **Camera Integration**: Mobile/desktop AprilTag scanning with camera access
|
||||
- **Multi-Tag Detection**: Spatial analysis for multiple tags in the same view
|
||||
- **Bulk Operations**: CSV import/export and batch component updates
|
||||
- **Component Templates**: Reusable templates for common component types
|
||||
- **Specifications Tracking**: Detailed technical specifications with custom fields
|
||||
- **Version History**: Track changes to component data over time
|
||||
|
||||
### Storage Organization
|
||||
- **Physical Layout Mapping** - Visual representation of shelves, drawers, and boxes
|
||||
- **Bulk AprilTag Printing** - Generate printable sheets of AprilTags for labeling
|
||||
### Advanced Features
|
||||
- **API Integration**: Connect with distributor APIs for price and availability
|
||||
- **Low Stock Alerts**: Automatic notifications for components below threshold
|
||||
- **Bill of Materials (BOM)**: Project-based component tracking and allocation
|
||||
- **Purchase History**: Track component purchases and supplier information
|
||||
- **Location Mapping**: Visual representation of physical storage layout
|
||||
|
||||
## ✅ Recently Implemented Features
|
||||
|
||||
### Storage Location System Foundation ✅ **COMPLETED**
|
||||
- **Database Schema** ✅ Complete - Hierarchical storage locations with parent-child relationships
|
||||
- **Storage Location CRUD** ✅ Complete - Full create, read, update, delete operations via web interface
|
||||
- **Hierarchical Organization** ✅ Complete - Unlimited nesting (shelf → drawer → box)
|
||||
- **Web Interface** ✅ Complete - Storage locations management page with navigation
|
||||
- **Component-Storage Integration** ✅ Complete - Components can now be assigned to storage locations via dropdown interface
|
||||
|
||||
### AprilTag System 🚧 **PARTIALLY IMPLEMENTED**
|
||||
- **Visual AprilTag Generation** ❌ Partially Implemented - Placeholder SVGs generated
|
||||
- **Flexible Assignment Options** ✅ Complete - Auto-assign, manual selection, or no AprilTag assignment for storage locations
|
||||
- **AprilTag Download** ✅ Complete - Individual AprilTag SVG files can be downloaded for printing
|
||||
- **AprilTag Scanning** ❌ Missing - No camera integration or scanning functionality (future enhancement)
|
||||
- **AprilTag Processing** ❌ Missing - Backend logic for processing scanned tags (future enhancement)
|
||||
|
||||
### Image Upload System ✅ **COMPLETED**
|
||||
- **Phoenix LiveView file uploads** with `.live_file_input` component
|
||||
- **Image preview** during upload with progress indication
|
||||
- **File validation** (JPG, PNG, GIF up to 5MB)
|
||||
- **Automatic cleanup** of old images when updated or deleted
|
||||
- **Responsive image display** in component listings with fallback placeholders
|
||||
- **Upload error handling** with user-friendly messages
|
||||
|
||||
### Visual Datasheet Indicators ✅ **COMPLETED**
|
||||
- **Datasheet emoji** (📄) displayed next to component names when datasheet URL is present
|
||||
- **Clickable datasheet links** with clear visual indication
|
||||
- **Improved component listing** with image thumbnails and datasheet indicators
|
||||
|
||||
### Technical Implementation Details
|
||||
|
||||
#### Image Upload Architecture
|
||||
- **LiveView uploads** configured with `allow_upload/3` in mount
|
||||
- **File processing** with `consume_uploaded_entries/3` for secure file handling
|
||||
- **Unique filename generation** to prevent conflicts
|
||||
- **Static file serving** through Phoenix.Plug.Static with `/uploads` path
|
||||
- **Database integration** with `image_filename` field in components schema
|
||||
|
||||
#### Upload Features
|
||||
- **File type validation**: Only JPG, PNG, GIF files accepted
|
||||
- **Size limits**: Maximum 5MB per file
|
||||
- **Single file uploads**: One image per component
|
||||
- **Progress indication**: Real-time upload progress display
|
||||
- **Cancel functionality**: Users can cancel uploads in progress
|
||||
- **Preview system**: Live image preview before form submission
|
||||
|
||||
#### File Management
|
||||
- **Automatic cleanup**: Old images deleted when new ones uploaded
|
||||
- **Orphan prevention**: Images deleted when components are removed
|
||||
- **Error handling**: Graceful fallback for missing or corrupted files
|
||||
- **Static serving**: Images served directly through Phoenix static file handler
|
||||
### AprilTag Enhancement
|
||||
- **Scanner App**: Dedicated mobile app for inventory management
|
||||
- **Batch Printing**: Generate printable sheets of multiple AprilTags
|
||||
- **Smart Scanning**: Context-aware actions based on scanned tags
|
||||
|
||||
## Development
|
||||
|
||||
@@ -208,151 +190,65 @@ mix credo
|
||||
# Reset database
|
||||
mix ecto.reset
|
||||
|
||||
# Add new migration
|
||||
# Create new migration
|
||||
mix ecto.gen.migration add_feature
|
||||
|
||||
# Check migration status
|
||||
mix ecto.migrations
|
||||
```
|
||||
|
||||
### Development Environment
|
||||
The project includes:
|
||||
- **Hot code reloading** for rapid development
|
||||
- **TailwindCSS** with live recompilation
|
||||
- **DaisyUI** for consistent component styling
|
||||
- **Database seeding** with sample data
|
||||
|
||||
## Deployment
|
||||
|
||||
### 🐳 Docker Deployment (Recommended)
|
||||
|
||||
#### Prerequisites
|
||||
- Docker and Docker Compose installed
|
||||
- Git for cloning the repository
|
||||
Docker provides the easiest deployment method with all dependencies included.
|
||||
|
||||
#### Quick Start
|
||||
|
||||
1. **Clone the repository:**
|
||||
1. **Clone and setup:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd components_elixir
|
||||
cp docker-compose.yml.example docker-compose.yml
|
||||
```
|
||||
|
||||
2. **Copy docker-compose.yml.example to docker-compose.yml**
|
||||
Follow steps in [Customizing Docker Deployment](#customizing-docker-deployment).
|
||||
2. **Configure environment** (edit `docker-compose.yml`):
|
||||
```yaml
|
||||
environment:
|
||||
SECRET_KEY_BASE: "your-64-character-secret-key" # Generate with: mix phx.gen.secret
|
||||
AUTH_PASSWORD: "your-secure-password"
|
||||
PHX_HOST: "localhost" # Change to your domain
|
||||
```
|
||||
|
||||
3. **Build and run with Docker Compose:**
|
||||
3. **Deploy:**
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
**⚠️ Build Time Notice**: The initial build can take 5-15 minutes because:
|
||||
- Tailwind CSS downloads ~500MB from GitHub (can be very slow)
|
||||
- ESBuild downloads additional build tools
|
||||
- Elixir compiles all dependencies
|
||||
- Network conditions may significantly affect download speeds
|
||||
4. **Access:** [http://localhost:4000](http://localhost:4000)
|
||||
|
||||
4. **Access the application:**
|
||||
- Open [http://localhost:4000](http://localhost:4000)
|
||||
- Default password: `changeme`
|
||||
|
||||
#### Docker Configuration Files
|
||||
|
||||
The project includes these Docker files:
|
||||
|
||||
- **`Dockerfile`** - Multi-stage build for production
|
||||
- **`docker-compose.yml`** - Local development/testing setup
|
||||
- **`.dockerignore`** - Excludes unnecessary files from build context
|
||||
|
||||
#### Customizing Docker Deployment
|
||||
|
||||
1. **Environment Variables**: Edit `docker-compose.yml` to customize:
|
||||
```yaml
|
||||
environment:
|
||||
DATABASE_URL: "ecto://postgres:postgres@db:5432/components_elixir_prod"
|
||||
SECRET_KEY_BASE: "your-secret-key-here" # Generate with: mix phx.gen.secret
|
||||
PHX_HOST: "localhost" # Change to your domain
|
||||
PHX_SERVER: "true"
|
||||
PORT: "4000"
|
||||
```
|
||||
|
||||
2. **Generate a secure secret key:**
|
||||
|
||||
**With Elixir/Phoenix installed:**
|
||||
```bash
|
||||
mix phx.gen.secret
|
||||
```
|
||||
|
||||
**Without Elixir/Phoenix (Linux/Unix):**
|
||||
```bash
|
||||
dd if=/dev/random bs=1 count=64 status=none | base64 -w0 | cut -c1-64
|
||||
```
|
||||
|
||||
> **Note**: The SECRET_KEY_BASE must be a cryptographically random string that's at least 64 characters long. Phoenix uses it to sign session cookies, CSRF tokens, and other security-sensitive data.
|
||||
|
||||
3. **Database Configuration**: The default setup includes:
|
||||
- PostgreSQL 15 container
|
||||
- Automatic database creation
|
||||
- Health checks to ensure proper startup order
|
||||
- Persistent data storage with Docker volumes
|
||||
|
||||
#### Production Docker Deployment
|
||||
#### Production Configuration
|
||||
|
||||
For production environments:
|
||||
|
||||
1. **Create a production docker-compose.yml:**
|
||||
```yaml
|
||||
services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_USER: components_user
|
||||
POSTGRES_PASSWORD: secure_db_password
|
||||
POSTGRES_DB: components_elixir_prod
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
build: .
|
||||
ports:
|
||||
- "80:4000"
|
||||
environment:
|
||||
DATABASE_URL: "ecto://components_user:secure_db_password@db:5432/components_elixir_prod"
|
||||
SECRET_KEY_BASE: "your-64-char-secret-key"
|
||||
PHX_HOST: "yourdomain.com"
|
||||
PHX_SERVER: "true"
|
||||
PORT: "4000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
command: ["/bin/sh", "-c", "/app/bin/migrate && /app/bin/server"]
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
```
|
||||
|
||||
2. **Deploy to production:**
|
||||
```bash
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
```
|
||||
|
||||
#### Docker Troubleshooting
|
||||
|
||||
**Build Issues:**
|
||||
- **Slow Tailwind download**: This is normal - GitHub releases can be slow
|
||||
- **Network timeouts**: Retry the build with `docker compose up --build`
|
||||
- **AprilTag compilation errors**: Ensure `apriltags.ps` file exists in project root
|
||||
|
||||
**Runtime Issues:**
|
||||
- **Database connection errors**: Wait for PostgreSQL health check to pass
|
||||
- **Permission errors**: Check file ownership and Docker user permissions
|
||||
- **Port conflicts**: Change the port mapping in docker-compose.yml
|
||||
|
||||
**Performance:**
|
||||
- **Slow startup**: First-time container startup includes database initialization
|
||||
- **Memory usage**: Elixir/Phoenix applications typically use 50-200MB RAM
|
||||
- **Storage**: PostgreSQL data persists in Docker volumes
|
||||
- **Generate secure keys**: Use `mix phx.gen.secret` for SECRET_KEY_BASE
|
||||
- **Set strong passwords**: Use AUTH_PASSWORD environment variable
|
||||
- **Configure domain**: Set PHX_HOST to your actual domain
|
||||
- **Database security**: Use strong PostgreSQL credentials
|
||||
- **SSL/HTTPS**: Configure reverse proxy (nginx, Caddy) for SSL termination
|
||||
|
||||
### 🚀 Traditional Deployment
|
||||
|
||||
For production deployment without Docker:
|
||||
For deployment without Docker:
|
||||
|
||||
1. **Set environment variables:**
|
||||
1. **Environment setup:**
|
||||
```bash
|
||||
export AUTH_PASSWORD=your_secure_password
|
||||
export SECRET_KEY_BASE=your_secret_key
|
||||
@@ -364,18 +260,24 @@ For production deployment without Docker:
|
||||
MIX_ENV=prod mix release
|
||||
```
|
||||
|
||||
3. **Run migrations:**
|
||||
3. **Database setup:**
|
||||
```bash
|
||||
_build/prod/rel/components_elixir/bin/components_elixir eval "ComponentsElixir.Release.migrate"
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This is a modernized, idiomatic Elixir/Phoenix implementation that maintains feature parity with the original PHP version while providing significant improvements in code quality, security, and user experience.
|
||||
This project follows Phoenix and Elixir best practices:
|
||||
|
||||
The application follows Phoenix and Elixir best practices:
|
||||
- Contexts for business logic
|
||||
- LiveView for interactive UI
|
||||
- Ecto for database operations
|
||||
- Comprehensive error handling
|
||||
- Input validation and sanitization
|
||||
- **Phoenix Contexts** for business logic separation
|
||||
- **LiveView** for real-time, interactive user interfaces
|
||||
- **Ecto** for type-safe database operations
|
||||
- **Comprehensive testing** with ExUnit
|
||||
- **Input validation** and sanitization throughout
|
||||
- **CSRF protection** and security best practices
|
||||
|
||||
The codebase emphasizes:
|
||||
- **Maintainability**: Clear module organization and documentation
|
||||
- **Performance**: Optimized queries and efficient data structures
|
||||
- **Reliability**: Comprehensive error handling and graceful degradation
|
||||
- **Extensibility**: Modular design for easy feature additions
|
||||
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@ import Config
|
||||
# Run `mix help test` for more information.
|
||||
config :components_elixir, ComponentsElixir.Repo,
|
||||
username: "postgres",
|
||||
password: "postgres",
|
||||
password: System.get_env("POSTGRES_PASSWORD") || "fCnPB8VQdPkhJAD29hq6sZEY",
|
||||
hostname: "localhost",
|
||||
database: "components_elixir_test#{System.get_env("MIX_TEST_PARTITION")}",
|
||||
pool: Ecto.Adapters.SQL.Sandbox,
|
||||
|
||||
@@ -31,6 +31,7 @@ defmodule ComponentsElixir.AprilTag do
|
||||
def valid_apriltag_id?(id) when is_integer(id) do
|
||||
id >= 0 and id < @tag36h11_count
|
||||
end
|
||||
|
||||
def valid_apriltag_id?(_), do: false
|
||||
|
||||
@doc """
|
||||
@@ -78,8 +79,8 @@ defmodule ComponentsElixir.AprilTag do
|
||||
def used_apriltag_ids do
|
||||
ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
end
|
||||
|
||||
@@ -130,10 +131,11 @@ defmodule ComponentsElixir.AprilTag do
|
||||
This should be run once during setup to pre-generate all AprilTag images.
|
||||
"""
|
||||
def generate_all_apriltag_svgs(opts \\ []) do
|
||||
static_dir = Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static"),
|
||||
"apriltags"
|
||||
])
|
||||
static_dir =
|
||||
Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static"),
|
||||
"apriltags"
|
||||
])
|
||||
|
||||
# Ensure directory exists
|
||||
File.mkdir_p!(static_dir)
|
||||
@@ -143,21 +145,7 @@ defmodule ComponentsElixir.AprilTag do
|
||||
results =
|
||||
all_apriltag_ids()
|
||||
|> Task.async_stream(
|
||||
fn apriltag_id ->
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join(static_dir, filename)
|
||||
|
||||
if force_regenerate || !File.exists?(file_path) do
|
||||
svg_content = generate_apriltag_svg(apriltag_id, opts)
|
||||
|
||||
case File.write(file_path, svg_content) do
|
||||
:ok -> {:ok, apriltag_id, file_path}
|
||||
{:error, reason} -> {:error, apriltag_id, reason}
|
||||
end
|
||||
else
|
||||
{:ok, apriltag_id, file_path}
|
||||
end
|
||||
end,
|
||||
&generate_apriltag_file(&1, static_dir, force_regenerate, opts),
|
||||
timeout: :infinity,
|
||||
max_concurrency: System.schedulers_online() * 2
|
||||
)
|
||||
@@ -174,6 +162,26 @@ defmodule ComponentsElixir.AprilTag do
|
||||
}
|
||||
end
|
||||
|
||||
defp generate_apriltag_file(apriltag_id, static_dir, force_regenerate, opts) do
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join(static_dir, filename)
|
||||
|
||||
if force_regenerate || !File.exists?(file_path) do
|
||||
write_apriltag_file(apriltag_id, file_path, opts)
|
||||
else
|
||||
{:ok, apriltag_id, file_path}
|
||||
end
|
||||
end
|
||||
|
||||
defp write_apriltag_file(apriltag_id, file_path, opts) do
|
||||
svg_content = generate_apriltag_svg(apriltag_id, opts)
|
||||
|
||||
case File.write(file_path, svg_content) do
|
||||
:ok -> {:ok, apriltag_id, file_path}
|
||||
{:error, reason} -> {:error, apriltag_id, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Cleans up AprilTag SVG file for a specific ID.
|
||||
|
||||
@@ -181,10 +189,12 @@ defmodule ComponentsElixir.AprilTag do
|
||||
"""
|
||||
def cleanup_apriltag_svg(apriltag_id) do
|
||||
filename = "tag36h11_id_#{String.pad_leading(to_string(apriltag_id), 3, "0")}.svg"
|
||||
file_path = Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static/apriltags"),
|
||||
filename
|
||||
])
|
||||
|
||||
file_path =
|
||||
Path.join([
|
||||
Application.app_dir(:components_elixir, "priv/static/apriltags"),
|
||||
filename
|
||||
])
|
||||
|
||||
if File.exists?(file_path) do
|
||||
File.rm(file_path)
|
||||
|
||||
@@ -16,6 +16,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
|
||||
[_, id_str, hex_pattern] ->
|
||||
{String.to_integer(id_str), String.downcase(hex_pattern)}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
@@ -23,26 +24,30 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
# Extract patterns from PostScript file at compile time
|
||||
@all_patterns (
|
||||
path = Path.join([File.cwd!(), "apriltags.ps"])
|
||||
path = Path.join([File.cwd!(), "apriltags.ps"])
|
||||
|
||||
if File.exists?(path) do
|
||||
File.read!(path)
|
||||
|> String.split("\n")
|
||||
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|
||||
|> Enum.map(fn line ->
|
||||
case Regex.run(~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/, line) do
|
||||
[_, id_str, hex_pattern] ->
|
||||
{String.to_integer(id_str), String.downcase(hex_pattern)}
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Map.new()
|
||||
else
|
||||
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
|
||||
end
|
||||
)
|
||||
if File.exists?(path) do
|
||||
File.read!(path)
|
||||
|> String.split("\n")
|
||||
|> Enum.filter(&String.contains?(&1, "april.tag.Tag36h11"))
|
||||
|> Enum.map(fn line ->
|
||||
case Regex.run(
|
||||
~r/\(april\.tag\.Tag36h11, id = (\d+)\) <([0-9a-fA-F]+)> maketag/,
|
||||
line
|
||||
) do
|
||||
[_, id_str, hex_pattern] ->
|
||||
{String.to_integer(id_str), String.downcase(hex_pattern)}
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end)
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Map.new()
|
||||
else
|
||||
raise "Error: apriltags.ps file not found in project root. This file is required for AprilTag pattern generation."
|
||||
end
|
||||
)
|
||||
|
||||
# Sample of real tag36h11 hex patterns from AprilRobotics repository
|
||||
# This will be populated with patterns extracted from the PostScript file
|
||||
@@ -64,7 +69,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Map.new()
|
||||
else
|
||||
%{} # Return empty map if file not found, will fall back to hardcoded patterns
|
||||
# Return empty map if file not found, will fall back to hardcoded patterns
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -76,6 +82,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
def get_hex_pattern(id) when is_integer(id) and id >= 0 and id < 587 do
|
||||
Map.get(@tag36h11_patterns, id)
|
||||
end
|
||||
|
||||
def get_hex_pattern(_), do: nil
|
||||
|
||||
@doc """
|
||||
@@ -97,6 +104,7 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
# Each scanline is padded to a byte boundary: 3 bytes per 10 pixels (2 bpp)
|
||||
row_bytes = 3
|
||||
|
||||
rows =
|
||||
for row <- 0..9 do
|
||||
<<_::binary-size(row * row_bytes), r::binary-size(row_bytes), _::binary>> = bytes
|
||||
@@ -104,12 +112,23 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
samples =
|
||||
[
|
||||
b0 >>> 6 &&& 0x3, b0 >>> 4 &&& 0x3, b0 >>> 2 &&& 0x3, b0 &&& 0x3,
|
||||
b1 >>> 6 &&& 0x3, b1 >>> 4 &&& 0x3, b1 >>> 2 &&& 0x3, b1 &&& 0x3,
|
||||
b2 >>> 6 &&& 0x3, b2 >>> 4 &&& 0x3, b2 >>> 2 &&& 0x3, b2 &&& 0x3
|
||||
b0 >>> 6 &&& 0x3,
|
||||
b0 >>> 4 &&& 0x3,
|
||||
b0 >>> 2 &&& 0x3,
|
||||
b0 &&& 0x3,
|
||||
b1 >>> 6 &&& 0x3,
|
||||
b1 >>> 4 &&& 0x3,
|
||||
b1 >>> 2 &&& 0x3,
|
||||
b1 &&& 0x3,
|
||||
b2 >>> 6 &&& 0x3,
|
||||
b2 >>> 4 &&& 0x3,
|
||||
b2 >>> 2 &&& 0x3,
|
||||
b2 &&& 0x3
|
||||
]
|
||||
|> Enum.take(10) # drop the 2 padding samples at end of row
|
||||
|> Enum.map(&(&1 == 0)) # 0 = black, 3 = white → boolean
|
||||
# drop the 2 padding samples at end of row
|
||||
|> Enum.take(10)
|
||||
# 0 = black, 3 = white → boolean
|
||||
|> Enum.map(&(&1 == 0))
|
||||
|
||||
samples
|
||||
end
|
||||
@@ -133,7 +152,8 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
Only black modules are drawn over a white background.
|
||||
"""
|
||||
def binary_matrix_to_svg(binary_matrix, opts \\ []) do
|
||||
size = Keyword.get(opts, :size, 200) # final CSS size in px
|
||||
# final CSS size in px
|
||||
size = Keyword.get(opts, :size, 200)
|
||||
id_text = Keyword.get(opts, :id_text, "")
|
||||
|
||||
# binary_matrix is 10x10 of booleans: true=black, false=white
|
||||
@@ -172,9 +192,11 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
|
||||
<!-- caption -->
|
||||
#{if id_text != "" do
|
||||
~s(<text x="#{modules_w/2}" y="#{modules_h + 1.4}" text-anchor="middle"
|
||||
~s(<text x="#{modules_w / 2}" y="#{modules_h + 1.4}" text-anchor="middle"
|
||||
font-family="Arial" font-size="0.9">#{id_text}</text>)
|
||||
else "" end}
|
||||
else
|
||||
""
|
||||
end}
|
||||
</svg>
|
||||
"""
|
||||
end
|
||||
@@ -197,11 +219,13 @@ defmodule ComponentsElixir.AprilTag.Tag36h11 do
|
||||
opts_with_id = Keyword.put(opts, :id_text, id_text)
|
||||
binary_matrix_to_svg(binary_matrix, opts_with_id)
|
||||
end
|
||||
end # Generate a placeholder pattern for IDs we don't have real data for yet
|
||||
end
|
||||
|
||||
# Generate a placeholder pattern for IDs we don't have real data for yet
|
||||
defp generate_placeholder_svg(id, opts) do
|
||||
size = Keyword.get(opts, :size, 200)
|
||||
margin = Keyword.get(opts, :margin, div(size, 10))
|
||||
square_size = size - (2 * margin)
|
||||
square_size = size - 2 * margin
|
||||
|
||||
"""
|
||||
<svg width="#{size}" height="#{size + 30}" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -28,6 +28,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] ->
|
||||
{:ok, URI.parse(url)}
|
||||
|
||||
_ ->
|
||||
{:error, "Invalid URL scheme. Only HTTP and HTTPS are supported."}
|
||||
end
|
||||
@@ -36,9 +37,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
defp generate_filename(url) do
|
||||
# Try to extract a meaningful filename from the URL
|
||||
uri = URI.parse(url)
|
||||
|
||||
original_filename =
|
||||
case Path.basename(uri.path || "") do
|
||||
"" -> "datasheet"
|
||||
"" ->
|
||||
"datasheet"
|
||||
|
||||
basename ->
|
||||
# Remove extension and sanitize
|
||||
basename
|
||||
@@ -54,10 +58,14 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
|
||||
defp sanitize_filename(filename) do
|
||||
filename
|
||||
|> String.replace(~r/[^\w\-_]/, "_") # Replace non-word chars with underscores
|
||||
|> String.replace(~r/_+/, "_") # Replace multiple underscores with single
|
||||
|> String.trim("_") # Remove leading/trailing underscores
|
||||
|> String.slice(0, 50) # Limit length
|
||||
# Replace non-word chars with underscores
|
||||
|> String.replace(~r/[^\w\-_]/, "_")
|
||||
# Replace multiple underscores with single
|
||||
|> String.replace(~r/_+/, "_")
|
||||
# Remove leading/trailing underscores
|
||||
|> String.trim("_")
|
||||
# Limit length
|
||||
|> String.slice(0, 50)
|
||||
|> case do
|
||||
"" -> "datasheet"
|
||||
name -> name
|
||||
@@ -66,17 +74,19 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
|
||||
defp fetch_pdf(url) do
|
||||
case Req.get(url,
|
||||
redirect: true,
|
||||
max_redirects: 5,
|
||||
receive_timeout: 30_000,
|
||||
headers: [
|
||||
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
|
||||
]
|
||||
) do
|
||||
redirect: true,
|
||||
max_redirects: 5,
|
||||
receive_timeout: 30_000,
|
||||
headers: [
|
||||
{"User-Agent", "ComponentSystem/1.0 DatasheetDownloader"}
|
||||
]
|
||||
) do
|
||||
{:ok, %Req.Response{status: 200} = response} ->
|
||||
{:ok, response}
|
||||
|
||||
{:ok, %Req.Response{status: status}} ->
|
||||
{:error, "HTTP error: #{status}"}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to download PDF from #{url}: #{inspect(reason)}")
|
||||
{:error, "Download failed: #{inspect(reason)}"}
|
||||
@@ -88,6 +98,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
case body do
|
||||
<<"%PDF", _rest::binary>> ->
|
||||
:ok
|
||||
|
||||
_ ->
|
||||
{:error, "Downloaded content is not a valid PDF file"}
|
||||
end
|
||||
@@ -105,10 +116,12 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
:ok ->
|
||||
Logger.info("Successfully saved datasheet: #{filename}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to save datasheet file: #{inspect(reason)}")
|
||||
{:error, "Failed to save file: #{inspect(reason)}"}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to create datasheets directory: #{inspect(reason)}")
|
||||
{:error, "Failed to create directory: #{inspect(reason)}"}
|
||||
@@ -129,6 +142,7 @@ defmodule ComponentsElixir.DatasheetDownloader do
|
||||
:ok ->
|
||||
Logger.info("Deleted datasheet file: #{filename}")
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to delete datasheet file #{filename}: #{inspect(reason)}")
|
||||
{:error, reason}
|
||||
|
||||
@@ -19,7 +19,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
locations =
|
||||
StorageLocation
|
||||
|> order_by([sl], asc: sl.name)
|
||||
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||
|> preload(parent: [parent: [parent: [parent: :parent]]])
|
||||
|> Repo.all()
|
||||
|
||||
# Ensure AprilTag SVGs exist for all locations
|
||||
@@ -162,7 +162,7 @@ defmodule ComponentsElixir.Inventory do
|
||||
"""
|
||||
def list_categories do
|
||||
Category
|
||||
|> preload([parent: [parent: [parent: [parent: :parent]]]])
|
||||
|> preload(parent: [parent: [parent: [parent: :parent]]])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@@ -217,8 +217,15 @@ defmodule ComponentsElixir.Inventory do
|
||||
|
||||
# Verify the category exists before getting descendants
|
||||
case Enum.find(categories, &(&1.id == category_id)) do
|
||||
nil -> []
|
||||
_category -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(categories, category_id, &(&1.parent_id))
|
||||
nil ->
|
||||
[]
|
||||
|
||||
_category ->
|
||||
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
|
||||
categories,
|
||||
category_id,
|
||||
& &1.parent_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -233,13 +240,21 @@ defmodule ComponentsElixir.Inventory do
|
||||
for typical storage location tree sizes (hundreds of locations). For very large storage location trees,
|
||||
a recursive CTE query could be used instead.
|
||||
"""
|
||||
def get_storage_location_and_descendant_ids(storage_location_id) when is_integer(storage_location_id) do
|
||||
def get_storage_location_and_descendant_ids(storage_location_id)
|
||||
when is_integer(storage_location_id) do
|
||||
storage_locations = list_storage_locations()
|
||||
|
||||
# Verify the storage location exists before getting descendants
|
||||
case Enum.find(storage_locations, &(&1.id == storage_location_id)) do
|
||||
nil -> []
|
||||
_storage_location -> ComponentsElixir.Inventory.Hierarchical.descendant_ids(storage_locations, storage_location_id, &(&1.parent_id))
|
||||
nil ->
|
||||
[]
|
||||
|
||||
_storage_location ->
|
||||
ComponentsElixir.Inventory.Hierarchical.descendant_ids(
|
||||
storage_locations,
|
||||
storage_location_id,
|
||||
& &1.parent_id
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -288,18 +303,25 @@ defmodule ComponentsElixir.Inventory do
|
||||
end
|
||||
|
||||
defp apply_component_sorting(query, opts) do
|
||||
case Keyword.get(opts, :sort_criteria, "name_asc") do
|
||||
"name_asc" -> order_by(query, [c], [asc: c.name, asc: c.id])
|
||||
"name_desc" -> order_by(query, [c], [desc: c.name, asc: c.id])
|
||||
"inserted_at_asc" -> order_by(query, [c], [asc: c.inserted_at, asc: c.id])
|
||||
"inserted_at_desc" -> order_by(query, [c], [desc: c.inserted_at, asc: c.id])
|
||||
"updated_at_asc" -> order_by(query, [c], [asc: c.updated_at, asc: c.id])
|
||||
"updated_at_desc" -> order_by(query, [c], [desc: c.updated_at, asc: c.id])
|
||||
"count_asc" -> order_by(query, [c], [asc: c.count, asc: c.id])
|
||||
"count_desc" -> order_by(query, [c], [desc: c.count, asc: c.id])
|
||||
# Default fallback
|
||||
_ -> order_by(query, [c], [asc: c.name, asc: c.id])
|
||||
end
|
||||
sort_criteria = Keyword.get(opts, :sort_criteria, "name_asc")
|
||||
sort_order = get_sort_order(sort_criteria)
|
||||
order_by(query, [c], ^sort_order)
|
||||
end
|
||||
|
||||
# Map of sort criteria to their corresponding sort orders
|
||||
@sort_orders %{
|
||||
"name_asc" => [asc: :name, asc: :id],
|
||||
"name_desc" => [desc: :name, asc: :id],
|
||||
"inserted_at_asc" => [asc: :inserted_at, asc: :id],
|
||||
"inserted_at_desc" => [desc: :inserted_at, asc: :id],
|
||||
"updated_at_asc" => [asc: :updated_at, asc: :id],
|
||||
"updated_at_desc" => [desc: :updated_at, asc: :id],
|
||||
"count_asc" => [asc: :count, asc: :id],
|
||||
"count_desc" => [desc: :count, asc: :id]
|
||||
}
|
||||
|
||||
defp get_sort_order(criteria) do
|
||||
Map.get(@sort_orders, criteria, asc: :name, asc: :id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -331,10 +353,12 @@ defmodule ComponentsElixir.Inventory do
|
||||
case ComponentsElixir.DatasheetDownloader.download_pdf_from_url(url) do
|
||||
{:ok, filename} ->
|
||||
Map.put(attrs, "datasheet_filename", filename)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Continue without datasheet file if download fails
|
||||
attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
@@ -365,13 +389,18 @@ defmodule ComponentsElixir.Inventory do
|
||||
{:ok, filename} ->
|
||||
# Delete old datasheet file if it exists
|
||||
if component.datasheet_filename do
|
||||
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(component.datasheet_filename)
|
||||
ComponentsElixir.DatasheetDownloader.delete_datasheet_file(
|
||||
component.datasheet_filename
|
||||
)
|
||||
end
|
||||
|
||||
Map.put(attrs, "datasheet_filename", filename)
|
||||
|
||||
{:error, _reason} ->
|
||||
# Keep existing filename if download fails
|
||||
attrs
|
||||
end
|
||||
|
||||
_ ->
|
||||
attrs
|
||||
end
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,18 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
@doc false
|
||||
def changeset(component, attrs) do
|
||||
component
|
||||
|> cast(attrs, [:name, :description, :keywords, :position, :count, :datasheet_url, :datasheet_filename, :image_filename, :category_id, :storage_location_id])
|
||||
|> cast(attrs, [
|
||||
:name,
|
||||
:description,
|
||||
:keywords,
|
||||
:position,
|
||||
:count,
|
||||
:datasheet_url,
|
||||
:datasheet_filename,
|
||||
:image_filename,
|
||||
:category_id,
|
||||
:storage_location_id
|
||||
])
|
||||
|> validate_required([:name, :category_id])
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> validate_length(:description, max: 2000)
|
||||
@@ -70,17 +81,21 @@ defmodule ComponentsElixir.Inventory.Component do
|
||||
|
||||
defp validate_url(changeset, field) do
|
||||
validate_change(changeset, field, fn ^field, url ->
|
||||
if url && url != "" do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] -> []
|
||||
_ -> [{field, "must be a valid URL"}]
|
||||
end
|
||||
else
|
||||
[]
|
||||
cond do
|
||||
is_nil(url) or url == "" -> []
|
||||
valid_url?(url) -> []
|
||||
true -> [{field, "must be a valid URL"}]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp valid_url?(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: scheme} when scheme in ["http", "https"] -> true
|
||||
_ -> false
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the component has an image.
|
||||
"""
|
||||
|
||||
@@ -29,10 +29,12 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
case parent_accessor_fn.(entity) do
|
||||
nil ->
|
||||
entity.name
|
||||
|
||||
%Ecto.Association.NotLoaded{} ->
|
||||
# Parent not loaded - fall back to database lookup
|
||||
# This is a fallback and should be rare if preloading is done correctly
|
||||
build_path_with_db_lookup(entity, separator)
|
||||
|
||||
parent ->
|
||||
"#{full_path(parent, parent_accessor_fn, separator)}#{separator}#{entity.name}"
|
||||
end
|
||||
@@ -52,12 +54,14 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
nil ->
|
||||
# This is a root entity, add its name and return the complete path
|
||||
[entity.name | path_so_far]
|
||||
|
||||
parent_id ->
|
||||
# Load parent from database
|
||||
case load_parent_entity(entity, parent_id) do
|
||||
nil ->
|
||||
# Parent not found (orphaned record), treat this as root
|
||||
[entity.name | path_so_far]
|
||||
|
||||
parent ->
|
||||
# Recursively get the path from the parent, then add current entity
|
||||
collect_path_from_root(parent, [entity.name | path_so_far])
|
||||
@@ -93,9 +97,9 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
entity_id = id_accessor_fn.(entity)
|
||||
|
||||
# Remove self-reference
|
||||
entity_id == editing_entity_id ||
|
||||
# Remove descendants (they would create a cycle)
|
||||
is_descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
|
||||
entity_id == editing_entity_id ||
|
||||
descendant?(entities, entity_id, editing_entity_id, parent_id_accessor_fn)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -103,24 +107,32 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
Checks if an entity is a descendant of an ancestor entity.
|
||||
Used for cycle detection in parent selection.
|
||||
"""
|
||||
def is_descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
|
||||
def descendant?(entities, descendant_id, ancestor_id, parent_id_accessor_fn) do
|
||||
descendant = Enum.find(entities, fn e -> e.id == descendant_id end)
|
||||
|
||||
case descendant do
|
||||
nil -> false
|
||||
entity -> is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn)
|
||||
entity -> descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn)
|
||||
end
|
||||
end
|
||||
|
||||
defp is_descendant_recursive(entities, entity, ancestor_id, parent_id_accessor_fn) do
|
||||
defp descendant_recursive?(entities, entity, ancestor_id, parent_id_accessor_fn) do
|
||||
case parent_id_accessor_fn.(entity) do
|
||||
nil -> false
|
||||
^ancestor_id -> true
|
||||
nil ->
|
||||
false
|
||||
|
||||
^ancestor_id ->
|
||||
true
|
||||
|
||||
parent_id ->
|
||||
parent = Enum.find(entities, fn e -> e.id == parent_id end)
|
||||
|
||||
case parent do
|
||||
nil -> false
|
||||
parent_entity -> is_descendant_recursive(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
|
||||
nil ->
|
||||
false
|
||||
|
||||
parent_entity ->
|
||||
descendant_recursive?(entities, parent_entity, ancestor_id, parent_id_accessor_fn)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -182,15 +194,20 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
Includes proper filtering to prevent cycles and formatted display names.
|
||||
Results are sorted hierarchically for intuitive navigation.
|
||||
"""
|
||||
def parent_select_options(entities, editing_entity_id, parent_accessor_fn, nil_option_text \\ "No parent") do
|
||||
def parent_select_options(
|
||||
entities,
|
||||
editing_entity_id,
|
||||
parent_accessor_fn,
|
||||
nil_option_text \\ "No parent"
|
||||
) do
|
||||
available_entities =
|
||||
filter_parent_options(
|
||||
entities,
|
||||
editing_entity_id,
|
||||
&(&1.id),
|
||||
&(&1.parent_id)
|
||||
& &1.id,
|
||||
& &1.parent_id
|
||||
)
|
||||
|> sort_hierarchically(&(&1.parent_id))
|
||||
|> sort_hierarchically(& &1.parent_id)
|
||||
|> Enum.map(fn entity ->
|
||||
{display_name(entity, parent_accessor_fn), entity.id}
|
||||
end)
|
||||
@@ -205,7 +222,7 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
def select_options(entities, parent_accessor_fn, nil_option_text \\ nil) do
|
||||
sorted_entities =
|
||||
entities
|
||||
|> sort_hierarchically(&(&1.parent_id))
|
||||
|> sort_hierarchically(& &1.parent_id)
|
||||
|> Enum.map(fn entity ->
|
||||
{display_name(entity, parent_accessor_fn), entity.id}
|
||||
end)
|
||||
@@ -300,9 +317,10 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
descendant_ids_only = List.delete(all_descendant_ids, entity_id)
|
||||
|
||||
# Sum counts for all descendants
|
||||
children_count = Enum.reduce(descendant_ids_only, 0, fn id, acc ->
|
||||
acc + count_fn.(id)
|
||||
end)
|
||||
children_count =
|
||||
Enum.reduce(descendant_ids_only, 0, fn id, acc ->
|
||||
acc + count_fn.(id)
|
||||
end)
|
||||
|
||||
{self_count, children_count, self_count + children_count}
|
||||
end
|
||||
@@ -320,7 +338,13 @@ defmodule ComponentsElixir.Inventory.Hierarchical do
|
||||
- singular_noun: What to call a single item (default: "component")
|
||||
- plural_noun: What to call multiple items (default: "components")
|
||||
"""
|
||||
def format_count_display(self_count, children_count, is_expanded, singular_noun \\ "component", plural_noun \\ "components") do
|
||||
def format_count_display(
|
||||
self_count,
|
||||
children_count,
|
||||
is_expanded,
|
||||
singular_noun \\ "component",
|
||||
plural_noun \\ "components"
|
||||
) do
|
||||
total_count = self_count + children_count
|
||||
|
||||
count_noun = if total_count == 1, do: singular_noun, else: plural_noun
|
||||
|
||||
@@ -25,7 +25,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
timestamps(type: :naive_datetime_usec)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@doc false
|
||||
def changeset(storage_location, attrs) do
|
||||
storage_location
|
||||
|> cast(attrs, [:name, :description, :parent_id, :apriltag_id])
|
||||
@@ -40,7 +40,7 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
# HierarchicalSchema implementations
|
||||
@impl true
|
||||
def full_path(%StorageLocation{} = storage_location) do
|
||||
Hierarchical.full_path(storage_location, &(&1.parent), path_separator())
|
||||
Hierarchical.full_path(storage_location, & &1.parent, path_separator())
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -80,11 +80,12 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
|
||||
defp get_next_available_apriltag_id do
|
||||
# Get all used AprilTag IDs
|
||||
used_ids = ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
used_ids =
|
||||
ComponentsElixir.Repo.all(
|
||||
from sl in ComponentsElixir.Inventory.StorageLocation,
|
||||
where: not is_nil(sl.apriltag_id),
|
||||
select: sl.apriltag_id
|
||||
)
|
||||
|
||||
# Find the first available ID (0-586)
|
||||
0..586
|
||||
@@ -93,7 +94,9 @@ defmodule ComponentsElixir.Inventory.StorageLocation do
|
||||
nil ->
|
||||
# All IDs are used - this should be handled at the application level
|
||||
raise "All AprilTag IDs are in use"
|
||||
id -> id
|
||||
|
||||
id ->
|
||||
id
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -13,7 +13,8 @@ defmodule ComponentsElixirWeb.FileController do
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(mime_type)
|
||||
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
|
||||
# Cache for 1 day
|
||||
|> put_resp_header("cache-control", "public, max-age=86400")
|
||||
|> send_file(200, file_path)
|
||||
else
|
||||
conn
|
||||
@@ -40,7 +41,8 @@ defmodule ComponentsElixirWeb.FileController do
|
||||
|
||||
conn
|
||||
|> put_resp_content_type(mime_type)
|
||||
|> put_resp_header("cache-control", "public, max-age=86400") # Cache for 1 day
|
||||
# Cache for 1 day
|
||||
|> put_resp_header("cache-control", "public, max-age=86400")
|
||||
|> put_resp_header("content-disposition", "inline; filename=\"#{filename}\"")
|
||||
|> send_file(200, file_path)
|
||||
else
|
||||
@@ -64,9 +66,9 @@ defmodule ComponentsElixirWeb.FileController do
|
||||
# Security validation: prevent directory traversal and only allow safe characters
|
||||
# Allow letters, numbers, spaces, dots, dashes, underscores, parentheses, and basic punctuation
|
||||
if String.match?(decoded_filename, ~r/^[a-zA-Z0-9\s_\-\.\(\)\[\]]+$/) and
|
||||
not String.contains?(decoded_filename, "..") and
|
||||
not String.starts_with?(decoded_filename, "/") and
|
||||
not String.contains?(decoded_filename, "\\") do
|
||||
not String.contains?(decoded_filename, "..") and
|
||||
not String.starts_with?(decoded_filename, "/") and
|
||||
not String.contains?(decoded_filename, "\\") do
|
||||
{:ok, decoded_filename}
|
||||
else
|
||||
{:error, "Invalid filename: contains unsafe characters"}
|
||||
|
||||
@@ -7,9 +7,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Check authentication
|
||||
unless Auth.authenticated?(session) do
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
else
|
||||
if Auth.authenticated?(session) do
|
||||
categories = Inventory.list_categories()
|
||||
|
||||
{:ok,
|
||||
@@ -22,6 +20,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
|> assign(:form, nil)
|
||||
|> assign(:expanded_categories, MapSet.new())
|
||||
|> assign(:page_title, "Category Management")}
|
||||
else
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -46,12 +46,13 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
def handle_event("show_edit_form", %{"id" => id}, socket) do
|
||||
category = Inventory.get_category!(id)
|
||||
# Create a changeset with current values forced into changes for proper form display
|
||||
changeset = Inventory.change_category(category, %{
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
parent_id: category.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
|
||||
changeset =
|
||||
Inventory.change_category(category, %{
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
parent_id: category.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, category.parent_id)
|
||||
|
||||
form = to_form(changeset)
|
||||
|
||||
@@ -112,7 +113,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
|> reload_categories()}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Cannot delete category - it may have components assigned or child categories")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Cannot delete category - it may have components assigned or child categories"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -120,11 +126,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
category_id = String.to_integer(id)
|
||||
expanded_categories = socket.assigns.expanded_categories
|
||||
|
||||
new_expanded = if MapSet.member?(expanded_categories, category_id) do
|
||||
MapSet.delete(expanded_categories, category_id)
|
||||
else
|
||||
MapSet.put(expanded_categories, category_id)
|
||||
end
|
||||
new_expanded =
|
||||
if MapSet.member?(expanded_categories, category_id) do
|
||||
MapSet.delete(expanded_categories, category_id)
|
||||
else
|
||||
MapSet.put(expanded_categories, category_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :expanded_categories, new_expanded)}
|
||||
end
|
||||
@@ -138,17 +145,17 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
Hierarchical.parent_select_options(
|
||||
categories,
|
||||
editing_category_id,
|
||||
&(&1.parent),
|
||||
& &1.parent,
|
||||
"No parent (Root category)"
|
||||
)
|
||||
end
|
||||
|
||||
defp root_categories(categories) do
|
||||
Hierarchical.root_entities(categories, &(&1.parent_id))
|
||||
Hierarchical.root_entities(categories, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp child_categories(categories, parent_id) do
|
||||
Hierarchical.child_entities(categories, parent_id, &(&1.parent_id))
|
||||
Hierarchical.child_entities(categories, parent_id, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp count_components_in_category(category_id) do
|
||||
@@ -164,38 +171,41 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
|
||||
|
||||
# Icon size and button size based on depth
|
||||
{icon_size, button_size, text_size, title_tag} = case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
{icon_size, button_size, text_size, title_tag} =
|
||||
case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
|
||||
children = child_categories(assigns.categories, assigns.category.id)
|
||||
has_children = !Enum.empty?(children)
|
||||
is_expanded = MapSet.member?(assigns.expanded_categories, assigns.category.id)
|
||||
|
||||
# Calculate component counts including descendants
|
||||
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants(
|
||||
assigns.category.id,
|
||||
assigns.categories,
|
||||
&(&1.parent_id),
|
||||
&count_components_in_category/1
|
||||
)
|
||||
{self_count, children_count, _total_count} =
|
||||
Hierarchical.count_with_descendants(
|
||||
assigns.category.id,
|
||||
assigns.categories,
|
||||
& &1.parent_id,
|
||||
&count_components_in_category/1
|
||||
)
|
||||
|
||||
# Format count display
|
||||
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
|
||||
|
||||
assigns = assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:children, children)
|
||||
|> assign(:has_children, has_children)
|
||||
|> assign(:is_expanded, is_expanded)
|
||||
|> assign(:count_display, count_display)
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:children, children)
|
||||
|> assign(:has_children, has_children)
|
||||
|> assign(:is_expanded, is_expanded)
|
||||
|> assign(:count_display, count_display)
|
||||
|
||||
~H"""
|
||||
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
|
||||
@@ -215,12 +225,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-6"></div> <!-- Spacer for alignment -->
|
||||
<div class="w-6"></div>
|
||||
<!-- Spacer for alignment -->
|
||||
<% end %>
|
||||
|
||||
<.icon name="hero-folder" class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
|
||||
|
||||
<!-- Content area - always starts at same vertical position -->
|
||||
<.icon
|
||||
name="hero-folder"
|
||||
class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
|
||||
/>
|
||||
|
||||
<!-- Content area - always starts at same vertical position -->
|
||||
<div class="flex-1">
|
||||
<!-- Minimized view (default) -->
|
||||
<%= unless @is_expanded do %>
|
||||
@@ -268,8 +282,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Expanded view -->
|
||||
|
||||
<!-- Expanded view -->
|
||||
<%= if @is_expanded do %>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
@@ -321,11 +335,16 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Render children recursively (only when expanded) -->
|
||||
|
||||
<!-- Render children recursively (only when expanded) -->
|
||||
<%= if @is_expanded do %>
|
||||
<%= for child <- @children do %>
|
||||
<.category_item category={child} categories={@categories} expanded_categories={@expanded_categories} depth={@depth + 1} />
|
||||
<.category_item
|
||||
category={child}
|
||||
categories={@categories}
|
||||
expanded_categories={@expanded_categories}
|
||||
depth={@depth + 1}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -368,8 +387,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Category Modal -->
|
||||
|
||||
<!-- Add Category Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -424,8 +443,8 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
|
||||
<!-- Edit Category Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -480,13 +499,15 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Categories List -->
|
||||
|
||||
<!-- Categories List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-lg font-medium text-base-content">Category Hierarchy</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">Manage your component categories and subcategories</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Manage your component categories and subcategories
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@categories) do %>
|
||||
@@ -501,8 +522,7 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
phx-click="show_add_form"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
||||
Add Category
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Category
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -511,7 +531,12 @@ defmodule ComponentsElixirWeb.CategoriesLive do
|
||||
<!-- Recursive Category Tree -->
|
||||
<%= for category <- root_categories(@categories) do %>
|
||||
<div class="px-6 py-4">
|
||||
<.category_item category={category} categories={@categories} expanded_categories={@expanded_categories} depth={0} />
|
||||
<.category_item
|
||||
category={category}
|
||||
categories={@categories}
|
||||
expanded_categories={@expanded_categories}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
use ComponentsElixirWeb, :live_view
|
||||
|
||||
alias ComponentsElixir.{Inventory, Auth}
|
||||
alias ComponentsElixir.Inventory.{Component, Category, StorageLocation, Hierarchical}
|
||||
alias ComponentsElixir.Inventory.{Component, StorageLocation, Hierarchical}
|
||||
|
||||
@items_per_page 20
|
||||
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Check authentication
|
||||
unless Auth.authenticated?(session) do
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
else
|
||||
if Auth.authenticated?(session) do
|
||||
categories = Inventory.list_categories()
|
||||
storage_locations = Inventory.list_storage_locations()
|
||||
stats = Inventory.component_stats()
|
||||
@@ -53,6 +51,8 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
max_file_size: 10_000_000
|
||||
)
|
||||
|> load_components()}
|
||||
else
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -139,7 +139,11 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|> push_patch(to: path)}
|
||||
end
|
||||
|
||||
def handle_event("storage_location_filter", %{"storage_location_id" => storage_location_id}, socket) do
|
||||
def handle_event(
|
||||
"storage_location_filter",
|
||||
%{"storage_location_id" => storage_location_id},
|
||||
socket
|
||||
) do
|
||||
storage_location_id = String.to_integer(storage_location_id)
|
||||
query_string = build_query_params_with_storage_location(socket, storage_location_id)
|
||||
path = if query_string == "", do: "/", else: "/?" <> query_string
|
||||
@@ -387,7 +391,10 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|> save_uploaded_image(component_params)
|
||||
|> save_uploaded_datasheet(socket)
|
||||
|
||||
case Inventory.update_component_with_datasheet(socket.assigns.editing_component, updated_params) do
|
||||
case Inventory.update_component_with_datasheet(
|
||||
socket.assigns.editing_component,
|
||||
updated_params
|
||||
) do
|
||||
{:ok, _component} ->
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -431,61 +438,73 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
DateTime.compare(now, socket.assigns.sort_freeze_until) != :lt
|
||||
|
||||
if should_reload do
|
||||
# Normal loading - query database with current sort criteria
|
||||
filters =
|
||||
[
|
||||
search: socket.assigns.search,
|
||||
sort_criteria: socket.assigns.sort_criteria,
|
||||
category_id: socket.assigns.selected_category,
|
||||
storage_location_id: socket.assigns.selected_storage_location,
|
||||
limit: @items_per_page,
|
||||
offset: socket.assigns.offset
|
||||
]
|
||||
|> Enum.reject(fn
|
||||
{_, v} when is_nil(v) -> true
|
||||
{:search, v} when v == "" -> true
|
||||
{_, _} -> false
|
||||
end)
|
||||
|
||||
%{components: new_components, has_more: has_more} =
|
||||
Inventory.paginate_components(filters)
|
||||
|
||||
components =
|
||||
if append do
|
||||
socket.assigns.components ++ new_components
|
||||
else
|
||||
new_components
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:components, components)
|
||||
|> assign(:has_more, has_more)
|
||||
load_components_from_db(socket, append)
|
||||
else
|
||||
# Frozen - just update the specific component in place without reordering
|
||||
if socket.assigns.interacting_with do
|
||||
updated_components =
|
||||
Enum.map(socket.assigns.components, fn component ->
|
||||
if to_string(component.id) == socket.assigns.interacting_with do
|
||||
# Reload this specific component to get updated count
|
||||
Inventory.get_component!(component.id)
|
||||
else
|
||||
component
|
||||
end
|
||||
end)
|
||||
|
||||
assign(socket, :components, updated_components)
|
||||
else
|
||||
socket
|
||||
end
|
||||
update_frozen_components(socket)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_components_from_db(socket, append) do
|
||||
filters = build_component_filters(socket)
|
||||
%{components: new_components, has_more: has_more} = Inventory.paginate_components(filters)
|
||||
|
||||
components =
|
||||
if append do
|
||||
socket.assigns.components ++ new_components
|
||||
else
|
||||
new_components
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:components, components)
|
||||
|> assign(:has_more, has_more)
|
||||
end
|
||||
|
||||
defp update_frozen_components(socket) do
|
||||
if socket.assigns.interacting_with do
|
||||
updated_components = update_interacting_component(socket)
|
||||
assign(socket, :components, updated_components)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp update_interacting_component(socket) do
|
||||
interacting_id = socket.assigns.interacting_with
|
||||
|
||||
Enum.map(socket.assigns.components, fn component ->
|
||||
if to_string(component.id) == interacting_id do
|
||||
# Reload this specific component to get updated count
|
||||
Inventory.get_component!(component.id)
|
||||
else
|
||||
component
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_component_filters(socket) do
|
||||
[
|
||||
search: socket.assigns.search,
|
||||
sort_criteria: socket.assigns.sort_criteria,
|
||||
category_id: socket.assigns.selected_category,
|
||||
storage_location_id: socket.assigns.selected_storage_location,
|
||||
limit: @items_per_page,
|
||||
offset: socket.assigns.offset
|
||||
]
|
||||
|> Enum.reject(fn
|
||||
{_, v} when is_nil(v) -> true
|
||||
{:search, v} when v == "" -> true
|
||||
{_, _} -> false
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_query_params(socket, overrides) do
|
||||
params = %{
|
||||
search: Map.get(overrides, :search, socket.assigns.search),
|
||||
criteria: Map.get(overrides, :criteria, socket.assigns.sort_criteria),
|
||||
category_id: Map.get(overrides, :category_id, socket.assigns.selected_category),
|
||||
storage_location_id: Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
|
||||
storage_location_id:
|
||||
Map.get(overrides, :storage_location_id, socket.assigns.selected_storage_location)
|
||||
}
|
||||
|
||||
params
|
||||
@@ -495,12 +514,14 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
defp parse_filter_id(nil), do: nil
|
||||
defp parse_filter_id(""), do: nil
|
||||
|
||||
defp parse_filter_id(id) when is_binary(id) do
|
||||
case Integer.parse(id) do
|
||||
{int_id, ""} -> int_id
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_filter_id(id) when is_integer(id), do: id
|
||||
|
||||
defp build_query_params_with_category(socket, category_id) do
|
||||
@@ -542,7 +563,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
end
|
||||
|
||||
defp category_options(categories) do
|
||||
Hierarchical.select_options(categories, &(&1.parent), "Select a category")
|
||||
Hierarchical.select_options(categories, & &1.parent, "Select a category")
|
||||
end
|
||||
|
||||
defp storage_location_display_name(location) do
|
||||
@@ -550,7 +571,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
end
|
||||
|
||||
defp storage_location_options(storage_locations) do
|
||||
Hierarchical.select_options(storage_locations, &(&1.parent), "No storage location")
|
||||
Hierarchical.select_options(storage_locations, & &1.parent, "No storage location")
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -599,7 +620,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
@@ -676,23 +697,27 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
class="inline-flex items-center px-3 py-2 border border-base-300 text-sm font-medium rounded-md text-base-content bg-base-100 hover:bg-base-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary"
|
||||
>
|
||||
<.icon name="hero-adjustments-horizontal" class="w-4 h-4 mr-2" />
|
||||
<%= if @show_advanced_filters, do: "Hide", else: "More" %> Filters
|
||||
{if @show_advanced_filters, do: "Hide", else: "More"} Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Filters (Collapsible) -->
|
||||
|
||||
<!-- Advanced Filters (Collapsible) -->
|
||||
<%= if @show_advanced_filters do %>
|
||||
<div class="mt-4 p-4 bg-base-100 border border-base-300 rounded-md">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content mb-2">Storage Location</label>
|
||||
<label class="block text-sm font-medium text-base-content mb-2">
|
||||
Storage Location
|
||||
</label>
|
||||
<form phx-change="storage_location_filter">
|
||||
<select
|
||||
name="storage_location_id"
|
||||
class="block w-full px-3 py-2 border border-base-300 rounded-md shadow-sm bg-base-100 text-base-content focus:outline-none focus:ring-primary focus:border-primary sm:text-sm"
|
||||
>
|
||||
<option value="" selected={is_nil(@selected_storage_location)}>All Storage Locations</option>
|
||||
<option value="" selected={is_nil(@selected_storage_location)}>
|
||||
All Storage Locations
|
||||
</option>
|
||||
<%= for {location_name, location_id} <- Hierarchical.select_options(@storage_locations, &(&1.parent)) do %>
|
||||
<option value={location_id} selected={@selected_storage_location == location_id}>
|
||||
{location_name}
|
||||
@@ -705,7 +730,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Add Component Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
@@ -884,7 +909,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Edit Component Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
@@ -1018,8 +1043,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
target="_blank"
|
||||
class="inline-flex items-center text-primary hover:text-primary/80"
|
||||
>
|
||||
<.icon name="hero-document-text" class="w-4 h-4 mr-1" />
|
||||
View PDF
|
||||
<.icon name="hero-document-text" class="w-4 h-4 mr-1" /> View PDF
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1086,7 +1110,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Components List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
@@ -1155,7 +1179,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content area with image and details -->
|
||||
<div class="flex gap-6">
|
||||
<!-- Large Image -->
|
||||
@@ -1181,18 +1205,22 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Details -->
|
||||
<div class="flex-1 space-y-4 select-text">
|
||||
<!-- Full Description -->
|
||||
<%= if component.description do %>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-base-content mb-2">Description</h4>
|
||||
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation --%>
|
||||
<p class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap">{component.description}</p>
|
||||
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
|
||||
Use phx-no-format so the formatter won't break the layout. --%>
|
||||
<p
|
||||
phx-no-format
|
||||
class="text-sm text-base-content/70 leading-relaxed whitespace-pre-wrap"
|
||||
>{component.description}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<%= if component.storage_location do %>
|
||||
@@ -1249,7 +1277,10 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
<%= if component.datasheet_filename || component.datasheet_url do %>
|
||||
<div class="flex items-start">
|
||||
<.icon name="hero-document-text" class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0" />
|
||||
<.icon
|
||||
name="hero-document-text"
|
||||
class="w-4 h-4 mr-2 text-base-content/50 mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<span class="font-medium text-base-content">Datasheet:</span>
|
||||
<div class="space-y-1 mt-1">
|
||||
@@ -1272,8 +1303,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
target="_blank"
|
||||
class="inline-flex items-center text-base-content/70 hover:text-primary text-sm"
|
||||
>
|
||||
<.icon name="hero-link" class="w-4 h-4 mr-1" />
|
||||
Original URL
|
||||
<.icon name="hero-link" class="w-4 h-4 mr-1" /> Original URL
|
||||
</a>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -1284,7 +1314,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end items-center space-x-2 pt-4 border-t border-base-300">
|
||||
<button
|
||||
@@ -1378,15 +1408,19 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Middle row: Description -->
|
||||
<%= if component.description do %>
|
||||
<div class="mt-1">
|
||||
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation --%>
|
||||
<p class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap">{component.description}</p>
|
||||
<%!-- Keep interpolation inline to prevent whitespace-pre-wrap from preserving template indentation.
|
||||
Use phx-no-format so the formatter won't break the layout. --%>
|
||||
<p
|
||||
phx-no-format
|
||||
class="text-sm text-base-content/70 line-clamp-2 whitespace-pre-wrap"
|
||||
>{component.description}</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<!-- Bottom row: Metadata -->
|
||||
<div class="mt-2 grid grid-cols-1 sm:grid-cols-3 gap-x-4 gap-y-1 text-sm text-base-content/60">
|
||||
<%= if component.storage_location do %>
|
||||
@@ -1414,7 +1448,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Keywords row -->
|
||||
<%= if component.keywords do %>
|
||||
<div class="mt-2">
|
||||
@@ -1497,7 +1531,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
>
|
||||
<!-- Background overlay -->
|
||||
<div class="absolute inset-0 bg-black bg-opacity-75"></div>
|
||||
|
||||
|
||||
<!-- Modal content -->
|
||||
<div
|
||||
class="relative bg-base-100 rounded-lg shadow-xl max-w-4xl w-full max-h-full overflow-auto"
|
||||
@@ -1515,7 +1549,7 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-6 bg-base-100 rounded-b-lg">
|
||||
<div class="text-center">
|
||||
@@ -1539,9 +1573,6 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
|
||||
# Helper functions for image upload handling
|
||||
defp save_uploaded_image(socket, component_params) do
|
||||
IO.puts("=== DEBUG: Starting save_uploaded_image ===")
|
||||
IO.inspect(socket.assigns.uploads.image.entries, label: "Upload entries")
|
||||
|
||||
uploaded_files =
|
||||
consume_uploaded_entries(socket, :image, fn %{path: path}, entry ->
|
||||
filename = "#{System.unique_integer([:positive])}_#{entry.client_name}"
|
||||
@@ -1549,47 +1580,29 @@ defmodule ComponentsElixirWeb.ComponentsLive do
|
||||
upload_dir = Path.join([uploads_dir, "images"])
|
||||
dest = Path.join(upload_dir, filename)
|
||||
|
||||
IO.puts("=== DEBUG: Processing upload ===")
|
||||
IO.puts("Filename: #{filename}")
|
||||
IO.puts("Upload dir: #{upload_dir}")
|
||||
IO.puts("Destination: #{dest}")
|
||||
|
||||
# Ensure the upload directory exists
|
||||
File.mkdir_p!(upload_dir)
|
||||
|
||||
# Copy the file
|
||||
case File.cp(path, dest) do
|
||||
:ok ->
|
||||
IO.puts("=== DEBUG: File copy successful ===")
|
||||
{:ok, filename}
|
||||
|
||||
{:error, reason} ->
|
||||
IO.puts("=== DEBUG: File copy failed: #{inspect(reason)} ===")
|
||||
{:postpone, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
|
||||
IO.inspect(uploaded_files, label: "Uploaded files result")
|
||||
case uploaded_files do
|
||||
[filename] when is_binary(filename) ->
|
||||
Map.put(component_params, "image_filename", filename)
|
||||
|
||||
result =
|
||||
case uploaded_files do
|
||||
[filename] when is_binary(filename) ->
|
||||
IO.puts("=== DEBUG: Adding filename to params: #{filename} ===")
|
||||
Map.put(component_params, "image_filename", filename)
|
||||
[] ->
|
||||
component_params
|
||||
|
||||
[] ->
|
||||
IO.puts("=== DEBUG: No files uploaded ===")
|
||||
component_params
|
||||
|
||||
_error ->
|
||||
IO.puts("=== DEBUG: Upload error ===")
|
||||
IO.inspect(uploaded_files, label: "Unexpected upload result")
|
||||
component_params
|
||||
end
|
||||
|
||||
IO.inspect(result, label: "Final component_params")
|
||||
IO.puts("=== DEBUG: End save_uploaded_image ===")
|
||||
result
|
||||
_error ->
|
||||
component_params
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function for datasheet upload handling
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
@impl true
|
||||
def mount(_params, session, socket) do
|
||||
# Check authentication
|
||||
unless Auth.authenticated?(session) do
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
else
|
||||
if Auth.authenticated?(session) do
|
||||
storage_locations = list_storage_locations()
|
||||
|
||||
{:ok,
|
||||
@@ -28,6 +26,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|> assign(:scanned_tags, [])
|
||||
|> assign(:expanded_locations, MapSet.new())
|
||||
|> assign(:page_title, "Storage Location Management")}
|
||||
else
|
||||
{:ok, socket |> push_navigate(to: ~p"/login")}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -54,12 +54,13 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
def handle_event("show_edit_form", %{"id" => id}, socket) do
|
||||
location = Inventory.get_storage_location!(id)
|
||||
# Create a changeset with current values forced into changes for proper form display
|
||||
changeset = Inventory.change_storage_location(location, %{
|
||||
name: location.name,
|
||||
description: location.description,
|
||||
parent_id: location.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
|
||||
changeset =
|
||||
Inventory.change_storage_location(location, %{
|
||||
name: location.name,
|
||||
description: location.description,
|
||||
parent_id: location.parent_id
|
||||
})
|
||||
|> Ecto.Changeset.force_change(:parent_id, location.parent_id)
|
||||
|
||||
form = to_form(changeset)
|
||||
|
||||
@@ -82,29 +83,31 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|
||||
def handle_event("save_location", %{"storage_location" => location_params}, socket) do
|
||||
# Process AprilTag assignment based on mode
|
||||
processed_params = case socket.assigns.apriltag_mode do
|
||||
"none" ->
|
||||
# Remove any apriltag_id from params to ensure it's nil
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
processed_params =
|
||||
case socket.assigns.apriltag_mode do
|
||||
"none" ->
|
||||
# Remove any apriltag_id from params to ensure it's nil
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
|
||||
"auto" ->
|
||||
# Auto-assign next available AprilTag ID
|
||||
case AprilTag.next_available_apriltag_id() do
|
||||
nil ->
|
||||
# No available IDs, proceed without AprilTag
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
apriltag_id ->
|
||||
Map.put(location_params, "apriltag_id", apriltag_id)
|
||||
end
|
||||
"auto" ->
|
||||
# Auto-assign next available AprilTag ID
|
||||
case AprilTag.next_available_apriltag_id() do
|
||||
nil ->
|
||||
# No available IDs, proceed without AprilTag
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
|
||||
"manual" ->
|
||||
# Use the manually entered apriltag_id (validation will be handled by changeset)
|
||||
location_params
|
||||
apriltag_id ->
|
||||
Map.put(location_params, "apriltag_id", apriltag_id)
|
||||
end
|
||||
|
||||
_ ->
|
||||
# Fallback: remove apriltag_id
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
end
|
||||
"manual" ->
|
||||
# Use the manually entered apriltag_id (validation will be handled by changeset)
|
||||
location_params
|
||||
|
||||
_ ->
|
||||
# Fallback: remove apriltag_id
|
||||
Map.delete(location_params, "apriltag_id")
|
||||
end
|
||||
|
||||
case Inventory.create_storage_location(processed_params) do
|
||||
{:ok, _location} ->
|
||||
@@ -147,7 +150,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|> reload_storage_locations()}
|
||||
|
||||
{:error, _changeset} ->
|
||||
{:noreply, put_flash(socket, :error, "Cannot delete storage location - it may have components assigned or child locations")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Cannot delete storage location - it may have components assigned or child locations"
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -164,10 +172,17 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
{apriltag_id, ""} when apriltag_id >= 0 and apriltag_id <= 586 ->
|
||||
case Inventory.get_storage_location_by_apriltag_id(apriltag_id) do
|
||||
nil ->
|
||||
{:noreply, put_flash(socket, :error, "Storage location not found for AprilTag ID: #{apriltag_id}")}
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Storage location not found for AprilTag ID: #{apriltag_id}"
|
||||
)}
|
||||
|
||||
location ->
|
||||
scanned_tags = [%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags]
|
||||
scanned_tags = [
|
||||
%{apriltag_id: apriltag_id, location: location} | socket.assigns.scanned_tags
|
||||
]
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -188,11 +203,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
location_id = String.to_integer(id)
|
||||
expanded_locations = socket.assigns.expanded_locations
|
||||
|
||||
new_expanded = if MapSet.member?(expanded_locations, location_id) do
|
||||
MapSet.delete(expanded_locations, location_id)
|
||||
else
|
||||
MapSet.put(expanded_locations, location_id)
|
||||
end
|
||||
new_expanded =
|
||||
if MapSet.member?(expanded_locations, location_id) do
|
||||
MapSet.delete(expanded_locations, location_id)
|
||||
else
|
||||
MapSet.put(expanded_locations, location_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :expanded_locations, new_expanded)}
|
||||
end
|
||||
@@ -203,19 +219,26 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
|
||||
def handle_event("set_edit_apriltag_mode", %{"mode" => mode}, socket) do
|
||||
# Clear the apriltag_id field when switching modes
|
||||
form = case mode do
|
||||
"remove" ->
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
|
||||
"keep" ->
|
||||
current_id = socket.assigns.editing_location.apriltag_id
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id))
|
||||
_ ->
|
||||
socket.assigns.form
|
||||
end
|
||||
form =
|
||||
case mode do
|
||||
"remove" ->
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(:params, Map.put(socket.assigns.form.params || %{}, "apriltag_id", nil))
|
||||
|
||||
"keep" ->
|
||||
current_id = socket.assigns.editing_location.apriltag_id
|
||||
|
||||
socket.assigns.form
|
||||
|> Phoenix.Component.to_form()
|
||||
|> Map.put(
|
||||
:params,
|
||||
Map.put(socket.assigns.form.params || %{}, "apriltag_id", current_id)
|
||||
)
|
||||
|
||||
_ ->
|
||||
socket.assigns.form
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
@@ -234,7 +257,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
{:noreply, put_flash(socket, :error, "Failed to get AprilTag URL")}
|
||||
|
||||
apriltag_url ->
|
||||
filename = "#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
|
||||
filename =
|
||||
"#{location.name |> String.replace(" ", "_")}_AprilTag_#{location.apriltag_id}.svg"
|
||||
|
||||
# Send file download to browser
|
||||
{:noreply,
|
||||
@@ -257,7 +281,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
Hierarchical.parent_select_options(
|
||||
storage_locations,
|
||||
editing_location_id,
|
||||
&(&1.parent),
|
||||
& &1.parent,
|
||||
"No parent (Root location)"
|
||||
)
|
||||
end
|
||||
@@ -267,11 +291,11 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
end
|
||||
|
||||
defp root_storage_locations(storage_locations) do
|
||||
Hierarchical.root_entities(storage_locations, &(&1.parent_id))
|
||||
Hierarchical.root_entities(storage_locations, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp child_storage_locations(storage_locations, parent_id) do
|
||||
Hierarchical.child_entities(storage_locations, parent_id, &(&1.parent_id))
|
||||
Hierarchical.child_entities(storage_locations, parent_id, & &1.parent_id)
|
||||
end
|
||||
|
||||
defp count_components_in_location(location_id) do
|
||||
@@ -291,46 +315,53 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
border_class = if assigns.depth > 0, do: "border-l-2 border-base-300 pl-6", else: ""
|
||||
|
||||
# Icon size and button size based on depth
|
||||
{icon_size, button_size, text_size, title_tag} = case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
{icon_size, button_size, text_size, title_tag} =
|
||||
case assigns.depth do
|
||||
0 -> {"w-5 h-5", "p-2", "text-lg", "h3"}
|
||||
1 -> {"w-4 h-4", "p-1.5", "text-base", "h4"}
|
||||
_ -> {"w-3 h-3", "p-1", "text-sm", "h5"}
|
||||
end
|
||||
|
||||
# Different icons based on level - QR code is always present for storage locations
|
||||
icon_name = case assigns.depth do
|
||||
0 -> "hero-building-office" # Shelf/Room
|
||||
1 -> "hero-archive-box" # Drawer/Cabinet
|
||||
_ -> "hero-cube" # Box/Container
|
||||
end
|
||||
icon_name =
|
||||
case assigns.depth do
|
||||
# Shelf/Room
|
||||
0 -> "hero-building-office"
|
||||
# Drawer/Cabinet
|
||||
1 -> "hero-archive-box"
|
||||
# Box/Container
|
||||
_ -> "hero-cube"
|
||||
end
|
||||
|
||||
children = child_storage_locations(assigns.storage_locations, assigns.location.id)
|
||||
has_children = !Enum.empty?(children)
|
||||
is_expanded = MapSet.member?(assigns.expanded_locations, assigns.location.id)
|
||||
|
||||
# Calculate component counts including descendants
|
||||
{self_count, children_count, _total_count} = Hierarchical.count_with_descendants(
|
||||
assigns.location.id,
|
||||
assigns.storage_locations,
|
||||
&(&1.parent_id),
|
||||
&count_components_in_location/1
|
||||
)
|
||||
{self_count, children_count, _total_count} =
|
||||
Hierarchical.count_with_descendants(
|
||||
assigns.location.id,
|
||||
assigns.storage_locations,
|
||||
& &1.parent_id,
|
||||
&count_components_in_location/1
|
||||
)
|
||||
|
||||
# Format count display
|
||||
count_display = Hierarchical.format_count_display(self_count, children_count, is_expanded)
|
||||
|
||||
assigns = assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:icon_name, icon_name)
|
||||
|> assign(:children, children)
|
||||
|> assign(:has_children, has_children)
|
||||
|> assign(:is_expanded, is_expanded)
|
||||
|> assign(:count_display, count_display)
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:margin_left, margin_left)
|
||||
|> assign(:border_class, border_class)
|
||||
|> assign(:icon_size, icon_size)
|
||||
|> assign(:button_size, button_size)
|
||||
|> assign(:text_size, text_size)
|
||||
|> assign(:title_tag, title_tag)
|
||||
|> assign(:icon_name, icon_name)
|
||||
|> assign(:children, children)
|
||||
|> assign(:has_children, has_children)
|
||||
|> assign(:is_expanded, is_expanded)
|
||||
|> assign(:count_display, count_display)
|
||||
|
||||
~H"""
|
||||
<div class={[@border_class, @depth > 0 && "mt-4"]} style={"margin-left: #{@margin_left}px"}>
|
||||
@@ -350,12 +381,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<% end %>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-6"></div> <!-- Spacer for alignment -->
|
||||
<div class="w-6"></div>
|
||||
<!-- Spacer for alignment -->
|
||||
<% end %>
|
||||
|
||||
<.icon name={@icon_name} class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"} />
|
||||
|
||||
<!-- Content area - always starts at same vertical position -->
|
||||
<.icon
|
||||
name={@icon_name}
|
||||
class={"#{@icon_size} #{if @depth == 0, do: "text-primary", else: "text-base-content/60"} mt-0.5"}
|
||||
/>
|
||||
|
||||
<!-- Content area - always starts at same vertical position -->
|
||||
<div class="flex-1">
|
||||
<!-- Minimized view (default) -->
|
||||
<%= unless @is_expanded do %>
|
||||
@@ -408,8 +443,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Expanded view -->
|
||||
|
||||
<!-- Expanded view -->
|
||||
<%= if @is_expanded do %>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
@@ -468,8 +503,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
class="inline-flex items-center px-3 py-1.5 border border-base-300 rounded-md shadow-sm text-sm font-medium text-base-content bg-base-100 hover:bg-base-200 flex-shrink-0"
|
||||
title="Download AprilTag"
|
||||
>
|
||||
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" />
|
||||
Download
|
||||
<.icon name="hero-arrow-down-tray" class="w-4 h-4 mr-1.5" /> Download
|
||||
</button>
|
||||
<% end %>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -495,11 +529,16 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Render children recursively (only when expanded) -->
|
||||
|
||||
<!-- Render children recursively (only when expanded) -->
|
||||
<%= if @is_expanded do %>
|
||||
<%= for child <- @children do %>
|
||||
<.location_item location={child} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={@depth + 1} />
|
||||
<.location_item
|
||||
location={child}
|
||||
storage_locations={@storage_locations}
|
||||
expanded_locations={@expanded_locations}
|
||||
depth={@depth + 1}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -552,8 +591,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Location Modal -->
|
||||
|
||||
<!-- Add Location Modal -->
|
||||
<%= if @show_add_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -589,7 +628,9 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-base-content">AprilTag ID (Optional)</label>
|
||||
<label class="block text-sm font-medium text-base-content">
|
||||
AprilTag ID (Optional)
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
@@ -647,10 +688,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
class="w-32"
|
||||
/>
|
||||
<div class="text-xs text-base-content/60">
|
||||
Available IDs: <%= length(@available_apriltag_ids) %> of 587
|
||||
Available IDs: {length(@available_apriltag_ids)} of 587
|
||||
<%= if length(@available_apriltag_ids) < 20 do %>
|
||||
<br/>Next available: <%= @available_apriltag_ids |> Enum.take(10) |> Enum.join(", ") %>
|
||||
<%= if length(@available_apriltag_ids) > 10, do: "..." %>
|
||||
<br />Next available: {@available_apriltag_ids
|
||||
|> Enum.take(10)
|
||||
|> Enum.join(", ")}
|
||||
{if length(@available_apriltag_ids) > 10, do: "..."}
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -678,8 +721,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Edit Location Modal -->
|
||||
|
||||
<!-- Edit Location Modal -->
|
||||
<%= if @show_edit_form do %>
|
||||
<div class="fixed inset-0 bg-base-content/50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -773,12 +816,14 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
class="w-32"
|
||||
/>
|
||||
<div class="text-xs text-base-content/60">
|
||||
Available IDs: <%= length(@available_apriltag_ids) %> of 587
|
||||
Available IDs: {length(@available_apriltag_ids)} of 587
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
Current: <%= if @editing_location.apriltag_id, do: "ID #{@editing_location.apriltag_id}", else: "None" %>
|
||||
Current: {if @editing_location.apriltag_id,
|
||||
do: "ID #{@editing_location.apriltag_id}",
|
||||
else: "None"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -803,8 +848,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- AprilTag Scanner Modal -->
|
||||
|
||||
<!-- AprilTag Scanner Modal -->
|
||||
<%= if @apriltag_scanner_open do %>
|
||||
<div class="fixed inset-0 bg-base-content/30 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-10 mx-auto p-5 border border-base-300 w-11/12 md:w-1/2 shadow-lg rounded-md bg-base-100">
|
||||
@@ -818,16 +863,20 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<.icon name="hero-x-mark" class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- AprilTag Scanner Interface -->
|
||||
|
||||
<!-- AprilTag Scanner Interface -->
|
||||
<div class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center">
|
||||
<.icon name="hero-qr-code" class="mx-auto h-12 w-12 text-base-content/50" />
|
||||
<p class="mt-2 text-sm text-base-content/70">Camera AprilTag scanner would go here</p>
|
||||
<p class="text-xs text-base-content/60 mt-1">In a real implementation, this would use JavaScript AprilTag detection</p>
|
||||
|
||||
<!-- Test buttons for demo -->
|
||||
<p class="text-xs text-base-content/60 mt-1">
|
||||
In a real implementation, this would use JavaScript AprilTag detection
|
||||
</p>
|
||||
|
||||
<!-- Test buttons for demo -->
|
||||
<div class="mt-4 space-y-2">
|
||||
<p class="text-sm font-medium text-base-content/80">Test with sample AprilTag IDs:</p>
|
||||
<p class="text-sm font-medium text-base-content/80">
|
||||
Test with sample AprilTag IDs:
|
||||
</p>
|
||||
<button
|
||||
phx-click="apriltag_scanned"
|
||||
phx-value-apriltag_id="0"
|
||||
@@ -848,8 +897,8 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Scanned Tags Display -->
|
||||
|
||||
<!-- Scanned Tags Display -->
|
||||
<%= if length(@scanned_tags) > 0 do %>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
@@ -863,26 +912,35 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div :for={scan <- @scanned_tags} class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300">
|
||||
<div
|
||||
:for={scan <- @scanned_tags}
|
||||
class="flex items-center justify-between bg-base-100 p-2 rounded border border-base-300"
|
||||
>
|
||||
<div>
|
||||
<span class="font-medium text-base-content">{location_display_name(scan.location)}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">(AprilTag ID {scan.apriltag_id})</span>
|
||||
<span class="font-medium text-base-content">
|
||||
{location_display_name(scan.location)}
|
||||
</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
(AprilTag ID {scan.apriltag_id})
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-green-600 bg-green-100 px-2 py-1 rounded">
|
||||
Level <%= Hierarchical.compute_level(scan.location, &(&1.parent)) %>
|
||||
Level {Hierarchical.compute_level(scan.location, & &1.parent)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Storage Locations List -->
|
||||
|
||||
<!-- Storage Locations List -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div class="bg-base-100 shadow overflow-hidden sm:rounded-md">
|
||||
<div class="px-6 py-4 border-b border-base-300">
|
||||
<h2 class="text-lg font-medium text-base-content">Storage Location Hierarchy</h2>
|
||||
<p class="text-sm text-base-content/60 mt-1">Manage your physical storage locations and AprilTags</p>
|
||||
<p class="text-sm text-base-content/60 mt-1">
|
||||
Manage your physical storage locations and AprilTags
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<%= if Enum.empty?(@storage_locations) do %>
|
||||
@@ -897,8 +955,7 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
phx-click="show_add_form"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
|
||||
>
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" />
|
||||
Add Location
|
||||
<.icon name="hero-plus" class="w-4 h-4 mr-2" /> Add Location
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -907,7 +964,12 @@ defmodule ComponentsElixirWeb.StorageLocationsLive do
|
||||
<!-- Recursive Storage Location Tree -->
|
||||
<%= for location <- root_storage_locations(@storage_locations) do %>
|
||||
<div class="px-6 py-4">
|
||||
<.location_item location={location} storage_locations={@storage_locations} expanded_locations={@expanded_locations} depth={0} />
|
||||
<.location_item
|
||||
location={location}
|
||||
storage_locations={@storage_locations}
|
||||
expanded_locations={@expanded_locations}
|
||||
depth={0}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -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} ->
|
||||
|
||||
3
mix.exs
3
mix.exs
@@ -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
|
||||
|
||||
|
||||
8
mix.lock
8
mix.lock
@@ -1,6 +1,8 @@
|
||||
%{
|
||||
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
@@ -8,7 +10,6 @@
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ex_maybe": {:hex, :ex_maybe, "1.1.1", "95c0188191b43bd278e876ae4f0a688922e3ca016a9efd97ee7a0b741a61b899", [:mix], [], "hexpm", "1af8c78c915c7f119a513b300a1702fc5cc9fed42d54fd85995265e4c4b763d2"},
|
||||
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||
@@ -18,7 +19,6 @@
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.7", "53aa9ebdbde8aec7c8ee03a8bdaec38dd56302995b0baeebf8dbe7cbdd550400", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "e115944e6ddb887c45cadfd660348934c318abec0341f7b7156e912b98d3eb95"},
|
||||
"matrix_reloaded": {:hex, :matrix_reloaded, "2.3.0", "eea41bc6713021f8f51dde0c2d6b72e695a99098753baebf0760e10aed8fa777", [:mix], [{:ex_maybe, "~> 1.0", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}], "hexpm", "4013c0cebe5dfffc8f2316675b642fb2f5a1dfc4bdc40d2c0dfa0563358fa496"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
@@ -33,11 +33,8 @@
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"pngex": {:hex, :pngex, "0.1.2", "824c2da291fda236397729f236b29f87b98a434d58124ea9f7fa03d3b3cf8587", [:mix], [], "hexpm", "9f9f2d9aa286d03f6c317017a09e1b548fa0aa6b901291e24dbf65d8212b22b0"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"qr_code": {:hex, :qr_code, "3.2.0", "416ad75b7284c1b43c3a248bae0304ac933dc16ba501af49f22c0262e55916e1", [:mix], [{:ex_maybe, "~> 1.1.1", [hex: :ex_maybe, repo: "hexpm", optional: false]}, {:matrix_reloaded, "~> 2.3", [hex: :matrix_reloaded, repo: "hexpm", optional: false]}, {:pngex, "~> 0.1.0", [hex: :pngex, repo: "hexpm", optional: false]}, {:result, "~> 1.7", [hex: :result, repo: "hexpm", optional: false]}, {:xml_builder, "~> 2.3", [hex: :xml_builder, repo: "hexpm", optional: false]}], "hexpm", "fc5366d61087753a781c2e2d2659fce71f91b6258c8341f0ee47f31d5a185497"},
|
||||
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
|
||||
"result": {:hex, :result, "1.7.2", "a57c569f7cf5c158d2299d3b5624a48b69bd1520d0771dc711bcf9f3916e8ab6", [:mix], [], "hexpm", "89f98e98cfbf64237ecf4913aa36b76b80463e087775d19953dc4b435a35f087"},
|
||||
"swoosh": {:hex, :swoosh, "1.19.5", "5abd71be78302ba21be56a2b68d05c9946ff1f1bd254f949efef09d253b771ac", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c953f51ee0a8b237e0f4307c9cefd3eb1eb751c35fcdda2a8bccb991766473be"},
|
||||
"tailwind": {:hex, :tailwind, "0.4.0", "4b2606713080437e3d94a0fa26527e7425737abc001f172b484b42f43e3274c0", [:mix], [], "hexpm", "530bd35699333f8ea0e9038d7146c2f0932dfec2e3636bd4a8016380c4bc382e"},
|
||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||
@@ -46,5 +43,4 @@
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
|
||||
"xml_builder": {:hex, :xml_builder, "2.4.0", "b20d23077266c81f593360dc037ea398461dddb6638a329743da6c73afa56725", [:mix], [], "hexpm", "833e325bb997f032b5a1b740d2fd6feed3c18ca74627f9f5f30513a9ae1a232d"},
|
||||
}
|
||||
|
||||
@@ -14,7 +14,9 @@ defmodule ComponentsElixir.Repo.Migrations.MigrateQrToApriltag do
|
||||
create unique_index(:storage_locations, [:apriltag_id])
|
||||
|
||||
# Add constraint to ensure apriltag_id is in valid range (0-586 for tag36h11)
|
||||
create constraint(:storage_locations, :apriltag_id_range, check: "apriltag_id >= 0 AND apriltag_id <= 586")
|
||||
create constraint(:storage_locations, :apriltag_id_range,
|
||||
check: "apriltag_id >= 0 AND apriltag_id <= 586"
|
||||
)
|
||||
|
||||
# Note: We keep qr_code_old for now in case we need to rollback
|
||||
# It can be removed in a future migration after confirming everything works
|
||||
|
||||
@@ -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:")
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule ComponentsElixirWeb.ErrorHTMLTest do
|
||||
end
|
||||
|
||||
test "renders 500.html" do
|
||||
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) == "Internal Server Error"
|
||||
assert render_to_string(ComponentsElixirWeb.ErrorHTML, "500", "html", []) ==
|
||||
"Internal Server Error"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,7 +2,9 @@ defmodule ComponentsElixirWeb.ErrorJSONTest do
|
||||
use ComponentsElixirWeb.ConnCase, async: true
|
||||
|
||||
test "renders 404" do
|
||||
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{errors: %{detail: "Not Found"}}
|
||||
assert ComponentsElixirWeb.ErrorJSON.render("404.json", %{}) == %{
|
||||
errors: %{detail: "Not Found"}
|
||||
}
|
||||
end
|
||||
|
||||
test "renders 500" do
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule ComponentsElixirWeb.PageControllerTest do
|
||||
|
||||
test "GET /", %{conn: conn} do
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "Peace of mind from prototype to production"
|
||||
# redirect to login
|
||||
assert html_response(conn, 302)
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user